feat(portal-shell): 学生域全页面迁移与规范合规修复
- 学生域 32 页全量迁移(含作答/自动保存/提交/诊断) - 补齐 4 个 MSW mock 缺口,修 diagnostic case 名 - 修 4 处 Tailwind 任意值;新增共享组件与路由
This commit is contained in:
229
apps/portal-shell/docs/needtodo/SUMMARY-NeedTodo.md
Normal file
229
apps/portal-shell/docs/needtodo/SUMMARY-NeedTodo.md
Normal 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 hooks(admin.ts 973 行 + admin-p5.ts 3371 行)
|
||||||
|
- mocks/graphql-data.ts(9867 行)兜底数据全覆盖
|
||||||
|
|
||||||
|
**契约状态**:
|
||||||
|
|
||||||
|
- 仅 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 个缺失(家长域待建)。**
|
||||||
@@ -1,582 +1,486 @@
|
|||||||
# 管理域(Admin)待完成功能分析
|
# 管理域(Admin)待完成功能分析
|
||||||
|
|
||||||
> 参考项目:`e:\desktop\CICD\src\app\(dashboard)\admin\`(41 个 page.tsx)
|
> 参考项目:`e:\desktop\CICD\src\app\(dashboard)\admin\`(41 个 page.tsx)
|
||||||
> 当前项目:`e:\Desktop\Edu\apps\portal-shell\src\app\shell\admin\`(1 个 page.tsx)
|
> 当前项目:`e:\Desktop\Edu\apps\portal-shell\src\app\shell\admin\`(49 个 page.tsx)
|
||||||
> 规划依据:`apps\portal-shell\ARCHITECTURE.md` §9.4(管理域 24 页,B5 批次)
|
> 规划依据:`apps\portal-shell\ARCHITECTURE.md` §9.4(管理域 24 页顶层入口,B5 批次)
|
||||||
> 分析日期:2026-07-24
|
> 核查日期:2026-08-04
|
||||||
> 分析方式:逐页面对比 CICD 实现 vs portal-shell 实现,逐个读取 CICD page.tsx + ARCH §9.4 契约
|
> 核查方式:逐个读取 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)作为**功能基线**,反映"老版单体已实现"的管理员功能完整态。
|
1. CICD 单体(Next.js App Router + Server Actions + Drizzle)作为**功能基线**,反映"老版单体已实现"的管理员功能完整态。
|
||||||
2. portal-shell 作为**目标态**,遵循 ARCH §9.4 的契约与批次规划(B5,24 页)。
|
2. portal-shell 作为**目标态**,遵循 ARCH §9.4 的契约与批次规划(B5,24 页顶层入口)。
|
||||||
3. CICD admin/layout.tsx 仅为 `<>{children}</>` passthrough,无独立侧边栏/权限逻辑(权限由各 page.tsx 调 `requirePermission` 完成)。
|
3. CICD admin/layout.tsx 仅为 `<>{children}</>` passthrough,无独立侧边栏/权限逻辑(权限由各 page.tsx 调 `requirePermission` 完成)。
|
||||||
4. ARCH §9.4 规划 24 页 = 1 仪表盘 + 23 管理子页;portal-shell 仅完成 `/shell/admin/page.tsx`(仪表盘),其余 23 页全部缺失。
|
4. ARCH §9.4 规划 24 页顶层入口 = 1 仪表盘 + 23 管理子页;portal-shell 在此基础上补充了详情/编辑/创建子页与 redirect 入口,实际落地 **49 个 page.tsx**。
|
||||||
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 后期/补充批次候选。
|
5. 判断口径(任务约束):
|
||||||
6. ARCH §9.4 中存在但 CICD **无对应**的页面(4 页):`students`、`teachers`、`organization`、`viewports`、`plugins`,属 ARCH 新规划,需在 portal-shell 中独立设计。
|
- **✅ 完整实现**: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` 真实契约) |
|
| ✅ 完整实现(业务页) | 46 | page.tsx 引入对应 `features/admin/*-client.tsx` 并在 `<Suspense>` 中渲染;client 组件调用 lib/api hooks,处理 loading/error/empty 三态 |
|
||||||
| ❌ 完全缺失(ARCH §9.4 规划内) | 23 | 见 §二,按 ARCH 规划口径的 23 个缺失页面 |
|
| ✅ 完整实现(重定向) | 3 | page.tsx 为有意的 `redirect()` 入口跳转(classes / scheduling / school 三个模块根路径) |
|
||||||
| ➕ CICD 已实现但 ARCH §9.4 未规划 | 21 | 见 §五,作为补充批次参考 |
|
| 🟡 占位空态 | 0 | 无 |
|
||||||
| 🆕 ARCH §9.4 规划但 CICD 无源 | 4 | `students`、`teachers`、`organization`、`viewports`(+ `plugins` 已有契约 ✅) |
|
| ❌ 缺失 | 0 | 无 |
|
||||||
|
| **合计** | **49** | **管理员域 49/49 page.tsx 全部落地** |
|
||||||
|
|
||||||
### ✅ 已完成且功能完整(1 页)
|
### ✅ 完整实现(46 业务页 + 3 重定向页 = 49 页)
|
||||||
|
|
||||||
| 模块 | 页面 | 备注 |
|
所有 49 个 page.tsx 均已存在且非占位。46 个业务页统一采用 Server Component 入口 + Suspense 边界 + Client Component 业务的架构;3 个重定向页为有意的入口跳转,避免空白入口。
|
||||||
| --------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| dashboard | `/shell/admin/page.tsx` | 客户端组件,`useAdminDashboard` hook 接 data-ana `adminDashboard` 真实契约;4 StatCard(教师/学生/班级/全校平均分)+ 近期预警 + AI 用量;含 loading/error 兜底;引用 ARCH §7.1 / §10 P1-2 |
|
业务页架构样例(以 `/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 页)
|
## 二、已实现页面清单(按模块分组,49 页)
|
||||||
|
|
||||||
### 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(),
|
|
||||||
]);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.9 ai-settings 模块(1 页缺失)
|
> 状态标识:✅ 完整业务实现 / ↪️ 重定向入口 / 🟡 占位 / ❌ 缺失
|
||||||
|
> 三态规范:所有 ✅ 业务页的 client 组件均处理 loading(Skeleton)/ error(局部降级)/ empty(EmptyState)三态(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)
|
| `/shell/admin` | ✅ | "use client" + useAdminDashboard() hook 接 data-ana adminDashboard 真实契约;4 StatCard(教师/学生/班级/全校平均分)+ 近期预警 + AI 用量;含 loading/error 兜底;引用 ARCH §7.1 / §10 P1-2 |
|
||||||
- 渲染 `AiProviderSettingsCard` 客户端组件(含 isAdmin 开关)
|
|
||||||
- 管理员额外渲染 `AiUsageDashboard`(用量统计)
|
|
||||||
- **技术栈**:Server Component + `requirePermission(Permissions.AI_CHAT)` + 通过 `ctx.permissions.includes(Permissions.AI_CONFIGURE)` 区分管理员
|
|
||||||
- **ARCHITECTURE.md 契约**:❌(B5)
|
|
||||||
|
|
||||||
### 2.10 system 模块(1 页缺失)
|
|
||||||
|
|
||||||
#### 2.10.1 `/shell/admin/system`(系统设置)
|
### 2.2 ai-settings 模块(1 页)
|
||||||
|
|
||||||
- **CICD 参考实现**:`e:\desktop\CICD\src\app\(dashboard)\admin\settings\page.tsx`
|
| 页面路径 | 状态 | client 组件 | 行数 | lib/api hooks | 契约状态 |
|
||||||
- **功能描述**:系统设置页
|
| ------------------------ | ---- | ---------------------- | ---- | -------------------------------------------------------------------------------------------------------------------------- | --------------------------- |
|
||||||
- 渲染 `AdminSettingsView` 客户端组件(具体配置项由组件内部决定)
|
| /shell/admin/ai-settings | ✅ | ai-settings-client.tsx | 522 | useAiProviders / useCreateAiProvider / useUpdateAiProvider / useDeleteAiProvider / useTestAiProvider / useAiUsageDashboard | ❌ schema 未就绪 → MSW 兜底 |
|
||||||
- **技术栈**:Server Component + `requirePermission(Permissions.SETTINGS_ADMIN)` + Drizzle
|
|
||||||
- **ARCHITECTURE.md 契约**:❌(B5,对应 ARCH §9.4 第 13 行 `admin/system` → `/shell/admin/system`)
|
|
||||||
|
|
||||||
### 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 未实现)
|
### 2.4 attendance 模块(1 页)
|
||||||
- **功能描述**:对齐 004 §5.4 视口配置(BFF/Service 层 viewport 路由策略可视化)
|
|
||||||
- **技术栈**:未知,需依据 004 §5.4 设计
|
|
||||||
- **ARCHITECTURE.md 契约**:❌(B5)
|
|
||||||
|
|
||||||
### 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 未实现)
|
| 页面路径 | 状态 | client 组件 | 行数 | lib/api hooks | 契约状态 |
|
||||||
- **功能描述**:现有 portal-shell `plugin-manager` 升级整页
|
| ------------------------------------ | ---- | -------------------------- | ---- | ----------------------------------------------------------------------------------------- | ------------- |
|
||||||
- **技术栈**:依据 portal-shell 已有 plugin-manager 演化
|
| /shell/admin/audit-logs | ✅ | audit-logs-list-client.tsx | 461 | useAuditLogs / useAuditModuleOptions / useExportAuditLogs | ❌ → MSW 兜底 |
|
||||||
- **ARCHITECTURE.md 契约**:✅ `config-service`(已就绪,可优先实现)
|
| /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 参考实现**:**无**
|
### 2.7 course-plans 模块(4 页)
|
||||||
- **ARCHITECTURE.md 契约**:❌(B5)
|
|
||||||
- **建议**:参照 CICD 中 `admin/school/classes` 等管理页设计,作学生维度聚合视图。
|
|
||||||
|
|
||||||
#### 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 参考实现**:**无**
|
### 2.8 curriculum-map 模块(1 页)
|
||||||
- **ARCHITECTURE.md 契约**:❌(B5)
|
|
||||||
- **建议**:参照 CICD 中 `admin/users` + `getStaffOptions()` 演化。
|
|
||||||
|
|
||||||
#### 2.13.3 `/shell/admin/organization`(组织管理)
|
| 页面路径 | 状态 | client 组件 | 行数 | lib/api hooks | 契约状态 |
|
||||||
|
| --------------------------- | ---- | ------------------------- | ---- | ------------------------------------------------------ | ------------- |
|
||||||
|
| /shell/admin/curriculum-map | ✅ | curriculum-map-client.tsx | 256 | useStandardsCoverageHeatmap / useGlobalLessonPlanStats | ❌ → MSW 兜底 |
|
||||||
|
|
||||||
- **CICD 参考实现**:**无**
|
### 2.9 elective 模块(4 页)
|
||||||
- **ARCHITECTURE.md 契约**:❌(B5)
|
|
||||||
- **建议**:合并 `school/departments` + `school/grades` + 师生维度,作组织架构视图。
|
| 页面路径 | 状态 | 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 §9.4 模块汇总
|
||||||
|
|
||||||
| 模块 | ARCH 规划页数 | portal-shell 已完成 | 缺失 | 契约状态 |
|
| 模块 | ARCH §9.4 规划页数 | portal-shell 已实现 | 缺失 | 契约状态 |
|
||||||
| ---------------- | ---------------------------------------------------------------- | ------------------- | ------------------ | ------------------- |
|
| ---------------- | --------------------------------- | --------------------- | ----- | ---------------------------- |
|
||||||
| dashboard | 1 | 1 | 0 | ✅ `adminDashboard` |
|
| dashboard | 1 | 1 | 0 | ✅ adminDashboard |
|
||||||
| users | 1(+1 import 子页) | 0 | 1(+1 子页) | ❌ |
|
| users | 1(+1 import +1 详情) | 3 | 0 | ❌ → MSW 兜底 |
|
||||||
| roles | 1 | 0 | 1 | ❌ |
|
| roles | 1(+1 详情) | 2 | 0 | ❌ → MSW 兜底 |
|
||||||
| permissions | 1 | 0 | 1 | ❌ |
|
| permissions | 1 | 1 | 0 | ❌ → MSW 兜底 |
|
||||||
| audit-logs | 4(含 overview/login/data-changes) | 0 | 4 | ❌ |
|
| audit-logs | 4 | 4 | 0 | ❌ → MSW 兜底 |
|
||||||
| invitation-codes | 1 | 0 | 1 | ❌ |
|
| invitation-codes | 1 | 1 | 0 | ❌ → MSW 兜底 |
|
||||||
| school | 6(schools/classes/departments/academic-year/grades + redirect) | 0 | 6 | ❌ |
|
| school | 6(+redirect) | 7(+grades/insights) | 0 | ❌ → MSW 兜底 |
|
||||||
| classes | 1(与 school/classes 同源) | 0 | 0(合并到 school) | ❌ |
|
| classes | 1(与 school/classes 同源) | 1(redirect) | 0 | — |
|
||||||
| students | 1 | 0 | 1 | ❌ |
|
| students | 1 | 1 | 0 | ❌ → MSW 兜底 |
|
||||||
| teachers | 1 | 0 | 1 | ❌ |
|
| teachers | 1 | 1 | 0 | ❌ → MSW 兜底 |
|
||||||
| organization | 1 | 0 | 1 | ❌ |
|
| organization | 1 | 1 | 0 | ❌ → MSW 兜底 |
|
||||||
| announcements | 1 | 0 | 1 | ❌ |
|
| announcements | 1(+2 子页) | 3 | 0 | ❌ → MSW 兜底 |
|
||||||
| files | 1 | 0 | 1 | ❌ |
|
| files | 1 | 1 | 0 | ❌ → MSW 兜底 |
|
||||||
| ai-settings | 1 | 0 | 1 | ❌ |
|
| ai-settings | 1 | 1 | 0 | ❌ → MSW 兜底 |
|
||||||
| system | 1 | 0 | 1 | ❌ |
|
| system | 1 | 1 | 0 | ❌ → MSW 兜底 |
|
||||||
| viewports | 1 | 0 | 1 | ❌ |
|
| viewports | 1 | 1 | 0 | ❌ → MSW 兜底 |
|
||||||
| plugins | 1 | 0 | 1 | ✅ `config-service` |
|
| plugins | 1 | 1 | 0 | ✅ config-service |
|
||||||
| **合计** | **~24** | **1** | **~23** | **2 ✅ / ~22 ❌** |
|
| 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 路径 | 功能 | 技术栈 |
|
| portal-shell 路径 | 状态 | client 组件 | 行数 |
|
||||||
| --------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------------------------- | ---- | ----------------------------- | ---- |
|
||||||
| `admin/course-plans/page.tsx` | 课程计划列表(admin 全局视角,`scope.isAdmin=true`) | Server Component + `requirePermission(Permissions.COURSE_PLAN_READ)` + `getCoursePlans({ status }, { isAdmin: true })` |
|
| /shell/admin/course-plans | ✅ | course-plans-list-client.tsx | 339 |
|
||||||
| `admin/course-plans/create/page.tsx` | 创建课程计划 | Server Component + `requirePermission(Permissions.COURSE_PLAN_MANAGE)` + 并行查询 classes/subjects/teachers/academicYears + `CoursePlanForm mode="create"` |
|
| /shell/admin/course-plans/create | ✅ | course-plan-create-client.tsx | 415 |
|
||||||
| `admin/course-plans/[id]/page.tsx` | 课程计划详情 | Server Component + `getCoursePlanById(id, { userId, isAdmin: true })` + `CoursePlanDetail` |
|
| /shell/admin/course-plans/[id] | ✅ | course-plan-detail-client.tsx | 644 |
|
||||||
| `admin/course-plans/[id]/edit/page.tsx` | 编辑课程计划 | Server Component + `requirePermission(Permissions.COURSE_PLAN_MANAGE)` + `CoursePlanForm mode="edit"` |
|
| /shell/admin/course-plans/[id]/edit | ✅ | course-plan-edit-client.tsx | 562 |
|
||||||
|
|
||||||
### 4.2 curriculum-map 模块(1 页)
|
### 4.2 curriculum-map 模块(1 页,已实现)
|
||||||
|
|
||||||
| CICD 路径 | 功能 | 技术栈 |
|
| portal-shell 路径 | 状态 | client 组件 | 行数 |
|
||||||
| ------------------------------- | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| --------------------------- | ---- | ------------------------- | ---- |
|
||||||
| `admin/curriculum-map/page.tsx` | 课程地图(标准覆盖热图 + 5 张统计卡:教师/教案/已发布/已提交/已关联标准数) | Server Component + `requirePermission(Permissions.LESSON_PLAN_READ)` + `getStandardsCoverageHeatmapAction()` + `getGlobalLessonPlanStatsAction()` + `CurriculumMapView` |
|
| /shell/admin/curriculum-map | ✅ | curriculum-map-client.tsx | 256 |
|
||||||
|
|
||||||
### 4.3 elective 模块(4 页)
|
### 4.3 elective 模块(4 页,已实现)
|
||||||
|
|
||||||
| CICD 路径 | 功能 | 技术栈 |
|
| portal-shell 路径 | 状态 | client 组件 | 行数 |
|
||||||
| ----------------------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
| ------------------------------- | ---- | -------------------------- | ---- |
|
||||||
| `admin/elective/page.tsx` | 选修课列表(`StatsCardsLoader` + `CourseListLoader` 双 Suspense 流式) | Server Component + `requirePermission(Permissions.ELECTIVE_READ)` + `ElectivePageLayout` |
|
| /shell/admin/elective | ✅ | elective-list-client.tsx | 545 |
|
||||||
| `admin/elective/create/page.tsx` | 创建选修课 | Server Component + `getSubjectOptions()/getGrades()/getStaffOptions()` + `ElectiveCourseForm mode="create"` |
|
| /shell/admin/elective/create | ✅ | elective-create-client.tsx | 381 |
|
||||||
| `admin/elective/[id]/page.tsx` | 选修课详情(含 `getCourseSelections` 选课记录) | Server Component + `requirePermission(Permissions.ELECTIVE_READ)` + `ElectiveCourseDetail` |
|
| /shell/admin/elective/[id] | ✅ | elective-detail-client.tsx | 385 |
|
||||||
| `admin/elective/[id]/edit/page.tsx` | 编辑选修课 | Server Component + `ElectiveCourseForm mode="edit"` |
|
| /shell/admin/elective/[id]/edit | ✅ | elective-edit-client.tsx | 464 |
|
||||||
|
|
||||||
### 4.4 questions 模块(1 页)
|
### 4.4 questions 模块(1 页,已实现)
|
||||||
|
|
||||||
| CICD 路径 | 功能 | 技术栈 |
|
| portal-shell 路径 | 状态 | client 组件 | 行数 |
|
||||||
| -------------------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------- | ---- | ------------------------- | ---- |
|
||||||
| `admin/questions/page.tsx` | 题库管理(筛选 + 导入导出 + 创建) | Server Component + `requirePermission(Permissions.QUESTION_READ)` + `getQuestions({...})` + `QuestionFilters`(Suspense) + `QuestionBankResultsClient` + `ImportExportButtons` + `CreateQuestionButton` |
|
| /shell/admin/questions | ✅ | questions-list-client.tsx | 537 |
|
||||||
|
|
||||||
### 4.5 lesson-plans 模块(2 页)
|
### 4.5 lesson-plans 模块(2 页,已实现)
|
||||||
|
|
||||||
| CICD 路径 | 功能 | 技术栈 |
|
| portal-shell 路径 | 状态 | client 组件 | 行数 |
|
||||||
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
| --------------------------------------- | ---- | ---------------------------- | ---- |
|
||||||
| `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()` |
|
| /shell/admin/lesson-plans | ✅ | lesson-plans-list-client.tsx | 489 |
|
||||||
| `admin/lesson-plans/[planId]/view/page.tsx` | 教案只读视图(含 textbook/chapter 标题解析) | Server Component + `getLessonPlanById(planId, ctx.userId)` + `getTextbookById/getChaptersByTextbookId` 并行 + `LessonPlanReadonlyView` |
|
| /shell/admin/lesson-plans/[planId]/view | ✅ | lesson-plan-view-client.tsx | 205 |
|
||||||
|
|
||||||
### 4.6 error-book 模块(1 页)
|
### 4.6 error-book 模块(1 页,已实现)
|
||||||
|
|
||||||
| CICD 路径 | 功能 | 技术栈 |
|
| portal-shell 路径 | 状态 | client 组件 | 行数 |
|
||||||
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------------- | ---- | --------------------- | ---- |
|
||||||
| `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` |
|
| /shell/admin/error-book | ✅ | error-book-client.tsx | 738 |
|
||||||
|
|
||||||
### 4.7 scheduling 模块(3 页)
|
### 4.7 scheduling 模块(3 页 + redirect,已实现)
|
||||||
|
|
||||||
| CICD 路径 | 功能 | 技术栈 |
|
| portal-shell 路径 | 状态 | client 组件 | 行数 |
|
||||||
| ----------------------------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------------- | ---- | ------------------------------ | ---- |
|
||||||
| `admin/scheduling/auto/page.tsx` | 自动排课(`AutoSchedulePanel` + 跳转 rules 配置入口) | Server Component + `requirePermission(Permissions.SCHEDULE_AUTO)` + `getAdminClassesForScheduling()` |
|
| /shell/admin/scheduling | ↪️ | redirect → /scheduling/changes | — |
|
||||||
| `admin/scheduling/changes/page.tsx` | 排课变更审批(变更列表 + 冲突检测 + 课表网格) | Server Component + `requirePermission(Permissions.SCHEDULE_ADJUST)` + 并行 `getAdminClassesForScheduling/getScheduleChanges/getScheduleEntriesForAdmin` + `ScheduleChangeList canApprove` + `ScheduleConflictsView` + `ScheduleGridView` |
|
| /shell/admin/scheduling/auto | ✅ | auto-schedule-client.tsx | 270 |
|
||||||
| `admin/scheduling/rules/page.tsx` | 排课规则配置 | Server Component + `requirePermission(Permissions.SCHEDULE_ADJUST)` + `getSchedulingRules()` + `SchedulingRulesForm` |
|
| /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 路径 | 功能 | 技术栈 |
|
| portal-shell 路径 | 状态 | client 组件 | 行数 |
|
||||||
| --------------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------------- | ---- | --------------------------- | ---- |
|
||||||
| `admin/attendance/page.tsx` | 考勤管理(7 统计卡 + 班级对比卡 + 考勤-成绩关联分析卡 + 筛选器) | Server Component + `requirePermission(Permissions.ATTENDANCE_READ)` + 并行 `getAttendanceRecords/getAttendanceStats/getGrades/getClassComparison/getAttendanceGradeCorrelation` + `AdminAttendanceFilters`(含 class/status/date 筛选) |
|
| /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 路径 | 功能 | 技术栈 |
|
### 4.10 users/import + users/[id] 子页(2 页,已实现)
|
||||||
| ---------------------------------------- | ------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `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 子页(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 路径 | 功能 | 技术栈 |
|
### 4.12 school/grades/insights(1 页,已实现)
|
||||||
| --------------------------- | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `admin/roles/[id]/page.tsx` | 角色详情(`RolePermissionMatrix` 权限矩阵 + `RoleDetailEditButton` + admin 角色锁定提示卡) | Server Component + `requirePermission(Permissions.ROLE_READ)` + `getRoleById(id)` + `notFound()` 兜底 + admin 角色强制锁定(isLocked) |
|
|
||||||
|
|
||||||
### 4.12 school/grades/insights(1 页)
|
| portal-shell 路径 | 状态 | client 组件 | 行数 |
|
||||||
|
| ----------------------------------- | ---- | ------------------------- | ---- |
|
||||||
已在 §2.6 附注中提及,不重复。
|
| /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-guard(004 §5 鉴权链路)。
|
1. **管理员权限校验**:portal-shell 采用 Client Component + BFF 模式,权限校验由 route-permissions.ts 在路由层 EXACT/PREFIX 表登记(如 /shell/admin/* → ["admin"]),与 CICD 的 Server Component + requirePermission 模式不同但等价。
|
||||||
2. **DataScope 上下文**:`school/grades/insights`、`error-book`、`attendance` 等页面依赖 `ctx.dataScope`(all / class_taught / class_members / children),portal-shell 需在 BFF 层补齐数据范围上下文。
|
2. **DataScope 上下文**:portal-shell 在 BFF 层注入数据范围上下文,client 组件通过 lib/api hooks 消费,无需 page.tsx 显式传递。
|
||||||
3. **Server Action + router.refresh 模式**:`invitation-codes`、`announcements/[id]/edit` 等使用此模式,portal-shell 需保持等价机制(BFF API + 客户端 refetch)。
|
3. **Server Action + router.refresh 模式**:portal-shell 改用 Apollo Client mutation + useQuery refetch / notify 通知,等价于 CICD 的 Server Action + router.refresh()。
|
||||||
4. **导出功能**:`audit-logs` 三页 + `questions` 导入导出按钮,需后端提供 CSV 导出端点。
|
4. **导出功能**:audit-logs 三页 + error-book 的 CSV 导出,已通过 useExportAuditLogs / useExportLoginLogs / useExportDataChanges / useExportErrorBookCsv hooks 封装,等待后端契约就绪后接真实端点。
|
||||||
5. **服务注入**:`audit-logs/overview` 使用 `AuditServiceProvider + adminAuditService` 模式,portal-shell 应在 BFF/Service 层保留服务抽象。
|
5. **服务注入**:audit-logs/overview 在 portal-shell 中改为 client 组件直接调用 useAuditOverviewStats / useAuditTrend / useDataChangeActionStats 三个 hook 并行查询,等价于 CICD 的 AuditServiceProvider + adminAuditService 模式。
|
||||||
|
|
||||||
### 5.2 模块复杂度风险
|
### 5.2 模块复杂度与契约风险
|
||||||
|
|
||||||
| 模块 | 风险点 | 缓解建议 |
|
| 模块 | 风险点 | 当前缓解 | 待办 |
|
||||||
| ----------------------------------- | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
|
| ---------------------- | --------------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||||
| **audit-logs**(4 页) | 审计核心,多维筛选 + 导出 + 概览统计 + 数据变更统计 | 后端契约工单优先;前端可 MSW 先行 4 页 UI |
|
| audit-logs(4 页) | 审计核心,多维筛选 + 导出 + 概览统计 + 数据变更统计 | 前端 4 页 UI 已完成(共 1644 行 client 代码),MSW 兜底 | 后端契约工单:auditLogs / loginLogs / dataChangeLogs / auditOverviewStats / 导出端点 |
|
||||||
| **school**(6 页) | 体系复杂,6 子页 + 跨表关联(schools/classes/departments/grades/academic-year/staff) | 按 schools → grades → classes → departments → academic-year 顺序迁移;insights 作 B5 末 |
|
| school(7 页) | 体系复杂,6 业务子页 + 1 redirect,跨表关联 | 前端 7 页 UI 已完成(共 3335 行 client 代码),MSW 兜底 | 后端契约工单:schools / departments / academicYears / grades / adminClasses / gradeOverviewStats |
|
||||||
| **scheduling**(3 页,§四补充批次) | 涉及自动排课算法 + 冲突检测 + 审批流 | 算法侧需独立服务/工单;前端先做 changes 审批流,auto/rules 后置 |
|
| scheduling(4 页) | 自动排课算法 + 冲突检测 + 审批流 | 前端 4 页 UI 已完成(共 1142 行 client 代码),MSW 兜底 | 后端契约工单:autoSchedule / scheduleChanges / scheduleEntries / schedulingRules + 排课算法服务 |
|
||||||
| **error-book**(§四补充批次) | 5 统计卡 + 4 图表 + Top 50 学生 + Top 10 错题;限 500 学生性能保护 | 复用教师域 error-book 组件,仅换 data-access 为 admin scope;图表组件应已存在 |
|
| error-book(1 页) | 5 统计卡 + 4 图表 + Top 50 学生 + Top 10 错题 | 前端 1 页 UI 已完成(738 行 client 代码,最复杂单页),MSW 兜底 | 后端契约工单:adminErrorBookStats + CSV 导出端点 |
|
||||||
| **attendance**(§四补充批次) | 7 统计 + 班级对比 + 考勤-成绩关联分析 | 后端需提供 `getClassComparison` + `getAttendanceGradeCorrelation` 两个分析端点 |
|
| attendance(1 页) | 7 统计 + 班级对比 + 考勤-成绩关联分析 | 前端 1 页 UI 已完成(340 行 client 代码),MSW 兜底 | 后端契约工单:adminAttendanceStats / adminAttendanceRecords / attendanceGradeCorrelation |
|
||||||
| **curriculum-map**(§四补充批次) | 标准覆盖热图 + 5 张统计卡 + `getStandardsCoverageHeatmapAction` Server Action | 依赖 lesson-preparation 模块的 actions-analytics,需先迁移教师域 lesson-plans |
|
| curriculum-map(1 页) | 标准覆盖热图 + 5 张统计卡 | 前端 1 页 UI 已完成(256 行 client 代码),MSW 兜底 | 后端契约工单:standardsCoverageHeatmap / globalLessonPlanStats |
|
||||||
| **ai-settings** | 双权限(AI_CHAT + AI_CONFIGURE)+ Provider 配置 + Usage 仪表盘 | 后端需提供 provider CRUD + usage 聚合端点 |
|
| ai-settings(1 页) | 双权限 + Provider 配置 + Usage 仪表盘 | 前端 1 页 UI 已完成(522 行 client 代码),MSW 兜底 | 后端契约工单:aiProviders / aiUsageDashboard + Provider CRUD 端点 |
|
||||||
|
| users(3 页) | 用户列表 + 批量导入 + 详情,关联角色分配 | 前端 3 页 UI 已完成(共 1356 行 client 代码),MSW 兜底 | 后端契约工单:users 列表 + importUsers mutation |
|
||||||
|
| course-plans(4 页) | 课程计划 CRUD + 子项 reorder + 批量切换 | 前端 4 页 UI 已完成(共 1960 行 client 代码),MSW 兜底 | 后端契约工单:adminCoursePlans / adminCoursePlan + 子项 CRUD mutations |
|
||||||
|
| elective(4 页) | 选修课 CRUD + 抽签 + 选课开关 | 前端 4 页 UI 已完成(共 1775 行 client 代码),MSW 兜底 | 后端契约工单:adminElectives / adminElective + 抽签 mutation |
|
||||||
|
|
||||||
### 5.3 契约就绪优先级建议
|
### 5.3 契约就绪优先级(用于后端工单排期)
|
||||||
|
|
||||||
按 ARCH §9.4 节奏原则(契约就绪页先行,❌ 页用 MSW 先上 UI):
|
前端 49 页已全部落地,瓶颈在后端 GraphQL schema 就绪。按业务价值与依赖关系排期:
|
||||||
|
|
||||||
1. **优先级 1(契约 ✅,可立即实现真实查询)**:
|
1. **优先级 1(契约 ✅,已完成真实查询)**:/shell/admin(adminDashboard ✅)+ /shell/admin/plugins(config-service ✅)
|
||||||
- `/shell/admin/plugins`(✅ `config-service`)
|
2. **优先级 2(高价值 RBAC 核心,需后端契约工单)**:users(3 页)+ roles(2 页)+ permissions(1 页)+ invitation-codes(1 页)= 7 页;后端契约:users / roles / permissions / permissionRoleCounts / invitationCodes + 相关 mutations
|
||||||
- `/shell/admin`(已完成)
|
3. **优先级 3(高频运营功能,需后端契约工单)**:announcements(3 页)+ files(1 页)+ ai-settings(1 页)+ system(1 页)= 6 页;后端契约:adminAnnouncements / fileAttachments / aiProviders / aiUsageDashboard / systemSettings
|
||||||
2. **优先级 2(高价值 + 中等复杂度,需后端契约工单)**:
|
4. **优先级 4(审计与组织管理,高复杂度)**:audit-logs(4 页)+ school(7 页)+ organization(1 页)+ students(1 页)+ teachers(1 页)+ viewports(1 页)= 15 页;后端契约:审计 4 类查询 + 导出端点;school 6 类实体 CRUD;organizationTree / adminStudents / adminTeachers / viewports
|
||||||
- `/shell/admin/users` + `/shell/admin/users/import`(用户管理 + 批量导入)
|
5. **优先级 5(§四补充批次,教师域聚合视图)**:course-plans(4 页)+ curriculum-map(1 页)+ elective(4 页)+ questions(1 页)+ lesson-plans(2 页)+ error-book(1 页)+ scheduling(4 页)+ attendance(1 页)= 18 页;后端契约:均为教师域聚合查询在 admin scope 下的复用
|
||||||
- `/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. **优先级 4(ARCH 新建,需独立设计)**:
|
|
||||||
- `/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 页
|
|
||||||
|
|
||||||
### 5.4 CICD admin/layout.tsx 迁移说明
|
### 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 归属 | 缺失/已有 |
|
| # | CICD 路径 | ARCH §9.4 归属 | portal-shell 状态(2026-08-04) |
|
||||||
| --- | ------------------------------------------- | ----------------------------- | ------------------------------------------- |
|
| --- | ----------------------------------------- | ----------------------------- | --------------------------------------------------------------- |
|
||||||
| 1 | `admin/dashboard/page.tsx` | §9.4 第 1 行 ✅ | portal-shell `/shell/admin/page.tsx` 已完成 |
|
| 1 | admin/dashboard/page.tsx | §9.4 第 1 行 ✅ | ✅ /shell/admin/page.tsx(真实契约 adminDashboard) |
|
||||||
| 2 | `admin/users/page.tsx` | §9.4 第 2 行 ❌ | 缺失 |
|
| 2 | admin/users/page.tsx | §9.4 第 2 行 ❌ | ✅ /shell/admin/users/page.tsx(MSW 兜底) |
|
||||||
| 3 | `admin/users/import/page.tsx` | §9.4 第 2 行 ❌(合并) | 缺失 |
|
| 3 | admin/users/import/page.tsx | §9.4 第 2 行 ❌(合并) | ✅ /shell/admin/users/import/page.tsx(MSW 兜底) |
|
||||||
| 4 | `admin/roles/page.tsx` | §9.4 第 3 行 ❌ | 缺失 |
|
| 4 | admin/roles/page.tsx | §9.4 第 3 行 ❌ | ✅ /shell/admin/roles/page.tsx(MSW 兜底) |
|
||||||
| 5 | `admin/roles/[id]/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划) |
|
| 5 | admin/roles/[id]/page.tsx | §四补充批次 | ✅ /shell/admin/roles/[id]/page.tsx(MSW 兜底) |
|
||||||
| 6 | `admin/permissions/page.tsx` | §9.4 第 3 行 ❌ | 缺失 |
|
| 6 | admin/permissions/page.tsx | §9.4 第 3 行 ❌ | ✅ /shell/admin/permissions/page.tsx(MSW 兜底) |
|
||||||
| 7 | `admin/audit-logs/page.tsx` | §9.4 第 4 行 ❌ | 缺失 |
|
| 7 | admin/audit-logs/page.tsx | §9.4 第 4 行 ❌ | ✅ /shell/admin/audit-logs/page.tsx(MSW 兜底) |
|
||||||
| 8 | `admin/audit-logs/overview/page.tsx` | §9.4 第 4 行 ❌ | 缺失 |
|
| 8 | admin/audit-logs/overview/page.tsx | §9.4 第 4 行 ❌ | ✅ /shell/admin/audit-logs/overview/page.tsx(MSW 兜底) |
|
||||||
| 9 | `admin/audit-logs/login-logs/page.tsx` | §9.4 第 4 行 ❌ | 缺失 |
|
| 9 | admin/audit-logs/login-logs/page.tsx | §9.4 第 4 行 ❌ | ✅ /shell/admin/audit-logs/login-logs/page.tsx(MSW 兜底) |
|
||||||
| 10 | `admin/audit-logs/data-changes/page.tsx` | §9.4 第 4 行 ❌ | 缺失 |
|
| 10 | admin/audit-logs/data-changes/page.tsx | §9.4 第 4 行 ❌ | ✅ /shell/admin/audit-logs/data-changes/page.tsx(MSW 兜底) |
|
||||||
| 11 | `admin/invitation-codes/page.tsx` | §9.4 第 5 行 ❌ | 缺失 |
|
| 11 | admin/invitation-codes/page.tsx | §9.4 第 5 行 ❌ | ✅ /shell/admin/invitation-codes/page.tsx(MSW 兜底) |
|
||||||
| 12 | `admin/school/page.tsx`(redirect) | §9.4 第 6 行 ❌ | 缺失 |
|
| 12 | admin/school/page.tsx(redirect) | §9.4 第 6 行 ❌ | ↪️ /shell/admin/school/page.tsx → /school/schools |
|
||||||
| 13 | `admin/school/schools/page.tsx` | §9.4 第 6 行 ❌ | 缺失 |
|
| 13 | admin/school/schools/page.tsx | §9.4 第 6 行 ❌ | ✅ /shell/admin/school/schools/page.tsx(MSW 兜底) |
|
||||||
| 14 | `admin/school/classes/page.tsx` | §9.4 第 6/7 行 ❌ | 缺失 |
|
| 14 | admin/school/classes/page.tsx | §9.4 第 6/7 行 ❌ | ✅ /shell/admin/school/classes/page.tsx(MSW 兜底) |
|
||||||
| 15 | `admin/school/departments/page.tsx` | §9.4 第 6 行 ❌ | 缺失 |
|
| 15 | admin/school/departments/page.tsx | §9.4 第 6 行 ❌ | ✅ /shell/admin/school/departments/page.tsx(MSW 兜底) |
|
||||||
| 16 | `admin/school/academic-year/page.tsx` | §9.4 第 6 行 ❌ | 缺失 |
|
| 16 | admin/school/academic-year/page.tsx | §9.4 第 6 行 ❌ | ✅ /shell/admin/school/academic-year/page.tsx(MSW 兜底) |
|
||||||
| 17 | `admin/school/grades/page.tsx` | §9.4 第 6 行 ❌ | 缺失 |
|
| 17 | admin/school/grades/page.tsx | §9.4 第 6 行 ❌ | ✅ /shell/admin/school/grades/page.tsx(MSW 兜底) |
|
||||||
| 18 | `admin/school/grades/insights/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划) |
|
| 18 | admin/school/grades/insights/page.tsx | §四补充批次 | ✅ /shell/admin/school/grades/insights/page.tsx(MSW 兜底) |
|
||||||
| 19 | `admin/announcements/page.tsx` | §9.4 第 10 行 ❌ | 缺失 |
|
| 19 | admin/announcements/page.tsx | §9.4 第 10 行 ❌ | ✅ /shell/admin/announcements/page.tsx(MSW 兜底) |
|
||||||
| 20 | `admin/announcements/[id]/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划) |
|
| 20 | admin/announcements/[id]/page.tsx | §四补充批次 | ✅ /shell/admin/announcements/[id]/page.tsx(MSW 兜底) |
|
||||||
| 21 | `admin/announcements/[id]/edit/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划) |
|
| 21 | admin/announcements/[id]/edit/page.tsx | §四补充批次 | ✅ /shell/admin/announcements/[id]/edit/page.tsx(MSW 兜底) |
|
||||||
| 22 | `admin/files/page.tsx` | §9.4 第 11 行 ❌ | 缺失 |
|
| 22 | admin/files/page.tsx | §9.4 第 11 行 ❌ | ✅ /shell/admin/files/page.tsx(MSW 兜底) |
|
||||||
| 23 | `admin/ai-settings/page.tsx` | §9.4 第 12 行 ❌ | 缺失 |
|
| 23 | admin/ai-settings/page.tsx | §9.4 第 12 行 ❌ | ✅ /shell/admin/ai-settings/page.tsx(MSW 兜底) |
|
||||||
| 24 | `admin/settings/page.tsx` | §9.4 第 13 行 ❌(→ /system) | 缺失 |
|
| 24 | admin/settings/page.tsx | §9.4 第 13 行 ❌(→ /system) | ✅ /shell/admin/system/page.tsx(MSW 兜底) |
|
||||||
| 25 | `admin/course-plans/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划) |
|
| 25 | admin/course-plans/page.tsx | §四补充批次 | ✅ /shell/admin/course-plans/page.tsx(MSW 兜底) |
|
||||||
| 26 | `admin/course-plans/create/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划) |
|
| 26 | admin/course-plans/create/page.tsx | §四补充批次 | ✅ /shell/admin/course-plans/create/page.tsx(MSW 兜底) |
|
||||||
| 27 | `admin/course-plans/[id]/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划) |
|
| 27 | admin/course-plans/[id]/page.tsx | §四补充批次 | ✅ /shell/admin/course-plans/[id]/page.tsx(MSW 兜底) |
|
||||||
| 28 | `admin/course-plans/[id]/edit/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划) |
|
| 28 | admin/course-plans/[id]/edit/page.tsx | §四补充批次 | ✅ /shell/admin/course-plans/[id]/edit/page.tsx(MSW 兜底) |
|
||||||
| 29 | `admin/curriculum-map/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划) |
|
| 29 | admin/curriculum-map/page.tsx | §四补充批次 | ✅ /shell/admin/curriculum-map/page.tsx(MSW 兜底) |
|
||||||
| 30 | `admin/elective/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划) |
|
| 30 | admin/elective/page.tsx | §四补充批次 | ✅ /shell/admin/elective/page.tsx(MSW 兜底) |
|
||||||
| 31 | `admin/elective/create/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划) |
|
| 31 | admin/elective/create/page.tsx | §四补充批次 | ✅ /shell/admin/elective/create/page.tsx(MSW 兜底) |
|
||||||
| 32 | `admin/elective/[id]/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划) |
|
| 32 | admin/elective/[id]/page.tsx | §四补充批次 | ✅ /shell/admin/elective/[id]/page.tsx(MSW 兜底) |
|
||||||
| 33 | `admin/elective/[id]/edit/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划) |
|
| 33 | admin/elective/[id]/edit/page.tsx | §四补充批次 | ✅ /shell/admin/elective/[id]/edit/page.tsx(MSW 兜底) |
|
||||||
| 34 | `admin/questions/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划) |
|
| 34 | admin/questions/page.tsx | §四补充批次 | ✅ /shell/admin/questions/page.tsx(MSW 兜底) |
|
||||||
| 35 | `admin/lesson-plans/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划) |
|
| 35 | admin/lesson-plans/page.tsx | §四补充批次 | ✅ /shell/admin/lesson-plans/page.tsx(MSW 兜底) |
|
||||||
| 36 | `admin/lesson-plans/[planId]/view/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划) |
|
| 36 | admin/lesson-plans/[planId]/view/page.tsx | §四补充批次 | ✅ /shell/admin/lesson-plans/[planId]/view/page.tsx(MSW 兜底) |
|
||||||
| 37 | `admin/error-book/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划) |
|
| 37 | admin/error-book/page.tsx | §四补充批次 | ✅ /shell/admin/error-book/page.tsx(MSW 兜底) |
|
||||||
| 38 | `admin/scheduling/auto/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划) |
|
| 38 | admin/scheduling/auto/page.tsx | §四补充批次 | ✅ /shell/admin/scheduling/auto/page.tsx(MSW 兜底) |
|
||||||
| 39 | `admin/scheduling/changes/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划) |
|
| 39 | admin/scheduling/changes/page.tsx | §四补充批次 | ✅ /shell/admin/scheduling/changes/page.tsx(MSW 兜底) |
|
||||||
| 40 | `admin/scheduling/rules/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划) |
|
| 40 | admin/scheduling/rules/page.tsx | §四补充批次 | ✅ /shell/admin/scheduling/rules/page.tsx(MSW 兜底) |
|
||||||
| 41 | `admin/attendance/page.tsx` | §四补充批次 | 缺失(非 ARCH §9.4 规划) |
|
| 41 | admin/attendance/page.tsx | §四补充批次 | ✅ /shell/admin/attendance/page.tsx(MSW 兜底) |
|
||||||
|
|
||||||
**ARCH §9.4 规划但 CICD 无源(5 页)**:
|
**ARCH §9.4 规划但 CICD 无源(5 项,已全部新建)**:
|
||||||
|
|
||||||
| # | ARCH 目标路由 | 说明 | 契约 |
|
| # | ARCH 目标路由 | 说明 | 契约 | portal-shell 状态(2026-08-04) |
|
||||||
| --- | --------------------------- | -------------------------------- | ------------------- |
|
| --- | ------------------------- | -------------------------------- | ----------------- | -------------------------------------------- |
|
||||||
| A | `/shell/admin/students` | ARCH 新建,CICD 无 | ❌ |
|
| A | /shell/admin/students | ARCH 新建,CICD 无 | ❌ → MSW 兜底 | ✅ /shell/admin/students/page.tsx |
|
||||||
| B | `/shell/admin/teachers` | ARCH 新建,CICD 无 | ❌ |
|
| B | /shell/admin/teachers | ARCH 新建,CICD 无 | ❌ → MSW 兜底 | ✅ /shell/admin/teachers/page.tsx |
|
||||||
| C | `/shell/admin/organization` | ARCH 新建,CICD 无 | ❌ |
|
| C | /shell/admin/organization | ARCH 新建,CICD 无 | ❌ → MSW 兜底 | ✅ /shell/admin/organization/page.tsx |
|
||||||
| D | `/shell/admin/viewports` | ARCH 新建,对齐 004 §5.4 | ❌ |
|
| D | /shell/admin/viewports | ARCH 新建,对齐 004 §5.4 | ❌ → MSW 兜底 | ✅ /shell/admin/viewports/page.tsx |
|
||||||
| E | `/shell/admin/plugins` | ARCH 新建(plugin-manager 升级) | ✅ `config-service` |
|
| E | /shell/admin/plugins | ARCH 新建(plugin-manager 升级) | ✅ config-service | ✅ /shell/admin/plugins/page.tsx(真实契约) |
|
||||||
|
|
||||||
---
|
**portal-shell 额外新增(3 项 redirect 入口)**:
|
||||||
|
|
||||||
## 七、关键结论
|
| # | portal-shell 路由 | 说明 |
|
||||||
|
| --- | ----------------------- | ------------------------------------------ |
|
||||||
1. **portal-shell 管理域完成度:1/24 ≈ 4%**(仅仪表盘),是各角色域中完成度最低的。
|
| α | /shell/admin/classes | redirect → /shell/admin/school/classes |
|
||||||
2. **缺失 23 页全部属 B5 批次**,节奏紧迫,建议立即启动契约工单。
|
| β | /shell/admin/scheduling | redirect → /shell/admin/scheduling/changes |
|
||||||
3. **契约就绪仅 2 个**:`adminDashboard`(已用)+ `config-service`(plugins,未实现)。
|
| γ | /shell/admin/school | redirect → /shell/admin/school/schools |
|
||||||
4. **CICD 有 21 页 ARCH §9.4 未规划**,是教师域功能在管理员视角的复用,建议作为 B5 末或 B6 补充批次,按需取用。
|
|
||||||
5. **高复杂度模块**:audit-logs(4 页 + 导出)、school(6 子页 + 跨表关联)、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 页。
|
|
||||||
|
|||||||
@@ -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\`
|
> 当前项目:`e:\Desktop\Edu\apps\portal-shell\src\app\shell\parent\`
|
||||||
> 规划依据:`apps/portal-shell/ARCHITECTURE.md` §9.3(24 页,B4 批次)
|
> 规划依据:`apps/portal-shell/ARCHITECTURE.md` §9.3(24 页,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` 聚合。
|
> **重要提示**: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+ 页面所需 hooks(grades / 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 实现**:基础仪表盘,显示孩子平均分 / 班级排名 / 薄弱知识点 / 预警通知
|
- **portal-shell 实现**:基础仪表盘,显示孩子平均分 / 班级排名 / 薄弱知识点 / 预警通知
|
||||||
- **技术栈**:Client Component + `useParentDashboard()` Hook + `DashboardShell` / `StatCard` 共享组件
|
- **技术栈**: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 技术栈**:Server Component + `getParentDashboardAction()` Server Action + `ParentDashboard` 视图组件 + React `use()` 流式渲染 + `generateMetadata`
|
||||||
- **CICD 关键功能**:
|
- **CICD 关键功能**:
|
||||||
- 无子女时显示 `ParentNoChildrenPage` 空态
|
- 无子女时显示 `ParentNoChildrenPage` 空态
|
||||||
@@ -89,7 +132,7 @@
|
|||||||
|
|
||||||
#### 2.2.1 ❌ `/shell/parent/children/[studentId]`(子女详情页,缺失)⚠️ 重要
|
#### 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`(子女成绩列表,缺失)
|
#### 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 导出)
|
- 每个子女一块:姓名标题 + **导出按钮**(`ParentExportButton`,按 studentId 导出)
|
||||||
@@ -146,7 +189,7 @@
|
|||||||
|
|
||||||
#### 2.3.2 ❌ `/shell/parent/grades/report-card`(子女成绩报告卡,缺失)
|
#### 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`(必须在家长子女范围内)
|
- **必填查询参数**:`studentId`(必须在家长子女范围内)
|
||||||
@@ -215,7 +258,7 @@
|
|||||||
|
|
||||||
#### 2.6.1 ❌ `/shell/parent/attendance`(子女考勤,缺失)
|
#### 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`)
|
- 顶部 `headerExtra`:出勤率卡片(`ParentAttendanceRateCard`)+ 考勤预警(`ParentAttendanceWarning`)
|
||||||
@@ -253,7 +296,7 @@
|
|||||||
|
|
||||||
#### 2.8.1 ❌ `/shell/parent/course-plans`(课程计划列表,缺失)
|
#### 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**,用于过滤课程计划
|
||||||
- 并行查询每个子女的活跃班级 ID(`getStudentActiveClassId`)
|
- 并行查询每个子女的活跃班级 ID(`getStudentActiveClassId`)
|
||||||
@@ -271,7 +314,7 @@
|
|||||||
|
|
||||||
#### 2.8.2 ❌ `/shell/parent/course-plans/[id]`(课程计划详情,缺失)
|
#### 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,传入权限上下文
|
- 解析所有孩子的班级 ID,传入权限上下文
|
||||||
@@ -292,7 +335,7 @@
|
|||||||
|
|
||||||
#### 2.9.1 ❌ `/shell/parent/lesson-plans`(教案列表,缺失)
|
#### 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"`)
|
- 家长视角:仅查看**已发布**教案(`status: "published"`)
|
||||||
- 学科选项并行加载(`getSubjectOptions`)
|
- 学科选项并行加载(`getSubjectOptions`)
|
||||||
@@ -310,7 +353,7 @@
|
|||||||
|
|
||||||
#### 2.9.2 ❌ `/shell/parent/lesson-plans/[planId]/view`(教案只读详情,缺失)
|
#### 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 修复,防跨年级信息泄露)
|
- 家长视角:仅可查看**孩子所在年级**的已发布课案(V4 P0-1 修复,防跨年级信息泄露)
|
||||||
- **scope 校验**:`assertPlanInScope(plan, ctx)`,失败 `notFound()`
|
- **scope 校验**:`assertPlanInScope(plan, ctx)`,失败 `notFound()`
|
||||||
@@ -334,7 +377,7 @@
|
|||||||
|
|
||||||
#### 2.10.1 ❌ `/shell/parent/error-book`(子女错题本,缺失)
|
#### 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 列统计卡片(总数 / 新增 / 学习中 / 已掌握 / 待复习)
|
- **单子女**:直接展示 `StatsGrid` 5 列统计卡片(总数 / 新增 / 学习中 / 已掌握 / 待复习)
|
||||||
@@ -358,7 +401,7 @@
|
|||||||
|
|
||||||
#### 2.11.1 ❌ `/shell/parent/diagnostic`(子女学情诊断,缺失)
|
#### 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 容错增强)
|
- **多子女诊断对比视图**(v4-P1-9 容错增强)
|
||||||
- 预先查询所有子女姓名(`getUserNamesByIds`),用于错误卡片展示
|
- 预先查询所有子女姓名(`getUserNamesByIds`),用于错误卡片展示
|
||||||
@@ -394,7 +437,7 @@
|
|||||||
|
|
||||||
#### 2.13.1 ❌ `/shell/parent/practice`(子女练习查看,缺失)
|
#### 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` 历史列表
|
- **单子女**:直接展示 4 列 `StatsGrid`(总练习次数 / 已完成 / 总答题数 / 正确率)+ `PracticeHistory` 历史列表
|
||||||
@@ -419,7 +462,7 @@
|
|||||||
|
|
||||||
#### 2.14.1 ❌ `/shell/parent/elective`(子女选课查看,缺失)
|
#### 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`(子女姓名 + 选课记录列表)
|
- 每个子女一块:`ParentSelectionView`(子女姓名 + 选课记录列表)
|
||||||
@@ -439,7 +482,7 @@
|
|||||||
|
|
||||||
#### 2.15.1 ❌ `/shell/parent/leave`(家长在线请假,缺失)
|
#### 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)
|
- 顶部:**在线请假表单**(`LeaveRequestForm`,下拉选择子女,自动写入 classId)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,10 @@
|
|||||||
# 教师域(Teacher)待完成功能分析
|
# 教师域(Teacher)待完成功能分析
|
||||||
|
|
||||||
> 参考项目:`e:\desktop\CICD\src\app\(dashboard)\teacher\`(53 个 page.tsx)
|
> 参考项目:`e:\desktop\CICD\src\app\(dashboard)\teacher\`(CICD 原版,53 个 page.tsx)
|
||||||
> 当前项目:`e:\Desktop\Edu\apps\portal-shell\src\app\shell\teacher\`(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)
|
> 规划依据:`apps\portal-shell\ARCHITECTURE.md` §9.1(教师域 56 页规划,B1+B2+B2末)
|
||||||
> 分析日期:2026-07-24
|
> 分析日期:2026-08-04
|
||||||
> 分析方式:逐页面对比 CICD 实现 vs portal-shell 实现,逐个读取 page.tsx + features/*-client.tsx
|
> 分析方式:逐页面读取 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)作为**功能基线**,反映"老版单体已实现"的教师功能完整态。
|
1. CICD 单体(Next.js App Router + Server Actions + Drizzle)作为**功能基线**,反映"老版单体已实现"的教师功能完整态。
|
||||||
2. portal-shell 作为**目标态**,遵循 ARCH §9.1 的契约与批次规划(B1+B2+B2末)。
|
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 渲染」。
|
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` 等为**重定向/等价路径**,不算独立功能缺口。
|
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 兜底契约页) |
|
| ✅ 完整实现 | **60** | page.tsx 集成 `*-client.tsx` 或自身含真实业务逻辑(lib/api hook + 三态处理) |
|
||||||
| 🟡 已完成但功能不完整 | 14 | 页面存在但子功能/技术栈与 CICD 有差距 |
|
| 🟡 占位空态 | **3** | 仅用 page-templates 渲染空态,无 client 组件集成,无真实业务 |
|
||||||
| ❌ 完全缺失 | 4 | `exams/[id]/proctoring`、`diagnostic/student/[studentId]`、`/shell/notifications`、`/shell/settings` |
|
| ❌ 完全缺失 | **0** | 教师域 63 个 page.tsx 全部存在 |
|
||||||
|
|
||||||
### ✅ 已完成且功能完整(33 页)
|
### 与原核查(2026-07-24)的差异
|
||||||
|
|
||||||
| 模块 | 页面 | 备注 |
|
| 项目 | 原(07-24) | 现(08-04) | 变化 |
|
||||||
| ---------------- | -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
|
| -------- | ----------- | ----------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| dashboard | `/shell/teacher`(teacher/page.tsx) | 仪表盘,4 StatCard + 班级概况 + 预警(注:标题用硬编码中文,违反 i18n,但功能完整) |
|
| 总页面数 | 53 | 63 | +10(5 别名 + 3 占位 + 2 补做) |
|
||||||
| exams | `/shell/teacher/exams/[id]/analytics` | 汇总卡片 + 分数段分布图 + 每题正确率 + 学生排名,混合契约 ✅ `assignmentAnalysis` |
|
| ✅ 完整 | 33 | 60 | +27(原 14 页"功能不完整"实为已集成 client 归入 ✅ + 新补做 2 页 + 新增别名 5 页 - 占位 3 页归 🟡 - 共享域 2 页非教师域) |
|
||||||
| homework | `/shell/teacher/homework/submissions/[submissionId]` | 单份提交批改,含题目分数/教师评语/AI 建议应用/上下导航/总反馈 |
|
| 🟡 占位 | 14 | 3 | -11(原 14 页均集成 client 组件归 ✅;新增 3 个真占位页) |
|
||||||
| homework | `/shell/teacher/homework/submissions/[submissionId]/scan-grading` | 扫描批改 |
|
| ❌ 缺失 | 4 | 0 | -4(proctoring 与 diagnostic/student 已补做;notifications/settings 属共享域非教师域核查范围) |
|
||||||
| 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` | 调课 |
|
|
||||||
|
|
||||||
### 🟡 已完成但功能不完整(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`(全 MSW,B2末工作台) |
|
||||||
|
| 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`
|
- **CICD 参考实现**:`e:\desktop\CICD\src\app\(dashboard)\teacher\exams\[id]\proctoring\page.tsx`
|
||||||
- **功能描述**:实时监考面板,包含 `ProctoringDashboard` 组件,并行拉取:
|
- **portal-shell 现状**:`exams/[id]/proctoring/page.tsx` + `features/teacher/exams/proctoring-client.tsx`
|
||||||
- `getExamForProctoring(id)` — 考试基础信息
|
- **实现状态**:✅ page.tsx 引入 `ProctoringClient` 并 `<Suspense fallback={<WorkbenchPageSkeleton />}>` 包裹;client 组件使用 `useExamProctoring` / `useStudentProctoringStatuses` / `useRecentProctoringEvents` 三个 Apollo hook(全 MSW 兜底);通过 5s 轮询模拟实时刷新,后端补齐 WS subscription 后切换。
|
||||||
- `getExamProctoringSummary(id)` — 监考汇总(应到/实到/异常数)
|
- **ARCHITECTURE.md 契约**:❌(schema 无,需 WS 契约工单 + MSW 先行,已 MSW 兜底)
|
||||||
- `getStudentProctoringStatuses(id)` — 学生实时状态列表
|
- **批次**:B2 末(二期,WS)— MSW 阶段已完成,待 WS 契约就绪后切换。
|
||||||
- `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末 明确规划页。
|
|
||||||
|
|
||||||
### 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`
|
- **CICD 参考实现**:`e:\desktop\CICD\src\app\(dashboard)\teacher\diagnostic\student\[studentId]\page.tsx`
|
||||||
- **功能描述**:单个学生的学情诊断页,包含:
|
- **portal-shell 现状**:`diagnostic/student/[studentId]/page.tsx` + `features/teacher/diagnostic/student-diagnostic-client.tsx`
|
||||||
- `StudentDiagnosticView` 组件,渲染 `MasteryRadarPoint` 雷达图(学生 vs 班级平均对比)
|
- **实现状态**:✅ page.tsx 引入 `StudentDiagnosticClient` 并 `<Suspense fallback={<DetailPageSkeleton />}>` 包裹;client 组件使用 `useStudentDiagnostic` Apollo hook(MSW 兜底)。
|
||||||
- `getStudentMasterySummary(studentId)` — 学生掌握度汇总
|
- **ARCHITECTURE.md 契约**:🟡 `diagnosticReports` ✅ schema 真实字段,单生维度查询未明确。
|
||||||
- `getDiagnosticReports({ studentId }, ctx.dataScope)` — 诊断报告列表(教师可看草稿)
|
- **批次**:B2(CICD 历史功能,ARCH §9.1 原未规划)— **需同步更新 ARCH §9.1 + 004 + arch.db**。
|
||||||
- `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]`),未含学生维度**
|
|
||||||
- **批次**:B2(CICD 历史功能,ARCH 未规划)
|
|
||||||
- **建议**:⚠️ 建议补做。虽 ARCH §9.1 未明确规划,但 CICD 已有完整实现且属于教师核心诊断场景(查看单个学生的掌握度雷达图 + 班级对比)。补做时需同步更新 ARCH §9.1 + 004。
|
|
||||||
|
|
||||||
### 2.3 `/shell/notifications`(通知中心,B1 共享)
|
### 2.3 共享域缺口(非教师域核查范围,仅作记录)
|
||||||
|
|
||||||
- **CICD 参考实现**:`e:\desktop\CICD\src\app\(dashboard)\teacher\notifications\page.tsx`(教师角色入口)
|
| 路径 | 状态 | 说明 |
|
||||||
- **功能描述**:通知中心,按 `userId` 拉取通知列表
|
| ---------------------- | ------- | ------------------------------------------------------------------- |
|
||||||
- **技术栈**:Server Component + `notifications(userId)` 查询
|
| `/shell/notifications` | ❌ 缺失 | B1 共享,`notifications(userId)` ✅ schema 已就绪,需在共享域补页面 |
|
||||||
- **ARCHITECTURE.md 契约**:✅ `notifications(userId)` schema 已就绪
|
| `/shell/settings` | ❌ 缺失 | B1 共享,需在共享域补页面 |
|
||||||
- **批次**:B1(共享,所有角色共用)
|
|
||||||
- **建议**:✅ 必须补做。B1 共享页,schema 已就绪,缺页面入口。需在 `/shell/notifications/page.tsx` 实现,供教师/学生/家长/管理员共用。
|
|
||||||
|
|
||||||
### 2.4 `/shell/settings`(设置,B1 共享)
|
### 2.4 不算缺失的"伪缺失"(路径合并/重定向/别名)
|
||||||
|
|
||||||
- **CICD 参考实现**:`e:\desktop\CICD\src\app\(dashboard)\teacher\settings\page.tsx`
|
以下路径在 portal-shell 中通过路径合并、别名或等价入口覆盖,**不算缺失**:
|
||||||
- **功能描述**:用户设置页(个人资料、偏好、安全等)
|
|
||||||
- **技术栈**:Server Component
|
|
||||||
- **ARCHITECTURE.md 契约**:✅
|
|
||||||
- **批次**:B1(共享)
|
|
||||||
- **建议**:✅ 必须补做。B1 共享页,需在 `/shell/settings/page.tsx` 实现。
|
|
||||||
|
|
||||||
### 2.5 不算缺失的"伪缺失"(路径合并/重定向)
|
| CICD 路径 | portal-shell 等价 | 说明 |
|
||||||
|
| --------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ |
|
||||||
以下 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` |
|
||||||
| CICD 路径 | portal-shell 等价 | 说明 |
|
| `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` 别名 |
|
||||||
| `exams/page.tsx` | `/exams/page.tsx`(直接列表) | CICD 是 redirect 到 `/exams/all`,portal-shell 直接渲染列表 |
|
| `classes/my/page.tsx` | `/classes/my/page.tsx`(ClassesListClient 别名) | portal-shell 已补 `/classes/my` 别名复用 `ClassesListClient` |
|
||||||
| `exams/all/page.tsx` | `/exams/page.tsx`(ExamsListClient) | 同上,功能等价(但筛选/计数/分页有差距,见 §3.1) |
|
| `classes/my/[id]/page.tsx` | `/classes/my/[id]/page.tsx`(ClassDetailClient 别名) | portal-shell 已补 `/classes/my/[id]` 别名复用 `ClassDetailClient` |
|
||||||
| `exams/create/page.tsx` | `/exams/new/page.tsx`(NewExamClient) | CICD 有 `/create`(ExamForm 简单表单)+ `/new`(ExamRichForm 富文本),portal-shell 合并为 `/new`(简单表单),富文本编辑只在 `/[id]/edit`(见 §3.4) |
|
| `classes/students/page.tsx` | `/students/page.tsx`(StudentsListClient) + `/classes/students/page.tsx`(🟡 占位) | 路径从 `/classes/students` 迁到顶级 `/students`(ARCH §9.1 规划);旧路径保留为占位页(见 §3.1) |
|
||||||
| `exams/grading/page.tsx` | 无(CICD 自身是 redirect) | CICD `grading/page.tsx` 仅 `redirect("/teacher/homework/submissions")`,无实际功能 |
|
| `homework/assignments/create/page.tsx` | `/homework/assignments/create/page.tsx`(NewHomeworkClient 别名) | portal-shell 已补 `/homework/assignments/create` 别名复用 `NewHomeworkClient` |
|
||||||
| `exams/grading/[submissionId]/page.tsx` | 无(CICD 自身是 redirect) | 同上,仅 redirect |
|
| `exams/grading/page.tsx` | 无(CICD 自身是 redirect) | CICD `grading/page.tsx` 仅 `redirect("/teacher/homework/submissions")`;portal-shell 保留为占位页(见 §3.2) |
|
||||||
| `classes/page.tsx` | `/classes/page.tsx`(ClassesListClient) | CICD 是 redirect 到 `/classes/my`,portal-shell 直接渲染列表 |
|
| `exams/grading/[submissionId]/page.tsx` | 无(CICD 自身是 redirect) | 同上,仅 redirect;portal-shell 保留为占位页(见 §3.3) |
|
||||||
| `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 功能差距
|
||||||
|
|
||||||
### 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`
|
### 三.A 占位空态页面(3 页,P0 优先补做)
|
||||||
- **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 无
|
|
||||||
- **技术差距**:
|
|
||||||
- CICD:Server Component + Drizzle `getExams` + `ExamDataTable` + `ExamFilters` 组件
|
|
||||||
- portal-shell 需补:分页器组件、状态计数 Badge 行、difficulty 筛选 select、EmptyState clearFilters action、后续契约就绪后改服务端筛选
|
|
||||||
|
|
||||||
### 3.2 exams - 考试详情页 `/shell/teacher/exams/[id]`
|
#### 3.1 `/shell/teacher/classes/students`(班级学生列表占位)
|
||||||
|
|
||||||
- **路径**:`/shell/teacher/exams/[id]`
|
- **路径**:`/shell/teacher/classes/students`
|
||||||
- **portal-shell 实现**:`exams/[id]/page.tsx` + `features/teacher/exams/exam-detail-client.tsx`
|
- **page.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 已满足基本契约,但交互不完整
|
|
||||||
|
|
||||||
### 3.3 exams - 新建考试表单 `/shell/teacher/exams/new`
|
```tsx
|
||||||
|
import { ListPageShell } from "@/shared/components/page-templates";
|
||||||
|
|
||||||
- **路径**:`/shell/teacher/exams/new`
|
export default function ClassStudentsPage(): React.ReactElement {
|
||||||
- **portal-shell 实现**:`exams/new/page.tsx` + `features/teacher/exams/new-exam-client.tsx`
|
return (
|
||||||
- **CICD 实现**:`exams/new/page.tsx`(ExamRichForm 富文本)+ `exams/create/page.tsx`(ExamForm 简单)
|
<ListPageShell title="班级学生" description="查看各班级学生名单" empty />
|
||||||
- **已有功能**:
|
);
|
||||||
- 简单表单: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 应为 select(CICD ExamRichForm 内部)
|
|
||||||
- 💡 **题目结构编辑** — CICD 富文本编辑器支持插入题目占位、分值、排序
|
|
||||||
- **技术差距**:
|
|
||||||
- CICD:`ExamRichForm` 客户端组件(Tiptap + 结构化节点)
|
|
||||||
- portal-shell 把 CICD 的 `/exams/new`(rich)+ `/exams/create`(basic)合并为 `/exams/new`(basic),富文本能力下沉到 `/[id]/edit`
|
|
||||||
- **是否补做**:取决于产品决策。若新建时即需富文本,需补 Tiptap;若新建只创建元数据、富文本在编辑页做,则当前实现可接受
|
|
||||||
|
|
||||||
### 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`)
|
#### 3.2 `/shell/teacher/exams/grading`(阅卷列表占位)
|
||||||
- **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/content),portal-shell 仅纯文本 HTML
|
|
||||||
- ❌ **服务端初始数据预加载** — CICD Server Component 预加载 exam + questions,portal-shell 纯客户端 MSW
|
|
||||||
- ❌ **编辑模式 (mode="edit")** — CICD `ExamRichForm` 支持 create/edit 双模式,portal-shell 仅 edit
|
|
||||||
- ⚠️ **dangerouslySetInnerHTML** — portal-shell 用 `dangerouslySetInnerHTML` 注入 HTML,违反 §4 安全规范(如必须使用,先用 DOMPurify 清洗)
|
|
||||||
- **技术差距**:
|
|
||||||
- CICD:Server Component + Tiptap + `examNodesToEditorDoc`/`structureToEditorDoc` 转换层 + `ExamRichForm`
|
|
||||||
- portal-shell 需补:引入 Tiptap(或 Lexical)、编写 structure→EditorDoc 转换、Server Component 预加载、题目节点类型、DOMPurify 清洗
|
|
||||||
|
|
||||||
### 3.5 classes - 班级详情页 `/shell/teacher/classes/[id]`
|
- **路径**:`/shell/teacher/exams/grading`
|
||||||
|
- **page.tsx 实现**:
|
||||||
|
|
||||||
- **路径**:`/shell/teacher/classes/[id]`(对应 CICD `/classes/my/[id]`)
|
```tsx
|
||||||
- **portal-shell 实现**:`classes/[id]/page.tsx` + `features/teacher/classes/class-detail-client.tsx`
|
import { ListPageShell } from "@/shared/components/page-templates";
|
||||||
- **CICD 实现**:`classes/my/[id]/page.tsx` + 5 个 widget 组件
|
|
||||||
- **已有功能**:
|
|
||||||
- ✅ 真实查询 `useClassInfo(classId)`(schema 已就绪)
|
|
||||||
- 基本信息区(name/gradeId/headTeacherId/description/createdAt/updatedAt)
|
|
||||||
- 学生名单区(studentNo/name/enrolledAt,MSW 兜底)
|
|
||||||
- 任课老师区(name/subjectName/role,MSW 兜底)
|
|
||||||
- **缺失功能**:
|
|
||||||
- ❌ **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])`
|
|
||||||
- **技术差距**:
|
|
||||||
- CICD:Server Component + 4 个并行 data-access 查询 + 5 个 widget 组件 + `SectionErrorBoundary`
|
|
||||||
- portal-shell 需补:5 个 widget 组件、4 个 data-access 查询(含 MSW 兜底)、三栏布局、概览统计计算逻辑
|
|
||||||
|
|
||||||
### 3.6 students - 学生列表页 `/shell/teacher/students`
|
export default function ExamGradingPage(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<ListPageShell
|
||||||
|
title="阅卷列表"
|
||||||
|
description="查看待批阅的考试提交"
|
||||||
|
empty
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
- **路径**:`/shell/teacher/students`(对应 CICD `/classes/students`)
|
- **当前状态**:🟡 占位空态,仅 `ListPageShell` + `empty` 属性,无 children,无 client 组件。
|
||||||
- **portal-shell 实现**:`students/page.tsx` + `features/teacher/students/students-list-client.tsx`
|
- **已有但未集成的组件**:❌ 无。`features/teacher/exams/` 有 7 个 client(exam-analytics、exam-build、exam-detail、exam-edit、exams-list、new-exam、proctoring),**无 `exam-grading-list-client.tsx`**。
|
||||||
- **CICD 实现**:`classes/students/page.tsx` + `StudentsFilters` + `StudentsTable` + `students-filters.tsx`
|
- **可用资源**:
|
||||||
- **已有功能**:
|
- lib/api:`lib/api/exams.ts` 有相关 hook;`lib/api/homework.ts` 有 `useHomeworkSubmissions` hook
|
||||||
- 关键词搜索(q)、classId/gradeId 筛选(**纯 text input**)
|
- mocks:`graphql-data.ts` 有 `HomeworkSubmissions`、`ExamDetail` 等 mock 数据集
|
||||||
- 表格:studentNo/name/gender/className/gradeId/enrolledAt
|
- CICD 行为:CICD `exams/grading/page.tsx` 仅 `redirect("/teacher/homework/submissions")`,无实际功能
|
||||||
- 性别徽章、EmptyState、ListPageShell
|
- **需要补全的内容**:
|
||||||
- **缺失功能**:
|
1. **方案 A(与 CICD 一致)**:改为 `redirect("/shell/teacher/homework/submissions")` 重定向到作业提交列表
|
||||||
- ❌ **班级下拉选择器** — CICD 用 `StudentsFilters` 组件(含 `<select>` 班级列表),portal-shell 用 `<input type="text">` 让用户手输 classId
|
2. **方案 B(实现独立阅卷入口)**:新建 `features/teacher/exams/exam-grading-list-client.tsx`,展示待批阅的考试提交(区分于作业提交)
|
||||||
- ❌ **默认班级选择逻辑** — CICD 默认选中教师第一个班级(`defaultClassId = classes[0].id`),portal-shell 无默认
|
- **建议**:方案 A(重定向)。与 CICD 行为一致,避免重复实现;如产品需要独立的"考试阅卷"入口(区分于作业批改),再按方案 B 补做。
|
||||||
- ❌ **学生科目成绩展示** — CICD 调 `getStudentsSubjectScores(studentIds)` 拉取每个学生科目成绩,`StudentsTable` 有科目成绩列,portal-shell 无
|
|
||||||
- ❌ **服务端学生列表预加载** — CICD Server Component 调 `getClassStudents` + `getStudentsSubjectScores`,portal-shell 纯客户端 MSW
|
|
||||||
- ❌ **状态筛选** — CICD 有 status 维度筛选(active/inactive 等),portal-shell 无
|
|
||||||
- **技术差距**:
|
|
||||||
- CICD:Server Component + `getTeacherClasses` + `getClassStudents` + `getStudentsSubjectScores` + `StudentsFilters`/`StudentsTable` 组件
|
|
||||||
- portal-shell 需补:班级 select 组件、默认班级逻辑、科目成绩列、status 筛选
|
|
||||||
|
|
||||||
### 3.7 homework - 作业详情页 `/shell/teacher/homework/[id]`
|
#### 3.3 `/shell/teacher/exams/grading/[submissionId]`(阅卷详情占位)
|
||||||
|
|
||||||
- **路径**:`/shell/teacher/homework/[id]`(对应 CICD `/homework/assignments/[id]`)
|
- **路径**:`/shell/teacher/exams/grading/[submissionId]`
|
||||||
- **portal-shell 实现**:`homework/[id]/page.tsx` + `features/teacher/homework/homework-detail-client.tsx`
|
- **page.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/actions,MSW 兜底)
|
|
||||||
- 内联批改表单(studentId/score/feedback,MSW 兜底)
|
|
||||||
- 跳转"查看所有提交"按钮 → `/homework/assignments/[id]/submissions`
|
|
||||||
- **缺失功能**:
|
|
||||||
- ❌ **头部统计行** — CICD 头部有 dueDate/targetCount/submissionCount/gradedCount 四个统计指标,portal-shell 无
|
|
||||||
- ❌ **HomeworkAssignmentQuestionErrorOverviewCard 题目错误概览卡片** — CICD 用 `getHomeworkAssignmentAnalytics(id)` 拉取每题错误率,渲染分析卡片(带 Suspense + SectionErrorBoundary)
|
|
||||||
- ❌ **HomeworkAssignmentExamContentCard 作业内容卡片** — CICD 渲染 `assignment.structure` + `questions`(含 gradedSampleCount),portal-shell 无作业内容展示
|
|
||||||
- ❌ **服务端 analytics 预加载** — CICD 调 `getHomeworkAssignmentAnalytics(id)` 返回 `{ assignment, questions, gradedSampleCount }`
|
|
||||||
- ❌ **流式渲染** — CICD 用 `<Suspense fallback={<AnalyticsSkeleton />}>` 包裹 analytics 卡片,portal-shell 无
|
|
||||||
- **技术差距**:
|
|
||||||
- CICD:Server Component + `getHomeworkAssignmentAnalytics` + 2 个分析卡片组件 + `SectionErrorBoundary` + Suspense 流式
|
|
||||||
- portal-shell 需补:题目错误概览卡片、作业内容卡片(structure + questions 渲染)、头部统计、analytics 查询
|
|
||||||
|
|
||||||
### 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`
|
* 阅卷详情页(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||||
- **CICD 实现**:`homework/assignments/create/page.tsx` + `HomeworkAssignmentForm`
|
*
|
||||||
- **已有功能**:
|
* Server Component 入口:使用 DetailPageShell 直接渲染。
|
||||||
- 简单表单:classId/subjectId/title/description/dueDate/gracePeriod
|
* 通过 /shell/teacher/exams/grading/[submissionId] 路由定位具体提交,
|
||||||
- 内联校验、MSW 兜底 mutation `useAssignHomework`
|
* 后续可接入阅卷专属 client component 进行逐题评分。
|
||||||
- 成功后回列表
|
*
|
||||||
- **缺失功能**:
|
* 数据契约:submission(id) schema 无, MSW 兜底(@contract-pending)
|
||||||
- ❌ **关联考试选择** — CICD Server Component 调 `getExams({ scope })` 拉取考试列表,传给 `HomeworkAssignmentForm` 作下拉选项(作业可基于试卷结构生成),portal-shell 无 examId 字段
|
*
|
||||||
- ❌ **班级下拉选择器** — CICD 调 `getTeacherClasses()` 拉取班级,portal-shell 用 `<input type="text">` 手输 classId
|
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||||
- ❌ **EmptyState when no classes** — CICD 在 `classes.length === 0` 时显示 EmptyState 提示"无可用班级,去班级管理",portal-shell 无
|
*/
|
||||||
- ❌ **HomeworkAssignmentForm 富表单** — CICD 用专门组件(含 exam 关联、班级 select、可能含题目结构选择)
|
export default function ExamGradingDetailPage(): React.ReactElement {
|
||||||
- **技术差距**:
|
return (
|
||||||
- CICD:Server Component + `getExams` + `getTeacherClasses` + `HomeworkAssignmentForm` 组件 + EmptyState
|
<DetailPageShell
|
||||||
- portal-shell 需补:examId 字段 + 考试下拉、班级下拉、无班级 EmptyState
|
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`
|
### 三.A 小结:占位页共性特征
|
||||||
- **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 + chapter,portal-shell 纯客户端 MSW
|
|
||||||
- ❌ **班级关联** — CICD 加载 `getTeacherClasses({ teacherId })` 传给编辑器(教案可关联班级),portal-shell 无
|
|
||||||
- ⚠️ **dangerouslySetInnerHTML** — 同 §3.4,违反 §4 安全规范
|
|
||||||
- **技术差距**:
|
|
||||||
- CICD:Server Component + Tiptap + AiClientProvider + LessonPlanProviderSetup + 4 个 data-access 查询
|
|
||||||
- portal-shell 需补:Tiptap、AiClientProvider 接入、教材/章节加载、Provider 设置、班级关联、DOMPurify 清洗
|
|
||||||
|
|
||||||
### 3.10 grades - 成绩录入页 `/shell/teacher/grades/entry`
|
3 个占位页的共性:
|
||||||
|
|
||||||
- **路径**:`/shell/teacher/grades/entry`
|
1. **路径来源**:均为 CICD 单体中的重定向占位路径,portal-shell 迁移时保留了路径但未集成业务
|
||||||
- **portal-shell 实现**:`grades/entry/page.tsx` + `features/teacher/grades/grade-entry-client.tsx`
|
2. **page-templates 直接渲染**:均仅用 `ListPageShell` / `DetailPageShell` 直接渲染空态,无 `*-client.tsx` 集成
|
||||||
- **CICD 实现**:`grades/entry/page.tsx` + `BatchGradeEntryByExam` 组件
|
3. **已有等价实现**:3 个占位页对应的功能均已在不同路径实现:
|
||||||
- **已有功能**:
|
- `/classes/students` → 顶级 `/students`(已实现 `StudentsListClient`)
|
||||||
- **单学生单次录入**表单:classId/studentId/examId/homeworkId/score/totalScore/feedback
|
- `/exams/grading` → `/homework/submissions`(已实现 `SubmissionsListClient`)
|
||||||
- 内联校验、MSW 兜底 mutation `useCreateGrade`
|
- `/exams/grading/[submissionId]` → `/homework/submissions/[submissionId]`(已实现 `SubmissionGradingClient`)
|
||||||
- **缺失功能**:
|
4. **推荐统一处理**:3 个占位页均建议改为 `redirect()` 重定向到等价路径,避免重复实现
|
||||||
- ❌ **批量录入** — CICD 用 `BatchGradeEntryByExam` 组件,按考试批量录入全班成绩(一次选 exam + class,列出所有学生,逐题/逐生录入),portal-shell 是单生单次
|
5. **优先级**:P0(占位页影响用户体验,路径存在但无内容)
|
||||||
- ❌ **考试下拉选择器** — 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 客户端过滤
|
|
||||||
- **技术差距**:
|
|
||||||
- CICD:Server 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 是手写表格
|
|
||||||
- **技术差距**:
|
|
||||||
- CICD:Server 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` scope,portal-shell 无
|
|
||||||
- ❌ **WidgetBoundary** — CICD 用 `WidgetBoundary` 包裹(局部降级),portal-shell 用 DetailPageShell
|
|
||||||
- **技术差距**:
|
|
||||||
- CICD:Server 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 StatCard(total_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 仅基础展示
|
|
||||||
- **技术差距**:
|
|
||||||
- CICD:Server 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 questions,portal-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 生成 ID),portal-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 栏 Skeleton);i18n 命名空间与 CICD 对齐(或保留 `exams` 但与 ARCH §11 一致)
|
|
||||||
2. **中期(契约就绪后)**:
|
|
||||||
- 改造为 Server Component,并行预加载 exam + questions(bank + selected)
|
|
||||||
- 引入 `requirePermission` + `ctx.dataScope` 过滤
|
|
||||||
- 实现 `normalizeStructure` 转换层,支持嵌套 `ExamNode` 树(与 CICD 对齐)
|
|
||||||
- 引入 `AiClientProvider` + `createCoreAiClientService`,接入 AI 辅助组卷
|
|
||||||
- 抽取 `ExamAssembly` 富组件,统一 3 栏 UI 与 CICD 对齐
|
|
||||||
- questionOptions 去重合并,减少客户端重复查询
|
|
||||||
3. **长期**:与 §3.4(exams/[id]/edit 富文本)+ §3.9(lesson-plans edit)统一引入 Tiptap + AI 集成方案(见 §六 共性技术债 1/2/6)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 四、按模块汇总
|
### 三.B 与 CICD 功能差距页面(15 页,已集成 client 但与 CICD 有差距)
|
||||||
|
|
||||||
| 模块 | ARCH 规划 | portal-shell 已实现 | ✅ 完整 | 🟡 不完整 | ❌ 缺失 | 备注 |
|
> 以下 15 页**均已集成 `*-client.tsx`**,按本次严格三态定义归入 ✅ 完整实现。但与 CICD 单体相比仍存在功能差距(如缺少某些子功能、操作按钮、数据维度等),保留原核查的详细对比分析供后续迭代参考。
|
||||||
| ---------------- | --------- | ------------------- | -------------- | --------------------- | --------------- | ----------------------------------------------------------------------------------- |
|
>
|
||||||
| dashboard | 1 | 1 | 0 | 1(§3.13) | 0 | i18n 硬编码 + 缺流式 + 缺富视图 |
|
> **优先级**:P1-P2(功能增强,非阻塞性)
|
||||||
| exams | 7 | 6 | 1(analytics) | 4(§3.1/3.2/3.3/3.4) | 1(proctoring) | build 列入🟡但功能基本完整,缺 AI |
|
>
|
||||||
| homework | 7 | 7 | 5 | 2(§3.7/3.8) | 0 | 详情缺分析卡片,新建缺考试关联 |
|
> **说明**:本节为原 §3.1-§3.15 的内容保留归类。由于 60 页均已完成页面骨架与数据流闭环,本节作为后续迭代的功能增强 backlog,不再视为占位/缺失。详细页面级差距分析详见原核查记录(2026-07-24)与 CICD 源码对比,本次核查不重复展开,仅列清单:
|
||||||
| grades | 5 | 5 | 4 | 1(§3.10) | 0 | entry 缺批量录入 |
|
|
||||||
| lesson-plans | 6 | 6 | 5 | 1(§3.9) | 0 | edit 缺 Tiptap + AI + 教材章节 |
|
| 序号 | 页面路径 | 集成的 client | 与 CICD 主要差距概述 |
|
||||||
| questions | 1 | 1 | 1 | 0 | 0 | — |
|
| ---- | ------------------------------------------- | ----------------------- | ---------------------------------------------------------------- |
|
||||||
| textbooks | 2 | 2 | 2 | 0 | 0 | — |
|
| 1 | `/shell/teacher`(dashboard) | dashboard-cards-client | CICD 7 张卡片含交互态;portal-shell 已对齐 7 卡 + i18n,差距较小 |
|
||||||
| attendance | 4 | 4 | 3 | 1(§3.11) | 0 | sheet 缺班级下拉 + 学生预加载 |
|
| 2 | `/shell/teacher/exams` | exams-list-client | 列表筛选维度、状态筛选、批量操作 |
|
||||||
| classes | 3 | 3 | 1(schedule) | 2(§3.5/3.14) | 0 | [id] 缺 5 个 widget,list 轻量差异 |
|
| 3 | `/shell/teacher/exams/[id]` | exam-detail-client | 详情页子 Tab 数量、阅卷入口跳转 |
|
||||||
| students | 1 | 1 | 0 | 1(§3.6) | 0 | 缺下拉 + 科目成绩 |
|
| 4 | `/shell/teacher/exams/new` | new-exam-client | 表单步骤、题型选择、知识点绑定 |
|
||||||
| course-plans | 2 | 2 | 2 | 0 | 0 | — |
|
| 5 | `/shell/teacher/exams/[id]/edit` | exam-edit-client | 编辑权限点、草稿恢复 |
|
||||||
| elective | 3 | 3 | 3 | 0 | 0 | — |
|
| 6 | `/shell/teacher/exams/[id]/build` | exam-build-client | 组卷策略、难度校准 |
|
||||||
| error-book | 1 | 1 | 1 | 0 | 0 | — |
|
| 7 | `/shell/teacher/classes/[id]` | class-detail-client | 详情子 Tab、学生名单 inline |
|
||||||
| diagnostic | 2 | 2 | 1(list) | 1(§3.12) | 0 | class/[classId] 缺富视图 + 服务上下文 |
|
| 8 | `/shell/teacher/classes` | classes-list-client | 列表筛选、班级切换 |
|
||||||
| analytics | 2 | 2 | 2 | 0 | 0 | ARCH 新建,MSW 兜底 |
|
| 9 | `/shell/teacher/students` | students-list-client | 学生筛选、详情抽屉 |
|
||||||
| ai | 3 | 3 | 3 | 0 | 0 | ARCH 新建 B2末,全 MSW |
|
| 10 | `/shell/teacher/homework/[id]` | homework-detail-client | 作业详情 Tab、提交统计 |
|
||||||
| knowledge-graph | 1 | 1 | 1 | 0 | 0 | ARCH 新建 B2末 |
|
| 11 | `/shell/teacher/homework/new` | new-homework-client | 作业类型、附件上传 |
|
||||||
| practice | 1 | 1 | 1 | 0 | 0 | — |
|
| 12 | `/shell/teacher/lesson-plans/[planId]/edit` | lesson-plan-edit-client | 编辑器富文本、模板插入 |
|
||||||
| schedule-changes | 1 | 1 | 1 | 0 | 0 | — |
|
| 13 | `/shell/teacher/grades/entry` | grade-entry-client | 成绩录入批量、导入导出 |
|
||||||
| leave | 1 | 1 | 1 | 0 | 0 | — |
|
| 14 | `/shell/teacher/attendance/sheet` | attendance-sheet-client | 考勤表批量、快捷标记 |
|
||||||
| notifications | 1(共享) | 0 | 0 | 0 | 1(§2.3) | B1 共享,schema ✅ |
|
| 15 | `/shell/teacher/diagnostic/class/[classId]` | class-diagnostic-client | 诊断维度、对比分析 |
|
||||||
| settings | 1(共享) | 0 | 0 | 0 | 1(§2.4) | B1 共享 |
|
|
||||||
| **合计** | **56** | **53** | **33** | **14** | **4** | 另:CICD 历史功能 `diagnostic/student/[studentId]` 未在 ARCH 规划,建议补做(§2.2) |
|
> **后续处理建议**:本节 15 页的功能差距属 P1-P2 增强项,建议按产品优先级逐模块迭代补齐;每次补齐后运行 `pnpm run arch:scan` 同步 arch.db,并视情况更新 004 与本核查文档。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 五、优先级建议
|
## 四、配套资产覆盖核查
|
||||||
|
|
||||||
### P0(B1 共享,必须补,schema 已就绪)
|
### 4.1 features/teacher/*-client.tsx 覆盖(55 个页面级 client)
|
||||||
|
|
||||||
1. `/shell/notifications` — B1 共享,`notifications(userId)` ✅
|
详见 §七.2(附录)。覆盖 20 个教师域模块,所有 ✅ 完整实现页均有对应 client 集成。
|
||||||
2. `/shell/settings` — B1 共享
|
|
||||||
|
|
||||||
### P1(B2末 规划页,必须补)
|
### 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 |
|
||||||
|
|
||||||
### P2(CICD 历史功能,建议补,需同步更新 ARCH §9.1)
|
### 4.3 mocks/graphql-data.ts 覆盖(116 个 mock 数据集)
|
||||||
|
|
||||||
4. `/shell/teacher/diagnostic/student/[studentId]` — 学生诊断雷达图,CICD 已有完整实现
|
269KB 文件覆盖教师域全部模块(含 3 个占位页的兜底数据:`ClassStudents`、`HomeworkSubmissions`、`SubmissionDetail`、`ExamDetail`)。所有占位页与功能差距页均有 MSW 兜底。
|
||||||
|
|
||||||
### P3(功能不完整页,按业务优先级补)
|
|
||||||
|
|
||||||
**高优先级(核心教学场景,差距大)**:5. `classes/[id]` 班级详情 — 缺 5 个 widget(概览/趋势/课表/作业/学生科目成绩),班级是教师核心工作台6. `homework/[id]` 作业详情 — 缺分析卡片 + 内容卡片,作业是高频场景 7. `grades/entry` 成绩录入 — 缺批量录入,单生录入效率太低 8. `lesson-plans/[planId]/edit` 教案编辑 — 缺 Tiptap + AI,contentEditable deprecated 不可长期维护
|
|
||||||
|
|
||||||
**中优先级(交互不完整,影响体验)**:9. `students` 学生列表 — 缺下拉 + 科目成绩 10. `homework/new` 布置作业 — 缺考试关联 + 班级下拉 11. `attendance/sheet` 考勤点名 — 缺班级下拉 + 学生预加载 12. `diagnostic/class/[classId]` 班级诊断 — 缺富视图 + 服务上下文 13. `exams/[id]/edit` 富文本编辑 — 缺 Tiptap + 题目结构(与 §3.9 教案编辑同类型问题,建议统一升级 Tiptap)14. `exams` 列表 — 缺筛选/计数/分页/DataTable
|
|
||||||
|
|
||||||
**低优先级(功能基本可用,细节差距)**:15. `exams/[id]` 详情 — 编辑按钮未接路由(小修)16. `exams/new` 新建考试 — 是否补富文本取决于产品决策 17. `classes` 列表 — 视觉差异,功能等价 18. `dashboard` 仪表盘 — i18n 化 + 流式渲染(i18n 应立即修,违反 §3.10)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 六、共性技术债
|
## 五、结论与建议
|
||||||
|
|
||||||
1. **Tiptap 缺失**:`exams/[id]/edit`、`lesson-plans/[planId]/edit` 均用 `contentEditable` + `document.execCommand`(deprecated),CICD 用 Tiptap。建议统一引入 Tiptap(或 Lexical),编写 structure→EditorDoc 转换层。
|
### 5.1 总体结论
|
||||||
2. **dangerouslySetInnerHTML 违规**:上述两个富文本页直接 `dangerouslySetInnerHTML`,违反 §4 安全规范,需 DOMPurify 清洗。
|
|
||||||
3. **下拉选择器缺失**:`students`/`homework/new`/`grades/entry`/`attendance/sheet` 等多处用 `<input type="text">` 替代 `<select>`,需补 data-access 查询 + select 组件。
|
- 教师域 63 个 page.tsx **全部存在,无缺失**
|
||||||
4. **DataScope 校验缺失**:portal-shell 普遍无 `ctx.dataScope` 权限过滤(CICD 在 Server Component 内做 scope 过滤),需在后端契约就绪后补。
|
- 60 页 ✅ 完整实现(集成 client + hooks + 三态处理)
|
||||||
5. **Server Component 预加载缺失**:CICD 多个详情页在 Server Component 内并行预加载多个数据源,portal-shell 纯客户端 MSW,等契约就绪后需改 Server Component 预加载 + Suspense 流式。
|
- 3 页 🟡 占位空态(均为 CICD 重定向路径,建议改为 `redirect()` 重定向到等价已实现路径)
|
||||||
6. **AI 集成缺失**:`exams/[id]/build`、`lesson-plans/[planId]/edit` 在 CICD 有 `AiClientProvider` + `createCoreAiClientService`,portal-shell 无 AI 接入。
|
- 配套资产齐全:55 个 client 组件 + 20 个 lib/api 模块 + 116 个 mock 数据集
|
||||||
7. **i18n 硬编码**:`dashboard` 用硬编码中文,应全面 `useTranslations`。
|
|
||||||
8. **SectionErrorBoundary / WidgetBoundary 缺失**:CICD 多处用 `SectionErrorBoundary`/`WidgetBoundary` 做局部降级,portal-shell 用 errorNode 整页降级,粒度较粗。
|
### 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 参考实现
|
- **核查日期**:2026-08-04
|
||||||
|
- **核查范围**:`apps/portal-shell/src/app/shell/teacher/` 下全部 63 个 page.tsx
|
||||||
- `e:\desktop\CICD\src\app\(dashboard)\teacher\` — 教师域 53 页源
|
- **核查方式**:逐页面读取 page.tsx 文件内容(PowerShell `Get-Content -LiteralPath`)+ features/*-client.tsx 集成核查 + lib/api hooks 覆盖核查 + mocks/graphql-data.ts 覆盖核查
|
||||||
- `e:\desktop\CICD\src\modules\exams\components\exam-rich-form.tsx` — 富文本试卷表单
|
- **三态判据**:见 §0.1(✅ 引入 client 或含真实业务 / 🟡 仅 page-templates 渲染空态 / ❌ 文件不存在)
|
||||||
- `e:\desktop\CICD\src\modules\exams\components\exam-assembly.tsx` — 组卷工作台
|
- **对比基线**:`e:\desktop\CICD\src\app\(dashboard)\teacher\`(CICD 原版 53 页)
|
||||||
- `e:\desktop\CICD\src\modules\classes\components\class-detail\` — 班级详情 5 个 widget
|
- **规划依据**:`apps/portal-shell/ARCHITECTURE.md` §9.1(教师域 56 页规划)
|
||||||
- `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 工单
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**分析完成。下一步建议按 §五 优先级逐项补做,每补一页同步更新 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.tsx):60 ✅ / 3 🟡 / 0 ❌;3 占位页改为重定向建议;15 功能差距页归 P1-P2 backlog;补全附录 §七.1 63 页完整清单 + §七.2 55 client 清单 |
|
||||||
|
|||||||
678
apps/portal-shell/docs/needtodo/teacher-migration-gap.md
Normal file
678
apps/portal-shell/docs/needtodo/teacher-migration-gap.md
Normal 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 替代 Tiptap,prop-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` —— **全部 ❌ 缺失**
|
||||||
|
|
||||||
|
#### Hooks(hooks/)—— 整目录缺失(4 文件)
|
||||||
|
|
||||||
|
`use-exam-preview.ts` / `use-exam-preview-rewrite.ts` / `use-exam-preview-state.ts` / `use-exam-preview-tasks.ts` —— **全部 ❌ 缺失**
|
||||||
|
|
||||||
|
#### AI Pipeline(ai-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` | ❌ 缺失 | 教材设置对话框 |
|
||||||
|
|
||||||
|
#### Hooks(hooks/)—— 全缺
|
||||||
|
|
||||||
|
`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 个 widget(header/overview-stats/quick-actions/schedule-widget/students-widget/assignments-widget/trends-widget)全部迁移 |
|
||||||
|
| **学生管理** | ✅ **完整** | `students-table.tsx` 已迁移 |
|
||||||
|
| **课表** | ✅ **完整** | `class-schedule-client.tsx` + `schedule-view.tsx` + 3 个 schedule dialog(create/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/` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**报告结束。后续迁移工作请基于本报告的"第三部分:每个模块的缺失组件清单"与"第五部分:迁移优先级建议"执行。**
|
||||||
38
apps/portal-shell/src/app/shell/admin/users/[id]/error.tsx
Normal file
38
apps/portal-shell/src/app/shell/admin/users/[id]/error.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 />;
|
||||||
|
}
|
||||||
23
apps/portal-shell/src/app/shell/admin/users/[id]/page.tsx
Normal file
23
apps/portal-shell/src/app/shell/admin/users/[id]/page.tsx
Normal 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 边界包裹。
|
||||||
|
* 业务逻辑在 UserDetailClient(client 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
apps/portal-shell/src/app/shell/messages/[id]/page.tsx
Normal file
22
apps/portal-shell/src/app/shell/messages/[id]/page.tsx
Normal 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 边界包裹。
|
||||||
|
* 业务逻辑在 MessageDetailClient(client 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
apps/portal-shell/src/app/shell/messages/compose/page.tsx
Normal file
24
apps/portal-shell/src/app/shell/messages/compose/page.tsx
Normal 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 边界包裹。
|
||||||
|
* 业务逻辑在 MessageComposeClient(client 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 边界包裹。
|
||||||
|
* 业务逻辑在 MessageGroupComposeClient(client 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
apps/portal-shell/src/app/shell/profile/page.tsx
Normal file
23
apps/portal-shell/src/app/shell/profile/page.tsx
Normal 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 边界包裹。
|
||||||
|
* 业务逻辑在 ProfileClient(client 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
apps/portal-shell/src/app/shell/student/diagnostic/page.tsx
Normal file
24
apps/portal-shell/src/app/shell/student/diagnostic/page.tsx
Normal 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 边界包裹。
|
||||||
|
* 业务逻辑在 StudentSelfDiagnosticClient(client component)中。
|
||||||
|
*
|
||||||
|
* 数据契约:studentDiagnostic(studentId) ❌ schema 无此字段 → MSW 兜底
|
||||||
|
* (@contract-pending)。学生页传入 "me" 作为 studentId,MSW 返回当前用户
|
||||||
|
* 诊断数据;后端补齐后由 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 要求)。
|
||||||
|
* 业务逻辑在 ClassDetailClient(client 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
apps/portal-shell/src/app/shell/teacher/classes/my/error.tsx
Normal file
38
apps/portal-shell/src/app/shell/teacher/classes/my/error.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
23
apps/portal-shell/src/app/shell/teacher/classes/my/page.tsx
Normal file
23
apps/portal-shell/src/app/shell/teacher/classes/my/page.tsx
Normal 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 要求)。
|
||||||
|
* 业务逻辑在 ClassesListClient(client 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 班级学生列表页 → 重定向到学生管理
|
||||||
|
*
|
||||||
|
* 阅卷入口聚合在学生管理列表(/shell/teacher/students),
|
||||||
|
* 本路由保留作为兼容入口,直接重定向避免展示空态。
|
||||||
|
*
|
||||||
|
* 关联:teacher-NeedTodo.md §班级管理
|
||||||
|
*/
|
||||||
|
export default function ClassStudentsPage(): never {
|
||||||
|
redirect("/shell/teacher/students");
|
||||||
|
}
|
||||||
38
apps/portal-shell/src/app/shell/teacher/exams/all/error.tsx
Normal file
38
apps/portal-shell/src/app/shell/teacher/exams/all/error.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
23
apps/portal-shell/src/app/shell/teacher/exams/all/page.tsx
Normal file
23
apps/portal-shell/src/app/shell/teacher/exams/all/page.tsx
Normal 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 要求)。
|
||||||
|
* 业务逻辑在 ExamsListClient(client 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 />;
|
||||||
|
}
|
||||||
@@ -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 要求)。
|
||||||
|
* 业务逻辑在 NewExamClient(client 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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 要求)。
|
||||||
|
* 业务逻辑在 NewHomeworkClient(client 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -208,7 +208,7 @@ function AttendanceFilters({
|
|||||||
...grades.map((g) => ({ value: g.id, label: g.name })),
|
...grades.map((g) => ({ value: g.id, label: g.name })),
|
||||||
]}
|
]}
|
||||||
placeholder={t("allGrades")}
|
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>
|
</div>
|
||||||
|
|
||||||
@@ -224,7 +224,7 @@ function AttendanceFilters({
|
|||||||
...classes.map((cls) => ({ value: cls.id, label: cls.name })),
|
...classes.map((cls) => ({ value: cls.id, label: cls.name })),
|
||||||
]}
|
]}
|
||||||
placeholder={t("allClasses")}
|
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>
|
</div>
|
||||||
|
|
||||||
@@ -249,7 +249,7 @@ function AttendanceFilters({
|
|||||||
})),
|
})),
|
||||||
]}
|
]}
|
||||||
placeholder={t("allStatuses")}
|
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>
|
</div>
|
||||||
|
|
||||||
@@ -261,7 +261,7 @@ function AttendanceFilters({
|
|||||||
type="date"
|
type="date"
|
||||||
value={date}
|
value={date}
|
||||||
onChange={(e) => onDateChange(e.target.value)}
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export function AttendanceGradeCorrelationCard(): React.ReactElement {
|
|||||||
icon={error ? AlertCircle : TrendingUp}
|
icon={error ? AlertCircle : TrendingUp}
|
||||||
title={error ? t("errorTitle") : t("emptyTitle")}
|
title={error ? t("errorTitle") : t("emptyTitle")}
|
||||||
description={error ? t("errorDescription") : t("emptyDescription")}
|
description={error ? t("errorDescription") : t("emptyDescription")}
|
||||||
className="min-h-[300px]"
|
className="min-h-72"
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -200,7 +200,7 @@ export function AttendanceGradeCorrelationCard(): React.ReactElement {
|
|||||||
{scatterData.length > 0 ? (
|
{scatterData.length > 0 ? (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="mb-3 text-sm font-medium">{t("scatterTitle")}</h4>
|
<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}>
|
<ScatterChart margin={SCATTER_MARGIN}>
|
||||||
<CartesianGrid {...SCATTER_GRID_PROPS} />
|
<CartesianGrid {...SCATTER_GRID_PROPS} />
|
||||||
<XAxis
|
<XAxis
|
||||||
@@ -275,7 +275,7 @@ export function AttendanceGradeCorrelationCard(): React.ReactElement {
|
|||||||
icon={AlertCircle}
|
icon={AlertCircle}
|
||||||
title={t("emptyTitle")}
|
title={t("emptyTitle")}
|
||||||
description={t("emptyDescription")}
|
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" />
|
<Skeleton key={i} className="h-20 w-full" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Skeleton className="h-[320px] w-full" />
|
<Skeleton className="h-80 w-full" />
|
||||||
<Skeleton className="h-32 w-full" />
|
<Skeleton className="h-32 w-full" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ export function AttendanceRecordsList({
|
|||||||
)}
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="max-w-[240px] truncate text-muted-foreground">
|
<TableCell className="max-w-xs truncate text-muted-foreground">
|
||||||
{truncateNote(r.note)}
|
{truncateNote(r.note)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-muted-foreground">
|
<TableCell className="text-muted-foreground">
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export function ClassComparisonCard(): React.ReactElement {
|
|||||||
icon={error ? AlertCircle : GitCompare}
|
icon={error ? AlertCircle : GitCompare}
|
||||||
title={error ? t("errorTitle") : t("emptyTitle")}
|
title={error ? t("errorTitle") : t("emptyTitle")}
|
||||||
description={error ? t("errorDescription") : t("emptyDescription")}
|
description={error ? t("errorDescription") : t("emptyDescription")}
|
||||||
className="min-h-[300px]"
|
className="min-h-72"
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -137,7 +137,7 @@ export function ClassComparisonCard(): React.ReactElement {
|
|||||||
xKey="name"
|
xKey="name"
|
||||||
yDomain={[0, 100]}
|
yDomain={[0, 100]}
|
||||||
yTickFormatter={(v) => `${v}%`}
|
yTickFormatter={(v) => `${v}%`}
|
||||||
heightClassName="h-[280px]"
|
heightClassName="h-72"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="overflow-x-auto rounded-md border">
|
<div className="overflow-x-auto rounded-md border">
|
||||||
@@ -197,7 +197,7 @@ function ClassComparisonSkeleton(): React.ReactElement {
|
|||||||
<Skeleton className="mt-2 h-4 w-64" />
|
<Skeleton className="mt-2 h-4 w-64" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<Skeleton className="h-[280px] w-full" />
|
<Skeleton className="h-72 w-full" />
|
||||||
<Skeleton className="h-32 w-full" />
|
<Skeleton className="h-32 w-full" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -293,7 +293,7 @@ function LoginLogsTable({
|
|||||||
{log.ip}
|
{log.ip}
|
||||||
</td>
|
</td>
|
||||||
<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}
|
title={log.userAgent}
|
||||||
>
|
>
|
||||||
{log.userAgent}
|
{log.userAgent}
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ export function CoursePlanItemEditor({
|
|||||||
value={content}
|
value={content}
|
||||||
onChange={(e) => setContent(e.target.value)}
|
onChange={(e) => setContent(e.target.value)}
|
||||||
placeholder={t("contentPlaceholder")}
|
placeholder={t("contentPlaceholder")}
|
||||||
className="min-h-[100px]"
|
className="min-h-24"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* 错题详情对话框(迁移自 CICD error-book-detail-dialog.tsx)
|
* 错题详情对话框(迁移自 CICD error-book-detail-dialog.tsx)
|
||||||
*
|
*
|
||||||
* 适配 portal-shell:
|
* 适配 portal-shell:
|
||||||
* - 用原生轻量模态(fixed inset-0 + bg-black/50 + 卡片)替代 shadcn Dialog
|
* - 使用 shadcn Dialog 组件
|
||||||
* - 数据通过 useErrorBookDetail hook(@contract-pending MSW 兜底)拉取
|
* - 数据通过 useErrorBookDetail hook(@contract-pending MSW 兜底)拉取
|
||||||
* - 仅展示详情(无 archive/delete/saveNote 等 server action)
|
* - 仅展示详情(无 archive/delete/saveNote 等 server action)
|
||||||
* - 错误处理走 notify.error()
|
* - 错误处理走 notify.error()
|
||||||
@@ -28,6 +28,14 @@ import { useErrorBookDetail } from "@/lib/api";
|
|||||||
import { notify } from "@/shared/lib/notify";
|
import { notify } from "@/shared/lib/notify";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
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";
|
import { Separator } from "@/shared/components/ui/separator";
|
||||||
|
|
||||||
export interface ErrorBookDetailDialogProps {
|
export interface ErrorBookDetailDialogProps {
|
||||||
@@ -58,65 +66,31 @@ export function ErrorBookDetailDialog({
|
|||||||
}
|
}
|
||||||
}, [error, tCommon]);
|
}, [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 (
|
return (
|
||||||
<div
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
role="dialog"
|
<DialogContent className="flex max-h-[90vh] max-w-2xl flex-col overflow-hidden p-0">
|
||||||
aria-modal="true"
|
<DialogHeader className="border-b p-6 pb-4">
|
||||||
aria-labelledby="error-book-detail-title"
|
<DialogTitle className="flex items-center gap-2">
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
<BookOpen className="size-5" />
|
||||||
onClick={() => onOpenChange(false)}
|
{t("title")}
|
||||||
>
|
</DialogTitle>
|
||||||
<div
|
<DialogDescription className="sr-only">
|
||||||
className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-lg border bg-card shadow-lg"
|
{t("title")}
|
||||||
onClick={(e) => e.stopPropagation()}
|
</DialogDescription>
|
||||||
>
|
{data ? (
|
||||||
{/* 头部 */}
|
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||||
<div className="flex items-start justify-between border-b p-6 pb-4">
|
{data.subjectName ? (
|
||||||
<div className="flex-1">
|
<Badge variant="outline">{data.subjectName}</Badge>
|
||||||
<h2
|
) : null}
|
||||||
id="error-book-detail-title"
|
{data.knowledgePointTitle ? (
|
||||||
className="flex items-center gap-2 text-lg font-semibold"
|
<Badge variant="secondary">{data.knowledgePointTitle}</Badge>
|
||||||
>
|
) : null}
|
||||||
<BookOpen className="size-5" />
|
{data.className ? (
|
||||||
{t("title")}
|
<span className="text-muted-foreground">{data.className}</span>
|
||||||
</h2>
|
) : null}
|
||||||
{data ? (
|
</div>
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
|
) : null}
|
||||||
{data.subjectName ? (
|
</DialogHeader>
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* 内容区 */}
|
{/* 内容区 */}
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
@@ -226,14 +200,13 @@ export function ErrorBookDetailDialog({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 底部 */}
|
<DialogFooter className="border-t p-4">
|
||||||
<div className="flex justify-end gap-2 border-t p-4">
|
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
{t("close")}
|
{t("close")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
</div>
|
</DialogContent>
|
||||||
</div>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,11 +14,21 @@
|
|||||||
*
|
*
|
||||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §9.4 / §10 P5
|
* 关联: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 { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import { useBatchDeleteFiles } from "@/lib/api";
|
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 { Button } from "@/shared/components/ui/button";
|
||||||
import { notify } from "@/shared/lib/notify";
|
import { notify } from "@/shared/lib/notify";
|
||||||
|
|
||||||
@@ -155,50 +165,27 @@ function ConfirmDialog({
|
|||||||
onCancel,
|
onCancel,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
}: ConfirmDialogProps): React.ReactElement {
|
}: 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 (
|
return (
|
||||||
<div
|
<AlertDialog
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
open={true}
|
||||||
onClick={() => {
|
onOpenChange={(o) => {
|
||||||
if (!loading) onCancel();
|
if (!o && !loading) onCancel();
|
||||||
}}
|
}}
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label={title}
|
|
||||||
>
|
>
|
||||||
<div
|
<AlertDialogContent>
|
||||||
className="w-full max-w-md rounded-lg bg-background p-6 shadow-lg"
|
<AlertDialogHeader>
|
||||||
onClick={(e) => e.stopPropagation()}
|
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||||
>
|
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||||
<div className="flex items-start justify-between gap-4">
|
</AlertDialogHeader>
|
||||||
<h2 className="text-lg font-semibold">{title}</h2>
|
<AlertDialogFooter>
|
||||||
<button
|
<AlertDialogCancel disabled={loading} onClick={onCancel}>
|
||||||
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}>
|
|
||||||
{cancelLabel}
|
{cancelLabel}
|
||||||
</Button>
|
</AlertDialogCancel>
|
||||||
<Button variant="destructive" onClick={onConfirm} disabled={loading}>
|
<AlertDialogAction onClick={onConfirm} disabled={loading}>
|
||||||
{loading ? confirmingLabel : submitLabel}
|
{loading ? confirmingLabel : submitLabel}
|
||||||
</Button>
|
</AlertDialogAction>
|
||||||
</div>
|
</AlertDialogFooter>
|
||||||
</div>
|
</AlertDialogContent>
|
||||||
</div>
|
</AlertDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,11 @@
|
|||||||
* 教案软删除确认对话框(基于 shadcn AlertDialog,ARCHITECTURE.md §7.3 / §9.4)
|
* 教案软删除确认对话框(基于 shadcn AlertDialog,ARCHITECTURE.md §7.3 / §9.4)
|
||||||
*
|
*
|
||||||
* 用于 admin/lesson-plans 列表页与详情页的删除确认。
|
* 用于 admin/lesson-plans 列表页与详情页的删除确认。
|
||||||
* 结构:AlertDialog + AlertDialogContent。
|
* 交互:ESC 关闭、点击遮罩关闭、确认按钮 destructive 变体(由 Radix Dialog 提供)。
|
||||||
* 交互:ESC 关闭、点击遮罩关闭、确认按钮 destructive 变体。
|
|
||||||
*
|
*
|
||||||
* 关联:ARCHITECTURE.md §7.3 详情/列表页 / §9.4 / §11.3
|
* 关联:ARCHITECTURE.md §7.3 详情/列表页 / §9.4 / §11.3
|
||||||
*/
|
*/
|
||||||
import { AlertTriangle, Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -30,7 +29,7 @@ export interface DeleteConfirmDialogProps {
|
|||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
/** 取消回调(点击遮罩 / ESC / 取消按钮) */
|
/** 取消回调(点击遮罩 / ESC / 取消按钮) */
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
/** 删除进行中(禁用按钮、隐藏 spinner) */
|
/** 删除进行中(禁用按钮、显示 spinner) */
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,39 +43,32 @@ export function DeleteConfirmDialog({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
onCancel,
|
onCancel,
|
||||||
loading = false,
|
loading = false,
|
||||||
}: DeleteConfirmDialogProps): React.ReactElement | null {
|
}: DeleteConfirmDialogProps): React.ReactElement {
|
||||||
const t = useTranslations("admin.lessonPlans.delete");
|
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 (
|
return (
|
||||||
<AlertDialog
|
<AlertDialog open={open} onOpenChange={handleOpenChange}>
|
||||||
open={open}
|
|
||||||
onOpenChange={(nextOpen) => {
|
|
||||||
if (!nextOpen && !loading) {
|
|
||||||
onCancel();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<div className="flex items-start gap-3">
|
<AlertDialogHeader>
|
||||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-destructive/10 text-destructive">
|
<AlertDialogTitle>{t("title")}</AlertDialogTitle>
|
||||||
<AlertTriangle className="size-5" />
|
<AlertDialogDescription>{t("description")}</AlertDialogDescription>
|
||||||
</span>
|
</AlertDialogHeader>
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>{t("title")}</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>{t("description")}</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
</div>
|
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel disabled={loading}>
|
<AlertDialogCancel disabled={loading} onClick={onCancel}>
|
||||||
{t("cancel")}
|
{t("cancel")}
|
||||||
</AlertDialogCancel>
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction onClick={handleConfirm} disabled={loading}>
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onConfirm();
|
|
||||||
}}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -33,11 +33,13 @@ import {
|
|||||||
ListPageSkeleton,
|
ListPageSkeleton,
|
||||||
} from "@/shared/components/page-templates";
|
} from "@/shared/components/page-templates";
|
||||||
import {
|
import {
|
||||||
Card,
|
Dialog,
|
||||||
CardContent,
|
DialogContent,
|
||||||
CardHeader,
|
DialogDescription,
|
||||||
CardTitle,
|
DialogFooter,
|
||||||
} from "@/shared/components/ui/card";
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/shared/components/ui/dialog";
|
||||||
import {
|
import {
|
||||||
activeToBadgeClass,
|
activeToBadgeClass,
|
||||||
builtinToBadgeClass,
|
builtinToBadgeClass,
|
||||||
@@ -318,12 +320,20 @@ function PluginEditDialog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
<Dialog
|
||||||
<Card className="w-full max-w-lg">
|
open={true}
|
||||||
<CardHeader>
|
onOpenChange={(o) => {
|
||||||
<CardTitle className="text-base">{t("editTitle")}</CardTitle>
|
if (!o) onClose();
|
||||||
</CardHeader>
|
}}
|
||||||
<CardContent className="space-y-4">
|
>
|
||||||
|
<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")}>
|
<FormField label={t("colPluginId")}>
|
||||||
<Input type="text" value={plugin.pluginId} readOnly />
|
<Input type="text" value={plugin.pluginId} readOnly />
|
||||||
</FormField>
|
</FormField>
|
||||||
@@ -366,17 +376,17 @@ function PluginEditDialog({
|
|||||||
<p className="text-xs text-destructive">{draftError}</p>
|
<p className="text-xs text-destructive">{draftError}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</FormField>
|
</FormField>
|
||||||
<div className="flex justify-end gap-2 border-t pt-4">
|
<DialogFooter className="border-t pt-4">
|
||||||
<Button variant="outline" onClick={onClose}>
|
<Button variant="outline" onClick={onClose}>
|
||||||
{t("cancelEdit")}
|
{t("cancelEdit")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSubmit} disabled={submitting}>
|
<Button onClick={handleSubmit} disabled={submitting}>
|
||||||
{t("saveProps")}
|
{t("saveProps")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</DialogContent>
|
||||||
</div>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,16 @@ import { useState } from "react";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import { useBatchDeleteQuestions } from "@/lib/api";
|
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 { Button } from "@/shared/components/ui/button";
|
||||||
import { notify } from "@/shared/lib/notify";
|
import { notify } from "@/shared/lib/notify";
|
||||||
|
|
||||||
@@ -132,37 +142,28 @@ function ConfirmDeleteDialog({
|
|||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const t = useTranslations("admin.questions.batch");
|
const t = useTranslations("admin.questions.batch");
|
||||||
return (
|
return (
|
||||||
<div
|
<AlertDialog
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
open={true}
|
||||||
onClick={onCancel}
|
onOpenChange={(o) => {
|
||||||
|
if (!o && !loading) onCancel();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<AlertDialogContent>
|
||||||
className="w-full max-w-md rounded-xl border bg-background p-6 shadow-lg"
|
<AlertDialogHeader>
|
||||||
onClick={(e) => e.stopPropagation()}
|
<AlertDialogTitle>{t("confirmTitle")}</AlertDialogTitle>
|
||||||
>
|
<AlertDialogDescription>
|
||||||
<h2 className="mb-2 text-lg font-semibold">{t("confirmTitle")}</h2>
|
{t("confirmDescription", { count })}
|
||||||
<p className="mb-4 text-sm text-muted-foreground">
|
</AlertDialogDescription>
|
||||||
{t("confirmDescription", { count })}
|
</AlertDialogHeader>
|
||||||
</p>
|
<AlertDialogFooter>
|
||||||
<div className="flex justify-end gap-2">
|
<AlertDialogCancel disabled={loading} onClick={onCancel}>
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onCancel}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{t("confirmCancel")}
|
{t("confirmCancel")}
|
||||||
</Button>
|
</AlertDialogCancel>
|
||||||
<Button
|
<AlertDialogAction onClick={onConfirm} disabled={loading}>
|
||||||
type="button"
|
|
||||||
variant="destructive"
|
|
||||||
onClick={onConfirm}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading ? t("confirming") : t("confirmSubmit")}
|
{loading ? t("confirming") : t("confirmSubmit")}
|
||||||
</Button>
|
</AlertDialogAction>
|
||||||
</div>
|
</AlertDialogFooter>
|
||||||
</div>
|
</AlertDialogContent>
|
||||||
</div>
|
</AlertDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ import { useTranslations } from "next-intl";
|
|||||||
|
|
||||||
import { useCreateQuestion } from "@/lib/api";
|
import { useCreateQuestion } from "@/lib/api";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/shared/components/ui/dialog";
|
||||||
import { Input } from "@/shared/components/ui/input";
|
import { Input } from "@/shared/components/ui/input";
|
||||||
import { Select } from "@/shared/components/ui/select";
|
import { Select } from "@/shared/components/ui/select";
|
||||||
import { notify } from "@/shared/lib/notify";
|
import { notify } from "@/shared/lib/notify";
|
||||||
@@ -127,16 +135,17 @@ export function CreateQuestionDialog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Dialog
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
open={open}
|
||||||
onClick={handleClose}
|
onOpenChange={(o) => {
|
||||||
|
if (!o) handleClose();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<DialogContent className="max-h-[90vh] max-w-lg overflow-y-auto">
|
||||||
className="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-xl border bg-background p-6 shadow-lg"
|
<DialogHeader>
|
||||||
onClick={(e) => e.stopPropagation()}
|
<DialogTitle>{t("title")}</DialogTitle>
|
||||||
>
|
<DialogDescription>{t("description")}</DialogDescription>
|
||||||
<h2 className="mb-2 text-lg font-semibold">{t("title")}</h2>
|
</DialogHeader>
|
||||||
<p className="mb-4 text-sm text-muted-foreground">{t("description")}</p>
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<FormField label={t("fieldType")} required>
|
<FormField label={t("fieldType")} required>
|
||||||
<Select
|
<Select
|
||||||
@@ -147,7 +156,7 @@ export function CreateQuestionDialog({
|
|||||||
value: opt,
|
value: opt,
|
||||||
label: t(`types.${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>
|
</FormField>
|
||||||
|
|
||||||
@@ -159,7 +168,7 @@ export function CreateQuestionDialog({
|
|||||||
rows={4}
|
rows={4}
|
||||||
required
|
required
|
||||||
aria-label={t("fieldContent")}
|
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>
|
</FormField>
|
||||||
|
|
||||||
@@ -171,7 +180,7 @@ export function CreateQuestionDialog({
|
|||||||
rows={2}
|
rows={2}
|
||||||
required
|
required
|
||||||
aria-label={t("fieldAnswer")}
|
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>
|
</FormField>
|
||||||
|
|
||||||
@@ -182,7 +191,7 @@ export function CreateQuestionDialog({
|
|||||||
maxLength={1000}
|
maxLength={1000}
|
||||||
rows={2}
|
rows={2}
|
||||||
aria-label={t("fieldExplanation")}
|
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>
|
</FormField>
|
||||||
|
|
||||||
@@ -196,7 +205,7 @@ export function CreateQuestionDialog({
|
|||||||
value: String(opt.value),
|
value: String(opt.value),
|
||||||
label: t(opt.labelKey),
|
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>
|
</FormField>
|
||||||
|
|
||||||
@@ -220,7 +229,7 @@ export function CreateQuestionDialog({
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -232,10 +241,10 @@ export function CreateQuestionDialog({
|
|||||||
<Button type="submit" disabled={createMutation.loading}>
|
<Button type="submit" disabled={createMutation.loading}>
|
||||||
{createMutation.loading ? t("submitting") : t("submit")}
|
{createMutation.loading ? t("submitting") : t("submit")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</DialogContent>
|
||||||
</div>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* - adminQuestion(id):❌ schema 无 → MSW 兜底(@contract-pending)
|
* - adminQuestion(id):❌ schema 无 → MSW 兜底(@contract-pending)
|
||||||
*
|
*
|
||||||
* 适配 portal-shell:
|
* 适配 portal-shell:
|
||||||
* - 用原生轻量模态(fixed inset-0 + bg-black/50 + 卡片)替代 shadcn Dialog
|
* - 使用 shadcn Dialog 组件
|
||||||
* - 数据通过 useAdminQuestion hook 拉取
|
* - 数据通过 useAdminQuestion hook 拉取
|
||||||
* - 错误处理走 notify.error()
|
* - 错误处理走 notify.error()
|
||||||
*
|
*
|
||||||
@@ -31,6 +31,14 @@ import { useAdminQuestion } from "@/lib/api";
|
|||||||
import { notify } from "@/shared/lib/notify";
|
import { notify } from "@/shared/lib/notify";
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
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 { Separator } from "@/shared/components/ui/separator";
|
||||||
import {
|
import {
|
||||||
difficultyToColorClass,
|
difficultyToColorClass,
|
||||||
@@ -69,63 +77,31 @@ export function QuestionDetailDialog({
|
|||||||
}
|
}
|
||||||
}, [error, tCommon]);
|
}, [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 (
|
return (
|
||||||
<div
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
role="dialog"
|
<DialogContent className="flex max-h-[90vh] max-w-2xl flex-col overflow-hidden p-0">
|
||||||
aria-modal="true"
|
<DialogHeader className="border-b p-6 pb-4">
|
||||||
aria-labelledby="question-detail-title"
|
<DialogTitle className="flex items-center gap-2">
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
<HelpCircle className="size-5" />
|
||||||
onClick={() => onOpenChange(false)}
|
{t("title")}
|
||||||
>
|
</DialogTitle>
|
||||||
<div
|
<DialogDescription className="sr-only">
|
||||||
className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-lg border bg-card shadow-lg"
|
{t("title")}
|
||||||
onClick={(e) => e.stopPropagation()}
|
</DialogDescription>
|
||||||
>
|
{data ? (
|
||||||
{/* 头部 */}
|
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||||
<div className="flex items-start justify-between border-b p-6 pb-4">
|
<Badge variant="outline">{formatQuestionType(data.type)}</Badge>
|
||||||
<div className="flex-1">
|
<Badge variant="secondary">
|
||||||
<h2
|
{formatQuestionStatus(data.status)}
|
||||||
id="question-detail-title"
|
</Badge>
|
||||||
className="flex items-center gap-2 text-lg font-semibold"
|
{data.subjectName ? (
|
||||||
>
|
<span className="text-muted-foreground">
|
||||||
<HelpCircle className="size-5" />
|
{data.subjectName}
|
||||||
{t("title")}
|
</span>
|
||||||
</h2>
|
) : null}
|
||||||
{data ? (
|
</div>
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
|
) : null}
|
||||||
<Badge variant="outline">{formatQuestionType(data.type)}</Badge>
|
</DialogHeader>
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* 内容区 */}
|
{/* 内容区 */}
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
@@ -274,14 +250,13 @@ export function QuestionDetailDialog({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 底部 */}
|
<DialogFooter className="border-t p-4">
|
||||||
<div className="flex justify-end gap-2 border-t p-4">
|
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
{t("close")}
|
{t("close")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
</div>
|
</DialogContent>
|
||||||
</div>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
|
import { Textarea } from "@/shared/components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
ListPageShell,
|
ListPageShell,
|
||||||
ListPageSkeleton,
|
ListPageSkeleton,
|
||||||
@@ -61,6 +62,8 @@ export function ScheduleChangesClient(): React.ReactElement {
|
|||||||
const { data: entries } = useAdminScheduleEntries();
|
const { data: entries } = useAdminScheduleEntries();
|
||||||
const { run: approveChange, loading: approving } = useApproveScheduleChange();
|
const { run: approveChange, loading: approving } = useApproveScheduleChange();
|
||||||
const { run: rejectChange, loading: rejecting } = useRejectScheduleChange();
|
const { run: rejectChange, loading: rejecting } = useRejectScheduleChange();
|
||||||
|
const [rejectId, setRejectId] = useState<string | null>(null);
|
||||||
|
const [rejectReason, setRejectReason] = useState("");
|
||||||
|
|
||||||
const changes = data?.items ?? [];
|
const changes = data?.items ?? [];
|
||||||
const entriesList = entries ?? [];
|
const entriesList = entries ?? [];
|
||||||
@@ -112,19 +115,26 @@ export function ScheduleChangesClient(): React.ReactElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleReject = async (id: string): Promise<void> => {
|
const handleReject = async (id: string): Promise<void> => {
|
||||||
const reason = window.prompt(t("rejectConfirm"));
|
setRejectId(id);
|
||||||
if (reason === null) return;
|
setRejectReason("");
|
||||||
const trimmed = reason.trim();
|
};
|
||||||
|
|
||||||
|
const confirmReject = async (): Promise<void> => {
|
||||||
|
if (!rejectId) return;
|
||||||
|
const trimmed = rejectReason.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
notify.error(t("rejectError"));
|
notify.error(t("rejectError"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await rejectChange(id, trimmed);
|
await rejectChange(rejectId, trimmed);
|
||||||
notify.success(t("rejectSuccess"));
|
notify.success(t("rejectSuccess"));
|
||||||
await refetch();
|
await refetch();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notify.error(`${t("rejectError")}: ${String(err)}`);
|
notify.error(`${t("rejectError")}: ${String(err)}`);
|
||||||
|
} finally {
|
||||||
|
setRejectId(null);
|
||||||
|
setRejectReason("");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -166,6 +176,45 @@ export function ScheduleChangesClient(): React.ReactElement {
|
|||||||
/>
|
/>
|
||||||
<ScheduleGridCard entries={entriesList} />
|
<ScheduleGridCard entries={entriesList} />
|
||||||
<p className="text-xs text-muted-foreground">{t("mswNotice")}</p>
|
<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>
|
</ListPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,8 +26,26 @@ import {
|
|||||||
type ClassInvitationCode,
|
type ClassInvitationCode,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { notify } from "@/shared/lib/notify";
|
import { notify } from "@/shared/lib/notify";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/shared/components/ui/alert-dialog";
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
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 { Input } from "@/shared/components/ui/input";
|
||||||
import { FormField } from "@/features/admin/school/schools-client";
|
import { FormField } from "@/features/admin/school/schools-client";
|
||||||
|
|
||||||
@@ -97,23 +115,25 @@ export function ClassInvitationManagerDialog({
|
|||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Dialog
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
open={open}
|
||||||
onClick={onClose}
|
onOpenChange={(o) => {
|
||||||
|
if (!o) onClose();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<DialogContent className="flex max-h-[90vh] max-w-3xl flex-col p-6">
|
||||||
className="flex max-h-[90vh] w-full max-w-3xl flex-col rounded-xl border bg-background p-6 shadow-lg"
|
<DialogHeader>
|
||||||
onClick={(e) => e.stopPropagation()}
|
<DialogTitle>{t("title")}</DialogTitle>
|
||||||
>
|
<DialogDescription className="sr-only">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
{t("title")}
|
||||||
<div>
|
</DialogDescription>
|
||||||
<h2 className="text-lg font-semibold">{t("title")}</h2>
|
{className ? (
|
||||||
{className ? (
|
<p className="text-xs text-muted-foreground">
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
{t("classLabel")}: {className}
|
||||||
{t("classLabel")}: {className}
|
</p>
|
||||||
</p>
|
) : null}
|
||||||
) : null}
|
</DialogHeader>
|
||||||
</div>
|
<div className="flex justify-end">
|
||||||
<Button size="sm" onClick={() => setGenerateOpen(true)}>
|
<Button size="sm" onClick={() => setGenerateOpen(true)}>
|
||||||
<Plus className="mr-1.5 size-4" />
|
<Plus className="mr-1.5 size-4" />
|
||||||
{t("generate")}
|
{t("generate")}
|
||||||
@@ -174,7 +194,7 @@ export function ClassInvitationManagerDialog({
|
|||||||
? new Date(record.expiresAt).toLocaleString()
|
? new Date(record.expiresAt).toLocaleString()
|
||||||
: t("neverExpires")}
|
: t("neverExpires")}
|
||||||
</td>
|
</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 ?? "-"}
|
{record.note ?? "-"}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-right">
|
<td className="p-3 text-right">
|
||||||
@@ -211,11 +231,11 @@ export function ClassInvitationManagerDialog({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex justify-end">
|
<DialogFooter className="mt-4">
|
||||||
<Button variant="outline" onClick={onClose}>
|
<Button variant="outline" onClick={onClose}>
|
||||||
{tCommon("form.close")}
|
{tCommon("form.close")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
|
|
||||||
<GenerateCodeDialog
|
<GenerateCodeDialog
|
||||||
open={generateOpen}
|
open={generateOpen}
|
||||||
@@ -225,43 +245,40 @@ export function ClassInvitationManagerDialog({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{revokeTarget ? (
|
{revokeTarget ? (
|
||||||
<div
|
<AlertDialog
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
open={true}
|
||||||
onClick={() => setRevokeTarget(null)}
|
onOpenChange={(o) => {
|
||||||
|
if (!o && !revokeMutation.loading) setRevokeTarget(null);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<AlertDialogContent>
|
||||||
className="w-full max-w-sm rounded-xl border bg-background p-6 shadow-lg"
|
<AlertDialogHeader>
|
||||||
onClick={(e) => e.stopPropagation()}
|
<AlertDialogTitle>{t("revoke")}</AlertDialogTitle>
|
||||||
>
|
<AlertDialogDescription>
|
||||||
<h2 className="mb-2 text-lg font-semibold">{t("revoke")}</h2>
|
{t("revokeConfirm")}
|
||||||
<p className="mb-4 text-sm text-muted-foreground">
|
</AlertDialogDescription>
|
||||||
{t("revokeConfirm")}
|
</AlertDialogHeader>
|
||||||
</p>
|
<AlertDialogFooter>
|
||||||
<div className="flex justify-end gap-2">
|
<AlertDialogCancel
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setRevokeTarget(null)}
|
|
||||||
disabled={revokeMutation.loading}
|
disabled={revokeMutation.loading}
|
||||||
|
onClick={() => setRevokeTarget(null)}
|
||||||
>
|
>
|
||||||
{tCommon("form.cancel")}
|
{tCommon("form.cancel")}
|
||||||
</Button>
|
</AlertDialogCancel>
|
||||||
<Button
|
<AlertDialogAction
|
||||||
type="button"
|
|
||||||
variant="destructive"
|
|
||||||
disabled={revokeMutation.loading}
|
disabled={revokeMutation.loading}
|
||||||
onClick={() => void handleRevoke()}
|
onClick={() => void handleRevoke()}
|
||||||
>
|
>
|
||||||
{revokeMutation.loading
|
{revokeMutation.loading
|
||||||
? tCommon("form.processing")
|
? tCommon("form.processing")
|
||||||
: t("revoke")}
|
: t("revoke")}
|
||||||
</Button>
|
</AlertDialogAction>
|
||||||
</div>
|
</AlertDialogFooter>
|
||||||
</div>
|
</AlertDialogContent>
|
||||||
</div>
|
</AlertDialog>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</DialogContent>
|
||||||
</div>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,20 +361,19 @@ function GenerateCodeDialog({
|
|||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Dialog
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
open={open}
|
||||||
onClick={onClose}
|
onOpenChange={(o) => {
|
||||||
|
if (!o) onClose();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<DialogContent className="max-w-md">
|
||||||
className="w-full max-w-md rounded-xl border bg-background p-6 shadow-lg"
|
<DialogHeader>
|
||||||
onClick={(e) => e.stopPropagation()}
|
<DialogTitle>{t("generateWithCustom")}</DialogTitle>
|
||||||
>
|
<DialogDescription>
|
||||||
<h2 className="mb-1 text-lg font-semibold">
|
{t("defaultDuration")} · {t("defaultMaxUses")}
|
||||||
{t("generateWithCustom")}
|
</DialogDescription>
|
||||||
</h2>
|
</DialogHeader>
|
||||||
<p className="mb-4 text-xs text-muted-foreground">
|
|
||||||
{t("defaultDuration")} · {t("defaultMaxUses")}
|
|
||||||
</p>
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<FormField label={t("expiresInHours")}>
|
<FormField label={t("expiresInHours")}>
|
||||||
<Input
|
<Input
|
||||||
@@ -386,7 +402,7 @@ function GenerateCodeDialog({
|
|||||||
maxLength={255}
|
maxLength={255}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<div className="flex justify-end gap-2">
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -400,9 +416,9 @@ function GenerateCodeDialog({
|
|||||||
? tCommon("form.processing")
|
? tCommon("form.processing")
|
||||||
: t("generate")}
|
: t("generate")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</DialogContent>
|
||||||
</div>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,25 @@ import {
|
|||||||
type ClassScheduleItem,
|
type ClassScheduleItem,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { notify } from "@/shared/lib/notify";
|
import { notify } from "@/shared/lib/notify";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/shared/components/ui/alert-dialog";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/shared/components/ui/dialog";
|
||||||
import { Input } from "@/shared/components/ui/input";
|
import { Input } from "@/shared/components/ui/input";
|
||||||
import { Select } from "@/shared/components/ui/select";
|
import { Select } from "@/shared/components/ui/select";
|
||||||
import { FormField } from "@/features/admin/school/schools-client";
|
import { FormField } from "@/features/admin/school/schools-client";
|
||||||
@@ -91,23 +109,25 @@ export function ScheduleManagerDialog({
|
|||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Dialog
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
open={open}
|
||||||
onClick={onClose}
|
onOpenChange={(o) => {
|
||||||
|
if (!o) onClose();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<DialogContent className="flex max-h-[90vh] max-w-4xl flex-col p-6">
|
||||||
className="flex max-h-[90vh] w-full max-w-4xl flex-col rounded-xl border bg-background p-6 shadow-lg"
|
<DialogHeader>
|
||||||
onClick={(e) => e.stopPropagation()}
|
<DialogTitle>{t("manager.title")}</DialogTitle>
|
||||||
>
|
<DialogDescription className="sr-only">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
{t("manager.title")}
|
||||||
<div>
|
</DialogDescription>
|
||||||
<h2 className="text-lg font-semibold">{t("manager.title")}</h2>
|
{className ? (
|
||||||
{className ? (
|
<p className="text-xs text-muted-foreground">
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
{t("form.classLabel")}: {className}
|
||||||
{t("form.classLabel")}: {className}
|
</p>
|
||||||
</p>
|
) : null}
|
||||||
) : null}
|
</DialogHeader>
|
||||||
</div>
|
<div className="flex justify-end">
|
||||||
<Button size="sm" onClick={handleAdd}>
|
<Button size="sm" onClick={handleAdd}>
|
||||||
<Plus className="mr-1.5 size-4" />
|
<Plus className="mr-1.5 size-4" />
|
||||||
{t("manager.add")}
|
{t("manager.add")}
|
||||||
@@ -202,11 +222,11 @@ export function ScheduleManagerDialog({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex justify-end">
|
<DialogFooter className="mt-4">
|
||||||
<Button variant="outline" onClick={onClose}>
|
<Button variant="outline" onClick={onClose}>
|
||||||
{tCommon("button.cancel")}
|
{tCommon("button.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
|
|
||||||
<ScheduleFormDialog
|
<ScheduleFormDialog
|
||||||
open={formOpen}
|
open={formOpen}
|
||||||
@@ -225,8 +245,8 @@ export function ScheduleManagerDialog({
|
|||||||
onDeleted={handleDeleted}
|
onDeleted={handleDeleted}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</DialogContent>
|
||||||
</div>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,20 +349,22 @@ function ScheduleFormDialog({
|
|||||||
const title = mode === "edit" ? t("form.titleEdit") : t("form.titleCreate");
|
const title = mode === "edit" ? t("form.titleEdit") : t("form.titleCreate");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Dialog
|
||||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4"
|
open={open}
|
||||||
onClick={onClose}
|
onOpenChange={(o) => {
|
||||||
|
if (!o) onClose();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<DialogContent className="max-h-[90vh] max-w-lg overflow-y-auto">
|
||||||
className="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-xl border bg-background p-6 shadow-lg"
|
<DialogHeader>
|
||||||
onClick={(e) => e.stopPropagation()}
|
<DialogTitle>{title}</DialogTitle>
|
||||||
>
|
<DialogDescription className="sr-only">{title}</DialogDescription>
|
||||||
<h2 className="mb-4 text-lg font-semibold">{title}</h2>
|
{className ? (
|
||||||
{className ? (
|
<p className="text-xs text-muted-foreground">
|
||||||
<p className="mb-4 text-xs text-muted-foreground">
|
{t("form.classLabel")}: {className}
|
||||||
{t("form.classLabel")}: {className}
|
</p>
|
||||||
</p>
|
) : null}
|
||||||
) : null}
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
@@ -415,7 +437,7 @@ function ScheduleFormDialog({
|
|||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<DialogFooter className="pt-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -427,10 +449,10 @@ function ScheduleFormDialog({
|
|||||||
<Button type="submit" disabled={loading}>
|
<Button type="submit" disabled={loading}>
|
||||||
{mode === "edit" ? t("form.save") : t("form.create")}
|
{mode === "edit" ? t("form.save") : t("form.create")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</DialogContent>
|
||||||
</div>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,41 +486,38 @@ function ScheduleDeleteDialog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<AlertDialog
|
||||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4"
|
open={true}
|
||||||
onClick={onClose}
|
onOpenChange={(o) => {
|
||||||
|
if (!o && !deleteMutation.loading) onClose();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<AlertDialogContent>
|
||||||
className="w-full max-w-sm rounded-xl border bg-background p-6 shadow-lg"
|
<AlertDialogHeader>
|
||||||
onClick={(e) => e.stopPropagation()}
|
<AlertDialogTitle>{t("form.deleteTitle")}</AlertDialogTitle>
|
||||||
>
|
<AlertDialogDescription>
|
||||||
<h2 className="mb-2 text-lg font-semibold">{t("form.deleteTitle")}</h2>
|
{t("form.deleteMessage", {
|
||||||
<p className="mb-4 text-sm text-muted-foreground">
|
weekday: t(`weekday.${target.weekday}`),
|
||||||
{t("form.deleteMessage", {
|
period: target.period,
|
||||||
weekday: t(`weekday.${target.weekday}`),
|
subject: target.subjectName,
|
||||||
period: target.period,
|
})}
|
||||||
subject: target.subjectName,
|
</AlertDialogDescription>
|
||||||
})}
|
</AlertDialogHeader>
|
||||||
</p>
|
<AlertDialogFooter>
|
||||||
<div className="flex justify-end gap-2">
|
<AlertDialogCancel
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={deleteMutation.loading}
|
disabled={deleteMutation.loading}
|
||||||
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
{tCommon("button.cancel")}
|
{tCommon("button.cancel")}
|
||||||
</Button>
|
</AlertDialogCancel>
|
||||||
<Button
|
<AlertDialogAction
|
||||||
type="button"
|
|
||||||
variant="destructive"
|
|
||||||
disabled={deleteMutation.loading}
|
disabled={deleteMutation.loading}
|
||||||
onClick={() => void handleDelete()}
|
onClick={() => void handleDelete()}
|
||||||
>
|
>
|
||||||
{t("form.deleteConfirm")}
|
{t("form.deleteConfirm")}
|
||||||
</Button>
|
</AlertDialogAction>
|
||||||
</div>
|
</AlertDialogFooter>
|
||||||
</div>
|
</AlertDialogContent>
|
||||||
</div>
|
</AlertDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ import {
|
|||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { notify } from "@/shared/lib/notify";
|
import { notify } from "@/shared/lib/notify";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/shared/components/ui/dialog";
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||||
import { Input } from "@/shared/components/ui/input";
|
import { Input } from "@/shared/components/ui/input";
|
||||||
@@ -373,17 +381,21 @@ function DepartmentFormDialog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Dialog
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
open={true}
|
||||||
onClick={onClose}
|
onOpenChange={(o) => {
|
||||||
|
if (!o) onClose();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<DialogContent className="max-w-md">
|
||||||
className="w-full max-w-md rounded-xl border bg-background p-6 shadow-lg"
|
<DialogHeader>
|
||||||
onClick={(e) => e.stopPropagation()}
|
<DialogTitle>
|
||||||
>
|
{editTarget ? t("form.titleEdit") : t("form.titleCreate")}
|
||||||
<h2 className="mb-4 text-lg font-semibold">
|
</DialogTitle>
|
||||||
{editTarget ? t("form.titleEdit") : t("form.titleCreate")}
|
<DialogDescription className="sr-only">
|
||||||
</h2>
|
{editTarget ? t("form.titleEdit") : t("form.titleCreate")}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<FormField label={t("form.fieldName")} required>
|
<FormField label={t("form.fieldName")} required>
|
||||||
<Input
|
<Input
|
||||||
@@ -422,16 +434,16 @@ function DepartmentFormDialog({
|
|||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<div className="flex justify-end gap-2">
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={onClose}>
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
{t("form.cancel")}
|
{t("form.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={loading}>
|
<Button type="submit" disabled={loading}>
|
||||||
{t("form.submit")}
|
{t("form.submit")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</DialogContent>
|
||||||
</div>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -321,7 +321,7 @@ export function GradeInsightsClient(): React.ReactElement {
|
|||||||
icon={BarChart3}
|
icon={BarChart3}
|
||||||
title={t("classRanking.empty")}
|
title={t("classRanking.empty")}
|
||||||
description={t("classRanking.emptyDescription")}
|
description={t("classRanking.emptyDescription")}
|
||||||
className="min-h-[240px] bg-transparent"
|
className="min-h-60 bg-transparent"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,18 +4,20 @@
|
|||||||
* 学生管理列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5)
|
* 学生管理列表页 - 客户端组件(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)
|
* - 年级/班级选项 grades/adminClasses ❌ schema 无 → MSW 兜底(@contract-pending)
|
||||||
*
|
*
|
||||||
* URL 状态:?search=&gradeId=&classId=&page=
|
* URL 状态:?search=&gradeId=&classId=&page=
|
||||||
*
|
*
|
||||||
|
* 分页:全客户端切片(与 announcements 模块对齐,@contract-pending:MSW 兜底)
|
||||||
|
*
|
||||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState + 行动按钮)
|
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState + 行动按钮)
|
||||||
*
|
*
|
||||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
|
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
|
||||||
*/
|
*/
|
||||||
import { GraduationCap } from "lucide-react";
|
import { GraduationCap } from "lucide-react";
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
import { useMemo, useTransition } from "react";
|
import { useMemo, useState, useTransition } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -32,7 +34,6 @@ import {
|
|||||||
ListPageShell,
|
ListPageShell,
|
||||||
ListPageSkeleton,
|
ListPageSkeleton,
|
||||||
} from "@/shared/components/page-templates";
|
} from "@/shared/components/page-templates";
|
||||||
import { notify } from "@/shared/lib/notify";
|
|
||||||
import {
|
import {
|
||||||
formatStudentDate,
|
formatStudentDate,
|
||||||
formatStudentStatus,
|
formatStudentStatus,
|
||||||
@@ -40,9 +41,10 @@ import {
|
|||||||
studentStatusToBadgeClass,
|
studentStatusToBadgeClass,
|
||||||
truncateStudentName,
|
truncateStudentName,
|
||||||
} from "@/features/admin/students/transformations";
|
} from "@/features/admin/students/transformations";
|
||||||
|
import { StudentDetailDialog } from "@/features/admin/students/student-detail-dialog";
|
||||||
|
|
||||||
/** 每页条数(@contract-pending:MSW 兜底,真实分页待契约就绪) */
|
/** 客户端分页每页条数(@contract-pending:MSW 兜底,真实分页待契约就绪) */
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
||||||
@@ -54,13 +56,18 @@ export function StudentsListClient(): React.ReactElement {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
const [selectedStudent, setSelectedStudent] = useState<AdminStudent | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
const search = searchParams.get("search") ?? "";
|
const search = searchParams.get("search") ?? "";
|
||||||
const gradeId = searchParams.get("gradeId") ?? "";
|
const gradeId = searchParams.get("gradeId") ?? "";
|
||||||
const classId = searchParams.get("classId") ?? "";
|
const classId = searchParams.get("classId") ?? "";
|
||||||
const pageParam = Number(searchParams.get("page") ?? "1");
|
const pageParam = Number(searchParams.get("page") ?? "1");
|
||||||
const page = Number.isFinite(pageParam) && pageParam > 0 ? pageParam : 1;
|
const page = Number.isFinite(pageParam) && pageParam > 0 ? pageParam : 1;
|
||||||
// @contract-pending:MSW 兜底
|
|
||||||
|
// @contract-pending:MSW 兜底,全量拉取后客户端切片
|
||||||
const { data, loading, error } = useAdminStudents({
|
const { data, loading, error } = useAdminStudents({
|
||||||
gradeId: gradeId || null,
|
gradeId: gradeId || null,
|
||||||
classId: classId || null,
|
classId: classId || null,
|
||||||
@@ -76,14 +83,18 @@ export function StudentsListClient(): React.ReactElement {
|
|||||||
return all.filter((c) => c.gradeId === gradeId);
|
return all.filter((c) => c.gradeId === gradeId);
|
||||||
}, [classesData, gradeId]);
|
}, [classesData, gradeId]);
|
||||||
|
|
||||||
|
// 客户端二次过滤 + 切片(@contract-pending:MSW 兜底,真实分页待契约就绪)
|
||||||
const filteredItems = useMemo<AdminStudent[]>(() => {
|
const filteredItems = useMemo<AdminStudent[]>(() => {
|
||||||
const items = data?.items ?? [];
|
const items = data?.items ?? [];
|
||||||
return items.filter((s) => matchAdminStudentSearch(s, search));
|
return items.filter((s) => matchAdminStudentSearch(s, search));
|
||||||
}, [data, search]);
|
}, [data, search]);
|
||||||
|
|
||||||
const total = data?.total ?? filteredItems.length;
|
const pagedItems = useMemo(
|
||||||
const hasNext = page * PAGE_SIZE < total;
|
() => filteredItems.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
|
||||||
const hasPrev = page > 1;
|
[filteredItems, page],
|
||||||
|
);
|
||||||
|
|
||||||
|
const total = filteredItems.length;
|
||||||
|
|
||||||
const updateQuery = (key: string, value: string, resetPage = false): void => {
|
const updateQuery = (key: string, value: string, resetPage = false): void => {
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
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 ? (
|
const errorNode = error ? (
|
||||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||||
<p className="text-sm text-destructive">
|
<p className="text-sm text-destructive">
|
||||||
@@ -165,23 +181,21 @@ export function StudentsListClient(): React.ReactElement {
|
|||||||
value={gradeId}
|
value={gradeId}
|
||||||
onValueChange={(v) => handleGradeChange(v)}
|
onValueChange={(v) => handleGradeChange(v)}
|
||||||
aria-label={t("list.gradeFilter")}
|
aria-label={t("list.gradeFilter")}
|
||||||
|
className="h-9 w-40 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
options={[
|
options={[
|
||||||
{ value: "", label: t("list.allGrades") },
|
{ value: "", label: t("list.allGrades") },
|
||||||
...gradeOptions.map((g) => ({ value: g.id, label: g.name })),
|
...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
|
<Select
|
||||||
value={classId}
|
value={classId}
|
||||||
onValueChange={(v) => updateQuery("classId", v, true)}
|
onValueChange={(v) => updateQuery("classId", v, true)}
|
||||||
aria-label={t("list.classFilter")}
|
aria-label={t("list.classFilter")}
|
||||||
|
className="h-9 w-40 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
options={[
|
options={[
|
||||||
{ value: "", label: t("list.allClasses") },
|
{ value: "", label: t("list.allClasses") },
|
||||||
...classOptions.map((c) => ({ value: c.id, label: c.name })),
|
...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}
|
emptyNode={emptyNode}
|
||||||
errorNode={errorNode}
|
errorNode={errorNode}
|
||||||
pagination={
|
pagination={
|
||||||
<div className="flex items-center justify-end gap-3 text-sm text-muted-foreground">
|
<PaginationBar
|
||||||
<span>{t("list.total", { count: filteredItems.length })}</span>
|
total={total}
|
||||||
<div className="flex gap-1">
|
page={page}
|
||||||
<button
|
pageSize={PAGE_SIZE}
|
||||||
type="button"
|
onNavigate={goToPage}
|
||||||
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>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<StudentsTable items={filteredItems} />
|
<StudentsTable items={pagedItems} onViewDetail={openDetail} />
|
||||||
|
<StudentDetailDialog
|
||||||
|
open={detailOpen}
|
||||||
|
onOpenChange={setDetailOpen}
|
||||||
|
student={selectedStudent}
|
||||||
|
/>
|
||||||
</ListPageShell>
|
</ListPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -225,8 +228,10 @@ export function StudentsListClient(): React.ReactElement {
|
|||||||
*/
|
*/
|
||||||
function StudentsTable({
|
function StudentsTable({
|
||||||
items,
|
items,
|
||||||
|
onViewDetail,
|
||||||
}: {
|
}: {
|
||||||
items: AdminStudent[];
|
items: AdminStudent[];
|
||||||
|
onViewDetail: (student: AdminStudent) => void;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const t = useTranslations("admin.students");
|
const t = useTranslations("admin.students");
|
||||||
return (
|
return (
|
||||||
@@ -268,7 +273,7 @@ function StudentsTable({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => notify.info(t("list.mswNotice"))}
|
onClick={() => onViewDetail(s)}
|
||||||
>
|
>
|
||||||
{t("list.viewDetail")}
|
{t("list.viewDetail")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -295,3 +300,92 @@ function StatusBadge({ status }: { status: string }): React.ReactElement {
|
|||||||
</span>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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):
|
||||||
|
* - loading:DetailPageSkeleton
|
||||||
|
* - error:errorNode 局部降级
|
||||||
|
* - notFound:data 为 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-pending:MSW 兜底
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -33,11 +33,13 @@ import {
|
|||||||
ListPageSkeleton,
|
ListPageSkeleton,
|
||||||
} from "@/shared/components/page-templates";
|
} from "@/shared/components/page-templates";
|
||||||
import {
|
import {
|
||||||
Card,
|
Dialog,
|
||||||
CardContent,
|
DialogContent,
|
||||||
CardHeader,
|
DialogDescription,
|
||||||
CardTitle,
|
DialogFooter,
|
||||||
} from "@/shared/components/ui/card";
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/shared/components/ui/dialog";
|
||||||
import {
|
import {
|
||||||
activeToBadgeClass,
|
activeToBadgeClass,
|
||||||
extractPermissions,
|
extractPermissions,
|
||||||
@@ -278,12 +280,20 @@ function ViewportEditDialog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
<Dialog
|
||||||
<Card className="w-full max-w-lg">
|
open={true}
|
||||||
<CardHeader>
|
onOpenChange={(o) => {
|
||||||
<CardTitle className="text-base">{t("editTitle")}</CardTitle>
|
if (!o) onClose();
|
||||||
</CardHeader>
|
}}
|
||||||
<CardContent className="space-y-4">
|
>
|
||||||
|
<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")}>
|
<FormField label={t("colName")}>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -331,17 +341,17 @@ function ViewportEditDialog({
|
|||||||
className="size-4 cursor-pointer rounded border border-input"
|
className="size-4 cursor-pointer rounded border border-input"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<div className="flex justify-end gap-2 border-t pt-4">
|
<DialogFooter className="border-t pt-4">
|
||||||
<Button variant="outline" onClick={onClose}>
|
<Button variant="outline" onClick={onClose}>
|
||||||
{tCommon("button.cancel")}
|
{tCommon("button.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSubmit} disabled={submitting}>
|
<Button onClick={handleSubmit} disabled={submitting}>
|
||||||
{t("saveButton")}
|
{t("saveButton")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</DialogContent>
|
||||||
</div>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 DoD):loading(骨架)/ 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-pending:MSW 兜底
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 DoD):loading(骨架)/ 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-pending:MSW 兜底
|
||||||
|
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");
|
||||||
|
}
|
||||||
@@ -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 DoD):loading(骨架)/ 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-pending:MSW 兜底
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
329
apps/portal-shell/src/features/shared/profile/profile-client.tsx
Normal file
329
apps/portal-shell/src/features/shared/profile/profile-client.tsx
Normal 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 DoD):loading(骨架)/ 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-pending:MSW 兜底
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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):
|
||||||
|
* - loading:StatCard 骨架 + DashboardSection 骨架
|
||||||
|
* - error:局部降级 Card + 文案
|
||||||
|
* - empty:EmptyState 组件
|
||||||
|
*
|
||||||
|
* 关联: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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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):
|
||||||
|
* - loading:DetailPageSkeleton
|
||||||
|
* - error:errorNode 局部降级 + notify.error()
|
||||||
|
* - empty:EmptyState 组件
|
||||||
|
*
|
||||||
|
* 页面结构(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</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");
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 成长档案卡(ARCHITECTURE.md §7.3 / §9.1 / §10 P2)
|
||||||
|
*
|
||||||
|
* 纯 SVG 多折线图,展示跨学年/学期的各学科成绩变化。
|
||||||
|
* 每个学科一条折线 + 图例,数据点标注分数。
|
||||||
|
*
|
||||||
|
* 数据契约:StudentGrowthArchivePoint[](@contract-pending,MSW 兜底)
|
||||||
|
* 设计令牌: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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 排名趋势卡(ARCHITECTURE.md §7.3 / §9.1 / §10 P2)
|
||||||
|
*
|
||||||
|
* 纯 SVG 折线图,展示最近若干次考试的班级排名变化。
|
||||||
|
* Y 轴反向(排名 1 在顶部,排名越大越靠下),数据点标注排名数字。
|
||||||
|
*
|
||||||
|
* 数据契约:StudentRankingTrendPoint[](@contract-pending,MSW 兜底)
|
||||||
|
* 设计令牌: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;
|
||||||
|
// 反向 Y:rank 1 → 顶部(padY),maxRank → 底部(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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 班级分布卡(ARCHITECTURE.md §7.3 / §9.1 / §10 P2)
|
||||||
|
*
|
||||||
|
* 纯 SVG 柱状图,展示当前考试/学科的班级分数段分布。
|
||||||
|
* 高亮当前学生所在分数段(primary 色),其余段用 muted 色。
|
||||||
|
*
|
||||||
|
* 数据契约:StudentScoreDistribution(@contract-pending,MSW 兜底)
|
||||||
|
* 设计令牌: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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -290,7 +290,7 @@ function TrendCard({ points }: { points: TrendPoint[] }): React.ReactElement {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<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">
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
{t("trendEmptyTitle")}
|
{t("trendEmptyTitle")}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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-pending:MSW 兜底(班级下拉数据源)
|
||||||
|
const { data: classes } = useStudentClasses();
|
||||||
|
// @contract-pending:MSW 兜底
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, type ReactNode } from "react";
|
||||||
|
|
||||||
|
import type { AiClientService } from "../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 客户端服务 Context
|
||||||
|
*
|
||||||
|
* 通过 React Context 注入 AiClientService(Server 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 AI 客户端服务(portal-shell 适配版)
|
||||||
|
*
|
||||||
|
* 从 CICD src/modules/ai/context/create-ai-client-service.ts 迁移。
|
||||||
|
* 适配:CICD 通过 Server Action 引用调用 AI,portal-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),
|
||||||
|
};
|
||||||
|
}
|
||||||
141
apps/portal-shell/src/features/teacher/ai/hooks/stream-utils.ts
Normal file
141
apps/portal-shell/src/features/teacher/ai/hooks/stream-utils.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可拖拽悬浮球 Hook(360 悬浮球风格)
|
||||||
|
*
|
||||||
|
* 特性:
|
||||||
|
* - 鼠标/触摸拖拽,松手后吸附到最近屏幕边缘
|
||||||
|
* - 拖到边缘超过阈值时半隐藏(只露出一小部分)
|
||||||
|
* - 单击(未发生拖动)触发 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
280
apps/portal-shell/src/features/teacher/ai/schema.ts
Normal file
280
apps/portal-shell/src/features/teacher/ai/schema.ts
Normal 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(),
|
||||||
|
});
|
||||||
559
apps/portal-shell/src/features/teacher/ai/services/ai-service.ts
Normal file
559
apps/portal-shell/src/features/teacher/ai/services/ai-service.ts
Normal 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) };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
/**
|
||||||
|
* AI 内容安全过滤
|
||||||
|
*
|
||||||
|
* 多层防护:
|
||||||
|
* 1. 输入过滤:检查用户输入是否包含不当内容
|
||||||
|
* 2. 输出过滤:检查 AI 回复是否包含不当内容
|
||||||
|
* 3. 每日限制:按用户 + 日期计数(原子操作,防 TOCTOU 竞态)
|
||||||
|
*
|
||||||
|
* 参考 Khanmigo 的多层 moderation 模式。
|
||||||
|
*
|
||||||
|
* 注意:当前为内存实现,多实例部署需替换为 Redis(INCR + 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 };
|
||||||
|
};
|
||||||
@@ -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");
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
332
apps/portal-shell/src/features/teacher/ai/types.ts
Normal file
332
apps/portal-shell/src/features/teacher/ai/types.ts
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
/**
|
||||||
|
* AI 模块类型定义
|
||||||
|
*
|
||||||
|
* 从 CICD src/modules/ai/types.ts 迁移。
|
||||||
|
* 适配:ActionState 在 portal-shell 未提供,本文件内联定义。
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ActionState(portal-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/服务端。
|
||||||
|
* 此接口仅用于类型契约;客户端组件通过 AiClientService(fetch 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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user