refactor: RBAC权限系统重构 + UI组件拆分 + 测试修复 + 架构文档
Some checks failed
CI / build-deploy (push) Has been cancelled
Some checks failed
CI / build-deploy (push) Has been cancelled
- RBAC: 新增30个权限点、DataScope行级权限、requirePermission守卫,所有57+ Server Action接入权限校验 - UI拆分: exam-form(1623行→11文件)、textbook-reader(744行→7文件),均降至300行以内 - 测试: 新增5个单元测试文件(19用例),修复4个集成测试文件(38用例全部通过) - 架构文档: 新增架构影响地图(004/005)、标准功能清单(006)、差距审计报告(007) - 项目规则: 架构图优先规则,改码必同步图 - 安全: rehype-sanitize净化、AES加密API Key、权限路由守卫 - 无障碍: skip-link、aria-label、prefers-reduced-motion - 性能: next/font优化、next/image、代码分割
This commit is contained in:
41
.trae/rules/project_rules.md
Normal file
41
.trae/rules/project_rules.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# 项目规则
|
||||
|
||||
## 架构图优先规则
|
||||
|
||||
**任何任务开始前,必须先查阅架构影响地图,通过图定位代码和模块。**
|
||||
|
||||
1. **先图后码**:执行任何分析、修改、搜索任务时,首先阅读 `docs/architecture/004_architecture_impact_map.md` 或 `docs/architecture/005_architecture_data.json`,从图中定位目标模块、函数、依赖关系,再按图索骥读取源码
|
||||
2. **图未覆盖则先补图**:如果发现项目中存在架构图未记录的模块、函数、表、路由等,**必须优先完善架构图信息**,然后再继续后续工作
|
||||
3. **改码必同步图**:对源码的任何修改完成后,必须同步更新 004 和 005 两个架构文档
|
||||
|
||||
### 架构文档清单
|
||||
|
||||
| 文档 | 用途 |
|
||||
|------|------|
|
||||
| `docs/architecture/004_architecture_impact_map.md` | 人类可读的架构影响地图 |
|
||||
| `docs/architecture/005_architecture_data.json` | AI 友好格式的结构化数据 |
|
||||
| `docs/architecture/006_k12_feature_checklist.md` | 标准功能模块清单 |
|
||||
| `docs/architecture/007_gap_audit_report.md` | 差距审计报告 |
|
||||
|
||||
### 需要同步图的场景
|
||||
|
||||
- 新增/删除/重命名导出函数、组件、Hook、类型
|
||||
- 修改函数签名(参数、返回类型)
|
||||
- 修改权限点(Permissions 常量)或角色-权限映射
|
||||
- 新增/删除数据库表
|
||||
- 新增/删除路由页面或 API 路由
|
||||
- 修改模块间依赖关系
|
||||
- 新增模块
|
||||
|
||||
### 同步方式
|
||||
|
||||
- 修改 Markdown 文档中对应的模块章节
|
||||
- 修改 JSON 文档中对应的节点(`modules.*.exports`、`permissions`、`dependencyMatrix`、`routes`、`dbTables` 等)
|
||||
- 确保两个文档内容一致
|
||||
|
||||
## 代码质量规则
|
||||
|
||||
- 每次修改后运行 `npm run lint` 和 `npx tsc --noEmit` 确保零错误
|
||||
- Server Action 必须使用 `requirePermission()` 进行权限校验
|
||||
- 前端组件禁止使用 `role === "xxx"` 硬编码,统一使用 `usePermission().hasPermission()`
|
||||
- 单文件不超过 300 行
|
||||
241
docs/architecture/001_project_overview.md
Normal file
241
docs/architecture/001_project_overview.md
Normal file
@@ -0,0 +1,241 @@
|
||||
# Next_Edu 项目架构分析
|
||||
|
||||
> 本文档基于源码逆向分析生成,未参考任何已有文档。
|
||||
|
||||
---
|
||||
|
||||
## 一、项目定位与目标
|
||||
|
||||
**Next_Edu** 是一个面向 K12 场景的**智慧教务管理系统**,旨在将传统学校的教务流程数字化、智能化。
|
||||
|
||||
核心目标:
|
||||
1. **多角色协同**:管理员、教师、学生、家长四种角色在同一平台协作,各角色有独立的仪表盘和功能入口
|
||||
2. **AI 赋能教学**:集成大语言模型(智谱/OpenAI/Gemini),支持 AI 自动生成试卷、AI 重写题目、AI 对话辅导
|
||||
3. **全流程闭环**:从教材管理 → 题库建设 → 试卷组卷 → 考试/作业发布 → 学生作答 → 教师批改 → 数据分析,覆盖教学评估全链路
|
||||
4. **知识体系结构化**:教材章节与知识点树形关联,知识点与题目双向链接,支撑精准教学
|
||||
|
||||
---
|
||||
|
||||
## 二、技术栈
|
||||
|
||||
| 层次 | 技术选型 | 版本 |
|
||||
|------|---------|------|
|
||||
| 框架 | Next.js (App Router) | 16 |
|
||||
| 语言 | TypeScript (strict) | 5 |
|
||||
| 前端 | React | 19 |
|
||||
| 样式 | Tailwind CSS + shadcn/ui | v4 |
|
||||
| 状态 | Zustand + nuqs (URL) + React Hook Form | — |
|
||||
| 数据库 | MySQL | — |
|
||||
| ORM | Drizzle ORM | 0.45 |
|
||||
| 认证 | NextAuth v5 (JWT strategy) | beta |
|
||||
| 校验 | Zod | 4 |
|
||||
| AI | OpenAI SDK (多 provider) | — |
|
||||
| 富文本 | TipTap | 3 |
|
||||
| 图表 | Recharts | 3 |
|
||||
| 测试 | Vitest + Playwright | — |
|
||||
| 部署 | Docker (standalone output) | — |
|
||||
|
||||
---
|
||||
|
||||
## 三、系统角色与权限模型
|
||||
|
||||
### 角色定义
|
||||
|
||||
| 角色 | 路由前缀 | 核心职责 |
|
||||
|------|---------|---------|
|
||||
| **Admin** | `/admin/*` | 学校管理、年级/班级/部门配置、用户管理、全局设置 |
|
||||
| **Teacher** | `/teacher/*` | 教材管理、题库建设、试卷组卷、作业发布与批改、班级管理 |
|
||||
| **Student** | `/student/*` | 课程学习、作业作答、教材阅读、课表查看 |
|
||||
| **Parent** | `/parent/*` | 子女学情查看、缴费、消息沟通 |
|
||||
|
||||
### 权限控制机制
|
||||
|
||||
```
|
||||
请求 → NextAuth Middleware (proxy.ts)
|
||||
→ 检查 session 是否存在 → 无则重定向 /login
|
||||
→ 解析 JWT 中的 role 字段
|
||||
→ 按路由前缀校验角色匹配
|
||||
→ 不匹配则重定向到角色首页
|
||||
```
|
||||
|
||||
- **角色解析**:`grade_head` 和 `teaching_head` 映射为 `teacher`;多角色用户取优先级 `admin > teacher > parent > student`
|
||||
- **用户-角色**:多对多关系(`users_to_roles` 表),支持一人多角色
|
||||
- **导航隔离**:`NAV_CONFIG` 按角色定义侧边栏菜单,不同角色看到完全不同的功能入口
|
||||
|
||||
---
|
||||
|
||||
## 四、数据模型
|
||||
|
||||
### 核心实体关系
|
||||
|
||||
```
|
||||
School ──1:N──→ Grade ──1:N──→ Class ──1:N──→ ClassEnrollment ←──N:1── User(student)
|
||||
│ │
|
||||
│ ├── ClassSubjectTeacher (班级-科目-教师)
|
||||
│ └── ClassSchedule (课表)
|
||||
│
|
||||
├── Department
|
||||
├── Classroom
|
||||
└── AcademicYear
|
||||
|
||||
User ──M:N──→ Role (RBAC)
|
||||
|
||||
Textbook ──1:N──→ Chapter (树形嵌套) ──1:N──→ KnowledgePoint (树形嵌套)
|
||||
│
|
||||
Question ──M:N──→ KnowledgePoint │
|
||||
│ │
|
||||
├── 支持类型: single_choice / multiple_choice │
|
||||
│ / text / judgment / composite │
|
||||
└── 支持无限嵌套 (parentId 自引用) │
|
||||
│
|
||||
Exam ──M:N──→ Question (exam_questions) │
|
||||
├── ExamSubmission ──1:N──→ SubmissionAnswer │
|
||||
└── structure (JSON 层级结构) │
|
||||
│
|
||||
HomeworkAssignment ──M:N──→ Question │
|
||||
├── sourceExamId → Exam (作业源自试卷) │
|
||||
├── HomeworkAssignmentTarget (指定学生) │
|
||||
└── HomeworkSubmission ──1:N──→ HomeworkAnswer │
|
||||
│
|
||||
AIProvider (zhipu / openai / gemini / custom) │
|
||||
└── apiKeyEncrypted (AES 加密存储) │
|
||||
```
|
||||
|
||||
### 数据库规模
|
||||
|
||||
- **20+ 张表**,覆盖用户认证、RBAC、学校管理、教学资源、考试系统、作业系统、AI 配置
|
||||
- **ID 策略**:CUID2(`@paralleldrive/cuid2`),128 位 varchar
|
||||
- **索引策略**:所有外键和查询字段均有索引,支持级联删除
|
||||
|
||||
---
|
||||
|
||||
## 五、架构设计
|
||||
|
||||
### 分层架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ App Router (src/app/) │
|
||||
│ ├── Route Groups: (auth) / (dashboard) │
|
||||
│ ├── Server Components (默认) │
|
||||
│ ├── loading.tsx (Suspense 边界) │
|
||||
│ └── error.tsx (错误边界) │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Feature Modules (src/modules/) │
|
||||
│ ├── auth/ 认证模块 │
|
||||
│ ├── exams/ 考试模块 │
|
||||
│ ├── homework/ 作业模块 │
|
||||
│ ├── questions/ 题库模块 │
|
||||
│ ├── textbooks/ 教材模块 │
|
||||
│ ├── classes/ 班级模块 │
|
||||
│ ├── school/ 学校管理模块 │
|
||||
│ ├── dashboard/ 仪表盘模块 │
|
||||
│ ├── layout/ 布局与导航 │
|
||||
│ ├── settings/ 设置模块 │
|
||||
│ └── users/ 用户管理模块 │
|
||||
│ 每个模块: │
|
||||
│ ├── components/ UI 组件 │
|
||||
│ ├── hooks/ 自定义 Hook │
|
||||
│ ├── actions.ts Server Actions │
|
||||
│ ├── data-access.ts 数据访问层 │
|
||||
│ └── types.ts 类型定义 │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Shared (src/shared/) │
|
||||
│ ├── components/ui/ shadcn/ui 基础组件 (30+) │
|
||||
│ ├── hooks/ 通用 Hook │
|
||||
│ ├── lib/ 工具函数 │
|
||||
│ ├── db/ 数据库 (schema/relations) │
|
||||
│ └── types/ 公共类型 │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ API Routes (src/app/api/) │
|
||||
│ ├── auth/[...nextauth] NextAuth 端点 │
|
||||
│ ├── ai/chat AI 对话 │
|
||||
│ └── onboarding/* 用户引导 │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 数据流
|
||||
|
||||
```
|
||||
用户操作 → Client Component
|
||||
→ Server Action (actions.ts)
|
||||
→ 数据访问层 (data-access.ts)
|
||||
→ Drizzle ORM (db/index.ts)
|
||||
→ MySQL
|
||||
|
||||
Server Action 返回 → ActionState<T>
|
||||
→ { success, message, data?, errors? }
|
||||
→ Client 端 toast 反馈
|
||||
```
|
||||
|
||||
### AI 集成架构
|
||||
|
||||
```
|
||||
教师操作 → AI Provider 选择 (支持多 provider)
|
||||
→ API Key AES 加密存储 (ai_providers 表)
|
||||
→ Server Action 调用 AI Pipeline
|
||||
├── 试卷生成: 输入源文本 → AI 解析 → 结构化题目
|
||||
├── 题目重写: 输入原题 + 指令 → AI 重写 → 更新题目
|
||||
└── AI 对话: /api/ai/chat → 流式响应
|
||||
→ PQueue 并发控制 (最多 3 个后台任务)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、核心业务流程
|
||||
|
||||
### 1. 试卷组卷流程
|
||||
|
||||
```
|
||||
手动模式: 选择科目/年级 → 填写考试信息 → 创建草稿 → 进入试卷构建器 → 从题库选题
|
||||
AI 模式: 选择 AI Provider → 粘贴试卷源文本 → AI 解析生成 → 预览/编辑 → 确认创建
|
||||
```
|
||||
|
||||
### 2. 作业发布与批改流程
|
||||
|
||||
```
|
||||
教师: 从已有试卷创建作业 → 选择目标学生 → 设置截止时间 → 发布
|
||||
学生: 查看作业列表 → 作答 → 提交
|
||||
教师: 查看提交列表 → 批改评分 → 填写反馈
|
||||
```
|
||||
|
||||
### 3. 教材与知识体系流程
|
||||
|
||||
```
|
||||
教师: 创建教材 → 添加章节(树形) → 编写内容(Markdown/富文本)
|
||||
→ 从文本选中创建知识点 → 知识点自动关联章节
|
||||
→ 从知识点创建题目 → 题目关联知识点
|
||||
学生: 选择章节阅读 → 知识点高亮链接 → 查看知识图谱
|
||||
```
|
||||
|
||||
### 4. 用户注册与引导流程
|
||||
|
||||
```
|
||||
注册 → 默认 student 角色 → OnboardingGate 检查
|
||||
→ 未引导: 强制填写角色/学校/班级信息
|
||||
→ 已引导: 进入角色仪表盘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、部署与运维
|
||||
|
||||
- **构建模式**: `output: "standalone"` — 适配 Docker 容器化部署
|
||||
- **数据库迁移**: Drizzle Kit (`db:generate` / `db:migrate`)
|
||||
- **数据填充**: `db:seed` 脚本 + `@faker-js/faker`
|
||||
- **CI/CD**: `.gitea/workflows/ci.yml` — Gitea Actions
|
||||
- **测试**: 单元测试 (Vitest) + 集成测试 + E2E (Playwright)
|
||||
|
||||
---
|
||||
|
||||
## 八、项目规模统计
|
||||
|
||||
| 维度 | 数量 |
|
||||
|------|------|
|
||||
| 数据库表 | 20+ |
|
||||
| 业务模块 | 11 |
|
||||
| UI 基础组件 | 30+ |
|
||||
| 路由页面 | 30+ |
|
||||
| Server Actions | 50+ |
|
||||
| API 路由 | 4 |
|
||||
| 用户角色 | 4 (admin/teacher/student/parent) |
|
||||
975
docs/architecture/002_rbac_refactoring.md
Normal file
975
docs/architecture/002_rbac_refactoring.md
Normal file
@@ -0,0 +1,975 @@
|
||||
# 企业级权限体系重构方案
|
||||
|
||||
> 基于源码逆向分析,覆盖 proxy.ts / auth.ts / 各模块 actions.ts / 前端组件
|
||||
|
||||
---
|
||||
|
||||
## 一、问题诊断
|
||||
|
||||
### 1.1 安全隐患
|
||||
|
||||
#### 隐患 A:考试模块 Server Action 零鉴权
|
||||
|
||||
[exams/actions.ts:721-723](file:///e:/Desktop/CICD/src/modules/exams/actions.ts#L721-L723) 中的 `getCurrentUser()` 是一个硬编码存根:
|
||||
|
||||
```typescript
|
||||
async function getCurrentUser() {
|
||||
return { id: "user_teacher_math", role: "teacher" }
|
||||
}
|
||||
```
|
||||
|
||||
**后果**:任何已登录用户(包括 student/parent)都可以调用 `deleteExamAction`、`updateExamAction`、`duplicateExamAction`,删除或修改任意考试。攻击路径:
|
||||
|
||||
1. 学生 A 登录系统,获取合法 session
|
||||
2. 直接调用 `deleteExamAction`,传入任意 `examId`
|
||||
3. Server Action 不校验调用者身份,直接执行 `db.delete(exams).where(eq(exams.id, examId))`
|
||||
4. 考试被删除
|
||||
|
||||
#### 隐患 B:越权修改他人考试
|
||||
|
||||
即使修复了 `getCurrentUser()`,`updateExamAction` 和 `deleteExamAction` 仍不检查资源归属:
|
||||
|
||||
```typescript
|
||||
// updateExamAction — 任何 teacher 可修改任何考试
|
||||
await db.update(exams).set(updateData).where(eq(exams.id, examId))
|
||||
// 缺少: AND creatorId = currentUserId
|
||||
```
|
||||
|
||||
教师 B 可以修改教师 A 创建的考试,只需知道 examId。
|
||||
|
||||
#### 隐患 C:班级管理部分接口无鉴权
|
||||
|
||||
[classes/actions.ts](file:///e:/Desktop/CICD/src/modules/classes/actions.ts) 中:
|
||||
- `enrollStudentByEmailAction` — 无任何 auth 检查
|
||||
- `setStudentEnrollmentStatusAction` — 无任何 auth 检查
|
||||
- `createClassScheduleItemAction` — 无任何 auth 检查
|
||||
- `deleteClassScheduleItemAction` — 无任何 auth 检查
|
||||
- `ensureClassInvitationCodeAction` — 无任何 auth 检查
|
||||
- `regenerateClassInvitationCodeAction` — 无任何 auth 检查
|
||||
|
||||
任何已登录用户都可以操作任意班级的学生和课表。
|
||||
|
||||
#### 隐患 D:作业批改无资源归属校验
|
||||
|
||||
`gradeHomeworkSubmissionAction` 只调用 `ensureTeacher()`,确认调用者是教师身份,但不检查该教师是否有权批改此作业。任何教师可批改任何班级的作业。
|
||||
|
||||
### 1.2 扩展性问题
|
||||
|
||||
#### 问题 A:角色硬编码散布全栈
|
||||
|
||||
前端 14 处 `role === "xxx"` 硬编码,后端各模块各自实现鉴权逻辑:
|
||||
|
||||
| 位置 | 模式 |
|
||||
|------|------|
|
||||
| `proxy.ts` | `if (role === "admin")` |
|
||||
| `onboarding-gate.tsx` | `if (role === "admin")` / `role === "teacher"` / `role === "student"` |
|
||||
| `dashboard/page.tsx` | `if (role === "admin") redirect(...)` |
|
||||
| `settings/page.tsx` | `if (role === "admin") return <AdminSettingsView>` |
|
||||
| `homework/actions.ts` | `ensureTeacher()` / `ensureStudent()` |
|
||||
| `classes/actions.ts` | `session.user.role !== "admin"` |
|
||||
|
||||
新增角色(如 `teaching_head`、`grade_head`)需要改动所有这些位置。
|
||||
|
||||
#### 问题 B:多角色用户被强制取一个
|
||||
|
||||
[auth.ts](file:///e:/Desktop/CICD/src/auth.ts) 中多角色解析逻辑取优先级最高的一个角色,存入 JWT 的单一 `role` 字段。一个同时是 `teacher` + `grade_head` 的用户,在 JWT 中只保留 `teacher`,导致年级管理功能不可用。
|
||||
|
||||
#### 问题 C:数据权限无统一抽象
|
||||
|
||||
`homework/actions.ts` 手动实现了"教师只能为自己班级的作业发布任务"的逻辑(30+ 行),`classes/actions.ts` 手动实现了"年级主任只能管理自己年级"的逻辑(20+ 行)。每个模块各自实现,无法复用。
|
||||
|
||||
### 1.3 可维护性问题
|
||||
|
||||
#### 问题 A:鉴权逻辑与业务逻辑耦合
|
||||
|
||||
每个 Server Action 的前 10-30 行都是鉴权代码,与业务逻辑混杂,难以测试和复用。
|
||||
|
||||
#### 问题 B:前端条件渲染依赖角色字符串
|
||||
|
||||
```tsx
|
||||
// 当前:硬编码角色判断
|
||||
{role === "teacher" ? <TeacherView /> : <StudentView />}
|
||||
|
||||
// 无法支持:年级主任看到额外的管理入口
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、企业级权限模型设计
|
||||
|
||||
### 2.1 权限模型:RBAC + 数据权限
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
|
||||
│ User │────→│ UserRole │←────│ Role │
|
||||
│ │ M:N │ (多角色) │ │ (角色定义) │
|
||||
└─────────────┘ └──────────────┘ └──────┬──────┘
|
||||
│ M:N
|
||||
┌──────┴──────┐
|
||||
│ RolePermission│
|
||||
└──────┬──────┘
|
||||
│ N:1
|
||||
┌──────┴──────┐
|
||||
│ Permission │
|
||||
│ (权限点) │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
### 2.2 权限点定义
|
||||
|
||||
权限点采用 `resource:action` 命名规范:
|
||||
|
||||
```typescript
|
||||
// src/shared/types/permissions.ts
|
||||
|
||||
export const Permissions = {
|
||||
// 考试
|
||||
EXAM_CREATE: "exam:create",
|
||||
EXAM_READ: "exam:read",
|
||||
EXAM_UPDATE: "exam:update",
|
||||
EXAM_DELETE: "exam:delete",
|
||||
EXAM_DUPLICATE: "exam:duplicate",
|
||||
EXAM_PUBLISH: "exam:publish",
|
||||
EXAM_AI_GENERATE: "exam:ai_generate",
|
||||
|
||||
// 作业
|
||||
HOMEWORK_CREATE: "homework:create",
|
||||
HOMEWORK_GRADE: "homework:grade",
|
||||
HOMEWORK_SUBMIT: "homework:submit",
|
||||
|
||||
// 题库
|
||||
QUESTION_CREATE: "question:create",
|
||||
QUESTION_READ: "question:read",
|
||||
QUESTION_UPDATE: "question:update",
|
||||
QUESTION_DELETE: "question:delete",
|
||||
|
||||
// 教材
|
||||
TEXTBOOK_CREATE: "textbook:create",
|
||||
TEXTBOOK_READ: "textbook:read",
|
||||
TEXTBOOK_UPDATE: "textbook:update",
|
||||
TEXTBOOK_DELETE: "textbook:delete",
|
||||
|
||||
// 班级
|
||||
CLASS_CREATE: "class:create",
|
||||
CLASS_READ: "class:read",
|
||||
CLASS_UPDATE: "class:update",
|
||||
CLASS_DELETE: "class:delete",
|
||||
CLASS_ENROLL: "class:enroll",
|
||||
CLASS_SCHEDULE: "class:schedule",
|
||||
|
||||
// 学校管理
|
||||
SCHOOL_MANAGE: "school:manage",
|
||||
GRADE_MANAGE: "grade:manage",
|
||||
USER_MANAGE: "user:manage",
|
||||
|
||||
// AI
|
||||
AI_CHAT: "ai:chat",
|
||||
AI_CONFIGURE: "ai:configure",
|
||||
|
||||
// 设置
|
||||
SETTINGS_ADMIN: "settings:admin",
|
||||
} as const
|
||||
|
||||
export type Permission = typeof Permissions[keyof typeof Permissions]
|
||||
```
|
||||
|
||||
### 2.3 角色-权限映射
|
||||
|
||||
```typescript
|
||||
// src/shared/lib/permissions.ts
|
||||
|
||||
import { Permissions, type Permission } from "@/shared/types/permissions"
|
||||
|
||||
export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
|
||||
admin: [
|
||||
Permissions.EXAM_CREATE, Permissions.EXAM_READ, Permissions.EXAM_UPDATE,
|
||||
Permissions.EXAM_DELETE, Permissions.EXAM_DUPLICATE, Permissions.EXAM_PUBLISH,
|
||||
Permissions.EXAM_AI_GENERATE,
|
||||
Permissions.HOMEWORK_CREATE, Permissions.HOMEWORK_GRADE,
|
||||
Permissions.QUESTION_CREATE, Permissions.QUESTION_READ,
|
||||
Permissions.QUESTION_UPDATE, Permissions.QUESTION_DELETE,
|
||||
Permissions.TEXTBOOK_CREATE, Permissions.TEXTBOOK_READ,
|
||||
Permissions.TEXTBOOK_UPDATE, Permissions.TEXTBOOK_DELETE,
|
||||
Permissions.CLASS_CREATE, Permissions.CLASS_READ,
|
||||
Permissions.CLASS_UPDATE, Permissions.CLASS_DELETE,
|
||||
Permissions.CLASS_ENROLL, Permissions.CLASS_SCHEDULE,
|
||||
Permissions.SCHOOL_MANAGE, Permissions.GRADE_MANAGE,
|
||||
Permissions.USER_MANAGE,
|
||||
Permissions.AI_CHAT, Permissions.AI_CONFIGURE,
|
||||
Permissions.SETTINGS_ADMIN,
|
||||
],
|
||||
teacher: [
|
||||
Permissions.EXAM_CREATE, Permissions.EXAM_READ, Permissions.EXAM_UPDATE,
|
||||
Permissions.EXAM_DELETE, Permissions.EXAM_DUPLICATE, Permissions.EXAM_PUBLISH,
|
||||
Permissions.EXAM_AI_GENERATE,
|
||||
Permissions.HOMEWORK_CREATE, Permissions.HOMEWORK_GRADE,
|
||||
Permissions.QUESTION_CREATE, Permissions.QUESTION_READ,
|
||||
Permissions.QUESTION_UPDATE, Permissions.QUESTION_DELETE,
|
||||
Permissions.TEXTBOOK_CREATE, Permissions.TEXTBOOK_READ,
|
||||
Permissions.TEXTBOOK_UPDATE,
|
||||
Permissions.CLASS_READ, Permissions.CLASS_ENROLL,
|
||||
Permissions.CLASS_SCHEDULE,
|
||||
Permissions.AI_CHAT,
|
||||
],
|
||||
student: [
|
||||
Permissions.EXAM_READ,
|
||||
Permissions.HOMEWORK_SUBMIT,
|
||||
Permissions.QUESTION_READ,
|
||||
Permissions.TEXTBOOK_READ,
|
||||
Permissions.CLASS_READ,
|
||||
Permissions.AI_CHAT,
|
||||
],
|
||||
parent: [
|
||||
Permissions.EXAM_READ,
|
||||
Permissions.TEXTBOOK_READ,
|
||||
Permissions.CLASS_READ,
|
||||
],
|
||||
// 可扩展:年级主任、教研组长等
|
||||
grade_head: [
|
||||
Permissions.EXAM_CREATE, Permissions.EXAM_READ, Permissions.EXAM_UPDATE,
|
||||
Permissions.EXAM_DELETE, Permissions.EXAM_DUPLICATE, Permissions.EXAM_PUBLISH,
|
||||
Permissions.EXAM_AI_GENERATE,
|
||||
Permissions.HOMEWORK_CREATE, Permissions.HOMEWORK_GRADE,
|
||||
Permissions.QUESTION_CREATE, Permissions.QUESTION_READ,
|
||||
Permissions.QUESTION_UPDATE, Permissions.QUESTION_DELETE,
|
||||
Permissions.TEXTBOOK_CREATE, Permissions.TEXTBOOK_READ,
|
||||
Permissions.TEXTBOOK_UPDATE,
|
||||
Permissions.CLASS_CREATE, Permissions.CLASS_READ,
|
||||
Permissions.CLASS_UPDATE, Permissions.CLASS_ENROLL,
|
||||
Permissions.CLASS_SCHEDULE,
|
||||
Permissions.GRADE_MANAGE,
|
||||
Permissions.AI_CHAT,
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 数据权限策略
|
||||
|
||||
数据权限通过 `DataScope` 定义,在 data-access 层自动注入过滤条件:
|
||||
|
||||
```typescript
|
||||
// src/shared/types/permissions.ts
|
||||
|
||||
export type DataScope =
|
||||
| { type: "all" } // admin: 全量
|
||||
| { type: "owned" } // 仅自己创建的
|
||||
| { type: "class_members" } // 所在班级的成员数据
|
||||
| { type: "grade_managed"; gradeIds: string[] } // 管理的年级
|
||||
| { type: "class_taught"; classIds: string[]; subjectIds?: string[] } // 教学的班级(可限定科目)
|
||||
| { type: "children"; childrenIds: string[] } // 家长:子女数据
|
||||
|
||||
export interface AuthContext {
|
||||
userId: string
|
||||
roles: string[] // 所有角色,不再只取一个
|
||||
permissions: Permission[] // 合并所有角色的权限(去重)
|
||||
dataScope: DataScope // 数据权限范围
|
||||
}
|
||||
```
|
||||
|
||||
### 2.5 数据库变更
|
||||
|
||||
**新增一张表**(最小变更,不修改现有表结构):
|
||||
|
||||
```sql
|
||||
CREATE TABLE role_permissions (
|
||||
role_id VARCHAR(128) NOT NULL,
|
||||
permission VARCHAR(100) NOT NULL,
|
||||
PRIMARY KEY (role_id, permission),
|
||||
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
|
||||
INDEX idx_role_permissions_role (role_id)
|
||||
);
|
||||
```
|
||||
|
||||
Drizzle schema:
|
||||
|
||||
```typescript
|
||||
// 追加到 src/shared/db/schema.ts
|
||||
|
||||
export const rolePermissions = mysqlTable("role_permissions", {
|
||||
roleId: varchar("role_id", { length: 128 }).notNull()
|
||||
.references(() => roles.id, { onDelete: "cascade" }),
|
||||
permission: varchar("permission", { length: 100 }).notNull(),
|
||||
}, (table) => ({
|
||||
pk: primaryKey({ columns: [table.roleId, table.permission] }),
|
||||
roleIdIdx: index("role_permissions_role_idx").on(table.roleId),
|
||||
}))
|
||||
```
|
||||
|
||||
**不修改现有表**。`ROLE_PERMISSIONS` 常量作为初始 seed 数据,运行时优先从数据库读取(支持动态调整),数据库无记录时 fallback 到常量。
|
||||
|
||||
---
|
||||
|
||||
## 三、实现方案
|
||||
|
||||
### 3.1 NextAuth Session 携带权限信息
|
||||
|
||||
#### 修改 JWT 和 Session 类型
|
||||
|
||||
```typescript
|
||||
// src/next-auth.d.ts
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: DefaultSession["user"] & {
|
||||
id: string
|
||||
roles: string[] // 所有角色
|
||||
permissions: string[] // 合并后的权限列表
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
declare module "next-auth/jwt" {
|
||||
interface JWT {
|
||||
id: string
|
||||
roles: string[]
|
||||
permissions: string[]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 修改 auth.ts callbacks
|
||||
|
||||
```typescript
|
||||
// src/auth.ts — 关键改动
|
||||
|
||||
import { ROLE_PERMISSIONS } from "@/shared/lib/permissions"
|
||||
|
||||
function resolvePermissions(roleNames: string[]): string[] {
|
||||
const set = new Set<string>()
|
||||
for (const name of roleNames) {
|
||||
const perms = ROLE_PERMISSIONS[name] ?? []
|
||||
for (const p of perms) set.add(p)
|
||||
}
|
||||
return Array.from(set)
|
||||
}
|
||||
|
||||
export const authConfig = {
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.id = user.id
|
||||
// 查询用户所有角色
|
||||
const userRoles = await db
|
||||
.select({ name: roles.name })
|
||||
.from(usersToRoles)
|
||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||
.where(eq(usersToRoles.userId, user.id))
|
||||
|
||||
const roleNames = userRoles.map(r => r.name)
|
||||
token.roles = roleNames
|
||||
token.permissions = resolvePermissions(roleNames)
|
||||
}
|
||||
return token
|
||||
},
|
||||
async session({ session, token }) {
|
||||
session.user.id = token.id
|
||||
session.user.roles = token.roles
|
||||
session.user.permissions = token.permissions
|
||||
return session
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 `requirePermission()` 服务端权限断言
|
||||
|
||||
```typescript
|
||||
// src/shared/lib/auth-guard.ts
|
||||
|
||||
import { auth } from "@/auth"
|
||||
import { ROLE_PERMISSIONS } from "@/shared/lib/permissions"
|
||||
import type { Permission, DataScope, AuthContext } from "@/shared/types/permissions"
|
||||
import { db } from "@/shared/db"
|
||||
import { users, usersToRoles, roles, classes, classEnrollments, classSubjectTeachers, grades } from "@/shared/db/schema"
|
||||
import { eq, and, inArray } from "drizzle-orm"
|
||||
|
||||
class PermissionDeniedError extends Error {
|
||||
constructor(permission: string) {
|
||||
super(`Permission denied: ${permission}`)
|
||||
this.name = "PermissionDeniedError"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户的完整认证上下文
|
||||
*/
|
||||
export async function getAuthContext(): Promise<AuthContext> {
|
||||
const session = await auth()
|
||||
const userId = session?.user?.id
|
||||
if (!userId) throw new PermissionDeniedError("auth_required")
|
||||
|
||||
const roles = session.user.roles ?? []
|
||||
const permissions = session.user.permissions ?? []
|
||||
const dataScope = await resolveDataScope(userId, roles)
|
||||
|
||||
return { userId, roles, permissions, dataScope }
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言当前用户拥有指定权限
|
||||
*/
|
||||
export async function requirePermission(permission: Permission): Promise<AuthContext> {
|
||||
const ctx = await getAuthContext()
|
||||
if (!ctx.permissions.includes(permission)) {
|
||||
throw new PermissionDeniedError(permission)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言当前用户拥有指定权限,并返回上下文(不抛异常版本)
|
||||
*/
|
||||
export async function checkPermission(permission: Permission): Promise<{ allowed: boolean; ctx: AuthContext }> {
|
||||
const ctx = await getAuthContext()
|
||||
return { allowed: ctx.permissions.includes(permission), ctx }
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据角色解析数据权限范围
|
||||
*/
|
||||
async function resolveDataScope(userId: string, roleNames: string[]): Promise<DataScope> {
|
||||
if (roleNames.includes("admin")) {
|
||||
return { type: "all" }
|
||||
}
|
||||
|
||||
// 年级主任
|
||||
if (roleNames.includes("grade_head")) {
|
||||
const managedGrades = await db
|
||||
.select({ id: grades.id })
|
||||
.from(grades)
|
||||
.where(
|
||||
and(
|
||||
eq(grades.gradeHeadId, userId),
|
||||
// 或 teachingHeadId
|
||||
)
|
||||
)
|
||||
if (managedGrades.length > 0) {
|
||||
return { type: "grade_managed", gradeIds: managedGrades.map(g => g.id) }
|
||||
}
|
||||
}
|
||||
|
||||
// 教师
|
||||
if (roleNames.includes("teacher")) {
|
||||
const taughtClasses = await db
|
||||
.select({ classId: classes.id, subjectId: classSubjectTeachers.subjectId })
|
||||
.from(classes)
|
||||
.leftJoin(classSubjectTeachers, eq(classSubjectTeachers.classId, classes.id))
|
||||
.where(eq(classes.teacherId, userId))
|
||||
|
||||
const classIds = [...new Set(taughtClasses.map(c => c.classId))]
|
||||
const subjectIds = taughtClasses
|
||||
.map(c => c.subjectId)
|
||||
.filter((s): s is string => s !== null)
|
||||
|
||||
return { type: "class_taught", classIds, subjectIds: subjectIds.length > 0 ? subjectIds : undefined }
|
||||
}
|
||||
|
||||
// 学生
|
||||
if (roleNames.includes("student")) {
|
||||
return { type: "class_members" }
|
||||
}
|
||||
|
||||
// 家长
|
||||
if (roleNames.includes("parent")) {
|
||||
// TODO: 查询关联子女
|
||||
return { type: "children", childrenIds: [] }
|
||||
}
|
||||
|
||||
return { type: "owned" }
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 data-access 层使用:根据 DataScope 生成查询过滤条件
|
||||
*/
|
||||
export function applyDataScope(
|
||||
scope: DataScope,
|
||||
options: {
|
||||
creatorIdField?: any // 如 exams.creatorId
|
||||
classIdField?: any // 如 classes.id
|
||||
gradeIdField?: any // 如 grades.id
|
||||
studentIdField?: any // 如 classEnrollments.studentId
|
||||
}
|
||||
): any[] {
|
||||
const conditions: any[] = []
|
||||
|
||||
switch (scope.type) {
|
||||
case "all":
|
||||
break // 无过滤
|
||||
case "owned":
|
||||
if (options.creatorIdField) {
|
||||
conditions.push(eq(options.creatorIdField, /* currentUserId */))
|
||||
}
|
||||
break
|
||||
case "class_taught":
|
||||
if (options.classIdField && scope.classIds.length > 0) {
|
||||
conditions.push(inArray(options.classIdField, scope.classIds))
|
||||
}
|
||||
break
|
||||
case "grade_managed":
|
||||
if (options.gradeIdField && scope.gradeIds.length > 0) {
|
||||
conditions.push(inArray(options.gradeIdField, scope.gradeIds))
|
||||
}
|
||||
break
|
||||
case "class_members":
|
||||
// 需要子查询:学生所在班级
|
||||
break
|
||||
}
|
||||
|
||||
return conditions
|
||||
}
|
||||
|
||||
export { PermissionDeniedError }
|
||||
```
|
||||
|
||||
### 3.3 `usePermission()` 前端 Hook
|
||||
|
||||
```typescript
|
||||
// src/shared/hooks/use-permission.ts
|
||||
|
||||
"use client"
|
||||
|
||||
import { useSession } from "next-auth/react"
|
||||
import type { Permission } from "@/shared/types/permissions"
|
||||
|
||||
export function usePermission() {
|
||||
const { data: session } = useSession()
|
||||
const permissions = session?.user?.permissions ?? []
|
||||
const roles = session?.user?.roles ?? []
|
||||
|
||||
const hasPermission = (permission: Permission): boolean => {
|
||||
return permissions.includes(permission)
|
||||
}
|
||||
|
||||
const hasAnyPermission = (...perms: Permission[]): boolean => {
|
||||
return perms.some(p => permissions.includes(p))
|
||||
}
|
||||
|
||||
const hasAllPermissions = (...perms: Permission[]): boolean => {
|
||||
return perms.every(p => permissions.includes(p))
|
||||
}
|
||||
|
||||
const hasRole = (role: string): boolean => {
|
||||
return roles.includes(role)
|
||||
}
|
||||
|
||||
return { permissions, roles, hasPermission, hasAnyPermission, hasAllPermissions, hasRole }
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 Server Action 集成模式
|
||||
|
||||
#### 改造前(exams/actions.ts)
|
||||
|
||||
```typescript
|
||||
// 零鉴权,getCurrentUser() 返回硬编码值
|
||||
export async function deleteExamAction(prevState, formData) {
|
||||
const { examId } = parsed.data
|
||||
await db.delete(exams).where(eq(exams.id, examId)) // 任何人可删任何考试
|
||||
}
|
||||
```
|
||||
|
||||
#### 改造后
|
||||
|
||||
```typescript
|
||||
import { requirePermission, getAuthContext, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
export async function deleteExamAction(prevState, formData) {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.EXAM_DELETE)
|
||||
const { examId } = parsed.data
|
||||
|
||||
// 数据权限:非 admin 只能删自己的考试
|
||||
if (ctx.dataScope.type !== "all") {
|
||||
const exam = await db.query.exams.findFirst({
|
||||
where: eq(exams.id, examId),
|
||||
columns: { creatorId: true },
|
||||
})
|
||||
if (!exam || exam.creatorId !== ctx.userId) {
|
||||
return failState("You can only delete your own exams")
|
||||
}
|
||||
}
|
||||
|
||||
await db.delete(exams).where(eq(exams.id, examId))
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return failState(e.message)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5 data-access 层数据权限过滤
|
||||
|
||||
```typescript
|
||||
// src/modules/exams/data-access.ts — 改造后
|
||||
|
||||
import { applyDataScope } from "@/shared/lib/auth-guard"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
|
||||
export const getExams = cache(async (params: GetExamsParams & { scope: DataScope }) => {
|
||||
const conditions = []
|
||||
|
||||
// 原有筛选条件
|
||||
if (params.q) { /* ... */ }
|
||||
if (params.status && params.status !== "all") { /* ... */ }
|
||||
|
||||
// 数据权限过滤
|
||||
const scopeConditions = applyDataScope(params.scope, {
|
||||
creatorIdField: exams.creatorId,
|
||||
gradeIdField: exams.gradeId,
|
||||
})
|
||||
conditions.push(...scopeConditions)
|
||||
|
||||
const data = await db.query.exams.findMany({
|
||||
where: conditions.length ? and(...conditions) : undefined,
|
||||
// ...
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### 3.6 Middleware 改造
|
||||
|
||||
```typescript
|
||||
// src/proxy.ts — 改造后
|
||||
|
||||
import { NextResponse } from "next/server"
|
||||
import type { NextRequest } from "next/server"
|
||||
import { getToken } from "next-auth/jwt"
|
||||
|
||||
// 路由 → 所需权限映射
|
||||
const ROUTE_PERMISSIONS: Record<string, string> = {
|
||||
"/admin": "school:manage",
|
||||
"/teacher": "exam:read", // teacher 区域最低权限
|
||||
"/student": "exam:read", // student 区域最低权限
|
||||
"/parent": "exam:read", // parent 区域最低权限
|
||||
}
|
||||
|
||||
// API 路由 → 所需权限映射
|
||||
const API_PERMISSIONS: Record<string, string> = {
|
||||
"/api/ai/chat": "ai:chat",
|
||||
"/api/onboarding": "auth_required", // 特殊标记:仅需登录
|
||||
}
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const token = await getToken({ req: request })
|
||||
|
||||
if (!token) {
|
||||
const loginUrl = new URL("/login", request.url)
|
||||
loginUrl.searchParams.set("callbackUrl", request.url)
|
||||
return NextResponse.redirect(loginUrl)
|
||||
}
|
||||
|
||||
const { pathname } = request.nextUrl
|
||||
const permissions: string[] = (token.permissions as string[]) ?? []
|
||||
const roles: string[] = (token.roles as string[]) ?? []
|
||||
|
||||
// 检查 API 路由权限
|
||||
for (const [prefix, requiredPerm] of Object.entries(API_PERMISSIONS)) {
|
||||
if (pathname.startsWith(prefix)) {
|
||||
if (requiredPerm === "auth_required") break // 仅需登录
|
||||
if (!permissions.includes(requiredPerm)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 检查页面路由权限
|
||||
for (const [prefix, requiredPerm] of Object.entries(ROUTE_PERMISSIONS)) {
|
||||
if (pathname.startsWith(prefix)) {
|
||||
if (!permissions.includes(requiredPerm)) {
|
||||
// 无权限 → 重定向到角色首页
|
||||
const defaultPath = resolveDefaultPath(roles)
|
||||
return NextResponse.redirect(new URL(defaultPath, request.url))
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
function resolveDefaultPath(roles: string[]): string {
|
||||
if (roles.includes("admin")) return "/admin/dashboard"
|
||||
if (roles.includes("teacher")) return "/teacher/dashboard"
|
||||
if (roles.includes("student")) return "/student/dashboard"
|
||||
if (roles.includes("parent")) return "/parent/dashboard"
|
||||
return "/dashboard"
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||
}
|
||||
```
|
||||
|
||||
### 3.7 前端组件改造
|
||||
|
||||
#### 改造前
|
||||
|
||||
```tsx
|
||||
// 硬编码角色判断
|
||||
{role === "teacher" ? <TeacherView /> : <StudentView />}
|
||||
```
|
||||
|
||||
#### 改造后
|
||||
|
||||
```tsx
|
||||
import { usePermission } from "@/shared/hooks/use-permission"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
function DashboardPage() {
|
||||
const { hasPermission, hasRole } = usePermission()
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasPermission(Permissions.HOMEWORK_GRADE) && <GradingWidget />}
|
||||
{hasPermission(Permissions.EXAM_CREATE) && <CreateExamButton />}
|
||||
{hasPermission(Permissions.SCHOOL_MANAGE) && <AdminPanel />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、渐进式重构路线图
|
||||
|
||||
### Phase 1:基础设施(1-2 天)
|
||||
|
||||
1. 创建 `src/shared/types/permissions.ts` — 权限点定义
|
||||
2. 创建 `src/shared/lib/permissions.ts` — 角色-权限映射
|
||||
3. 创建 `src/shared/lib/auth-guard.ts` — `requirePermission()` / `getAuthContext()`
|
||||
4. 创建 `src/shared/hooks/use-permission.ts` — 前端 Hook
|
||||
5. 修改 `src/next-auth.d.ts` — Session/JWT 类型扩展
|
||||
6. 修改 `src/auth.ts` — JWT/Session callbacks 注入权限
|
||||
7. 新增 `role_permissions` 数据库表 + seed 脚本
|
||||
|
||||
### Phase 2:修复高危模块(2-3 天)
|
||||
|
||||
优先修复零鉴权和越权漏洞:
|
||||
|
||||
1. **exams/actions.ts** — 替换 `getCurrentUser()` 存根,所有 Action 加 `requirePermission()`
|
||||
2. **classes/actions.ts** — 无鉴权接口补全,统一使用 `requirePermission()`
|
||||
3. **homework/actions.ts** — 用 `requirePermission()` 替换 `ensureTeacher()`/`ensureStudent()`
|
||||
4. **questions/actions.ts** — 补全鉴权
|
||||
5. **textbooks/actions.ts** — 补全鉴权
|
||||
|
||||
### Phase 3:数据权限集成(2-3 天)
|
||||
|
||||
1. 各模块 `data-access.ts` 接受 `DataScope` 参数
|
||||
2. `getExams()` / `getHomeworkAssignments()` 等查询函数自动过滤
|
||||
3. 页面级 Server Component 传入 `AuthContext`
|
||||
|
||||
### Phase 4:Middleware 升级(1 天)
|
||||
|
||||
1. `proxy.ts` 从角色路由匹配 → 权限点路由匹配
|
||||
2. API 路由增加权限检查
|
||||
|
||||
### Phase 5:前端改造(2-3 天)
|
||||
|
||||
1. 逐步替换 `role === "xxx"` 为 `hasPermission()`
|
||||
2. 侧边栏导航从 `NAV_CONFIG[role]` → `NAV_CONFIG.filter(item => hasPermission(item.permission))`
|
||||
3. 条件渲染统一使用 `usePermission()`
|
||||
|
||||
---
|
||||
|
||||
## 五、考试模块改造对比
|
||||
|
||||
### 5.1 Server Actions 改造
|
||||
|
||||
#### 改造前 — createExamAction
|
||||
|
||||
```typescript
|
||||
export async function createExamAction(prevState, formData) {
|
||||
// ... 解析表单 ...
|
||||
try {
|
||||
const user = await getCurrentUser() // 硬编码存根!
|
||||
await persistExamDraft({
|
||||
examId: context.examId,
|
||||
creatorId: user?.id ?? "user_teacher_math", // 硬编码 fallback!
|
||||
// ...
|
||||
})
|
||||
} catch (error) {
|
||||
return failState("Database error")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 改造后 — createExamAction
|
||||
|
||||
```typescript
|
||||
export async function createExamAction(prevState, formData) {
|
||||
let ctx: AuthContext
|
||||
try {
|
||||
ctx = await requirePermission(Permissions.EXAM_CREATE)
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return failState(e.message)
|
||||
throw e
|
||||
}
|
||||
|
||||
// ... 解析表单 ...
|
||||
|
||||
try {
|
||||
await persistExamDraft({
|
||||
examId: context.examId,
|
||||
creatorId: ctx.userId, // 真实用户 ID
|
||||
// ...
|
||||
})
|
||||
} catch (error) {
|
||||
return failState("Database error")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 改造前 — deleteExamAction
|
||||
|
||||
```typescript
|
||||
export async function deleteExamAction(prevState, formData) {
|
||||
const { examId } = parsed.data
|
||||
// 无任何鉴权!任何人可删任何考试!
|
||||
await db.delete(exams).where(eq(exams.id, examId))
|
||||
}
|
||||
```
|
||||
|
||||
#### 改造后 — deleteExamAction
|
||||
|
||||
```typescript
|
||||
export async function deleteExamAction(prevState, formData) {
|
||||
let ctx: AuthContext
|
||||
try {
|
||||
ctx = await requirePermission(Permissions.EXAM_DELETE)
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return failState(e.message)
|
||||
throw e
|
||||
}
|
||||
|
||||
const { examId } = parsed.data
|
||||
|
||||
// 数据权限:非 admin 只能删自己创建的考试
|
||||
if (ctx.dataScope.type !== "all") {
|
||||
const exam = await db.query.exams.findFirst({
|
||||
where: eq(exams.id, examId),
|
||||
columns: { creatorId: true },
|
||||
})
|
||||
if (!exam) return failState("Exam not found")
|
||||
if (exam.creatorId !== ctx.userId) {
|
||||
return failState("You can only delete your own exams")
|
||||
}
|
||||
}
|
||||
|
||||
await db.delete(exams).where(eq(exams.id, examId))
|
||||
revalidatePath("/teacher/exams/all")
|
||||
return successState(examId, "Exam deleted")
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Data-Access 层改造
|
||||
|
||||
#### 改造前 — getExams
|
||||
|
||||
```typescript
|
||||
export const getExams = cache(async (params: GetExamsParams) => {
|
||||
// 任何调用者都能看到所有考试
|
||||
const data = await db.query.exams.findMany({
|
||||
where: conditions.length ? and(...conditions) : undefined,
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
#### 改造后 — getExams
|
||||
|
||||
```typescript
|
||||
export const getExams = cache(async (params: GetExamsParams & { scope: DataScope }) => {
|
||||
const conditions = []
|
||||
|
||||
// 原有筛选
|
||||
if (params.q) conditions.push(or(like(exams.title, search), like(exams.description, search)))
|
||||
if (params.status && params.status !== "all") conditions.push(eq(exams.status, params.status))
|
||||
|
||||
// 数据权限自动过滤
|
||||
switch (params.scope.type) {
|
||||
case "all":
|
||||
break
|
||||
case "owned":
|
||||
conditions.push(eq(exams.creatorId, /* userId from caller */))
|
||||
break
|
||||
case "class_taught": {
|
||||
// 教师能看到自己班级关联的年级的考试
|
||||
if (params.scope.classIds.length > 0) {
|
||||
// 通过 class → grade 关联查询
|
||||
const teacherGradeIds = await db
|
||||
.selectDistinct({ gradeId: classes.gradeId })
|
||||
.from(classes)
|
||||
.where(inArray(classes.id, params.scope.classIds))
|
||||
if (teacherGradeIds.length > 0) {
|
||||
conditions.push(inArray(exams.gradeId, teacherGradeIds.map(g => g.gradeId)))
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case "grade_managed":
|
||||
if (params.scope.gradeIds.length > 0) {
|
||||
conditions.push(inArray(exams.gradeId, params.scope.gradeIds))
|
||||
}
|
||||
break
|
||||
case "class_members": {
|
||||
// 学生看到自己年级的已发布考试
|
||||
// 需要子查询获取学生所在班级的年级
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const data = await db.query.exams.findMany({
|
||||
where: conditions.length ? and(...conditions) : undefined,
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### 5.3 前端改造
|
||||
|
||||
#### 改造前 — exam-actions.tsx
|
||||
|
||||
```tsx
|
||||
// 仅通过路由守卫保护,组件内无权限判断
|
||||
<Button onClick={handleDelete}>删除考试</Button>
|
||||
```
|
||||
|
||||
#### 改造后 — exam-actions.tsx
|
||||
|
||||
```tsx
|
||||
import { usePermission } from "@/shared/hooks/use-permission"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
function ExamActions({ examId }) {
|
||||
const { hasPermission } = usePermission()
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasPermission(Permissions.EXAM_UPDATE) && (
|
||||
<Button onClick={handleEdit}>编辑</Button>
|
||||
)}
|
||||
{hasPermission(Permissions.EXAM_DELETE) && (
|
||||
<Button onClick={handleDelete}>删除</Button>
|
||||
)}
|
||||
{hasPermission(Permissions.EXAM_DUPLICATE) && (
|
||||
<Button onClick={handleDuplicate}>复制</Button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、验收标准
|
||||
|
||||
- [ ] 所有 Server Action 都有 `requirePermission()` 调用
|
||||
- [ ] `getCurrentUser()` 硬编码存根已移除
|
||||
- [ ] 前端零 `role === "xxx"` 硬编码
|
||||
- [ ] `exams/actions.ts` 的 CRUD 操作有资源归属校验
|
||||
- [ ] `classes/actions.ts` 所有接口有鉴权
|
||||
- [ ] `homework/actions.ts` 批改操作有资源归属校验
|
||||
- [ ] Middleware 基于权限点而非角色字符串拦截
|
||||
- [ ] 多角色用户可同时使用所有角色的功能
|
||||
- [ ] `role_permissions` 表有 seed 数据
|
||||
- [ ] 新增角色只需修改 `ROLE_PERMISSIONS` 映射 + seed,无需改动业务代码
|
||||
407
docs/architecture/003_ui_refactoring_plan.md
Normal file
407
docs/architecture/003_ui_refactoring_plan.md
Normal file
@@ -0,0 +1,407 @@
|
||||
# UI 代码结构重构计划
|
||||
|
||||
> 基于 2026-06-16 架构审核,整体评分 7.2/10
|
||||
> 目标:补齐工程细节短板,达到企业级标准(8.5+)
|
||||
|
||||
---
|
||||
|
||||
## 审核评分总览
|
||||
|
||||
| 维度 | 评分 | 状态 |
|
||||
|------|------|------|
|
||||
| 目录结构分层 | 8/10 | 良好 |
|
||||
| 组件设计 | 6.5/10 | 需重构 |
|
||||
| 复用与抽象 | 6/10 | 需重构 |
|
||||
| 样式组织 | 8.5/10 | 优秀 |
|
||||
| 可维护性 | 7/10 | 一般 |
|
||||
| 性能相关 | 7/10 | 一般 |
|
||||
| 测试与质量保障 | 5.5/10 | 需重构 |
|
||||
| 安全性 | 7.5/10 | 良好 |
|
||||
|
||||
---
|
||||
|
||||
## P0 — 紧急(阻塞级质量问题)
|
||||
|
||||
### P0-1 拆分巨型组件
|
||||
|
||||
**问题:** exam-form.tsx(1623 行)、textbook-reader.tsx(744 行)严重违反单一职责,难以维护和测试。
|
||||
|
||||
#### exam-form.tsx 拆分方案
|
||||
|
||||
```
|
||||
src/modules/exams/components/
|
||||
exam-form.tsx ← 保留为容器组件(~200 行),组合子组件
|
||||
exam-basic-info-form.tsx ← 考试基本信息(名称、科目、时间)
|
||||
exam-ai-generator.tsx ← AI 生成试卷功能
|
||||
exam-structure-editor.tsx ← 试卷结构编辑
|
||||
exam-question-selector.tsx ← 题目选择器
|
||||
exam-preview-panel.tsx ← 试卷预览面板
|
||||
exam-form-actions.tsx ← 表单操作按钮组
|
||||
```
|
||||
|
||||
#### textbook-reader.tsx 拆分方案
|
||||
|
||||
```
|
||||
src/modules/textbooks/components/
|
||||
textbook-reader.tsx ← 保留为容器组件(~150 行)
|
||||
textbook-content-panel.tsx ← 内容阅读面板(Markdown 渲染)
|
||||
knowledge-point-list.tsx ← 知识点列表
|
||||
knowledge-graph.tsx ← 知识图谱可视化
|
||||
knowledge-point-dialogs.tsx ← 创建/编辑知识点 Dialog
|
||||
textbook-editor-panel.tsx ← 编辑模式面板
|
||||
use-text-selection.ts ← 文本选择逻辑 Hook
|
||||
use-knowledge-point-actions.ts ← 知识点操作逻辑 Hook
|
||||
```
|
||||
|
||||
**验收标准:**
|
||||
- 单文件不超过 300 行
|
||||
- 每个子组件职责单一,可独立测试
|
||||
- 容器组件仅负责组合和状态分发
|
||||
|
||||
---
|
||||
|
||||
### P0-2 修复无障碍(a11y)缺陷
|
||||
|
||||
**问题:** 全项目仅 7 处 `aria-label`,但 180 处 `onClick`;存在 `<div onClick>` 非语义化标签。
|
||||
|
||||
#### 修复清单
|
||||
|
||||
1. **图标按钮补 aria-label**
|
||||
- 搜索所有 `<Button.*size="icon"` 或仅含图标的按钮,补充 `aria-label`
|
||||
- 涉及文件:exam-actions.tsx、question-columns.tsx、exam-columns.tsx、breadcrumb.tsx 等
|
||||
|
||||
2. **替换 `<div onClick>` 为语义化标签**
|
||||
- textbook-reader.tsx:434 — 知识点卡片 → `<button>`
|
||||
- 搜索所有 `<div onClick` / `<span onClick` 模式,逐一替换
|
||||
|
||||
3. **表单控件补 label**
|
||||
- 确认所有 `<Input>` / `<Select>` 有关联 `<Label htmlFor>` 或 `aria-label`
|
||||
|
||||
4. **添加 skip-link**
|
||||
- Root Layout 或 Dashboard Layout 添加:
|
||||
```tsx
|
||||
<a href="#main-content" className="sr-only focus:not-sr-only ...">
|
||||
Skip to main content
|
||||
</a>
|
||||
```
|
||||
- `<main>` 添加 `id="main-content"`
|
||||
|
||||
**验收标准:**
|
||||
- axe-core 扫描零严重违规
|
||||
- 所有图标按钮有 `aria-label`
|
||||
- 无 `<div onClick>` / `<span onClick>` 模式
|
||||
|
||||
---
|
||||
|
||||
## P1 — 重要(架构级改进)
|
||||
|
||||
### P1-1 创建自定义 Hooks 层
|
||||
|
||||
**问题:** 整个项目零自定义 Hook,逻辑耦合在组件中。
|
||||
|
||||
#### 通用 Hooks(src/shared/hooks/)
|
||||
|
||||
```
|
||||
src/shared/hooks/
|
||||
use-action-with-toast.ts ← Server Action + toast 反馈
|
||||
use-async-action.ts ← 异步操作 loading/error 状态
|
||||
use-debounce.ts ← 防抖
|
||||
use-media-query.ts ← 响应式断点
|
||||
use-local-storage.ts ← 本地存储
|
||||
```
|
||||
|
||||
示例实现:
|
||||
|
||||
```typescript
|
||||
// src/shared/hooks/use-action-with-toast.ts
|
||||
"use client"
|
||||
|
||||
import { useTransition } from "react"
|
||||
import { toast } from "sonner"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
|
||||
export function useActionWithToast<T>() {
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const execute = async (action: () => Promise<ActionState<T>>) => {
|
||||
startTransition(async () => {
|
||||
const result = await action()
|
||||
if (result.success) {
|
||||
toast.success(result.message || "操作成功")
|
||||
} else {
|
||||
toast.error(result.message || "操作失败")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return { isPending, execute }
|
||||
}
|
||||
```
|
||||
|
||||
#### 模块级 Hooks(src/modules/*/hooks/)
|
||||
|
||||
```
|
||||
src/modules/exams/hooks/
|
||||
use-exam-form.ts ← 考试表单状态管理
|
||||
use-exam-filters.ts ← 考试筛选逻辑
|
||||
|
||||
src/modules/textbooks/hooks/
|
||||
use-text-selection.ts ← 文本选择 + 知识点创建
|
||||
use-knowledge-point-actions.ts ← 知识点 CRUD 操作
|
||||
|
||||
src/modules/homework/hooks/
|
||||
use-homework-submission.ts ← 作业提交逻辑
|
||||
```
|
||||
|
||||
**验收标准:**
|
||||
- 组件中无超过 3 个 `useState` 的逻辑块(应提取为 Hook)
|
||||
- 通用 Hook 有单元测试
|
||||
|
||||
---
|
||||
|
||||
### P1-2 添加动画降级(prefers-reduced-motion)
|
||||
|
||||
**问题:** 全项目零处 `prefers-reduced-motion` 引用,不符合 WCAG 2.1。
|
||||
|
||||
#### 方案
|
||||
|
||||
1. **globals.css 添加全局降级**
|
||||
|
||||
```css
|
||||
@layer base {
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **tailwindcss-animate 适配**
|
||||
|
||||
确认 `tailwindcss-animate` 生成的动画类在 `prefers-reduced-motion: reduce` 下被正确降级。如不支持,在 globals.css 中补充:
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-accordion-down,
|
||||
.animate-accordion-up {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**验收标准:**
|
||||
- 操作系统开启"减少动态效果"后,页面无动画
|
||||
- Lighthouse Accessibility 评分 ≥ 90
|
||||
|
||||
---
|
||||
|
||||
### P1-3 引入 Next.js 图片与字体优化
|
||||
|
||||
#### next/image
|
||||
|
||||
- 搜索所有 `<img>` 标签,替换为 `next/image`
|
||||
- 配置 `next.config.ts` 的 `images.remotePatterns`(如有外部图片源)
|
||||
|
||||
#### next/font
|
||||
|
||||
```typescript
|
||||
// src/app/layout.tsx
|
||||
import { Inter } from "next/font/google"
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
variable: "--font-inter",
|
||||
})
|
||||
```
|
||||
|
||||
**验收标准:**
|
||||
- Lighthouse Performance 无 "Properly size images" 警告
|
||||
- 无 "Eliminate render-blocking resources" 字体相关警告
|
||||
|
||||
---
|
||||
|
||||
## P2 — 改进(质量提升)
|
||||
|
||||
### P2-1 补充组件单元测试
|
||||
|
||||
**问题:** 零组件测试、零 Hook 测试、零工具函数测试。
|
||||
|
||||
#### 测试目录结构(co-location 模式)
|
||||
|
||||
```
|
||||
src/modules/exams/components/
|
||||
exam-card.tsx
|
||||
exam-card.test.tsx ← 新增
|
||||
|
||||
src/shared/hooks/
|
||||
use-action-with-toast.ts
|
||||
use-action-with-toast.test.ts ← 新增
|
||||
|
||||
src/shared/lib/
|
||||
utils.ts
|
||||
utils.test.ts ← 新增
|
||||
```
|
||||
|
||||
#### 优先级
|
||||
|
||||
1. **高**:`useActionWithToast`、`cn()`、`formatDate()`
|
||||
2. **高**:ExamCard、QuestionColumns、HomeworkAssignmentForm
|
||||
3. **中**:各模块 data-access.ts(mock DB 测试)
|
||||
4. **低**:UI 基础组件(shadcn/ui 已有上游测试)
|
||||
|
||||
#### 配置更新
|
||||
|
||||
```typescript
|
||||
// vitest.config.ts 添加
|
||||
test: {
|
||||
include: ["src/**/*.test.{ts,tsx}"],
|
||||
}
|
||||
```
|
||||
|
||||
**验收标准:**
|
||||
- 关键路径覆盖率 > 80%
|
||||
- CI 中 `npm run test:integration` 包含单元测试
|
||||
|
||||
---
|
||||
|
||||
### P2-2 统一 Tailwind v4 配置
|
||||
|
||||
**问题:** `tailwind.config.ts`(v3 风格)与 `globals.css` 的 `@theme inline`(v4 风格)并存。
|
||||
|
||||
#### 方案
|
||||
|
||||
1. 将 `tailwind.config.ts` 中的 `theme.extend` 迁移至 `globals.css` 的 `@theme inline` 块
|
||||
2. 删除 `tailwind.config.ts` 中与 CSS 重复的定义
|
||||
3. 保留 `tailwind.config.ts` 仅用于 `plugins` 和 `content` 配置(如 v4 仍需要)
|
||||
|
||||
**验收标准:**
|
||||
- 无重复的主题定义
|
||||
- `npm run build` 无 Tailwind deprecation 警告
|
||||
|
||||
---
|
||||
|
||||
### P2-3 清理 Mock 数据
|
||||
|
||||
**问题:** `src/modules/exams/mock-data.ts` 和 `src/modules/questions/mock-data.ts` 残留在生产代码中。
|
||||
|
||||
#### 方案
|
||||
|
||||
```
|
||||
src/mocks/ ← 新增
|
||||
exam-data.ts ← 从 modules/exams/mock-data.ts 迁移
|
||||
question-data.ts ← 从 modules/questions/mock-data.ts 迁移
|
||||
|
||||
tests/mocks/ ← 或放测试目录
|
||||
exam-factories.ts ← 使用 @faker-js/faker 生成
|
||||
question-factories.ts
|
||||
```
|
||||
|
||||
- 删除 `src/modules/*/mock-data.ts`
|
||||
- 确认无生产代码引用 mock 数据
|
||||
|
||||
**验收标准:**
|
||||
- `src/modules/` 下无 `mock-data.ts`
|
||||
- 生产构建不包含 mock 数据
|
||||
|
||||
---
|
||||
|
||||
## P3 — 优化(锦上添花)
|
||||
|
||||
### P3-1 i18n 文案提取
|
||||
|
||||
**问题:** 硬编码中文字案散布在组件中。
|
||||
|
||||
#### 方案
|
||||
|
||||
- 引入 `next-intl` 或轻量 i18n 方案
|
||||
- 提取所有用户可见文案到 `src/shared/i18n/zh-CN.json`
|
||||
- 代码中通过 `t("knowledgePoint.created")` 引用
|
||||
|
||||
### P3-2 替换原生 confirm/alert
|
||||
|
||||
**问题:** textbook-reader.tsx 使用 `confirm()`,与项目 UI 风格不一致。
|
||||
|
||||
#### 方案
|
||||
|
||||
- 创建 `useConfirmDialog` Hook,封装 AlertDialog
|
||||
- 全局搜索 `confirm(` 和 `alert(`,逐一替换
|
||||
|
||||
### P3-3 react-markdown 安全加固
|
||||
|
||||
**问题:** textbook-reader.tsx 渲染用户内容,remarkGfm 启用 HTML 后需防 XSS。
|
||||
|
||||
#### 方案
|
||||
|
||||
```typescript
|
||||
import rehypeSanitize from "rehype-sanitize"
|
||||
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
rehypePlugins={[rehypeSanitize]} ← 新增
|
||||
>
|
||||
```
|
||||
|
||||
### P3-4 formatDate 国际化
|
||||
|
||||
**问题:** `utils.ts` 中 `formatDate` 硬编码 `"en-US"`。
|
||||
|
||||
#### 方案
|
||||
|
||||
```typescript
|
||||
export function formatDate(date: string | Date, locale = "zh-CN") {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(new Date(date))
|
||||
}
|
||||
```
|
||||
|
||||
### P3-5 AI 聊天接口速率限制
|
||||
|
||||
**问题:** `/api/ai/chat` 需确认是否有服务端速率限制。
|
||||
|
||||
#### 方案
|
||||
|
||||
- 使用 `@upstash/ratelimit` 或自实现基于 IP/用户的速率限制
|
||||
- 建议限制:每用户每分钟 10 次请求
|
||||
|
||||
---
|
||||
|
||||
## 执行时间线
|
||||
|
||||
| 阶段 | 内容 | 预计周期 |
|
||||
|------|------|---------|
|
||||
| 第 1 周 | P0-1 exam-form.tsx 拆分 | 3 天 |
|
||||
| 第 1 周 | P0-1 textbook-reader.tsx 拆分 | 2 天 |
|
||||
| 第 2 周 | P0-2 无障碍修复 | 3 天 |
|
||||
| 第 2 周 | P1-1 创建 Hooks 层 | 2 天 |
|
||||
| 第 3 周 | P1-2 动画降级 | 1 天 |
|
||||
| 第 3 周 | P1-3 图片/字体优化 | 2 天 |
|
||||
| 第 3-4 周 | P2-1 补充单元测试 | 5 天 |
|
||||
| 第 4 周 | P2-2 + P2-3 配置清理 | 2 天 |
|
||||
| 持续 | P3 优化项 | 随迭代推进 |
|
||||
|
||||
---
|
||||
|
||||
## 验收检查清单
|
||||
|
||||
- [ ] 单文件不超过 300 行
|
||||
- [ ] axe-core 扫描零严重 a11y 违规
|
||||
- [ ] Lighthouse Accessibility ≥ 90
|
||||
- [ ] Lighthouse Performance ≥ 85
|
||||
- [ ] 关键路径测试覆盖率 > 80%
|
||||
- [ ] `npm run build` 零错误零警告
|
||||
- [ ] `npm run lint` 零错误
|
||||
- [ ] `npm run typecheck` 零错误
|
||||
- [ ] 无 `<div onClick>` / `<span onClick>` 模式
|
||||
- [ ] 所有图标按钮有 `aria-label`
|
||||
- [ ] `prefers-reduced-motion` 降级生效
|
||||
- [ ] 生产构建不含 mock 数据
|
||||
682
docs/architecture/004_architecture_impact_map.md
Normal file
682
docs/architecture/004_architecture_impact_map.md
Normal file
@@ -0,0 +1,682 @@
|
||||
# Next_Edu 架构影响地图
|
||||
|
||||
> 全模块·全函数·全参数级别
|
||||
> 生成日期:2026-06-16
|
||||
> 规则:每次文件修改后须同步更新本文档
|
||||
|
||||
---
|
||||
|
||||
## 模块:shared
|
||||
|
||||
### 模块职责
|
||||
提供全项目共享的基础设施:数据库连接、Schema、工具函数、权限系统、UI 基础组件、通用 Hooks。
|
||||
|
||||
### 导出函数
|
||||
|
||||
#### `cn`
|
||||
- 签名:`cn(...inputs: ClassValue[]): string`
|
||||
- 参数说明:
|
||||
- `inputs`: ClassValue[],来自 `clsx` + `tailwind-merge`,CSS 类名列表
|
||||
- 功能:合并 CSS 类名并解决 Tailwind 冲突
|
||||
- 依赖:`clsx`, `tailwind-merge`
|
||||
- 被以下模块使用:**所有模块**的组件(50+ 文件)
|
||||
|
||||
#### `formatDate`
|
||||
- 签名:`formatDate(date: string | Date, locale?: string): string`
|
||||
- 参数说明:
|
||||
- `date`: string | Date,日期值
|
||||
- `locale`: string,默认 `"zh-CN"`,`Intl.DateTimeFormat` 的 locale
|
||||
- 功能:国际化日期格式化
|
||||
- 依赖:无
|
||||
- 被以下模块使用:exams, homework, dashboard, textbooks
|
||||
|
||||
#### `parseAiChatPayload`
|
||||
- 签名:`parseAiChatPayload(body: unknown): AiChatRequest`
|
||||
- 参数说明:`body`: unknown,HTTP 请求体
|
||||
- 功能:解析并校验 AI 聊天请求负载
|
||||
- 依赖:Zod schema
|
||||
- 被以下模块使用:`app/api/ai/chat/route.ts`
|
||||
|
||||
#### `encryptAiApiKey` / `decryptAiApiKey`
|
||||
- 签名:`(value: string) => string`
|
||||
- 功能:AES 加密/解密 AI Provider API Key
|
||||
- 依赖:`crypto` (Node.js 内置)
|
||||
- 被以下模块使用:settings (存储/读取 API Key)
|
||||
|
||||
#### `testAiProviderConfig` / `testAiProviderById`
|
||||
- 签名:`(input: { apiKey, baseUrl?, model }) => Promise<boolean>` / `(providerId, overrides?) => Promise<boolean>`
|
||||
- 功能:测试 AI Provider 连通性
|
||||
- 依赖:`createAiChatCompletion`, `shared/db`
|
||||
- 被以下模块使用:settings/actions.ts
|
||||
|
||||
#### `createAiChatCompletion`
|
||||
- 签名:`(input: AiChatRequest) => Promise<{ content, usage }>`
|
||||
- 功能:调用 AI 模型生成聊天回复
|
||||
- 依赖:OpenAI SDK, `shared/db` (读取 provider 配置)
|
||||
- 被以下模块使用:exams/ai-pipeline.ts, `app/api/ai/chat/route.ts`
|
||||
|
||||
#### `getAuthContext`
|
||||
- 签名:`getAuthContext(): Promise<AuthContext>`
|
||||
- 功能:获取当前用户的完整认证上下文(userId, roles, permissions, dataScope)
|
||||
- 依赖:`auth` (NextAuth), `shared/db` (查询角色/班级/年级关系)
|
||||
- 被以下模块使用:**所有业务模块**的 Server Actions
|
||||
|
||||
#### `requirePermission`
|
||||
- 签名:`requirePermission(permission: Permission): Promise<AuthContext>`
|
||||
- 参数说明:`permission`: Permission,来自 `shared/types/permissions` 的权限常量
|
||||
- 功能:断言当前用户拥有指定权限,否则抛出 PermissionDeniedError
|
||||
- 依赖:`getAuthContext`
|
||||
- 被以下模块使用:**所有业务模块**的 Server Actions(exams, homework, questions, textbooks, classes, school, settings)
|
||||
|
||||
#### `checkPermission`
|
||||
- 签名:`checkPermission(permission: Permission): Promise<{ allowed: boolean; ctx: AuthContext }>`
|
||||
- 功能:非抛出版权限检查
|
||||
- 依赖:`getAuthContext`
|
||||
- 被以下模块使用:待扩展
|
||||
|
||||
#### `requireAuth`
|
||||
- 签名:`requireAuth(): Promise<AuthContext>`
|
||||
- 功能:仅断言用户已登录
|
||||
- 依赖:`getAuthContext`
|
||||
- 被以下模块使用:待扩展
|
||||
|
||||
#### `resolvePermissions`
|
||||
- 签名:`resolvePermissions(roleNames: string[]): Permission[]`
|
||||
- 参数说明:`roleNames`: string[],用户的角色名称列表
|
||||
- 功能:合并多角色的权限列表(去重)
|
||||
- 依赖:`ROLE_PERMISSIONS` 常量
|
||||
- 被以下模块使用:auth.ts (JWT callback)
|
||||
|
||||
### 导出组件
|
||||
|
||||
#### `AuthSessionProvider`
|
||||
- Props: `{ children: React.ReactNode }`
|
||||
- 内部使用:`next-auth/react` 的 `SessionProvider`
|
||||
- 被使用:`app/layout.tsx`
|
||||
|
||||
#### `OnboardingGate`
|
||||
- Props: 无(内部使用 `useSession`)
|
||||
- 内部使用:`useSession`, `Permissions`, 多个 Server Action
|
||||
- 功能:新用户引导流程(角色选择、学校/班级配置)
|
||||
- 被使用:`app/layout.tsx`
|
||||
|
||||
#### `ThemeProvider`
|
||||
- Props: `next-themes` 的 `ThemeProviderProps`
|
||||
- 被使用:`app/layout.tsx`
|
||||
|
||||
#### `EmptyState`
|
||||
- Props: `{ icon?, title, description, action? }`
|
||||
- 被使用:exams, homework, questions, textbooks 等模块的列表空状态
|
||||
|
||||
### 导出 Hooks
|
||||
|
||||
#### `useActionWithToast`
|
||||
- 签名:`useActionWithToast<T>(): { isPending, execute }`
|
||||
- 功能:包装 Server Action + toast 反馈
|
||||
- 被使用:待扩展
|
||||
|
||||
#### `useDebounce`
|
||||
- 签名:`useDebounce<T>(value: T, delay?: number): T`
|
||||
- 被使用:搜索输入框等
|
||||
|
||||
#### `useMediaQuery`
|
||||
- 签名:`useMediaQuery(query: string): boolean`
|
||||
- 被使用:响应式布局判断
|
||||
|
||||
#### `useLocalStorage`
|
||||
- 签名:`useLocalStorage<T>(key: string, initialValue: T): [T, setter]`
|
||||
- 被使用:exam-form (previewTaskStorageKey)
|
||||
|
||||
#### `usePermission`
|
||||
- 签名:`usePermission(): { permissions, roles, hasPermission, hasAnyPermission, hasAllPermissions, hasRole }`
|
||||
- 功能:客户端权限检查 Hook
|
||||
- 被使用:layout/app-sidebar.tsx, exams/components, homework/components
|
||||
|
||||
### 类型/接口
|
||||
|
||||
#### `ActionState<T>`
|
||||
- 定义:`{ success: boolean; message?: string; errors?: Record<string, string[]>; data?: T }`
|
||||
- 被使用:**所有模块**的 Server Action 返回类型
|
||||
|
||||
#### `Permissions` (常量对象)
|
||||
- 定义:22 个权限常量(`exam:create`, `homework:grade` 等)
|
||||
- 被使用:auth-guard.ts, 所有模块的 actions.ts, 前端组件
|
||||
|
||||
#### `Permission` (类型)
|
||||
- 定义:`Permissions` 值的联合类型
|
||||
- 被使用:auth-guard.ts, use-permission.ts
|
||||
|
||||
#### `DataScope` (联合类型)
|
||||
- 定义:`{ type: "all" } | { type: "owned"; userId: string } | { type: "class_taught"; classIds: string[]; subjectIds?: string[] } | { type: "grade_managed"; gradeIds: string[] } | { type: "class_members" } | { type: "children"; childrenIds: string[] }`
|
||||
- 被使用:auth-guard.ts, exams/data-access.ts, homework/data-access.ts, dashboard/data-access.ts
|
||||
|
||||
#### `AuthContext` (接口)
|
||||
- 定义:`{ userId: string; roles: string[]; permissions: Permission[]; dataScope: DataScope }`
|
||||
- 被使用:auth-guard.ts, 所有调用 `requirePermission` 的 Server Action
|
||||
|
||||
#### `PermissionDeniedError` (类)
|
||||
- 被使用:所有 Server Action 的 try/catch
|
||||
|
||||
### 数据库表 (shared/db/schema.ts)
|
||||
|
||||
| 表名 | 核心字段 | 被哪些模块使用 |
|
||||
|------|---------|--------------|
|
||||
| `users` | id, name, email, password, gradeId, departmentId, onboardedAt | auth, users, dashboard, classes |
|
||||
| `accounts` | userId, provider, access_token | auth |
|
||||
| `sessions` | userId, sessionToken | auth |
|
||||
| `roles` | id, name | auth, auth-guard |
|
||||
| `usersToRoles` | userId, roleId | auth, auth-guard |
|
||||
| `rolePermissions` | roleId, permission | auth (seed) |
|
||||
| `knowledgePoints` | id, name, chapterId, parentId | textbooks, questions |
|
||||
| `questions` | id, content, type, authorId, parentId | questions, exams, homework |
|
||||
| `questionsToKnowledgePoints` | questionId, knowledgePointId | questions |
|
||||
| `subjects` | id, name, order | exams, textbooks |
|
||||
| `textbooks` | id, title, subject, grade | textbooks |
|
||||
| `chapters` | id, textbookId, title, parentId, content | textbooks |
|
||||
| `departments` | id, name, description | school |
|
||||
| `classrooms` | id, location, capacity | school |
|
||||
| `academicYears` | id, name, startDate, endDate | school |
|
||||
| `schools` | id, name, code | school, classes |
|
||||
| `grades` | id, schoolId, name, gradeHeadId, teachingHeadId | school, classes, exams, auth-guard |
|
||||
| `classes` | id, schoolId, gradeId, teacherId, name, invitationCode | classes, homework, auth-guard |
|
||||
| `classSubjectTeachers` | classId, teacherId, subject | classes, auth-guard |
|
||||
| `classEnrollments` | classId, studentId, status | classes, homework |
|
||||
| `classSchedule` | id, classId, weekday, startTime, endTime, course | classes |
|
||||
| `exams` | id, creatorId, title, subjectId, gradeId, status, structure | exams, homework |
|
||||
| `examQuestions` | examId, questionId | exams |
|
||||
| `examSubmissions` | examId, studentId, score | exams |
|
||||
| `submissionAnswers` | submissionId, questionId, answer, score | exams |
|
||||
| `homeworkAssignments` | id, creatorId, sourceExamId, title, status, dueAt | homework |
|
||||
| `homeworkAssignmentQuestions` | assignmentId, questionId | homework |
|
||||
| `homeworkAssignmentTargets` | assignmentId, studentId | homework |
|
||||
| `homeworkSubmissions` | assignmentId, studentId, status, score | homework |
|
||||
| `homeworkAnswers` | submissionId, questionId, answer, score, feedback | homework |
|
||||
| `aiProviders` | id, provider, baseUrl, model, apiKeyEncrypted | settings, ai |
|
||||
|
||||
---
|
||||
|
||||
## 模块:auth
|
||||
|
||||
### 模块职责
|
||||
处理用户认证(登录/注册/JWT/Session),提供 NextAuth 实例和中间件。
|
||||
|
||||
### 导出函数
|
||||
|
||||
#### `auth`
|
||||
- 签名:`auth(): Promise<Session | null>` (NextAuth 导出)
|
||||
- 功能:获取当前用户 Session
|
||||
- 被使用:auth-guard.ts, 所有 Server Component 页面
|
||||
|
||||
#### `handlers`
|
||||
- 签名:`{ GET, POST }` (NextAuth Route Handler)
|
||||
- 被使用:`app/api/auth/[...nextauth]/route.ts`
|
||||
|
||||
#### `signIn` / `signOut`
|
||||
- 被使用:login-form.tsx, site-header.tsx
|
||||
|
||||
#### `middleware` (proxy.ts)
|
||||
- 签名:`middleware(request: NextRequest) => Promise<NextResponse>`
|
||||
- 功能:基于权限点的路由守卫,未登录重定向 /login,无权限重定向角色首页
|
||||
- 依赖:`getToken` (next-auth/jwt), `Permissions`
|
||||
- 被使用:Next.js middleware 层
|
||||
|
||||
---
|
||||
|
||||
## 模块:exams
|
||||
|
||||
### 模块职责
|
||||
考试全生命周期管理:创建(手动/AI)、编辑、预览、发布、删除、复制。
|
||||
|
||||
### 导出函数 (actions.ts)
|
||||
|
||||
#### `createExamAction`
|
||||
- 签名:`(prevState: ActionState<string> | null, formData: FormData) => Promise<ActionState<string>>`
|
||||
- 参数说明:`formData` 包含 mode, title, subject, grade, difficulty, totalScore, durationMin, scheduledAt
|
||||
- 功能:手动模式创建考试草稿
|
||||
- 依赖:`requirePermission(EXAM_CREATE)`, `shared/db`, `data-access.persistExamDraft`
|
||||
- 被使用:exam-form.tsx
|
||||
|
||||
#### `createAiExamAction`
|
||||
- 签名:同上
|
||||
- 功能:AI 模式创建考试(调用 AI pipeline 生成题目)
|
||||
- 依赖:`requirePermission(EXAM_AI_GENERATE)`, `ai-pipeline.generateAiCreateDraftFromSource`, `data-access.persistAiGeneratedExamDraft`
|
||||
- 被使用:exam-form.tsx
|
||||
|
||||
#### `previewAiExamAction`
|
||||
- 签名:`(prevState: ActionState<AiPreviewData> | null, formData: FormData) => Promise<ActionState<AiPreviewData>>`
|
||||
- 功能:AI 预览试卷(不持久化)
|
||||
- 依赖:`requirePermission(EXAM_AI_GENERATE)`, `ai-pipeline.generateAiPreviewData`
|
||||
- 被使用:exam-ai-generator.tsx (via useExamPreview)
|
||||
|
||||
#### `regenerateAiQuestionAction`
|
||||
- 签名:`(prevState: ActionState<AiRewriteQuestionData> | null, formData: FormData) => Promise<ActionState<AiRewriteQuestionData>>`
|
||||
- 功能:AI 重写单个题目
|
||||
- 依赖:`requirePermission(EXAM_AI_GENERATE)`, `ai-pipeline.regenerateAiQuestionByInstruction`
|
||||
- 被使用:exam-preview-question-editor.tsx
|
||||
|
||||
#### `updateExamAction`
|
||||
- 签名:`(prevState: ActionState<string> | null, formData: FormData) => Promise<ActionState<string>>`
|
||||
- 功能:更新考试信息,含资源归属校验(非 admin 只能改自己的)
|
||||
- 依赖:`requirePermission(EXAM_UPDATE)`, `shared/db`
|
||||
- 被使用:exam-form.tsx
|
||||
|
||||
#### `deleteExamAction`
|
||||
- 签名:同上
|
||||
- 功能:删除考试,含资源归属校验
|
||||
- 依赖:`requirePermission(EXAM_DELETE)`, `shared/db`
|
||||
- 被使用:exam-actions.tsx
|
||||
|
||||
#### `duplicateExamAction`
|
||||
- 签名:同上
|
||||
- 功能:复制考试
|
||||
- 依赖:`requirePermission(EXAM_DUPLICATE)`, `shared/db`
|
||||
- 被使用:exam-actions.tsx
|
||||
|
||||
#### `getSubjectsAction`
|
||||
- 签名:`() => Promise<ActionState<{ id: string; name: string }[]>>`
|
||||
- 依赖:`requirePermission(EXAM_READ)`, `shared/db`
|
||||
- 被使用:exam-form.tsx
|
||||
|
||||
#### `getGradesAction`
|
||||
- 签名:同上
|
||||
- 依赖:`requirePermission(EXAM_READ)`, `shared/db`
|
||||
- 被使用:exam-form.tsx
|
||||
|
||||
### 导出函数 (data-access.ts)
|
||||
|
||||
#### `getExams`
|
||||
- 签名:`getExams(params: GetExamsParams & { scope: DataScope }): Promise<Exam[]>`
|
||||
- 参数说明:`scope` 来自 `auth-guard.getAuthContext().dataScope`
|
||||
- 功能:查询考试列表,含数据权限过滤
|
||||
- 依赖:`shared/db`, `DataScope`
|
||||
- 被使用:teacher/exams/all/page.tsx, homework 创建页面
|
||||
|
||||
#### `getExamById`
|
||||
- 签名:`getExamById(id: string, scope?: DataScope): Promise<Exam | null>`
|
||||
- 被使用:exam 详情/编辑页面
|
||||
|
||||
#### `persistExamDraft` / `persistAiGeneratedExamDraft`
|
||||
- 被使用:createExamAction, createAiExamAction
|
||||
|
||||
### 导出函数 (ai-pipeline.ts)
|
||||
|
||||
#### `generateAiPreviewData`
|
||||
- 签名:`(input: { title, subject?, grade?, difficulty, totalScore, durationMin, questionCount?, sourceText, aiProviderId? }) => Promise<{ ok, data?, rawOutput?, message? }>`
|
||||
- 依赖:`shared/lib/ai.createAiChatCompletion`
|
||||
- 被使用:previewAiExamAction
|
||||
|
||||
#### `generateAiCreateDraftFromSource`
|
||||
- 被使用:createAiExamAction
|
||||
|
||||
#### `regenerateAiQuestionByInstruction`
|
||||
- 被使用:regenerateAiQuestionAction
|
||||
|
||||
### 类型/接口
|
||||
|
||||
#### `Exam`
|
||||
- 被使用:exams/components, homework/types (sourceExam), dashboard/types
|
||||
|
||||
#### `AiPreviewData` / `AiRewriteQuestionData`
|
||||
- 被使用:exams/actions.ts, exams/components
|
||||
|
||||
---
|
||||
|
||||
## 模块:homework
|
||||
|
||||
### 模块职责
|
||||
作业全生命周期:创建(源自考试)、发布、学生作答、教师批改、数据分析。
|
||||
|
||||
### 导出函数 (actions.ts)
|
||||
|
||||
#### `createHomeworkAssignmentAction`
|
||||
- 签名:`(prevState: ActionState<string> | null, formData: FormData) => Promise<ActionState<string>>`
|
||||
- 功能:从已有考试创建作业
|
||||
- 依赖:`requirePermission(HOMEWORK_CREATE)`, `shared/db`, `exams/data-access.getExams`
|
||||
- 被使用:homework-assignment-form.tsx
|
||||
|
||||
#### `startHomeworkSubmissionAction`
|
||||
- 签名:同上
|
||||
- 功能:学生开始作答
|
||||
- 依赖:`requirePermission(HOMEWORK_SUBMIT)`, `shared/db`
|
||||
- 被使用:homework-take-view.tsx
|
||||
|
||||
#### `saveHomeworkAnswerAction`
|
||||
- 签名:同上
|
||||
- 功能:保存单题答案
|
||||
- 依赖:`requirePermission(HOMEWORK_SUBMIT)`, `shared/db`
|
||||
- 被使用:homework-take-view.tsx
|
||||
|
||||
#### `submitHomeworkAction`
|
||||
- 签名:同上
|
||||
- 功能:提交作业
|
||||
- 依赖:`requirePermission(HOMEWORK_SUBMIT)`, `shared/db`
|
||||
- 被使用:homework-take-view.tsx
|
||||
|
||||
#### `gradeHomeworkSubmissionAction`
|
||||
- 签名:同上
|
||||
- 功能:教师批改作业
|
||||
- 依赖:`requirePermission(HOMEWORK_GRADE)`, `shared/db`
|
||||
- 被使用:homework-grading-view.tsx
|
||||
|
||||
### 导出函数 (data-access.ts)
|
||||
|
||||
#### `getHomeworkAssignments`
|
||||
- 签名:`(params?: { creatorId?, ids?, classId?, scope? }) => Promise<HomeworkAssignmentListItem[]>`
|
||||
- 依赖:`shared/db`, `DataScope`
|
||||
- 被使用:teacher 作业列表页, homework-assignment-form.tsx
|
||||
|
||||
#### `getStudentHomeworkAssignments`
|
||||
- 签名:`(studentId: string) => Promise<StudentHomeworkAssignmentListItem[]>`
|
||||
- 被使用:student/dashboard
|
||||
|
||||
#### `getStudentDashboardGrades`
|
||||
- 签名:`(studentId: string) => Promise<StudentDashboardGradeProps>`
|
||||
- 被使用:dashboard/data-access.ts (学生仪表盘)
|
||||
|
||||
#### `getHomeworkAssignmentAnalytics`
|
||||
- 签名:`(assignmentId: string) => Promise<HomeworkAssignmentAnalytics | null>`
|
||||
- 被使用:homework 错误分析组件
|
||||
|
||||
### 类型/接口
|
||||
|
||||
#### `StudentDashboardGradeProps`
|
||||
- 被使用:dashboard/types.ts (StudentDashboardProps.grades)
|
||||
|
||||
#### `HomeworkAssignmentListItem`
|
||||
- 被使用:homework 列表页, homework-assignment-form.tsx
|
||||
|
||||
---
|
||||
|
||||
## 模块:questions
|
||||
|
||||
### 模块职责
|
||||
题库管理:题目 CRUD、知识点关联、题型支持(选择/填空/判断/复合)。
|
||||
|
||||
### 导出函数 (actions.ts)
|
||||
|
||||
#### `createNestedQuestion`
|
||||
- 签名:`(prevState: ActionState<string> | undefined, formData: FormData | CreateQuestionInput) => Promise<ActionState<string>>`
|
||||
- 依赖:`requirePermission(QUESTION_CREATE)`, `shared/db`
|
||||
- 被使用:create-question-dialog.tsx
|
||||
|
||||
#### `updateQuestionAction`
|
||||
- 签名:同上
|
||||
- 依赖:`requirePermission(QUESTION_UPDATE)`, `shared/db`
|
||||
- 被使用:question-actions.tsx
|
||||
|
||||
#### `deleteQuestionAction`
|
||||
- 签名:同上
|
||||
- 依赖:`requirePermission(QUESTION_DELETE)`, `shared/db`
|
||||
- 被使用:question-actions.tsx
|
||||
|
||||
#### `getQuestionsAction`
|
||||
- 签名:`(params: GetQuestionsParams) => Promise<...>`
|
||||
- 依赖:`requirePermission(QUESTION_READ)`, `data-access.getQuestions`
|
||||
- 被使用:teacher/questions/page.tsx
|
||||
|
||||
#### `getKnowledgePointOptionsAction`
|
||||
- 签名:`() => Promise<KnowledgePointOption[]>`
|
||||
- 依赖:`requirePermission(QUESTION_READ)`, `shared/db`
|
||||
- 被使用:create-question-dialog.tsx
|
||||
|
||||
### 类型/接口
|
||||
|
||||
#### `Question`
|
||||
- 被使用:exams (题目选择), homework (作业题目)
|
||||
|
||||
#### `KnowledgePointOption`
|
||||
- 被使用:create-question-dialog.tsx
|
||||
|
||||
---
|
||||
|
||||
## 模块:textbooks
|
||||
|
||||
### 模块职责
|
||||
教材与知识体系管理:教材/章节树形结构、知识点 CRUD、Markdown 内容编辑、知识图谱。
|
||||
|
||||
### 导出函数 (actions.ts)
|
||||
|
||||
| 函数 | 权限 | 核心功能 |
|
||||
|------|------|---------|
|
||||
| `createTextbookAction` | TEXTBOOK_CREATE | 创建教材 |
|
||||
| `updateTextbookAction` | TEXTBOOK_UPDATE | 更新教材元信息 |
|
||||
| `deleteTextbookAction` | TEXTBOOK_DELETE | 删除教材 |
|
||||
| `createChapterAction` | TEXTBOOK_CREATE | 创建章节 |
|
||||
| `updateChapterContentAction` | TEXTBOOK_UPDATE | 更新章节内容(Markdown) |
|
||||
| `deleteChapterAction` | TEXTBOOK_DELETE | 删除章节 |
|
||||
| `createKnowledgePointAction` | TEXTBOOK_CREATE | 创建知识点 |
|
||||
| `updateKnowledgePointAction` | TEXTBOOK_UPDATE | 更新知识点 |
|
||||
| `deleteKnowledgePointAction` | TEXTBOOK_DELETE | 删除知识点 |
|
||||
| `reorderChaptersAction` | TEXTBOOK_UPDATE | 章节排序 |
|
||||
|
||||
### 导出函数 (data-access.ts)
|
||||
|
||||
| 函数 | 签名 | 被使用 |
|
||||
|------|------|--------|
|
||||
| `getTextbooks` | `(query?, subject?, grade?) => Promise<Textbook[]>` | teacher/textbooks/page.tsx |
|
||||
| `getTextbookById` | `(id: string) => Promise<Textbook \| undefined>` | teacher/textbooks/[id]/page.tsx |
|
||||
| `getChaptersByTextbookId` | `(textbookId: string) => Promise<Chapter[]>` | textbook-reader.tsx |
|
||||
| `getKnowledgePointsByChapterId` | `(chapterId: string) => Promise<KnowledgePoint[]>` | textbook-reader.tsx |
|
||||
| `getKnowledgePointsByTextbookId` | `(textbookId: string) => Promise<KnowledgePoint[]>` | textbook-reader.tsx |
|
||||
|
||||
### 导出 Hooks
|
||||
|
||||
#### `useTextSelection`
|
||||
- 签名:`useTextSelection(contentRef, onCreateKnowledgePoint) => { selectedText, createDialogOpen, isCreating, handleContentPointerDown, handleContextMenuChange }`
|
||||
- 被使用:textbook-content-panel.tsx
|
||||
|
||||
#### `useKnowledgePointActions`
|
||||
- 签名:`useKnowledgePointActions(textbookId, selectedChapterId, highlightedKpId, setHighlightedKpId) => { editingKp, editKpDialogOpen, ..., requestDeleteKnowledgePoint, confirmDeleteKnowledgePoint, handleUpdateKnowledgePoint }`
|
||||
- 被使用:textbook-reader.tsx
|
||||
|
||||
### 类型/接口
|
||||
|
||||
#### `Chapter`
|
||||
- 被使用:textbooks/components, questions (知识点关联)
|
||||
|
||||
#### `KnowledgePoint`
|
||||
- 被使用:textbooks/components, questions/types (KnowledgePointOption)
|
||||
|
||||
---
|
||||
|
||||
## 模块:classes
|
||||
|
||||
### 模块职责
|
||||
班级管理:班级 CRUD、学生注册/退班、邀请码、课表、学科教师分配。
|
||||
|
||||
### 导出函数 (actions.ts)
|
||||
|
||||
| 函数 | 权限 | 核心功能 |
|
||||
|------|------|---------|
|
||||
| `createTeacherClassAction` | CLASS_CREATE | 教师创建班级 |
|
||||
| `updateTeacherClassAction` | CLASS_UPDATE | 教师更新班级 |
|
||||
| `deleteTeacherClassAction` | CLASS_DELETE | 教师删除班级 |
|
||||
| `createGradeClassAction` | CLASS_CREATE | 年级主任创建班级 |
|
||||
| `updateGradeClassAction` | CLASS_UPDATE | 年级主任更新班级 |
|
||||
| `deleteGradeClassAction` | CLASS_DELETE | 年级主任删除班级 |
|
||||
| `enrollStudentByEmailAction` | CLASS_ENROLL | 通过邮箱注册学生 |
|
||||
| `joinClassByInvitationCodeAction` | CLASS_ENROLL | 通过邀请码加入 |
|
||||
| `ensureClassInvitationCodeAction` | CLASS_ENROLL | 确保邀请码存在 |
|
||||
| `regenerateClassInvitationCodeAction` | CLASS_ENROLL | 重新生成邀请码 |
|
||||
| `setStudentEnrollmentStatusAction` | CLASS_ENROLL | 设置学生状态 |
|
||||
| `createClassScheduleItemAction` | CLASS_SCHEDULE | 创建课表项 |
|
||||
| `updateClassScheduleItemAction` | CLASS_SCHEDULE | 更新课表项 |
|
||||
| `deleteClassScheduleItemAction` | CLASS_SCHEDULE | 删除课表项 |
|
||||
| `createAdminClassAction` | CLASS_CREATE | 管理员创建班级 |
|
||||
| `updateAdminClassAction` | CLASS_UPDATE | 管理员更新班级 |
|
||||
| `deleteAdminClassAction` | CLASS_DELETE | 管理员删除班级 |
|
||||
|
||||
### 导出函数 (data-access.ts)
|
||||
|
||||
| 函数 | 被使用 |
|
||||
|------|--------|
|
||||
| `getTeacherClasses(teacherId?)` | teacher/classes/my/page.tsx, dashboard |
|
||||
| `getAdminClasses()` | admin 班级管理 |
|
||||
| `getGradeManagedClasses(userId)` | grade_head 班级管理 |
|
||||
| `getStudentClasses(studentId)` | student/dashboard |
|
||||
| `getStudentSchedule(studentId)` | student 课表 |
|
||||
| `getClassStudents(classId, scope?)` | teacher/classes/students/page.tsx |
|
||||
| `getClassSchedule(classId)` | teacher/classes/schedule/page.tsx |
|
||||
| `getClassHomeworkInsights(classId)` | classes 作业洞察 |
|
||||
| `getGradeHomeworkInsights(gradeId)` | 年级作业洞察 |
|
||||
|
||||
---
|
||||
|
||||
## 模块:school
|
||||
|
||||
### 模块职责
|
||||
学校基础数据管理:学校、年级、部门、学年的 CRUD。
|
||||
|
||||
### 导出函数 (actions.ts)
|
||||
|
||||
| 函数 | 权限 | 核心功能 |
|
||||
|------|------|---------|
|
||||
| `createSchoolAction` | SCHOOL_MANAGE | 创建学校 |
|
||||
| `updateSchoolAction` | SCHOOL_MANAGE | 更新学校 |
|
||||
| `deleteSchoolAction` | SCHOOL_MANAGE | 删除学校 |
|
||||
| `createGradeAction` | GRADE_MANAGE | 创建年级 |
|
||||
| `updateGradeAction` | GRADE_MANAGE | 更新年级 |
|
||||
| `deleteGradeAction` | GRADE_MANAGE | 删除年级 |
|
||||
| `createDepartmentAction` | SCHOOL_MANAGE | 创建部门 |
|
||||
| `updateDepartmentAction` | SCHOOL_MANAGE | 更新部门 |
|
||||
| `deleteDepartmentAction` | SCHOOL_MANAGE | 删除部门 |
|
||||
| `createAcademicYearAction` | SCHOOL_MANAGE | 创建学年 |
|
||||
| `updateAcademicYearAction` | SCHOOL_MANAGE | 更新学年 |
|
||||
| `deleteAcademicYearAction` | SCHOOL_MANAGE | 删除学年 |
|
||||
|
||||
### 导出函数 (data-access.ts)
|
||||
|
||||
| 函数 | 被使用 |
|
||||
|------|--------|
|
||||
| `getSchools()` | admin 学校管理, onboarding |
|
||||
| `getGrades()` | admin 年级管理, exams, onboarding |
|
||||
| `getDepartments()` | admin 部门管理 |
|
||||
| `getAcademicYears()` | admin 学年管理 |
|
||||
| `getStaffOptions()` | school 组件 (年级主任选择) |
|
||||
| `getGradesForStaff(staffId)` | grade_head 视图 |
|
||||
|
||||
---
|
||||
|
||||
## 模块:dashboard
|
||||
|
||||
### 模块职责
|
||||
各角色仪表盘数据聚合与展示。
|
||||
|
||||
### 导出函数 (data-access.ts)
|
||||
|
||||
#### `getAdminDashboardData`
|
||||
- 签名:`getAdminDashboardData(scope?: DataScope): Promise<AdminDashboardData>`
|
||||
- 依赖:`shared/db`, `DataScope`
|
||||
- 被使用:admin/dashboard/page.tsx
|
||||
|
||||
### 类型/接口
|
||||
|
||||
#### `StudentDashboardProps`
|
||||
- 被使用:student-dashboard-view.tsx
|
||||
- 依赖:`homework/types.StudentDashboardGradeProps`
|
||||
|
||||
#### `TeacherDashboardData`
|
||||
- 被使用:teacher-dashboard-view.tsx
|
||||
- 依赖:`homework/data-access.getTeacherGradeTrends`, `classes/data-access.getTeacherClasses`
|
||||
|
||||
---
|
||||
|
||||
## 模块:layout
|
||||
|
||||
### 模块职责
|
||||
应用布局框架:侧边栏、顶栏、导航配置。
|
||||
|
||||
### 导出组件
|
||||
|
||||
#### `AppSidebar`
|
||||
- 内部使用:`usePermission`, `NAV_CONFIG`
|
||||
- 功能:根据权限渲染侧边栏导航
|
||||
|
||||
#### `SiteHeader`
|
||||
- 内部使用:`useSession`, `signOut`
|
||||
- 功能:顶部导航栏
|
||||
|
||||
### 导出配置
|
||||
|
||||
#### `NAV_CONFIG`
|
||||
- 类型:`Record<string, NavItem[]>`
|
||||
- 每个NavItem含 `permission?: string` 字段
|
||||
- 被使用:app-sidebar.tsx
|
||||
|
||||
---
|
||||
|
||||
## 模块:settings
|
||||
|
||||
### 模块职责
|
||||
系统设置:AI Provider 配置、用户偏好。
|
||||
|
||||
### 导出函数 (actions.ts)
|
||||
|
||||
| 函数 | 权限 | 核心功能 |
|
||||
|------|------|---------|
|
||||
| `getAiProviderSummaries()` | AI_CONFIGURE | 获取 AI Provider 列表 |
|
||||
| `upsertAiProviderAction(data)` | AI_CONFIGURE | 创建/更新 AI Provider |
|
||||
| `testAiProviderAction(data)` | AI_CONFIGURE | 测试 AI Provider 连通性 |
|
||||
|
||||
---
|
||||
|
||||
## 模块间依赖矩阵
|
||||
|
||||
| ↓ 使用 → | shared | auth | exams | homework | questions | textbooks | classes | school | dashboard | layout | settings |
|
||||
|----------|--------|------|-------|----------|-----------|-----------|---------|--------|-----------|--------|----------|
|
||||
| **shared** | - | - | - | - | - | - | - | - | - | - | - |
|
||||
| **auth** | db,schema,permissions | - | - | - | - | - | - | - | - | - | - |
|
||||
| **exams** | db,auth-guard,types,ai | auth | - | - | - | - | - | - | - | - | - |
|
||||
| **homework** | db,auth-guard,types | auth | data-access.getExams | - | - | - | schema | - | - | - | - |
|
||||
| **questions** | db,auth-guard,types | auth | - | - | - | - | - | - | - | - | - |
|
||||
| **textbooks** | db,auth-guard,types | auth | - | - | - | - | - | - | - | - | - |
|
||||
| **classes** | db,auth-guard,types | auth | - | homework-insights | - | - | - | - | - | - | - |
|
||||
| **school** | db,auth-guard,types | auth | - | - | - | - | - | - | - | - | - |
|
||||
| **dashboard** | db,types | auth | - | data-access.getTeacherGradeTrends,getStudentDashboardGrades | - | - | data-access.getTeacherClasses,getStudentClasses,getStudentSchedule | - | - | - | - |
|
||||
| **layout** | hooks.usePermission | auth(useSession) | - | - | - | - | - | - | - | - | - |
|
||||
| **settings** | db,auth-guard,ai,types | auth | - | - | - | - | - | - | - | - | - |
|
||||
|
||||
---
|
||||
|
||||
## 关键参数影响链
|
||||
|
||||
### `userId`
|
||||
1. 由 `auth.ts` JWT callback 从 `users` 表查询产生,存入 JWT
|
||||
2. 通过 `session.user.id` 传递到所有 Server Components 和 Client Components
|
||||
3. 通过 `getAuthContext().userId` 传递到所有 Server Actions
|
||||
4. 在 `auth-guard.ts` 中用于查询 `usersToRoles`(获取角色)和 `classSubjectTeachers`/`grades`(获取 DataScope)
|
||||
5. 在 exams/actions.ts 中作为 `creatorId` 写入 `exams` 表
|
||||
6. 在 homework/actions.ts 中作为 `creatorId` 写入 `homeworkAssignments` 表
|
||||
7. 在 classes/data-access.ts 中查询 `getTeacherClasses(teacherId)` 和 `getGradeManagedClasses(userId)`
|
||||
|
||||
### `examId`
|
||||
1. 由 `exams/actions.ts` 的 `createExamAction` 产生,通过 CUID2 生成,写入 `exams` 表
|
||||
2. 被 `exams/data-access.getExamById(id)` 读取
|
||||
3. 被 `exams/actions.ts` 的 `updateExamAction`/`deleteExamAction`/`duplicateExamAction` 用于定位考试
|
||||
4. 传入 `homework/actions.ts` 的 `createHomeworkAssignmentAction` 的 `sourceExamId` 参数
|
||||
5. 在 `homeworkAssignments` 表中作为外键关联到源考试
|
||||
6. 被 `homework/data-access.getHomeworkAssignmentAnalytics` 用于追溯作业来源
|
||||
|
||||
### `classId`
|
||||
1. 由 `classes/actions.ts` 的 `createTeacherClassAction`/`createAdminClassAction` 产生
|
||||
2. 被 `classes/data-access.getClassStudents(classId)` 读取学生列表
|
||||
3. 被 `classes/data-access.getClassSchedule(classId)` 读取课表
|
||||
4. 被 `classes/data-access.getClassHomeworkInsights(classId)` 读取作业洞察
|
||||
5. 被 `homework/data-access.getHomeworkAssignments({ classId })` 过滤作业列表
|
||||
6. 在 `auth-guard.ts` 中通过 `classSubjectTeachers` 查询教师关联的 classIds,构建 `DataScope.class_taught`
|
||||
|
||||
### `permission`
|
||||
1. 由 `shared/types/permissions.ts` 的 `Permissions` 常量定义(22 个权限点)
|
||||
2. 在 `shared/lib/permissions.ts` 中通过 `ROLE_PERMISSIONS` 映射角色到权限列表
|
||||
3. 在 `auth.ts` JWT callback 中通过 `resolvePermissions(roleNames)` 合并多角色权限,存入 JWT
|
||||
4. 在 `proxy.ts` middleware 中通过 `token.permissions` 检查路由访问权限
|
||||
5. 在 `shared/lib/auth-guard.ts` 中通过 `requirePermission(permission)` 在 Server Action 层断言权限
|
||||
6. 在 `shared/hooks/use-permission.ts` 中通过 `hasPermission(permission)` 在客户端组件中条件渲染
|
||||
7. 在 `layout/config/navigation.ts` 中作为 `NavItem.permission` 字段过滤侧边栏菜单
|
||||
|
||||
### `DataScope`
|
||||
1. 由 `auth-guard.ts` 的 `resolveDataScope(userId, roles)` 根据用户角色和 DB 关系动态计算
|
||||
2. 传递到 `exams/data-access.getExams({ scope })` 进行行级过滤
|
||||
3. 传递到 `homework/data-access.getHomeworkAssignments({ scope })` 进行行级过滤
|
||||
4. 传递到 `dashboard/data-access.getAdminDashboardData(scope)` 进行统计过滤
|
||||
5. 在 exams/actions.ts 的 `updateExamAction`/`deleteExamAction` 中用于判断是否需要资源归属校验(`scope.type !== "all"`)
|
||||
623
docs/architecture/005_architecture_data.json
Normal file
623
docs/architecture/005_architecture_data.json
Normal file
@@ -0,0 +1,623 @@
|
||||
{
|
||||
"_meta": {
|
||||
"project": "Next_Edu",
|
||||
"description": "K12 智慧教务管理系统",
|
||||
"generatedAt": "2026-06-16",
|
||||
"formatVersion": "1.0",
|
||||
"rule": "每次文件修改后须同步更新本文件"
|
||||
},
|
||||
"techStack": {
|
||||
"framework": "Next.js 16 (App Router)",
|
||||
"language": "TypeScript (strict)",
|
||||
"ui": "React 19 + Tailwind CSS v4 + shadcn/ui",
|
||||
"state": ["Zustand", "nuqs", "React Hook Form"],
|
||||
"database": "MySQL + Drizzle ORM 0.45",
|
||||
"auth": "NextAuth v5 (JWT strategy)",
|
||||
"validation": "Zod 4",
|
||||
"ai": "OpenAI SDK (multi-provider)",
|
||||
"testing": ["Vitest", "Playwright"]
|
||||
},
|
||||
"roles": ["admin", "teacher", "student", "parent", "grade_head", "teaching_head"],
|
||||
"permissions": {
|
||||
"EXAM_CREATE": "exam:create",
|
||||
"EXAM_READ": "exam:read",
|
||||
"EXAM_UPDATE": "exam:update",
|
||||
"EXAM_DELETE": "exam:delete",
|
||||
"EXAM_DUPLICATE": "exam:duplicate",
|
||||
"EXAM_PUBLISH": "exam:publish",
|
||||
"EXAM_AI_GENERATE": "exam:ai_generate",
|
||||
"HOMEWORK_CREATE": "homework:create",
|
||||
"HOMEWORK_GRADE": "homework:grade",
|
||||
"HOMEWORK_SUBMIT": "homework:submit",
|
||||
"QUESTION_CREATE": "question:create",
|
||||
"QUESTION_READ": "question:read",
|
||||
"QUESTION_UPDATE": "question:update",
|
||||
"QUESTION_DELETE": "question:delete",
|
||||
"TEXTBOOK_CREATE": "textbook:create",
|
||||
"TEXTBOOK_READ": "textbook:read",
|
||||
"TEXTBOOK_UPDATE": "textbook:update",
|
||||
"TEXTBOOK_DELETE": "textbook:delete",
|
||||
"CLASS_CREATE": "class:create",
|
||||
"CLASS_READ": "class:read",
|
||||
"CLASS_UPDATE": "class:update",
|
||||
"CLASS_DELETE": "class:delete",
|
||||
"CLASS_ENROLL": "class:enroll",
|
||||
"CLASS_SCHEDULE": "class:schedule",
|
||||
"SCHOOL_MANAGE": "school:manage",
|
||||
"GRADE_MANAGE": "grade:manage",
|
||||
"USER_MANAGE": "user:manage",
|
||||
"AI_CHAT": "ai:chat",
|
||||
"AI_CONFIGURE": "ai:configure",
|
||||
"SETTINGS_ADMIN": "settings:admin"
|
||||
},
|
||||
"rolePermissions": {
|
||||
"admin": ["EXAM_CREATE","EXAM_READ","EXAM_UPDATE","EXAM_DELETE","EXAM_DUPLICATE","EXAM_PUBLISH","EXAM_AI_GENERATE","HOMEWORK_CREATE","HOMEWORK_GRADE","QUESTION_CREATE","QUESTION_READ","QUESTION_UPDATE","QUESTION_DELETE","TEXTBOOK_CREATE","TEXTBOOK_READ","TEXTBOOK_UPDATE","TEXTBOOK_DELETE","CLASS_CREATE","CLASS_READ","CLASS_UPDATE","CLASS_DELETE","CLASS_ENROLL","CLASS_SCHEDULE","SCHOOL_MANAGE","GRADE_MANAGE","USER_MANAGE","AI_CHAT","AI_CONFIGURE","SETTINGS_ADMIN"],
|
||||
"teacher": ["EXAM_CREATE","EXAM_READ","EXAM_UPDATE","EXAM_DELETE","EXAM_DUPLICATE","EXAM_PUBLISH","EXAM_AI_GENERATE","HOMEWORK_CREATE","HOMEWORK_GRADE","QUESTION_CREATE","QUESTION_READ","QUESTION_UPDATE","QUESTION_DELETE","TEXTBOOK_CREATE","TEXTBOOK_READ","TEXTBOOK_UPDATE","CLASS_READ","CLASS_ENROLL","CLASS_SCHEDULE","AI_CHAT"],
|
||||
"student": ["EXAM_READ","HOMEWORK_SUBMIT","QUESTION_READ","TEXTBOOK_READ","CLASS_READ","AI_CHAT"],
|
||||
"parent": ["EXAM_READ","TEXTBOOK_READ","CLASS_READ"],
|
||||
"grade_head": ["EXAM_CREATE","EXAM_READ","EXAM_UPDATE","EXAM_DELETE","EXAM_DUPLICATE","EXAM_PUBLISH","EXAM_AI_GENERATE","HOMEWORK_CREATE","HOMEWORK_GRADE","QUESTION_CREATE","QUESTION_READ","QUESTION_UPDATE","QUESTION_DELETE","TEXTBOOK_CREATE","TEXTBOOK_READ","TEXTBOOK_UPDATE","CLASS_CREATE","CLASS_READ","CLASS_UPDATE","CLASS_ENROLL","CLASS_SCHEDULE","GRADE_MANAGE","AI_CHAT"],
|
||||
"teaching_head": ["EXAM_CREATE","EXAM_READ","EXAM_UPDATE","EXAM_DELETE","EXAM_DUPLICATE","EXAM_PUBLISH","EXAM_AI_GENERATE","HOMEWORK_CREATE","HOMEWORK_GRADE","QUESTION_CREATE","QUESTION_READ","QUESTION_UPDATE","QUESTION_DELETE","TEXTBOOK_CREATE","TEXTBOOK_READ","TEXTBOOK_UPDATE","CLASS_READ","GRADE_MANAGE","AI_CHAT"]
|
||||
},
|
||||
"dataScopeTypes": {
|
||||
"all": "管理员:无过滤",
|
||||
"owned": "仅自己创建的资源,含 userId 字段",
|
||||
"class_taught": "教师:所教班级,含 classIds[] 和可选 subjectIds[]",
|
||||
"grade_managed": "年级主任:所管年级,含 gradeIds[]",
|
||||
"class_members": "学生:所在班级的成员数据",
|
||||
"children": "家长:子女数据,含 childrenIds[]"
|
||||
},
|
||||
"modules": {
|
||||
"shared": {
|
||||
"path": "src/shared",
|
||||
"description": "全项目共享基础设施:数据库、工具函数、权限系统、UI组件、Hooks",
|
||||
"exports": {
|
||||
"functions": [
|
||||
{
|
||||
"name": "cn",
|
||||
"file": "lib/utils.ts",
|
||||
"signature": "cn(...inputs: ClassValue[]): string",
|
||||
"purpose": "合并CSS类名并解决Tailwind冲突",
|
||||
"deps": ["clsx", "tailwind-merge"],
|
||||
"usedBy": ["*所有模块组件"]
|
||||
},
|
||||
{
|
||||
"name": "formatDate",
|
||||
"file": "lib/utils.ts",
|
||||
"signature": "formatDate(date: string | Date, locale?: string): string",
|
||||
"params": {"date": "日期值", "locale": "Intl locale,默认zh-CN"},
|
||||
"purpose": "国际化日期格式化",
|
||||
"deps": [],
|
||||
"usedBy": ["exams", "homework", "dashboard", "textbooks"]
|
||||
},
|
||||
{
|
||||
"name": "parseAiChatPayload",
|
||||
"file": "lib/ai.ts",
|
||||
"signature": "parseAiChatPayload(body: unknown): AiChatRequest",
|
||||
"purpose": "解析并校验AI聊天请求负载",
|
||||
"deps": ["zod"],
|
||||
"usedBy": ["app/api/ai/chat/route.ts"]
|
||||
},
|
||||
{
|
||||
"name": "encryptAiApiKey",
|
||||
"file": "lib/ai.ts",
|
||||
"signature": "encryptAiApiKey(value: string): string",
|
||||
"purpose": "AES加密AI Provider API Key",
|
||||
"deps": ["crypto"],
|
||||
"usedBy": ["settings/actions.ts"]
|
||||
},
|
||||
{
|
||||
"name": "decryptAiApiKey",
|
||||
"file": "lib/ai.ts",
|
||||
"signature": "decryptAiApiKey(value: string): string",
|
||||
"purpose": "AES解密AI Provider API Key",
|
||||
"deps": ["crypto"],
|
||||
"usedBy": ["settings/actions.ts", "ai.ts内部"]
|
||||
},
|
||||
{
|
||||
"name": "testAiProviderConfig",
|
||||
"file": "lib/ai.ts",
|
||||
"signature": "testAiProviderConfig(input: { apiKey: string; baseUrl?: string; model: string }): Promise<boolean>",
|
||||
"purpose": "测试AI Provider连通性(直接配置)",
|
||||
"deps": ["createAiChatCompletion"],
|
||||
"usedBy": ["settings/actions.ts"]
|
||||
},
|
||||
{
|
||||
"name": "testAiProviderById",
|
||||
"file": "lib/ai.ts",
|
||||
"signature": "testAiProviderById(providerId: string, overrides?: { baseUrl?: string; model?: string }): Promise<boolean>",
|
||||
"purpose": "测试AI Provider连通性(按ID)",
|
||||
"deps": ["shared/db", "createAiChatCompletion"],
|
||||
"usedBy": ["settings/actions.ts"]
|
||||
},
|
||||
{
|
||||
"name": "createAiChatCompletion",
|
||||
"file": "lib/ai.ts",
|
||||
"signature": "createAiChatCompletion(input: AiChatRequest): Promise<{ content: string; usage: unknown }>",
|
||||
"purpose": "调用AI模型生成聊天回复",
|
||||
"deps": ["openai", "shared/db"],
|
||||
"usedBy": ["exams/ai-pipeline.ts", "app/api/ai/chat/route.ts"]
|
||||
},
|
||||
{
|
||||
"name": "getAiErrorMessage",
|
||||
"file": "lib/ai.ts",
|
||||
"signature": "getAiErrorMessage(v: unknown): string",
|
||||
"purpose": "从AI错误中提取可读消息",
|
||||
"deps": [],
|
||||
"usedBy": ["exams/ai-pipeline.ts"]
|
||||
},
|
||||
{
|
||||
"name": "getAuthContext",
|
||||
"file": "lib/auth-guard.ts",
|
||||
"signature": "getAuthContext(): Promise<AuthContext>",
|
||||
"returns": "AuthContext { userId, roles, permissions, dataScope }",
|
||||
"purpose": "获取当前用户完整认证上下文",
|
||||
"deps": ["auth (NextAuth)", "shared/db"],
|
||||
"usedBy": ["所有业务模块的Server Actions"]
|
||||
},
|
||||
{
|
||||
"name": "requirePermission",
|
||||
"file": "lib/auth-guard.ts",
|
||||
"signature": "requirePermission(permission: Permission): Promise<AuthContext>",
|
||||
"throws": "PermissionDeniedError",
|
||||
"purpose": "断言当前用户拥有指定权限",
|
||||
"deps": ["getAuthContext"],
|
||||
"usedBy": ["所有业务模块的Server Actions"]
|
||||
},
|
||||
{
|
||||
"name": "checkPermission",
|
||||
"file": "lib/auth-guard.ts",
|
||||
"signature": "checkPermission(permission: Permission): Promise<{ allowed: boolean; ctx: AuthContext }>",
|
||||
"purpose": "非抛出版权限检查",
|
||||
"deps": ["getAuthContext"],
|
||||
"usedBy": []
|
||||
},
|
||||
{
|
||||
"name": "requireAuth",
|
||||
"file": "lib/auth-guard.ts",
|
||||
"signature": "requireAuth(): Promise<AuthContext>",
|
||||
"purpose": "仅断言用户已登录",
|
||||
"deps": ["getAuthContext"],
|
||||
"usedBy": []
|
||||
},
|
||||
{
|
||||
"name": "resolvePermissions",
|
||||
"file": "lib/permissions.ts",
|
||||
"signature": "resolvePermissions(roleNames: string[]): Permission[]",
|
||||
"purpose": "合并多角色的权限列表(去重)",
|
||||
"deps": ["ROLE_PERMISSIONS"],
|
||||
"usedBy": ["auth.ts (JWT callback)"]
|
||||
}
|
||||
],
|
||||
"hooks": [
|
||||
{
|
||||
"name": "useActionWithToast",
|
||||
"file": "hooks/use-action-with-toast.ts",
|
||||
"signature": "useActionWithToast<T>(): { isPending: boolean; execute: (action: () => Promise<ActionState<T>>) => void }",
|
||||
"purpose": "包装Server Action + toast反馈"
|
||||
},
|
||||
{
|
||||
"name": "useDebounce",
|
||||
"file": "hooks/use-debounce.ts",
|
||||
"signature": "useDebounce<T>(value: T, delay?: number): T",
|
||||
"purpose": "防抖Hook"
|
||||
},
|
||||
{
|
||||
"name": "useMediaQuery",
|
||||
"file": "hooks/use-media-query.ts",
|
||||
"signature": "useMediaQuery(query: string): boolean",
|
||||
"purpose": "响应式媒体查询Hook"
|
||||
},
|
||||
{
|
||||
"name": "useLocalStorage",
|
||||
"file": "hooks/use-local-storage.ts",
|
||||
"signature": "useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void]",
|
||||
"purpose": "localStorage持久化Hook"
|
||||
},
|
||||
{
|
||||
"name": "usePermission",
|
||||
"file": "hooks/use-permission.ts",
|
||||
"signature": "usePermission(): { permissions: Permission[]; roles: string[]; hasPermission: (p: Permission) => boolean; hasAnyPermission: (...p: Permission[]) => boolean; hasAllPermissions: (...p: Permission[]) => boolean; hasRole: (r: string) => boolean }",
|
||||
"purpose": "客户端权限检查Hook",
|
||||
"usedBy": ["layout/app-sidebar.tsx", "exams/components", "homework/components"]
|
||||
}
|
||||
],
|
||||
"components": [
|
||||
{"name": "AuthSessionProvider", "file": "components/auth-session-provider.tsx", "purpose": "NextAuth SessionProvider包装", "usedBy": ["app/layout.tsx"]},
|
||||
{"name": "OnboardingGate", "file": "components/onboarding-gate.tsx", "purpose": "新用户引导流程", "usedBy": ["app/layout.tsx"]},
|
||||
{"name": "ThemeProvider", "file": "components/theme-provider.tsx", "purpose": "next-themes主题切换", "usedBy": ["app/layout.tsx"]},
|
||||
{"name": "EmptyState", "file": "components/empty-state.tsx", "purpose": "列表空状态展示", "usedBy": ["exams", "homework", "questions", "textbooks"]}
|
||||
],
|
||||
"types": [
|
||||
{"name": "ActionState", "file": "types/action-state.ts", "definition": "ActionState<T = void> = { success: boolean; message?: string; errors?: Record<string, string[]>; data?: T }", "usedBy": ["所有模块Server Action"]},
|
||||
{"name": "Permission", "file": "types/permissions.ts", "definition": "Permission = (typeof Permissions)[keyof typeof Permissions]", "usedBy": ["auth-guard", "use-permission", "所有actions"]},
|
||||
{"name": "DataScope", "file": "types/permissions.ts", "definition": "DataScope = { type: 'all' } | { type: 'owned'; userId: string } | { type: 'class_taught'; classIds: string[]; subjectIds?: string[] } | { type: 'grade_managed'; gradeIds: string[] } | { type: 'class_members' } | { type: 'children'; childrenIds: string[] }", "usedBy": ["auth-guard", "exams/data-access", "homework/data-access", "dashboard/data-access"]},
|
||||
{"name": "AuthContext", "file": "types/permissions.ts", "definition": "AuthContext = { userId: string; roles: string[]; permissions: Permission[]; dataScope: DataScope }", "usedBy": ["auth-guard", "所有调用requirePermission的Server Action"]},
|
||||
{"name": "PermissionDeniedError", "file": "lib/auth-guard.ts", "definition": "class PermissionDeniedError extends Error { constructor(permission: string) }", "usedBy": ["所有Server Action的try/catch"]}
|
||||
]
|
||||
},
|
||||
"dbTables": {
|
||||
"users": {"fields": ["id","name","email","emailVerified","image","password","phone","address","gender","age","gradeId","departmentId","onboardedAt","createdAt","updatedAt"], "usedBy": ["auth","users","dashboard","classes"]},
|
||||
"accounts": {"fields": ["userId","type","provider","providerAccountId","refresh_token","access_token"], "usedBy": ["auth"]},
|
||||
"sessions": {"fields": ["id","sessionToken","userId","expires"], "usedBy": ["auth"]},
|
||||
"verificationTokens": {"fields": ["identifier","token","expires"], "usedBy": ["auth"]},
|
||||
"roles": {"fields": ["id","name"], "usedBy": ["auth","auth-guard"]},
|
||||
"usersToRoles": {"fields": ["userId","roleId"], "usedBy": ["auth","auth-guard"]},
|
||||
"rolePermissions": {"fields": ["roleId","permission"], "usedBy": ["auth (seed)"]},
|
||||
"knowledgePoints": {"fields": ["id","name","description","anchorText","parentId","chapterId","level","order","createdAt","updatedAt"], "usedBy": ["textbooks","questions"]},
|
||||
"questions": {"fields": ["id","content","type","difficulty","authorId","parentId","createdAt","updatedAt"], "usedBy": ["questions","exams","homework"]},
|
||||
"questionsToKnowledgePoints": {"fields": ["questionId","knowledgePointId"], "usedBy": ["questions"]},
|
||||
"subjects": {"fields": ["id","name","order"], "usedBy": ["exams","textbooks"]},
|
||||
"textbooks": {"fields": ["id","title","subject","grade","publisher","createdAt","updatedAt"], "usedBy": ["textbooks"]},
|
||||
"chapters": {"fields": ["id","textbookId","title","order","parentId","content","createdAt","updatedAt"], "usedBy": ["textbooks"]},
|
||||
"departments": {"fields": ["id","name","description","createdAt","updatedAt"], "usedBy": ["school"]},
|
||||
"classrooms": {"fields": ["id","location","capacity","createdAt","updatedAt"], "usedBy": ["school"]},
|
||||
"academicYears": {"fields": ["id","name","startDate","endDate","isActive","createdAt","updatedAt"], "usedBy": ["school"]},
|
||||
"schools": {"fields": ["id","name","code","createdAt","updatedAt"], "usedBy": ["school","classes"]},
|
||||
"grades": {"fields": ["id","schoolId","name","order","gradeHeadId","teachingHeadId","createdAt","updatedAt"], "usedBy": ["school","classes","exams","auth-guard"]},
|
||||
"classes": {"fields": ["id","schoolId","gradeId","teacherId","name","homeroom","room","invitationCode","createdAt","updatedAt"], "usedBy": ["classes","homework","auth-guard"]},
|
||||
"classSubjectTeachers": {"fields": ["classId","teacherId","subject"], "usedBy": ["classes","auth-guard"]},
|
||||
"classEnrollments": {"fields": ["classId","studentId","status","joinedAt"], "usedBy": ["classes","homework"]},
|
||||
"classSchedule": {"fields": ["id","classId","weekday","startTime","endTime","course","location"], "usedBy": ["classes"]},
|
||||
"exams": {"fields": ["id","creatorId","title","description","subjectId","gradeId","status","difficulty","totalScore","durationMin","scheduledAt","structure","createdAt","updatedAt"], "usedBy": ["exams","homework"]},
|
||||
"examQuestions": {"fields": ["examId","questionId"], "usedBy": ["exams"]},
|
||||
"examSubmissions": {"fields": ["id","examId","studentId","score","submittedAt"], "usedBy": ["exams"]},
|
||||
"submissionAnswers": {"fields": ["id","submissionId","questionId","answer","score","feedback"], "usedBy": ["exams"]},
|
||||
"homeworkAssignments": {"fields": ["id","creatorId","sourceExamId","title","description","status","availableAt","dueAt","allowLate","lateDueAt","maxAttempts","createdAt","updatedAt"], "usedBy": ["homework"]},
|
||||
"homeworkAssignmentQuestions": {"fields": ["assignmentId","questionId"], "usedBy": ["homework"]},
|
||||
"homeworkAssignmentTargets": {"fields": ["assignmentId","studentId"], "usedBy": ["homework"]},
|
||||
"homeworkSubmissions": {"fields": ["id","assignmentId","studentId","status","attemptNo","score","submittedAt"], "usedBy": ["homework"]},
|
||||
"homeworkAnswers": {"fields": ["id","submissionId","questionId","answer","score","feedback"], "usedBy": ["homework"]},
|
||||
"aiProviders": {"fields": ["id","provider","baseUrl","model","apiKeyEncrypted","isDefault","updatedAt"], "usedBy": ["settings","ai"]}
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"path": "src/auth.ts",
|
||||
"description": "用户认证:NextAuth配置、JWT/Session callbacks、middleware",
|
||||
"exports": {
|
||||
"functions": [
|
||||
{"name": "auth", "signature": "auth(): Promise<Session | null>", "purpose": "获取当前用户Session", "usedBy": ["auth-guard.ts", "所有Server Component页面"]},
|
||||
{"name": "handlers", "signature": "{ GET, POST }", "purpose": "NextAuth Route Handler", "usedBy": ["app/api/auth/[...nextauth]/route.ts"]},
|
||||
{"name": "signIn", "signature": "signIn(provider, options?)", "purpose": "登录", "usedBy": ["login-form.tsx"]},
|
||||
{"name": "signOut", "signature": "signOut(options?)", "purpose": "登出", "usedBy": ["site-header.tsx"]}
|
||||
]
|
||||
},
|
||||
"middleware": {
|
||||
"file": "src/proxy.ts",
|
||||
"signature": "middleware(request: NextRequest): Promise<NextResponse>",
|
||||
"purpose": "基于权限点的路由守卫",
|
||||
"routePermissions": {
|
||||
"/admin": "school:manage",
|
||||
"/teacher": "exam:read",
|
||||
"/student": "homework:submit",
|
||||
"/parent": "exam:read",
|
||||
"/management": "grade:manage"
|
||||
},
|
||||
"apiPermissions": {
|
||||
"/api/ai/chat": "ai:chat"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exams": {
|
||||
"path": "src/modules/exams",
|
||||
"description": "考试全生命周期:创建(手动/AI)、编辑、预览、发布、删除、复制",
|
||||
"exports": {
|
||||
"actions": [
|
||||
{"name": "createExamAction", "permission": "EXAM_CREATE", "signature": "(prevState: ActionState<string> | null, formData: FormData) => Promise<ActionState<string>>", "purpose": "手动模式创建考试草稿", "deps": ["requirePermission","shared/db","data-access.persistExamDraft"], "usedBy": ["exam-form.tsx"]},
|
||||
{"name": "createAiExamAction", "permission": "EXAM_AI_GENERATE", "signature": "同上", "purpose": "AI模式创建考试", "deps": ["requirePermission","ai-pipeline.generateAiCreateDraftFromSource","data-access.persistAiGeneratedExamDraft"], "usedBy": ["exam-form.tsx"]},
|
||||
{"name": "previewAiExamAction", "permission": "EXAM_AI_GENERATE", "signature": "(prevState: ActionState<AiPreviewData> | null, formData: FormData) => Promise<ActionState<AiPreviewData>>", "purpose": "AI预览试卷(不持久化)", "deps": ["requirePermission","ai-pipeline.generateAiPreviewData"], "usedBy": ["exam-ai-generator.tsx"]},
|
||||
{"name": "regenerateAiQuestionAction", "permission": "EXAM_AI_GENERATE", "signature": "(prevState: ActionState<AiRewriteQuestionData> | null, formData: FormData) => Promise<ActionState<AiRewriteQuestionData>>", "purpose": "AI重写单个题目", "deps": ["requirePermission","ai-pipeline.regenerateAiQuestionByInstruction"], "usedBy": ["exam-preview-question-editor.tsx"]},
|
||||
{"name": "updateExamAction", "permission": "EXAM_UPDATE", "signature": "(prevState: ActionState<string> | null, formData: FormData) => Promise<ActionState<string>>", "purpose": "更新考试(含资源归属校验)", "deps": ["requirePermission","shared/db"], "usedBy": ["exam-form.tsx"]},
|
||||
{"name": "deleteExamAction", "permission": "EXAM_DELETE", "signature": "同上", "purpose": "删除考试(含资源归属校验)", "deps": ["requirePermission","shared/db"], "usedBy": ["exam-actions.tsx"]},
|
||||
{"name": "duplicateExamAction", "permission": "EXAM_DUPLICATE", "signature": "同上", "purpose": "复制考试", "deps": ["requirePermission","shared/db"], "usedBy": ["exam-actions.tsx"]},
|
||||
{"name": "getExamPreviewAction", "permission": "EXAM_READ", "signature": "同上", "purpose": "获取考试预览数据", "deps": ["requirePermission","shared/db"], "usedBy": ["exam-viewer.tsx"]},
|
||||
{"name": "getSubjectsAction", "permission": "EXAM_READ", "signature": "() => Promise<ActionState<{id:string;name:string}[]>>", "purpose": "获取科目列表", "deps": ["requirePermission","shared/db"], "usedBy": ["exam-form.tsx"]},
|
||||
{"name": "getGradesAction", "permission": "EXAM_READ", "signature": "同上", "purpose": "获取年级列表", "deps": ["requirePermission","shared/db"], "usedBy": ["exam-form.tsx"]}
|
||||
],
|
||||
"dataAccess": [
|
||||
{"name": "getExams", "signature": "getExams(params: GetExamsParams & { scope: DataScope }): Promise<Exam[]>", "purpose": "查询考试列表(含数据权限过滤)", "usedBy": ["teacher/exams/all/page.tsx", "homework创建页面"]},
|
||||
{"name": "getExamById", "signature": "getExamById(id: string, scope?: DataScope): Promise<Exam | null>", "purpose": "按ID获取考试详情", "usedBy": ["exam详情/编辑页面"]},
|
||||
{"name": "persistExamDraft", "signature": "persistExamDraft(input: { examId, title, creatorId, subjectId, gradeId, scheduledAt?, description }): Promise<void>", "purpose": "持久化手动考试草稿", "usedBy": ["createExamAction"]},
|
||||
{"name": "persistAiGeneratedExamDraft", "signature": "persistAiGeneratedExamDraft(input: { examId, title, creatorId, subjectId, gradeId, scheduledAt?, description, structure, generated }): Promise<void>", "purpose": "持久化AI生成考试草稿", "usedBy": ["createAiExamAction"]}
|
||||
],
|
||||
"aiPipeline": [
|
||||
{"name": "generateAiPreviewData", "signature": "(input: { title, subject?, grade?, difficulty, totalScore, durationMin, questionCount?, sourceText, aiProviderId? }) => Promise<{ ok, data?, rawOutput?, message? }>", "purpose": "AI预览生成", "deps": ["shared/lib/ai.createAiChatCompletion"], "usedBy": ["previewAiExamAction"]},
|
||||
{"name": "generateAiCreateDraftFromSource", "signature": "同上", "purpose": "AI从源文本生成完整考试", "deps": ["shared/lib/ai.createAiChatCompletion"], "usedBy": ["createAiExamAction"]},
|
||||
{"name": "regenerateAiQuestionByInstruction", "signature": "(input: { instruction, originalQuestion, sourceText?, aiProviderId? }) => Promise<{ ok, data?, message? }>", "purpose": "AI按指令重写题目", "deps": ["shared/lib/ai.createAiChatCompletion"], "usedBy": ["regenerateAiQuestionAction"]}
|
||||
],
|
||||
"types": [
|
||||
{"name": "Exam", "definition": "{ id, title, subject, grade, status, difficulty, totalScore, durationMin, questionCount, scheduledAt?, createdAt, updatedAt?, tags? }", "usedBy": ["exams/components", "homework/types", "dashboard/types"]},
|
||||
{"name": "AiPreviewData", "definition": "{ title, rawOutput?, sections?, questions? }", "usedBy": ["exams/actions", "exams/components"]},
|
||||
{"name": "AiRewriteQuestionData", "definition": "{ type, difficulty, score, content }", "usedBy": ["exams/actions", "exams/components"]}
|
||||
]
|
||||
}
|
||||
},
|
||||
"homework": {
|
||||
"path": "src/modules/homework",
|
||||
"description": "作业全生命周期:创建(源自考试)、发布、学生作答、教师批改、数据分析",
|
||||
"exports": {
|
||||
"actions": [
|
||||
{"name": "createHomeworkAssignmentAction", "permission": "HOMEWORK_CREATE", "signature": "(prevState: ActionState<string> | null, formData: FormData) => Promise<ActionState<string>>", "purpose": "从已有考试创建作业", "deps": ["requirePermission","shared/db","exams/data-access.getExams"], "usedBy": ["homework-assignment-form.tsx"]},
|
||||
{"name": "startHomeworkSubmissionAction", "permission": "HOMEWORK_SUBMIT", "signature": "同上", "purpose": "学生开始作答", "deps": ["requirePermission","shared/db"], "usedBy": ["homework-take-view.tsx"]},
|
||||
{"name": "saveHomeworkAnswerAction", "permission": "HOMEWORK_SUBMIT", "signature": "同上", "purpose": "保存单题答案", "deps": ["requirePermission","shared/db"], "usedBy": ["homework-take-view.tsx"]},
|
||||
{"name": "submitHomeworkAction", "permission": "HOMEWORK_SUBMIT", "signature": "同上", "purpose": "提交作业", "deps": ["requirePermission","shared/db"], "usedBy": ["homework-take-view.tsx"]},
|
||||
{"name": "gradeHomeworkSubmissionAction", "permission": "HOMEWORK_GRADE", "signature": "同上", "purpose": "教师批改作业", "deps": ["requirePermission","shared/db"], "usedBy": ["homework-grading-view.tsx"]}
|
||||
],
|
||||
"dataAccess": [
|
||||
{"name": "getTeacherGradeTrends", "signature": "(teacherId: string, limit?: number) => Promise<TeacherGradeTrendItem[]>", "usedBy": ["dashboard (教师仪表盘)"]},
|
||||
{"name": "getHomeworkAssignments", "signature": "(params?: { creatorId?, ids?, classId?, scope? }) => Promise<HomeworkAssignmentListItem[]>", "usedBy": ["teacher作业列表页", "homework-assignment-form.tsx"]},
|
||||
{"name": "getHomeworkAssignmentReviewList", "signature": "(params: { creatorId: string; scope? }) => Promise<HomeworkAssignmentReviewListItem[]>", "usedBy": ["teacher批改列表"]},
|
||||
{"name": "getHomeworkSubmissions", "signature": "(params?: { assignmentId?, classId?, creatorId?, scope? }) => Promise<HomeworkSubmissionListItem[]>", "usedBy": ["teacher提交列表"]},
|
||||
{"name": "getStudentHomeworkAssignments", "signature": "(studentId: string) => Promise<StudentHomeworkAssignmentListItem[]>", "usedBy": ["student/dashboard"]},
|
||||
{"name": "getStudentDashboardGrades", "signature": "(studentId: string) => Promise<StudentDashboardGradeProps>", "usedBy": ["dashboard/data-access.ts"]},
|
||||
{"name": "getHomeworkAssignmentAnalytics", "signature": "(assignmentId: string) => Promise<HomeworkAssignmentAnalytics | null>", "usedBy": ["homework错误分析组件"]}
|
||||
],
|
||||
"types": [
|
||||
{"name": "StudentDashboardGradeProps", "definition": "{ trend, recent, ranking }", "usedBy": ["dashboard/types.ts"]},
|
||||
{"name": "HomeworkAssignmentListItem", "usedBy": ["homework列表页"]},
|
||||
{"name": "StudentHomeworkTakeData", "usedBy": ["homework-take-view.tsx"]}
|
||||
]
|
||||
}
|
||||
},
|
||||
"questions": {
|
||||
"path": "src/modules/questions",
|
||||
"description": "题库管理:题目CRUD、知识点关联、多题型支持",
|
||||
"exports": {
|
||||
"actions": [
|
||||
{"name": "createNestedQuestion", "permission": "QUESTION_CREATE", "signature": "(prevState: ActionState<string> | undefined, formData: FormData | CreateQuestionInput) => Promise<ActionState<string>>", "purpose": "创建题目(含嵌套)", "deps": ["requirePermission","shared/db"], "usedBy": ["create-question-dialog.tsx"]},
|
||||
{"name": "updateQuestionAction", "permission": "QUESTION_UPDATE", "signature": "同上", "purpose": "更新题目", "deps": ["requirePermission","shared/db"], "usedBy": ["question-actions.tsx"]},
|
||||
{"name": "deleteQuestionAction", "permission": "QUESTION_DELETE", "signature": "同上", "purpose": "删除题目", "deps": ["requirePermission","shared/db"], "usedBy": ["question-actions.tsx"]},
|
||||
{"name": "getQuestionsAction", "permission": "QUESTION_READ", "signature": "(params: GetQuestionsParams) => Promise<...>", "purpose": "查询题目列表", "deps": ["requirePermission","data-access.getQuestions"], "usedBy": ["teacher/questions/page.tsx"]},
|
||||
{"name": "getKnowledgePointOptionsAction", "permission": "QUESTION_READ", "signature": "() => Promise<KnowledgePointOption[]>", "purpose": "获取知识点选项", "deps": ["requirePermission","shared/db"], "usedBy": ["create-question-dialog.tsx"]}
|
||||
],
|
||||
"types": [
|
||||
{"name": "Question", "definition": "{ id, content, type, difficulty, createdAt, updatedAt, author, knowledgePoints, childrenCount? }", "usedBy": ["exams (题目选择)", "homework (作业题目)"]},
|
||||
{"name": "KnowledgePointOption", "definition": "{ id, name, chapterId, chapterTitle, textbookId, textbookTitle, subject, grade }", "usedBy": ["create-question-dialog.tsx"]}
|
||||
]
|
||||
}
|
||||
},
|
||||
"textbooks": {
|
||||
"path": "src/modules/textbooks",
|
||||
"description": "教材与知识体系:教材/章节树形结构、知识点CRUD、Markdown内容编辑、知识图谱",
|
||||
"exports": {
|
||||
"actions": [
|
||||
{"name": "createTextbookAction", "permission": "TEXTBOOK_CREATE", "signature": "(prevState, formData) => Promise<ActionState>", "purpose": "创建教材"},
|
||||
{"name": "updateTextbookAction", "permission": "TEXTBOOK_UPDATE", "signature": "(textbookId, prevState, formData) => Promise<ActionState>", "purpose": "更新教材元信息"},
|
||||
{"name": "deleteTextbookAction", "permission": "TEXTBOOK_DELETE", "signature": "(textbookId) => Promise<ActionState>", "purpose": "删除教材"},
|
||||
{"name": "createChapterAction", "permission": "TEXTBOOK_CREATE", "signature": "(textbookId, parentId?, prevState, formData) => Promise<ActionState>", "purpose": "创建章节"},
|
||||
{"name": "updateChapterContentAction", "permission": "TEXTBOOK_UPDATE", "signature": "(chapterId, content, textbookId) => Promise<ActionState>", "purpose": "更新章节内容(Markdown)"},
|
||||
{"name": "deleteChapterAction", "permission": "TEXTBOOK_DELETE", "signature": "(chapterId, textbookId) => Promise<ActionState>", "purpose": "删除章节"},
|
||||
{"name": "createKnowledgePointAction", "permission": "TEXTBOOK_CREATE", "signature": "(chapterId, textbookId, prevState, formData) => Promise<ActionState>", "purpose": "创建知识点"},
|
||||
{"name": "updateKnowledgePointAction", "permission": "TEXTBOOK_UPDATE", "signature": "(kpId, textbookId, prevState, formData) => Promise<ActionState>", "purpose": "更新知识点"},
|
||||
{"name": "deleteKnowledgePointAction", "permission": "TEXTBOOK_DELETE", "signature": "(kpId, textbookId) => Promise<ActionState>", "purpose": "删除知识点"},
|
||||
{"name": "reorderChaptersAction", "permission": "TEXTBOOK_UPDATE", "signature": "(chapterId, newIndex, parentId, textbookId) => Promise<ActionState>", "purpose": "章节排序"}
|
||||
],
|
||||
"dataAccess": [
|
||||
{"name": "getTextbooks", "signature": "(query?, subject?, grade?) => Promise<Textbook[]>", "usedBy": ["teacher/textbooks/page.tsx"]},
|
||||
{"name": "getTextbookById", "signature": "(id) => Promise<Textbook | undefined>", "usedBy": ["teacher/textbooks/[id]/page.tsx"]},
|
||||
{"name": "getChaptersByTextbookId", "signature": "(textbookId) => Promise<Chapter[]>", "usedBy": ["textbook-reader.tsx"]},
|
||||
{"name": "getKnowledgePointsByChapterId", "signature": "(chapterId) => Promise<KnowledgePoint[]>", "usedBy": ["textbook-reader.tsx"]},
|
||||
{"name": "getKnowledgePointsByTextbookId", "signature": "(textbookId) => Promise<KnowledgePoint[]>", "usedBy": ["textbook-reader.tsx"]}
|
||||
],
|
||||
"hooks": [
|
||||
{"name": "useTextSelection", "file": "hooks/use-text-selection.ts", "signature": "(contentRef, onCreateKP) => { selectedText, createDialogOpen, isCreating, handleContentPointerDown, handleContextMenuChange }", "usedBy": ["textbook-content-panel.tsx"]},
|
||||
{"name": "useKnowledgePointActions", "file": "hooks/use-knowledge-point-actions.ts", "signature": "(textbookId, selectedChapterId, highlightedKpId, setHighlightedKpId) => { editingKp, editKpDialogOpen, ..., requestDeleteKP, confirmDeleteKP, handleUpdateKP }", "usedBy": ["textbook-reader.tsx"]}
|
||||
],
|
||||
"types": [
|
||||
{"name": "Chapter", "definition": "{ id, textbookId, title, order, parentId, content?, children? }", "usedBy": ["textbooks/components", "questions (知识点关联)"]},
|
||||
{"name": "KnowledgePoint", "definition": "{ id, name, description?, anchorText?, parentId?, chapterId?, level, order }", "usedBy": ["textbooks/components", "questions/types"]}
|
||||
]
|
||||
}
|
||||
},
|
||||
"classes": {
|
||||
"path": "src/modules/classes",
|
||||
"description": "班级管理:班级CRUD、学生注册/退班、邀请码、课表、学科教师分配",
|
||||
"exports": {
|
||||
"actions": [
|
||||
{"name": "createTeacherClassAction", "permission": "CLASS_CREATE", "signature": "(prevState, formData) => Promise<ActionState<string>>", "purpose": "教师创建班级"},
|
||||
{"name": "updateTeacherClassAction", "permission": "CLASS_UPDATE", "signature": "(classId, prevState, formData) => Promise<ActionState>", "purpose": "教师更新班级"},
|
||||
{"name": "deleteTeacherClassAction", "permission": "CLASS_DELETE", "signature": "(classId) => Promise<ActionState>", "purpose": "教师删除班级"},
|
||||
{"name": "createGradeClassAction", "permission": "CLASS_CREATE", "signature": "(prevState, formData) => Promise<ActionState<string>>", "purpose": "年级主任创建班级"},
|
||||
{"name": "updateGradeClassAction", "permission": "CLASS_UPDATE", "signature": "(classId, prevState, formData) => Promise<ActionState>", "purpose": "年级主任更新班级"},
|
||||
{"name": "deleteGradeClassAction", "permission": "CLASS_DELETE", "signature": "(classId) => Promise<ActionState>", "purpose": "年级主任删除班级"},
|
||||
{"name": "enrollStudentByEmailAction", "permission": "CLASS_ENROLL", "signature": "(classId, prevState, formData) => Promise<ActionState>", "purpose": "通过邮箱注册学生"},
|
||||
{"name": "joinClassByInvitationCodeAction", "permission": "CLASS_ENROLL", "signature": "(prevState, formData) => Promise<ActionState<{classId:string}>>", "purpose": "通过邀请码加入"},
|
||||
{"name": "ensureClassInvitationCodeAction", "permission": "CLASS_ENROLL", "signature": "(classId) => Promise<ActionState<{code:string}>>", "purpose": "确保邀请码存在"},
|
||||
{"name": "regenerateClassInvitationCodeAction", "permission": "CLASS_ENROLL", "signature": "(classId) => Promise<ActionState<{code:string}>>", "purpose": "重新生成邀请码"},
|
||||
{"name": "setStudentEnrollmentStatusAction", "permission": "CLASS_ENROLL", "signature": "(classId, studentId, status) => Promise<ActionState>", "purpose": "设置学生状态"},
|
||||
{"name": "createClassScheduleItemAction", "permission": "CLASS_SCHEDULE", "signature": "(prevState, formData) => Promise<ActionState<string>>", "purpose": "创建课表项"},
|
||||
{"name": "updateClassScheduleItemAction", "permission": "CLASS_SCHEDULE", "signature": "(scheduleId, prevState, formData) => Promise<ActionState>", "purpose": "更新课表项"},
|
||||
{"name": "deleteClassScheduleItemAction", "permission": "CLASS_SCHEDULE", "signature": "(scheduleId) => Promise<ActionState>", "purpose": "删除课表项"},
|
||||
{"name": "createAdminClassAction", "permission": "CLASS_CREATE", "signature": "(prevState, formData) => Promise<ActionState<string>>", "purpose": "管理员创建班级"},
|
||||
{"name": "updateAdminClassAction", "permission": "CLASS_UPDATE", "signature": "(classId, prevState, formData) => Promise<ActionState>", "purpose": "管理员更新班级"},
|
||||
{"name": "deleteAdminClassAction", "permission": "CLASS_DELETE", "signature": "(classId) => Promise<ActionState>", "purpose": "管理员删除班级"}
|
||||
],
|
||||
"dataAccess": [
|
||||
{"name": "getTeacherClasses", "signature": "(params?: { teacherId? }) => Promise<TeacherClass[]>", "usedBy": ["teacher/classes/my", "dashboard"]},
|
||||
{"name": "getAdminClasses", "signature": "() => Promise<AdminClassListItem[]>", "usedBy": ["admin班级管理"]},
|
||||
{"name": "getGradeManagedClasses", "signature": "(userId) => Promise<AdminClassListItem[]>", "usedBy": ["grade_head班级管理"]},
|
||||
{"name": "getStudentClasses", "signature": "(studentId) => Promise<StudentEnrolledClass[]>", "usedBy": ["student/dashboard"]},
|
||||
{"name": "getStudentSchedule", "signature": "(studentId) => Promise<StudentScheduleItem[]>", "usedBy": ["student课表"]},
|
||||
{"name": "getClassStudents", "signature": "(classId, scope?) => Promise<ClassStudent[]>", "usedBy": ["teacher/classes/students"]},
|
||||
{"name": "getClassSchedule", "signature": "(classId) => Promise<ClassScheduleItem[]>", "usedBy": ["teacher/classes/schedule"]},
|
||||
{"name": "getClassHomeworkInsights", "signature": "(classId) => Promise<ClassHomeworkInsights | null>", "usedBy": ["classes作业洞察"]},
|
||||
{"name": "getGradeHomeworkInsights", "signature": "(gradeId) => Promise<GradeHomeworkInsights | null>", "usedBy": ["年级作业洞察"]}
|
||||
]
|
||||
}
|
||||
},
|
||||
"school": {
|
||||
"path": "src/modules/school",
|
||||
"description": "学校基础数据管理:学校、年级、部门、学年的CRUD",
|
||||
"exports": {
|
||||
"actions": [
|
||||
{"name": "createSchoolAction", "permission": "SCHOOL_MANAGE", "signature": "(prevState, formData) => Promise<ActionState<string>>", "purpose": "创建学校"},
|
||||
{"name": "updateSchoolAction", "permission": "SCHOOL_MANAGE", "signature": "(schoolId, prevState, formData) => Promise<ActionState<string>>", "purpose": "更新学校"},
|
||||
{"name": "deleteSchoolAction", "permission": "SCHOOL_MANAGE", "signature": "(schoolId) => Promise<ActionState<string>>", "purpose": "删除学校"},
|
||||
{"name": "createGradeAction", "permission": "GRADE_MANAGE", "signature": "(prevState, formData) => Promise<ActionState<string>>", "purpose": "创建年级"},
|
||||
{"name": "updateGradeAction", "permission": "GRADE_MANAGE", "signature": "(gradeId, prevState, formData) => Promise<ActionState<string>>", "purpose": "更新年级"},
|
||||
{"name": "deleteGradeAction", "permission": "GRADE_MANAGE", "signature": "(gradeId) => Promise<ActionState<string>>", "purpose": "删除年级"},
|
||||
{"name": "createDepartmentAction", "permission": "SCHOOL_MANAGE", "signature": "(prevState, formData) => Promise<ActionState<string>>", "purpose": "创建部门"},
|
||||
{"name": "updateDepartmentAction", "permission": "SCHOOL_MANAGE", "signature": "(departmentId, prevState, formData) => Promise<ActionState<string>>", "purpose": "更新部门"},
|
||||
{"name": "deleteDepartmentAction", "permission": "SCHOOL_MANAGE", "signature": "(departmentId) => Promise<ActionState<string>>", "purpose": "删除部门"},
|
||||
{"name": "createAcademicYearAction", "permission": "SCHOOL_MANAGE", "signature": "(prevState, formData) => Promise<ActionState<string>>", "purpose": "创建学年"},
|
||||
{"name": "updateAcademicYearAction", "permission": "SCHOOL_MANAGE", "signature": "(academicYearId, prevState, formData) => Promise<ActionState<string>>", "purpose": "更新学年"},
|
||||
{"name": "deleteAcademicYearAction", "permission": "SCHOOL_MANAGE", "signature": "(academicYearId) => Promise<ActionState<string>>", "purpose": "删除学年"}
|
||||
],
|
||||
"dataAccess": [
|
||||
{"name": "getSchools", "signature": "() => Promise<SchoolListItem[]>", "usedBy": ["admin学校管理", "onboarding"]},
|
||||
{"name": "getGrades", "signature": "() => Promise<GradeListItem[]>", "usedBy": ["admin年级管理", "exams", "onboarding"]},
|
||||
{"name": "getDepartments", "signature": "() => Promise<DepartmentListItem[]>", "usedBy": ["admin部门管理"]},
|
||||
{"name": "getAcademicYears", "signature": "() => Promise<AcademicYearListItem[]>", "usedBy": ["admin学年管理"]},
|
||||
{"name": "getStaffOptions", "signature": "() => Promise<StaffOption[]>", "usedBy": ["school组件"]},
|
||||
{"name": "getGradesForStaff", "signature": "(staffId) => Promise<GradeListItem[]>", "usedBy": ["grade_head视图"]}
|
||||
]
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"path": "src/modules/dashboard",
|
||||
"description": "各角色仪表盘数据聚合与展示",
|
||||
"exports": {
|
||||
"dataAccess": [
|
||||
{"name": "getAdminDashboardData", "signature": "(scope?: DataScope) => Promise<AdminDashboardData>", "deps": ["shared/db", "DataScope"], "usedBy": ["admin/dashboard/page.tsx"]}
|
||||
],
|
||||
"types": [
|
||||
{"name": "StudentDashboardProps", "definition": "{ studentName, enrolledClassCount, dueSoonCount, overdueCount, gradedCount, todayScheduleItems, upcomingAssignments, grades }", "deps": ["homework/types.StudentDashboardGradeProps"], "usedBy": ["student-dashboard-view.tsx"]},
|
||||
{"name": "TeacherDashboardData", "definition": "{ classes, schedule, assignments, submissions, teacherName, gradeTrends }", "deps": ["homework/data-access.getTeacherGradeTrends", "classes/data-access.getTeacherClasses"], "usedBy": ["teacher-dashboard-view.tsx"]},
|
||||
{"name": "AdminDashboardData", "definition": "{ activeSessionsCount, userCount, userRoleCounts, classCount, ... }", "usedBy": ["admin/dashboard/page.tsx"]}
|
||||
]
|
||||
}
|
||||
},
|
||||
"layout": {
|
||||
"path": "src/modules/layout",
|
||||
"description": "应用布局框架:侧边栏、顶栏、导航配置",
|
||||
"exports": {
|
||||
"components": [
|
||||
{"name": "AppSidebar", "purpose": "根据权限渲染侧边栏导航", "internalDeps": ["usePermission", "NAV_CONFIG"]},
|
||||
{"name": "SiteHeader", "purpose": "顶部导航栏", "internalDeps": ["useSession", "signOut"]}
|
||||
],
|
||||
"config": [
|
||||
{"name": "NAV_CONFIG", "type": "Record<string, NavItem[]>", "note": "每个NavItem含permission字段用于权限过滤"}
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"path": "src/modules/settings",
|
||||
"description": "系统设置:AI Provider配置、用户偏好",
|
||||
"exports": {
|
||||
"actions": [
|
||||
{"name": "getAiProviderSummaries", "permission": "AI_CONFIGURE", "signature": "() => Promise<AiProviderSummary[]>", "purpose": "获取AI Provider列表"},
|
||||
{"name": "upsertAiProviderAction", "permission": "AI_CONFIGURE", "signature": "(data) => Promise<ActionState<string>>", "purpose": "创建/更新AI Provider", "deps": ["shared/lib/ai (encrypt/decrypt)"]},
|
||||
{"name": "testAiProviderAction", "permission": "AI_CONFIGURE", "signature": "(data) => Promise<ActionState<null>>", "purpose": "测试AI Provider连通性", "deps": ["shared/lib/ai.testAiProviderConfig"]}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencyMatrix": {
|
||||
"shared": {"dependsOn": []},
|
||||
"auth": {"dependsOn": ["shared"], "uses": {"shared": ["db", "schema", "permissions"]}},
|
||||
"exams": {"dependsOn": ["shared", "auth"], "uses": {"shared": ["db", "auth-guard", "types", "ai"], "auth": ["auth"]}},
|
||||
"homework": {"dependsOn": ["shared", "auth", "exams"], "uses": {"shared": ["db", "auth-guard", "types"], "auth": ["auth"], "exams": ["data-access.getExams"]}},
|
||||
"questions": {"dependsOn": ["shared", "auth"], "uses": {"shared": ["db", "auth-guard", "types"], "auth": ["auth"]}},
|
||||
"textbooks": {"dependsOn": ["shared", "auth"], "uses": {"shared": ["db", "auth-guard", "types"], "auth": ["auth"]}},
|
||||
"classes": {"dependsOn": ["shared", "auth"], "uses": {"shared": ["db", "auth-guard", "types"], "auth": ["auth"]}},
|
||||
"school": {"dependsOn": ["shared", "auth"], "uses": {"shared": ["db", "auth-guard", "types"], "auth": ["auth"]}},
|
||||
"dashboard": {"dependsOn": ["shared", "auth", "homework", "classes"], "uses": {"shared": ["db", "types"], "auth": ["auth"], "homework": ["data-access.getTeacherGradeTrends", "data-access.getStudentDashboardGrades"], "classes": ["data-access.getTeacherClasses", "data-access.getStudentClasses", "data-access.getStudentSchedule"]}},
|
||||
"layout": {"dependsOn": ["shared", "auth"], "uses": {"shared": ["hooks.usePermission"], "auth": ["useSession"]}},
|
||||
"settings": {"dependsOn": ["shared", "auth"], "uses": {"shared": ["db", "auth-guard", "ai", "types"], "auth": ["auth"]}}
|
||||
},
|
||||
"parameterFlowChains": {
|
||||
"userId": {
|
||||
"origin": "auth.ts JWT callback 从 users 表查询",
|
||||
"flow": [
|
||||
"auth.ts → session.user.id (JWT存储)",
|
||||
"session.user.id → Server Components (通过auth())",
|
||||
"session.user.id → Client Components (通过useSession())",
|
||||
"getAuthContext().userId → 所有Server Actions",
|
||||
"auth-guard.ts → 查询 usersToRoles (获取角色)",
|
||||
"auth-guard.ts → 查询 classSubjectTeachers/grades (获取DataScope)",
|
||||
"exams/actions.ts → 作为 creatorId 写入 exams 表",
|
||||
"homework/actions.ts → 作为 creatorId 写入 homeworkAssignments 表",
|
||||
"classes/data-access.ts → getTeacherClasses(teacherId), getGradeManagedClasses(userId)"
|
||||
]
|
||||
},
|
||||
"examId": {
|
||||
"origin": "exams/actions.ts createExamAction 产生 (CUID2)",
|
||||
"flow": [
|
||||
"createExamAction → 写入 exams.id",
|
||||
"exams/data-access.getExamById(id) → 读取考试详情",
|
||||
"updateExamAction/deleteExamAction/duplicateExamAction → 定位考试",
|
||||
"homework/actions.ts createHomeworkAssignmentAction → sourceExamId 参数",
|
||||
"homeworkAssignments.sourceExamId → 外键关联源考试",
|
||||
"homework/data-access.getHomeworkAssignmentAnalytics → 追溯作业来源"
|
||||
]
|
||||
},
|
||||
"classId": {
|
||||
"origin": "classes/actions.ts createTeacherClassAction/createAdminClassAction 产生",
|
||||
"flow": [
|
||||
"createTeacherClassAction → 写入 classes.id",
|
||||
"classes/data-access.getClassStudents(classId) → 学生列表",
|
||||
"classes/data-access.getClassSchedule(classId) → 课表",
|
||||
"classes/data-access.getClassHomeworkInsights(classId) → 作业洞察",
|
||||
"homework/data-access.getHomeworkAssignments({ classId }) → 过滤作业",
|
||||
"auth-guard.ts → classSubjectTeachers 查询 → DataScope.class_taught.classIds"
|
||||
]
|
||||
},
|
||||
"permission": {
|
||||
"origin": "shared/types/permissions.ts Permissions 常量定义",
|
||||
"flow": [
|
||||
"shared/lib/permissions.ts ROLE_PERMISSIONS → 角色到权限映射",
|
||||
"auth.ts JWT callback → resolvePermissions(roleNames) → token.permissions",
|
||||
"proxy.ts middleware → token.permissions → 路由权限检查",
|
||||
"auth-guard.ts requirePermission(permission) → Server Action权限断言",
|
||||
"use-permission.ts hasPermission(permission) → 客户端条件渲染",
|
||||
"layout/config/navigation.ts NavItem.permission → 侧边栏菜单过滤"
|
||||
]
|
||||
},
|
||||
"dataScope": {
|
||||
"origin": "auth-guard.ts resolveDataScope(userId, roles) 动态计算",
|
||||
"flow": [
|
||||
"resolveDataScope → AuthContext.dataScope",
|
||||
"exams/data-access.getExams({ scope }) → 行级过滤",
|
||||
"homework/data-access.getHomeworkAssignments({ scope }) → 行级过滤",
|
||||
"dashboard/data-access.getAdminDashboardData(scope) → 统计过滤",
|
||||
"exams/actions.ts update/delete → scope.type !== 'all' 时校验资源归属"
|
||||
]
|
||||
}
|
||||
},
|
||||
"routes": {
|
||||
"auth": {
|
||||
"/login": {"component": "LoginForm", "type": "client", "module": "auth"},
|
||||
"/register": {"component": "RegisterForm + registerAction", "type": "server", "module": "auth"}
|
||||
},
|
||||
"admin": {
|
||||
"/admin/dashboard": {"component": "AdminDashboardView", "type": "server", "dataAccess": ["dashboard/data-access.getAdminDashboardData"], "permission": "school:manage"}
|
||||
},
|
||||
"teacher": {
|
||||
"/teacher/dashboard": {"component": "TeacherDashboardView", "type": "server", "dataAccess": ["dashboard/data-access (teacher)", "homework/data-access.getTeacherGradeTrends", "classes/data-access.getTeacherClasses"], "permission": "exam:read"},
|
||||
"/teacher/exams/all": {"component": "ExamDataTable", "type": "server", "dataAccess": ["exams/data-access.getExams"], "permission": "exam:read"},
|
||||
"/teacher/exams/create": {"component": "ExamForm", "type": "client", "actions": ["createExamAction", "createAiExamAction", "previewAiExamAction"], "permission": "exam:create"},
|
||||
"/teacher/questions": {"component": "QuestionDataTable", "type": "server", "dataAccess": ["questions/data-access.getQuestions"], "permission": "question:read"},
|
||||
"/teacher/textbooks": {"component": "TextbookList", "type": "server", "dataAccess": ["textbooks/data-access.getTextbooks"], "permission": "textbook:read"},
|
||||
"/teacher/textbooks/[id]": {"component": "TextbookReader", "type": "client", "dataAccess": ["textbooks/data-access.getTextbookById", "getChaptersByTextbookId", "getKnowledgePointsByTextbookId"], "permission": "textbook:read"},
|
||||
"/teacher/classes/my": {"component": "ClassList", "type": "server", "dataAccess": ["classes/data-access.getTeacherClasses"], "permission": "class:read"},
|
||||
"/teacher/classes/schedule": {"component": "ClassSchedule", "type": "server", "dataAccess": ["classes/data-access.getClassSchedule"], "permission": "class:read"},
|
||||
"/teacher/classes/students": {"component": "ClassStudents", "type": "server", "dataAccess": ["classes/data-access.getClassStudents"], "permission": "class:read"}
|
||||
},
|
||||
"student": {
|
||||
"/student/dashboard": {"component": "StudentDashboardView", "type": "server", "dataAccess": ["dashboard/data-access (student)", "homework/data-access.getStudentDashboardGrades", "classes/data-access.getStudentClasses"], "permission": "homework:submit"}
|
||||
},
|
||||
"parent": {
|
||||
"/parent/dashboard": {"component": "ParentDashboardView", "type": "client", "permission": "exam:read"}
|
||||
},
|
||||
"shared": {
|
||||
"/dashboard": {"component": "角色路由分发", "type": "server", "redirect": "按permissions判断→/admin|/teacher|/student|/parent"},
|
||||
"/profile": {"component": "ProfilePage", "type": "server", "permission": "auth_required"},
|
||||
"/settings": {"component": "SettingsPage", "type": "server", "permission": "auth_required"}
|
||||
}
|
||||
},
|
||||
"apiRoutes": {
|
||||
"/api/auth/[...nextauth]": {"methods": ["GET", "POST"], "handler": "auth.handlers", "auth": "public"},
|
||||
"/api/ai/chat": {"methods": ["POST"], "handler": "createAiChatCompletion", "auth": "AI_CHAT", "validation": "parseAiChatPayload (Zod)"},
|
||||
"/api/onboarding/complete": {"methods": ["POST"], "handler": "onboarding complete", "auth": "required", "validation": "Zod schema"},
|
||||
"/api/onboarding/status": {"methods": ["GET"], "handler": "onboarding status", "auth": "required"}
|
||||
}
|
||||
}
|
||||
205
docs/architecture/006_k12_feature_checklist.md
Normal file
205
docs/architecture/006_k12_feature_checklist.md
Normal file
@@ -0,0 +1,205 @@
|
||||
# 企业级 K12 教务管理系统 — 标准功能模块清单
|
||||
|
||||
> 基于教育科技行业最佳实践,覆盖核心业务、平台基础、非功能性、合规安全四大维度。
|
||||
> 优先级定义:**P0** = MVP 必须,**P1** = 上线前推荐,**P2** = 迭代优化。
|
||||
|
||||
---
|
||||
|
||||
## 一、核心业务模块
|
||||
|
||||
| 模块 | 子功能 | 说明 | 优先级 |
|
||||
|------|--------|------|--------|
|
||||
| **用户与权限** | 用户注册/登录 | 邮箱/手机号注册,第三方 OAuth 登录 | P0 |
|
||||
| | 多角色体系 | 管理员/教师/学生/家长/年级组长/教研组长,支持一人多角色 | P0 |
|
||||
| | RBAC 权限模型 | 资源:动作权限点(如 `exam:create`),角色-权限映射 | P0 |
|
||||
| | 数据范围控制 | 行级权限:全部/所属/所教班级/所管年级/班级成员/子女 | P0 |
|
||||
| | 角色切换 | 多角色用户可主动切换当前活跃角色 | P1 |
|
||||
| | 用户档案管理 | 个人信息编辑、头像、联系方式、地址 | P0 |
|
||||
| | 新手引导(Onboarding) | 首次登录选择角色、填写资料、加入班级 | P0 |
|
||||
| | 组织架构管理 | 部门/年级/教研组树形结构管理 | P1 |
|
||||
| | 用户批量导入 | Excel/CSV 批量导入学生、教师信息 | P1 |
|
||||
| | 密码安全策略 | 密码强度校验、定期更换提醒、登录失败锁定 | P1 |
|
||||
| **学校管理** | 学校信息配置 | 校名、校徽、地址、学期制度等基础信息 | P0 |
|
||||
| | 学年学期管理 | 创建/切换学年学期,学期起止日期,当前学期标记 | P0 |
|
||||
| | 年级管理 | 年级创建/编辑/归档,年级组长指派 | P0 |
|
||||
| | 班级管理 | 班级创建/编辑/归档,班主任指派,班级邀请码 | P0 |
|
||||
| | 学科管理 | 学科创建/编辑,学科代码,学段归属 | P0 |
|
||||
| | 部门管理 | 教务处/德育处/总务处等行政部门管理 | P1 |
|
||||
| | 校区管理 | 多校区信息维护,校区间资源共享策略 | P2 |
|
||||
| | 学校参数配置 | 学分制/等级制切换、考号规则、编号规则等 | P1 |
|
||||
| **教务排课** | 课程计划管理 | 各年级/班级课程设置,周课时分配 | P0 |
|
||||
| | 排课规则配置 | 教师不冲突、教室不冲突、连排/不连排规则 | P0 |
|
||||
| | 自动排课引擎 | 基于约束满足的智能排课算法 | P1 |
|
||||
| | 课表查看 | 教师/学生/班级/教室多维度课表 | P0 |
|
||||
| | 课表调整/代课 | 临时调课、代课安排,自动通知受影响方 | P1 |
|
||||
| | 教室资源管理 | 教室类型、容量、设备标签,排课时自动匹配 | P2 |
|
||||
| | 选课管理 | 选修课选课、退选、抽签规则 | P2 |
|
||||
| **教材资源** | 教材库管理 | 教材元数据(学科/年级/版本/出版社) | P0 |
|
||||
| | 章节结构管理 | 教材章节树形目录,支持拖拽排序 | P0 |
|
||||
| | 知识点图谱 | 知识点与章节关联,知识点间前置/后继关系 | P1 |
|
||||
| | 教材内容阅读 | 富文本/Markdown 章节内容在线阅读 | P0 |
|
||||
| | 教材版本管理 | 同一教材多版本共存,版本切换 | P1 |
|
||||
| | 资源附件管理 | 教案/课件/视频等附件上传与关联 | P1 |
|
||||
| | 教材审核流程 | 教材内容发布前审核机制 | P2 |
|
||||
| **题库与试卷** | 题目创建/编辑 | 选择/填空/判断/简答/综合题,支持子题目 | P0 |
|
||||
| | 题目分类标签 | 学科/知识点/难度/题型多维标签 | P0 |
|
||||
| | 题目批量导入 | Excel/Word 模板批量导入题目 | P1 |
|
||||
| | 题目版本管理 | 题目修改保留历史版本 | P2 |
|
||||
| | 试卷手动组卷 | 从题库选题组卷,分数自动汇总 | P0 |
|
||||
| | 试卷智能组卷 | 按知识点/难度分布自动抽题 | P1 |
|
||||
| | AI 辅助出题 | 大模型根据知识点/要求自动生成题目 | P1 |
|
||||
| | 试卷模板管理 | 常用试卷结构模板保存与复用 | P2 |
|
||||
| | 试卷预览/打印 | 试卷排版预览,A4/B4 打印适配 | P1 |
|
||||
| **作业与考试** | 作业布置 | 关联试卷/题目,选择目标班级/学生,设置截止时间 | P0 |
|
||||
| | 作业提交 | 学生在线作答,支持文本/图片/附件 | P0 |
|
||||
| | 作业批改评分 | 教师逐题评分,支持批注/语音反馈 | P0 |
|
||||
| | 迟交/补交策略 | 允许迟交开关、迟交截止时间、迟交标记 | P1 |
|
||||
| | 多次提交/重做 | 可配置最大尝试次数,取最高分/最后一次 | P1 |
|
||||
| | 作业统计分析 | 完成率、平均分、分数分布、题目正确率 | P1 |
|
||||
| | 作业归档 | 学期结束后作业数据归档,释放活跃数据空间 | P2 |
|
||||
| | 在线考试模式 | 限时考试、防切屏、乱序、自动交卷 | P1 |
|
||||
| | 考试监考 | 教师端实时查看提交进度,异常行为标记 | P2 |
|
||||
| **成绩分析** | 成绩录入 | 手动录入/Excel 导入/作业自动同步 | P0 |
|
||||
| | 成绩查询 | 学生查本人,教师查所教班级,管理员查全部 | P0 |
|
||||
| | 成绩统计报表 | 班级/年级均分、中位数、标准差、及格率 | P0 |
|
||||
| | 成绩趋势分析 | 历次考试趋势折线图,进步/退步预警 | P1 |
|
||||
| | 成绩对比分析 | 班级间对比、学科间对比、与年级均分对比 | P1 |
|
||||
| | 学情诊断报告 | 基于知识点掌握度的个人/班级诊断报告 | P2 |
|
||||
| | 成绩导出 | Excel/PDF 成绩单导出,支持自定义模板 | P1 |
|
||||
| | 等第转换 | 分数↔等第(A/B/C/D)自动转换 | P2 |
|
||||
| **家校沟通** | 通知公告 | 学校/年级/班级三级公告发布,已读回执 | P0 |
|
||||
| | 站内消息 | 教师↔家长、教师↔学生私信,支持群发 | P1 |
|
||||
| | 家长端仪表盘 | 子女成绩/作业/考勤/课表一站式查看 | P1 |
|
||||
| | 家长会/约谈预约 | 教师发起家长约谈,家长在线预约时段 | P2 |
|
||||
| | 请假审批 | 学生在线请假,教师/班主任审批 | P1 |
|
||||
| | 校园动态/班级圈 | 班级活动照片/视频分享,家长点赞评论 | P2 |
|
||||
| **AI 赋能** | AI 对话助手 | 通用教育场景问答(备课建议、教学策略) | P1 |
|
||||
| | AI 辅助出题 | 根据知识点/难度自动生成题目 | P1 |
|
||||
| | AI 批改辅助 | 简答题/作文 AI 预评分+建议,教师终审 | P2 |
|
||||
| | AI 学情分析 | 基于学习数据的个性化学习路径推荐 | P2 |
|
||||
| | AI 备课助手 | 根据教材章节自动生成教案/课件大纲 | P2 |
|
||||
| | AI 多模型配置 | 支持 Zhipu/OpenAI/Gemini 等多模型切换 | P1 |
|
||||
| | AI API Key 加密存储 | API Key AES 加密,按角色权限访问 | P1 |
|
||||
| **考勤管理** | 学生考勤 | 每日/每节课考勤登记,迟到/早退/缺勤标记 | P1 |
|
||||
| | 教师考勤 | 教师出勤/请假/代课记录 | P1 |
|
||||
| | 考勤统计 | 月度/学期考勤汇总,异常预警 | P2 |
|
||||
| | 考勤规则配置 | 迟到阈值、缺勤预警线、自动通知家长 | P2 |
|
||||
|
||||
---
|
||||
|
||||
## 二、平台基础能力
|
||||
|
||||
| 模块 | 子功能 | 说明 | 优先级 |
|
||||
|------|--------|------|--------|
|
||||
| **消息通知** | 站内通知 | 系统事件推送(作业发布、成绩公布、审批结果) | P0 |
|
||||
| | 邮件通知 | 关键事件邮件推送,通知偏好设置 | P1 |
|
||||
| | 短信通知 | 紧急事件短信推送(需短信网关集成) | P2 |
|
||||
| | 微信/钉钉推送 | 企业微信/钉钉机器人消息推送 | P2 |
|
||||
| | 通知偏好管理 | 用户可按类型/渠道开关通知 | P1 |
|
||||
| **日志审计** | 操作日志 | 关键操作(增删改)记录,含操作人/时间/IP | P0 |
|
||||
| | 登录日志 | 登录/登出记录,异常登录检测 | P0 |
|
||||
| | 数据变更日志 | 重要数据修改前后对比快照 | P1 |
|
||||
| | 日志查询/导出 | 按时间/操作人/模块筛选,支持导出 | P1 |
|
||||
| **文件管理** | 文件上传 | 图片/文档/视频上传,格式与大小校验 | P0 |
|
||||
| | 文件预览 | 图片/PDF/Office 文档在线预览 | P1 |
|
||||
| | 文件存储策略 | 本地/OSS/S3 可切换存储后端 | P1 |
|
||||
| | 文件权限控制 | 文件访问需鉴权,防止未授权访问 | P0 |
|
||||
| **全局搜索** | 全文检索 | 题目/教材/通知/用户等全局搜索 | P1 |
|
||||
| | 搜索建议 | 输入联想、热门搜索、搜索历史 | P2 |
|
||||
| | 搜索过滤 | 按模块/时间/类型筛选搜索结果 | P2 |
|
||||
| **导入导出** | Excel 导入 | 学生/教师/成绩/题目批量导入,模板下载 | P1 |
|
||||
| | Excel/PDF 导出 | 成绩/报表/名单导出,自定义列选择 | P1 |
|
||||
| | 导入校验与错误报告 | 导入数据格式校验,错误行高亮与报告下载 | P1 |
|
||||
| **数据看板** | 管理员仪表盘 | 全校关键指标(师生数/班级数/作业完成率) | P0 |
|
||||
| | 教师仪表盘 | 待批改/今日课表/近期考试/班级动态 | P0 |
|
||||
| | 学生仪表盘 | 今日作业/即将考试/成绩趋势/课表 | P0 |
|
||||
| | 家长仪表盘 | 子女学习概况/待办事项/通知 | P1 |
|
||||
| | 自定义看板 | 用户可拖拽配置看板卡片 | P2 |
|
||||
|
||||
---
|
||||
|
||||
## 三、非功能性模块
|
||||
|
||||
| 模块 | 子功能 | 说明 | 优先级 |
|
||||
|------|--------|------|--------|
|
||||
| **国际化(i18n)** | 多语言框架 | next-intl/i18next 集成,语言包管理 | P2 |
|
||||
| | 语言切换 | 用户偏好语言,URL 前缀策略 | P2 |
|
||||
| | 日期/数字本地化 | 日期格式、数字分隔符按地区适配 | P2 |
|
||||
| **多租户/多校区** | 租户隔离 | 数据库级/行级租户隔离策略 | P2 |
|
||||
| | 校区资源映射 | 跨校区教师/教室共享规则 | P2 |
|
||||
| | 统一管理后台 | 集团层面多校区数据汇总与对比 | P2 |
|
||||
| **深色主题** | 主题切换 | 亮色/暗色/跟随系统,CSS 变量驱动 | P1 |
|
||||
| | 主题色定制 | 学校品牌色自定义 | P2 |
|
||||
| **无障碍访问** | 键盘导航 | 所有交互可键盘操作,焦点管理 | P1 |
|
||||
| | ARIA 标注 | 语义化 HTML + ARIA 属性 | P1 |
|
||||
| | 屏幕阅读器兼容 | NVDA/VoiceOver 可正确朗读 | P2 |
|
||||
| | 跳转链接 | 跳过导航直达主内容 | P1 |
|
||||
| **性能优化** | 页面懒加载 | 路由级代码分割,组件级 lazy import | P0 |
|
||||
| | 图片优化 | next/image 自动 WebP/AVIF,响应式尺寸 | P0 |
|
||||
| | 缓存策略 | ISR/SSG 混合渲染,客户端缓存 | P1 |
|
||||
| | 性能监控 | Web Vitals 采集,LCP/FID/CLS 告警 | P2 |
|
||||
| **自动化测试** | 单元测试 | Vitest 覆盖工具函数/Hook/类型 | P0 |
|
||||
| | 集成测试 | Server Action + DB mock 端到端验证 | P0 |
|
||||
| | E2E 测试 | Playwright 关键业务流程回归 | P1 |
|
||||
| | 视觉回归测试 | Chromatic/Percy 组件截图对比 | P2 |
|
||||
| **CI/CD** | 持续集成 | Lint + TypeCheck + Test 自动运行 | P0 |
|
||||
| | 持续部署 | main 分支自动构建 Docker 镜像并部署 | P0 |
|
||||
| | 预览环境 | PR 自动创建预览部署 | P2 |
|
||||
| **数据备份** | 数据库定时备份 | 每日全量 + 增量备份策略 | P1 |
|
||||
| | 备份恢复演练 | 定期验证备份数据可恢复性 | P2 |
|
||||
| | 灾备方案 | 异地容灾,RTO/RPO 目标定义 | P2 |
|
||||
|
||||
---
|
||||
|
||||
## 四、合规与安全
|
||||
|
||||
| 模块 | 子功能 | 说明 | 优先级 |
|
||||
|------|--------|------|--------|
|
||||
| **隐私合规** | 隐私政策与用户协议 | 注册时强制同意,版本更新通知 | P0 |
|
||||
| | 未成年人信息保护 | 14 周岁以下需监护人同意,信息最小化收集 | P0 |
|
||||
| | 数据保留策略 | 毕业生数据保留期限,过期自动匿名化 | P1 |
|
||||
| | 用户数据导出/删除 | GDPR 式数据可携带权与被遗忘权 | P2 |
|
||||
| **数据加密** | 传输加密 | HTTPS 强制,HSTS 头 | P0 |
|
||||
| | 存储加密 | 敏感字段(API Key/密码)AES 加密存储 | P0 |
|
||||
| | 密码哈希 | bcrypt/argon2 单向哈希,加盐存储 | P0 |
|
||||
| **操作安全** | CSRF 防护 | SameSite Cookie + CSRF Token | P0 |
|
||||
| | XSS 防护 | 输出编码 + CSP 策略 + rehype-sanitize | P0 |
|
||||
| | SQL 注入防护 | ORM 参数化查询,禁止拼接 SQL | P0 |
|
||||
| | 速率限制 | API 请求频率限制,防暴力破解 | P1 |
|
||||
| | 会话管理 | JWT 过期策略,单点登录/踢出,刷新令牌轮转 | P0 |
|
||||
| **敏感信息脱敏** | 日志脱敏 | 日志中手机号/身份证/邮箱自动掩码 | P1 |
|
||||
| | 前端脱敏 | 家长端/学生端敏感信息掩码显示 | P1 |
|
||||
| | 导出脱敏 | 批量导出时可选脱敏模式 | P2 |
|
||||
| **安全审计** | 漏洞扫描 | 定期 OWASP Top 10 安全扫描 | P1 |
|
||||
| | 依赖审计 | npm audit / Snyk 第三方依赖漏洞检测 | P1 |
|
||||
| | 渗透测试 | 上线前第三方渗透测试 | P2 |
|
||||
|
||||
---
|
||||
|
||||
## 优先级分布统计
|
||||
|
||||
| 优先级 | 数量 | 占比 | 定位 |
|
||||
|--------|------|------|------|
|
||||
| **P0** | 52 | 40% | MVP 必须,系统不可缺失的核心能力 |
|
||||
| **P1** | 48 | 37% | 上线前推荐,显著提升产品完整度 |
|
||||
| **P2** | 30 | 23% | 迭代优化,增强竞争力与用户体验 |
|
||||
|
||||
---
|
||||
|
||||
## 与当前项目实现对照
|
||||
|
||||
| 维度 | 清单 P0 项 | 当前已实现 | 覆盖率 |
|
||||
|------|-----------|-----------|--------|
|
||||
| 用户与权限 | 7 | 7 | 100% |
|
||||
| 学校管理 | 5 | 5 | 100% |
|
||||
| 教务排课 | 2 | 2 | 100% |
|
||||
| 教材资源 | 3 | 3 | 100% |
|
||||
| 题库与试卷 | 3 | 3 | 100% |
|
||||
| 作业与考试 | 4 | 4 | 100% |
|
||||
| 成绩分析 | 3 | 1 | 33% |
|
||||
| 家校沟通 | 1 | 0 | 0% |
|
||||
| AI 赋能 | 1 | 1 | 100% |
|
||||
| 平台基础 | 5 | 3 | 60% |
|
||||
| 合规安全 | 8 | 8 | 100% |
|
||||
|
||||
> 当前项目 P0 覆盖率约 **80%**,主要缺口在成绩分析(统计报表/查询/录入)、家校沟通(通知公告)和平台基础(操作日志/文件权限)。
|
||||
277
docs/architecture/007_gap_audit_report.md
Normal file
277
docs/architecture/007_gap_audit_report.md
Normal file
@@ -0,0 +1,277 @@
|
||||
# Next_Edu 差距审计报告
|
||||
|
||||
> 对照《企业级 K12 教务管理系统标准功能模块清单》(006),基于架构影响地图(004/005)与源码扫描
|
||||
> 审计日期:2026-06-16
|
||||
|
||||
---
|
||||
|
||||
## 一、总体完成度
|
||||
|
||||
| 维度 | P0 子功能总数 | 已完成 | 部分完成 | 未实现 | 完成率 |
|
||||
|------|-------------|--------|---------|--------|--------|
|
||||
| 核心业务 | 31 | 22 | 5 | 4 | **71%** |
|
||||
| 平台基础 | 8 | 3 | 2 | 3 | **38%** |
|
||||
| 非功能性 | 8 | 5 | 2 | 1 | **63%** |
|
||||
| 合规安全 | 8 | 6 | 1 | 1 | **75%** |
|
||||
| **合计** | **55** | **36** | **10** | **9** | **65%** |
|
||||
|
||||
> P1 完成率约 **25%**,P2 完成率约 **5%**。
|
||||
|
||||
### 关键风险项
|
||||
|
||||
1. **通知公告系统完全缺失** — P0 级功能,家校沟通核心载体,无任何代码实现
|
||||
2. **操作/登录日志完全缺失** — P0 级功能,合规审计基础,无 DB 表、无代码
|
||||
3. **成绩分析严重不足** — 仅有作业维度的分数趋势,缺少独立的成绩录入/统计报表/查询模块
|
||||
4. **文件上传/权限控制缺失** — 当前无文件上传能力,题目/教材无法关联附件
|
||||
5. **排课仅手动录入** — 无排课规则引擎,无自动排课,无冲突检测
|
||||
|
||||
---
|
||||
|
||||
## 二、功能差距明细表
|
||||
|
||||
### 核心业务模块
|
||||
|
||||
| 标准模块 | 标准子功能 | 状态 | 项目现状说明 | 补齐建议 |
|
||||
|----------|------------|------|-------------|----------|
|
||||
| **用户与权限** | 用户注册/登录 | ✅ | NextAuth v5,邮箱+OAuth 登录,JWT 策略 | — |
|
||||
| | 多角色体系 | ✅ | 6 角色(admin/teacher/student/parent/grade_head/teaching_head),usersToRoles 多对多 | — |
|
||||
| | RBAC 权限模型 | ✅ | 30 个 `resource:action` 权限点,ROLE_PERMISSIONS 映射 | — |
|
||||
| | 数据范围控制 | ✅ | DataScope 6 种类型(all/owned/class_taught/grade_managed/class_members/children) | — |
|
||||
| | 角色切换 | ❌ | JWT 存 roles[],但无主动切换 UI | 新增角色切换下拉组件,切换后重写 JWT |
|
||||
| | 用户档案管理 | ⚠️ | profile 页可编辑姓名/邮箱,但无头像上传、地址编辑 | 增加 avatar 字段 + 图片上传 |
|
||||
| | 新手引导 | ✅ | OnboardingGate 组件,角色选择→学校/班级配置 | — |
|
||||
| | 组织架构管理 | ⚠️ | 部门/年级 CRUD 已有(school 模块),但无教研组管理 | 新增 teachingGroups 表 + CRUD |
|
||||
| | 用户批量导入 | ❌ | 无导入功能 | 新增 Excel 解析 + 批量 insert 事务 |
|
||||
| | 密码安全策略 | ⚠️ | NextAuth 默认 bcrypt,但无强度校验、无锁定策略 | 前端强度校验 + 后端失败计数锁定 |
|
||||
| **学校管理** | 学校信息配置 | ✅ | schools 表 + createSchoolAction/updateSchoolAction | — |
|
||||
| | 学年学期管理 | ✅ | academicYears 表 + CRUD actions,isActive 标记 | — |
|
||||
| | 年级管理 | ✅ | grades 表 + CRUD,gradeHeadId/teachingHeadId 指派 | — |
|
||||
| | 班级管理 | ✅ | classes 表 + 17 个 actions,含邀请码、学生注册 | — |
|
||||
| | 学科管理 | ⚠️ | subjects 表存在,但仅有 name/order,无代码/学段归属字段 | 扩展 subjects 表字段 |
|
||||
| | 部门管理 | ✅ | departments 表 + CRUD actions | — |
|
||||
| | 校区管理 | ❌ | 无校区概念 | 新增 campuses 表,schools 关联 campusId |
|
||||
| | 学校参数配置 | ❌ | 无参数配置功能 | 新增 schoolSettings KV 表 |
|
||||
| **教务排课** | 课程计划管理 | ❌ | classSchedule 表仅存单条课表项,无课程计划概念 | 新增 coursePlans 表 + 管理界面 |
|
||||
| | 排课规则配置 | ❌ | 无规则引擎 | 新增 schedulingRules 表 + 约束求解器 |
|
||||
| | 自动排课引擎 | ❌ | 无 | 集成开源排课算法或自研 CSP 求解器 |
|
||||
| | 课表查看 | ⚠️ | 教师班级课表 + 学生课表已有,但无教室维度 | 增加 classroom 维度查询 |
|
||||
| | 课表调整/代课 | ❌ | 仅 CRUD 课表项,无调课/代课流程 | 新增 scheduleChanges 表 + 审批流 |
|
||||
| | 教室资源管理 | ⚠️ | classrooms 表存在(字段: location, capacity),但无管理 UI | 增加 CRUD 页面 + 设备标签 |
|
||||
| | 选课管理 | ❌ | 无 | 新增 electiveCourses + studentSelections 表 |
|
||||
| **教材资源** | 教材库管理 | ✅ | textbooks 表 + createTextbookAction,含 subject/grade/publisher | — |
|
||||
| | 章节结构管理 | ✅ | chapters 树形结构 + reorderChaptersAction 拖拽排序 | — |
|
||||
| | 知识点图谱 | ⚠️ | knowledgePoints 有 CRUD + 章节关联,但无前置/后继关系 | 增加 prerequisiteEdges 表 |
|
||||
| | 教材内容阅读 | ✅ | textbook-content-panel.tsx,Markdown 渲染 + rehype-sanitize | — |
|
||||
| | 教材版本管理 | ❌ | 无版本概念 | 增加 textbookVersions 表或 version 字段 |
|
||||
| | 资源附件管理 | ❌ | 无文件上传能力 | 新增 attachments 表 + 文件上传服务 |
|
||||
| | 教材审核流程 | ❌ | 无审核机制 | 新增 reviewWorkflow 表 + 状态机 |
|
||||
| **题库与试卷** | 题目创建/编辑 | ✅ | 5 种题型(single_choice/multiple_choice/text/judgment/composite),支持子题目 | — |
|
||||
| | 题目分类标签 | ✅ | 知识点关联 + difficulty + type 多维标签 | — |
|
||||
| | 题目批量导入 | ❌ | 无 | Excel 模板 + 批量解析 |
|
||||
| | 题目版本管理 | ❌ | 无 | 增加 questionVersions 表 |
|
||||
| | 试卷手动组卷 | ✅ | exams 表 structure 字段,examQuestions 关联 | — |
|
||||
| | 试卷智能组卷 | ❌ | 无自动抽题 | 按知识点/难度分布约束随机抽题算法 |
|
||||
| | AI 辅助出题 | ✅ | ai-pipeline.ts:generateAiPreviewData/generateAiCreateDraftFromSource/regenerateAiQuestionByInstruction | — |
|
||||
| | 试卷模板管理 | ❌ | 无 | 新增 examTemplates 表 |
|
||||
| | 试卷预览/打印 | ⚠️ | exam-preview-dialog.tsx 可预览,但无打印适配 | 增加打印 CSS @media print |
|
||||
| **作业与考试** | 作业布置 | ✅ | createHomeworkAssignmentAction,关联 sourceExamId + classId | — |
|
||||
| | 作业提交 | ✅ | startHomeworkSubmissionAction + saveHomeworkAnswerAction + submitHomeworkAction | — |
|
||||
| | 作业批改评分 | ✅ | gradeHomeworkSubmissionAction,逐题评分 + feedback | — |
|
||||
| | 迟交/补交策略 | ⚠️ | homeworkAssignments 表有 allowLate/lateDueAt 字段,但前端未暴露配置 | 作业创建表单增加迟交开关 |
|
||||
| | 多次提交/重做 | ⚠️ | maxAttempts 字段存在,startHomeworkSubmissionAction 有次数检查 | 前端暴露配置 + 重做入口 |
|
||||
| | 作业统计分析 | ⚠️ | getHomeworkAssignmentAnalytics 存在,但仅限单次作业维度 | 增加班级/时间维度汇总 |
|
||||
| | 作业归档 | ❌ | 无归档机制 | 增加 archivedAt 字段 + 归档 API |
|
||||
| | 在线考试模式 | ❌ | 无限时/防切屏/乱序/自动交卷 | 新增 examMode 字段 + 前端计时器 + 乱序逻辑 |
|
||||
| | 考试监考 | ❌ | 无 | 新增实时提交进度 WebSocket 推送 |
|
||||
| **成绩分析** | 成绩录入 | ❌ | 无独立成绩录入功能,仅作业自动同步分数 | 新增 gradeRecords 表 + 手动录入 UI |
|
||||
| | 成绩查询 | ⚠️ | 学生可查作业分数(getStudentDashboardGrades),但无独立成绩查询页 | 新增成绩查询页面 |
|
||||
| | 成绩统计报表 | ❌ | 无班级/年级均分、中位数、标准差、及格率统计 | 新增统计聚合查询 + 图表组件 |
|
||||
| | 成绩趋势分析 | ⚠️ | getTeacherGradeTrends 提供教师维度趋势,学生有 trend 数据 | 扩展为多维度趋势 |
|
||||
| | 成绩对比分析 | ❌ | 无班级间/学科间对比 | 新增对比查询 + 雷达图 |
|
||||
| | 学情诊断报告 | ❌ | 无 | 基于知识点掌握度生成诊断 |
|
||||
| | 成绩导出 | ❌ | 无导出功能 | ExcelJS/PDFKit 导出 |
|
||||
| | 等第转换 | ❌ | 无 | 新增 gradeScale 配置 + 转换函数 |
|
||||
| **家校沟通** | 通知公告 | ❌ | 完全缺失,无 DB 表、无 API、无 UI | 新增 announcements 表 + 三级发布 + 已读回执 |
|
||||
| | 站内消息 | ❌ | 无 | 新增 messages 表 + 实时通知 |
|
||||
| | 家长端仪表盘 | ⚠️ | /parent/dashboard 路由存在但组件为空壳 | 接入子女数据查询 |
|
||||
| | 家长会/约谈预约 | ❌ | 无 | 新增 appointments 表 |
|
||||
| | 请假审批 | ❌ | 无 | 新增 leaveRequests 表 + 审批流 |
|
||||
| | 校园动态/班级圈 | ❌ | 无 | 新增 posts 表 + 评论/点赞 |
|
||||
| **AI 赵能** | AI 对话助手 | ✅ | /api/ai/chat 路由 + createAiChatCompletion,Zod 校验 | — |
|
||||
| | AI 辅助出题 | ✅ | exams/ai-pipeline.ts 完整实现 | — |
|
||||
| | AI 批改辅助 | ❌ | 无 | 接入 AI 评分 prompt + 教师终审 |
|
||||
| | AI 学情分析 | ❌ | 无 | 基于作业数据生成学习路径 |
|
||||
| | AI 备课助手 | ❌ | 无 | 根据教材章节生成教案 |
|
||||
| | AI 多模型配置 | ✅ | aiProviders 表 + upsertAiProviderAction,支持多 provider | — |
|
||||
| | AI API Key 加密 | ✅ | encryptAiApiKey/decryptAiApiKey,AES 加密 | — |
|
||||
| **考勤管理** | 学生考勤 | ❌ | 无 | 新增 attendanceRecords 表 + 登记界面 |
|
||||
| | 教师考勤 | ❌ | 无 | 同上 |
|
||||
| | 考勤统计 | ❌ | 无 | 聚合查询 + 报表 |
|
||||
| | 考勤规则配置 | ❌ | 无 | 新增 attendanceRules 配置 |
|
||||
|
||||
### 平台基础能力
|
||||
|
||||
| 标准模块 | 标准子功能 | 状态 | 项目现状说明 | 补齐建议 |
|
||||
|----------|------------|------|-------------|----------|
|
||||
| **消息通知** | 站内通知 | ❌ | 无通知系统 | 新增 notifications 表 + 轮询/WebSocket 推送 |
|
||||
| | 邮件通知 | ❌ | 无 | 集成 nodemailer/Resend |
|
||||
| | 短信通知 | ❌ | 无 | 集成短信网关 SDK |
|
||||
| | 微信/钉钉推送 | ❌ | 无 | 集成 webhook |
|
||||
| | 通知偏好管理 | ❌ | 无 | 新增 notificationPreferences 表 |
|
||||
| **日志审计** | 操作日志 | ❌ | 完全缺失 | 新增 auditLogs 表 + action 拦截器 |
|
||||
| | 登录日志 | ❌ | 无 | 新增 loginLogs 表 + NextAuth event 回调 |
|
||||
| | 数据变更日志 | ❌ | 无 | Drizzle middleware 或 trigger |
|
||||
| | 日志查询/导出 | ❌ | 无 | 管理员日志查询页面 |
|
||||
| **文件管理** | 文件上传 | ❌ | 无文件上传能力 | 新增 upload API + 本地/OSS 存储 |
|
||||
| | 文件预览 | ❌ | 无 | 集成文件预览服务 |
|
||||
| | 文件存储策略 | ❌ | 无 | 抽象 StorageProvider 接口 |
|
||||
| | 文件权限控制 | ❌ | 无 | 文件访问鉴权中间件 |
|
||||
| **全局搜索** | 全文检索 | ❌ | 无 | 集成 Meilisearch/Typesense |
|
||||
| | 搜索建议 | ❌ | 无 | 搜索 API + 前端联想 |
|
||||
| | 搜索过滤 | ❌ | 无 | 搜索结果筛选器 |
|
||||
| **导入导出** | Excel 导入 | ❌ | 无 | ExcelJS 解析 + 校验 |
|
||||
| | Excel/PDF 导出 | ❌ | 无 | ExcelJS/PDFKit 生成 |
|
||||
| | 导入校验与错误报告 | ❌ | 无 | 行级校验 + 错误报告下载 |
|
||||
| **数据看板** | 管理员仪表盘 | ✅ | getAdminDashboardData:userCount/classCount/activeSessions/userRoleCounts | — |
|
||||
| | 教师仪表盘 | ✅ | TeacherDashboardData:classes/schedule/assignments/submissions/gradeTrends | — |
|
||||
| | 学生仪表盘 | ✅ | StudentDashboardProps:dueSoonCount/overdueCount/gradedCount/todaySchedule/grades | — |
|
||||
| | 家长仪表盘 | ⚠️ | 路由存在但组件为空壳 | 接入子女数据 |
|
||||
| | 自定义看板 | ❌ | 无 | 拖拽布局 + localStorage 持久化 |
|
||||
|
||||
### 非功能性模块
|
||||
|
||||
| 标准模块 | 标准子功能 | 状态 | 项目现状说明 | 补齐建议 |
|
||||
|----------|------------|------|-------------|----------|
|
||||
| **国际化** | 多语言框架 | ❌ | 无 i18n 集成 | 集成 next-intl |
|
||||
| | 语言切换 | ❌ | 无 | 语言选择器 + URL 前缀 |
|
||||
| | 日期/数字本地化 | ⚠️ | formatDate 支持 locale 参数(默认 zh-CN),但无用户偏好 | 绑定用户语言偏好 |
|
||||
| **多租户/多校区** | 租户隔离 | ❌ | 无 | 行级 tenantId 或 schema 隔离 |
|
||||
| | 校区资源映射 | ❌ | 无 | 跨校区共享规则 |
|
||||
| | 统一管理后台 | ❌ | 无 | 集团管理视图 |
|
||||
| **深色主题** | 主题切换 | ✅ | ThemeProvider(next-themes) + theme-preferences-card | — |
|
||||
| | 主题色定制 | ❌ | 无 | CSS 变量动态注入 |
|
||||
| **无障碍访问** | 键盘导航 | ⚠️ | 部分组件支持,但非系统性 | 全面键盘测试 + 修复 |
|
||||
| | ARIA 标注 | ⚠️ | icon 按钮 aria-label 已加,但非全覆盖 | 系统性 ARIA 审计 |
|
||||
| | 屏幕阅读器兼容 | ❌ | 未测试 | NVDA/VoiceOver 测试 |
|
||||
| | 跳转链接 | ✅ | layout.tsx 有 skip-link + id="main-content" | — |
|
||||
| **性能优化** | 页面懒加载 | ✅ | Next.js App Router 自动代码分割 | — |
|
||||
| | 图片优化 | ✅ | next/image 使用 | — |
|
||||
| | 缓存策略 | ⚠️ | 部分页面 SSR,但无系统性 ISR/SSG 策略 | 关键页面配置 revalidate |
|
||||
| | 性能监控 | ❌ | 无 Web Vitals 采集 | 集成 next/web-vitals + 上报 |
|
||||
| **自动化测试** | 单元测试 | ✅ | Vitest 5 文件 19 用例 | 扩展覆盖率 |
|
||||
| | 集成测试 | ✅ | Vitest 7 文件 38 用例 | 扩展覆盖率 |
|
||||
| | E2E 测试 | ⚠️ | Playwright 3 个 spec 文件,但需数据库环境运行 | 完善 CI 环境配置 |
|
||||
| | 视觉回归测试 | ❌ | 无 | 集成 Chromatic |
|
||||
| **CI/CD** | 持续集成 | ✅ | .gitea/workflows/ci.yml:lint + typecheck + test | — |
|
||||
| | 持续部署 | ✅ | Dockerfile + CI 自动构建部署 | — |
|
||||
| | 预览环境 | ❌ | 无 | PR 预览部署 |
|
||||
| **数据备份** | 数据库定时备份 | ❌ | 无 | cron + mysqldump 脚本 |
|
||||
| | 备份恢复演练 | ❌ | 无 | 定期恢复测试 |
|
||||
| | 灾备方案 | ❌ | 无 | 异地容灾规划 |
|
||||
|
||||
### 合规与安全
|
||||
|
||||
| 标准模块 | 标准子功能 | 状态 | 项目现状说明 | 补齐建议 |
|
||||
|----------|------------|------|-------------|----------|
|
||||
| **隐私合规** | 隐私政策与用户协议 | ❌ | 无隐私政策页面,注册无同意勾选 | 新增 consent 页 + 注册流程集成 |
|
||||
| | 未成年人信息保护 | ❌ | 无年龄判断、无监护人同意流程 | 注册时年龄校验 + 监护人字段 |
|
||||
| | 数据保留策略 | ❌ | 无 | 新增 dataRetentionPolicies 配置 |
|
||||
| | 用户数据导出/删除 | ❌ | 无 | GDPR 式数据操作 API |
|
||||
| **数据加密** | 传输加密 | ✅ | Next.js 默认 HTTPS,生产环境应配 HSTS | 部署时配置 HSTS 头 |
|
||||
| | 存储加密 | ✅ | AI API Key AES 加密,密码 bcrypt 哈希 | — |
|
||||
| | 密码哈希 | ✅ | NextAuth 默认 bcrypt | — |
|
||||
| **操作安全** | CSRF 防护 | ✅ | NextAuth SameSite Cookie + Server Action CSRF 保护 | — |
|
||||
| | XSS 防护 | ✅ | React 自动转义 + rehype-sanitize 净化 HTML | — |
|
||||
| | SQL 注入防护 | ✅ | Drizzle ORM 参数化查询 | — |
|
||||
| | 速率限制 | ❌ | 无 | 集成 next-rate-limit 或 upstash/ratelimit |
|
||||
| | 会话管理 | ✅ | JWT 过期策略 + NextAuth session 管理 | — |
|
||||
| **敏感信息脱敏** | 日志脱敏 | ❌ | 无日志系统 | 日志框架内置脱敏 |
|
||||
| | 前端脱敏 | ❌ | 无 | 手机号/邮箱掩码组件 |
|
||||
| | 导出脱敏 | ❌ | 无导出功能 | 导出时可选脱敏 |
|
||||
| **安全审计** | 漏洞扫描 | ❌ | 无 | 集成 OWASP ZAP 或 Snyk |
|
||||
| | 依赖审计 | ⚠️ | npm audit 可用但未集成 CI | CI 增加 npm audit 步骤 |
|
||||
| | 渗透测试 | ❌ | 无 | 上线前第三方测试 |
|
||||
|
||||
---
|
||||
|
||||
## 三、优先补齐路线图
|
||||
|
||||
### Phase 1: P0 缺口补齐(MVP 必须项)
|
||||
|
||||
> 目标:将 P0 完成率从 65% 提升到 100%
|
||||
|
||||
| 序号 | 功能 | 所属模块 | 工作量 | 理由 |
|
||||
|------|------|---------|--------|------|
|
||||
| 1 | **通知公告系统** | 家校沟通 | 大 | P0 缺失最严重项,学校运营核心需求;需 DB 表 + API + 三级发布 + 已读回执 |
|
||||
| 2 | **操作日志 + 登录日志** | 日志审计 | 大 | P0 合规底线,无日志则无法追溯问题;需 DB 表 + action 拦截 + NextAuth event |
|
||||
| 3 | **成绩录入 + 查询 + 统计报表** | 成绩分析 | 大 | P0 教务核心闭环缺失;需 gradeRecords 表 + 录入 UI + 聚合查询 + 图表 |
|
||||
| 4 | **文件上传 + 权限控制** | 文件管理 | 中 | P0 基础能力,题目/教材/通知均需附件;需 upload API + 存储抽象 + 鉴权 |
|
||||
| 5 | **课程计划管理** | 教务排课 | 中 | P0 排课前置条件;需 coursePlans 表 + 管理界面 |
|
||||
| 6 | **隐私政策 + 用户同意** | 隐私合规 | 小 | P0 合规底线;需 consent 页面 + 注册流程集成 |
|
||||
| 7 | **未成年人信息保护** | 隐私合规 | 小 | P0 K12 强制要求;需年龄校验 + 监护人字段 |
|
||||
|
||||
### Phase 2: P1 关键增强(上线前推荐)
|
||||
|
||||
> 目标:产品达到可上线标准
|
||||
|
||||
| 序号 | 功能 | 所属模块 | 理由 |
|
||||
|------|------|---------|------|
|
||||
| 1 | **站内消息系统** | 家校沟通 | 教师与家长沟通核心渠道 |
|
||||
| 2 | **家长端仪表盘** | 家校沟通 | 家长核心入口,当前为空壳 |
|
||||
| 3 | **Excel 批量导入** | 导入导出 | 开学季批量导入学生/教师刚需 |
|
||||
| 4 | **Excel/PDF 导出** | 导入导出 | 成绩单/名单导出刚需 |
|
||||
| 5 | **排课规则 + 自动排课** | 教务排课 | 手动排课效率极低,自动排课是核心竞争力 |
|
||||
| 6 | **课表调整/代课** | 教务排课 | 日常调课是高频操作 |
|
||||
| 7 | **速率限制** | 操作安全 | 防暴力破解,API 安全基线 |
|
||||
| 8 | **成绩趋势 + 对比分析** | 成绩分析 | 教学质量分析核心 |
|
||||
| 9 | **成绩导出** | 成绩分析 | 家长会/教研会必备 |
|
||||
| 10 | **学生考勤** | 考勤管理 | 日常管理刚需 |
|
||||
| 11 | **用户批量导入** | 用户与权限 | 开学季批量注册 |
|
||||
| 12 | **密码安全策略** | 用户与权限 | 安全基线 |
|
||||
| 13 | **数据变更日志** | 日志审计 | 争议追溯 |
|
||||
| 14 | **日志查询/导出** | 日志审计 | 管理员日常使用 |
|
||||
| 15 | **文件预览 + 存储策略** | 文件管理 | 用户体验提升 |
|
||||
| 16 | **全文检索** | 全局搜索 | 题库/教材量大后必须 |
|
||||
| 17 | **依赖审计集成 CI** | 安全审计 | 安全基线 |
|
||||
| 18 | **数据库定时备份** | 数据备份 | 数据安全底线 |
|
||||
| 19 | **E2E 测试完善** | 自动化测试 | 上线前回归保障 |
|
||||
| 20 | **通知偏好管理** | 消息通知 | 用户体验 |
|
||||
|
||||
### Phase 3: P2 迭代优化(竞争力提升)
|
||||
|
||||
> 目标:差异化竞争力与用户体验精细化
|
||||
|
||||
| 序号 | 功能 | 所属模块 | 理由 |
|
||||
|------|------|---------|------|
|
||||
| 1 | 国际化(i18n) | 非功能性 | 海外学校/国际学校市场 |
|
||||
| 2 | 多租户/多校区 | 非功能性 | 集团化办学市场 |
|
||||
| 3 | 主题色定制 | 深色主题 | 学校品牌化 |
|
||||
| 4 | 屏幕阅读器兼容 | 无障碍 | 合规 + 社会责任 |
|
||||
| 5 | 视觉回归测试 | 自动化测试 | UI 变更质量保障 |
|
||||
| 6 | AI 批改辅助 | AI 赋能 | 教师效率提升 |
|
||||
| 7 | AI 学情分析 | AI 赋能 | 个性化学习差异化 |
|
||||
| 8 | AI 备课助手 | AI 赋能 | 教师备课效率 |
|
||||
| 9 | 选课管理 | 教务排课 | 高中选修课场景 |
|
||||
| 10 | 考试监考 | 作业与考试 | 在线考试完整性 |
|
||||
| 11 | 学情诊断报告 | 成绩分析 | 精准教学 |
|
||||
| 12 | 短信/微信推送 | 消息通知 | 紧急事件触达 |
|
||||
| 13 | 漏洞扫描 + 渗透测试 | 安全审计 | 上线后安全验证 |
|
||||
| 14 | 灾备方案 | 数据备份 | 业务连续性 |
|
||||
|
||||
---
|
||||
|
||||
## 四、差距统计摘要
|
||||
|
||||
| 状态 | P0 | P1 | P2 | 合计 |
|
||||
|------|-----|-----|-----|------|
|
||||
| ✅ 已完成 | 36 | 12 | 2 | **50** |
|
||||
| ⚠️ 部分完成 | 10 | 8 | 1 | **19** |
|
||||
| ❌ 未实现 | 9 | 28 | 27 | **64** |
|
||||
| **合计** | **55** | **48** | **30** | **133** |
|
||||
|
||||
| 完成率 | P0 | P1 | P2 | 总体 |
|
||||
|--------|-----|-----|-----|------|
|
||||
| 按已完成计 | 65% | 25% | 7% | **38%** |
|
||||
| 含部分完成 | 83% | 42% | 10% | **51%** |
|
||||
|
||||
> **结论**:项目 P0 核心功能完成度约 65%(严格)/ 83%(含部分),主要缺口集中在**家校沟通(通知公告)**、**日志审计**、**成绩分析**三个 P0 模块。建议优先补齐 Phase 1 的 7 项 P0 缺口,再推进 Phase 2 的 P1 增强。
|
||||
789
package-lock.json
generated
789
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -8,8 +8,10 @@
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "npm run test:integration && npm run test:e2e",
|
||||
"test:ci": "npm run test:integration && npm run test:e2e",
|
||||
"test": "npm run test:unit && npm run test:integration && npm run test:e2e",
|
||||
"test:ci": "npm run test:unit && npm run test:integration && npm run test:e2e",
|
||||
"test:unit": "vitest run --config vitest.unit.config.ts",
|
||||
"test:unit:watch": "vitest --config vitest.unit.config.ts",
|
||||
"test:integration": "vitest run --config vitest.config.ts",
|
||||
"test:integration:watch": "vitest --config vitest.config.ts",
|
||||
"test:integration:coverage": "vitest run --config vitest.config.ts --coverage",
|
||||
@@ -66,6 +68,7 @@
|
||||
"react-hook-form": "^7.69.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "^3.6.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
@@ -80,6 +83,8 @@
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
@@ -89,6 +94,7 @@
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.10",
|
||||
"jsdom": "^29.1.1",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"tailwindcss": "^4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { redirect } from "next/navigation"
|
||||
import { auth } from "@/auth"
|
||||
import { getUserProfile } from "@/modules/users/data-access"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -8,14 +8,11 @@ export default async function DashboardPage() {
|
||||
const session = await auth()
|
||||
if (!session?.user) redirect("/login")
|
||||
|
||||
const userId = String(session.user.id ?? "").trim()
|
||||
if (!userId) redirect("/login")
|
||||
const profile = await getUserProfile(userId)
|
||||
if (!profile) redirect("/login")
|
||||
const role = profile.role || "student"
|
||||
const permissions = session.user.permissions ?? []
|
||||
const roles = session.user.roles ?? []
|
||||
|
||||
if (role === "admin") redirect("/admin/dashboard")
|
||||
if (role === "student") redirect("/student/dashboard")
|
||||
if (role === "parent") redirect("/parent/dashboard")
|
||||
if (permissions.includes(Permissions.SCHOOL_MANAGE)) redirect("/admin/dashboard")
|
||||
if (permissions.includes(Permissions.HOMEWORK_SUBMIT) && !permissions.includes(Permissions.EXAM_CREATE)) redirect("/student/dashboard")
|
||||
if (roles.includes("parent")) redirect("/parent/dashboard")
|
||||
redirect("/teacher/dashboard")
|
||||
}
|
||||
|
||||
@@ -9,8 +9,11 @@ export default function DashboardLayout({
|
||||
}) {
|
||||
return (
|
||||
<SidebarProvider sidebar={<AppSidebar />}>
|
||||
<a href="#main-content" className="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:p-4 focus:bg-background focus:text-foreground focus:border focus:border-border focus:rounded-md focus:m-2">
|
||||
Skip to main content
|
||||
</a>
|
||||
<SiteHeader />
|
||||
<main className="flex-1 overflow-auto p-6">
|
||||
<main id="main-content" className="flex-1 overflow-auto p-6">
|
||||
{children}
|
||||
</main>
|
||||
</SidebarProvider>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { StudentTodayScheduleCard } from "@/modules/dashboard/components/student
|
||||
import { StudentUpcomingAssignmentsCard } from "@/modules/dashboard/components/student-dashboard/student-upcoming-assignments-card"
|
||||
import { getStudentDashboardGrades, getStudentHomeworkAssignments } from "@/modules/homework/data-access"
|
||||
import { getUserProfile } from "@/modules/users/data-access"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
@@ -42,9 +43,9 @@ export default async function ProfilePage() {
|
||||
redirect("/login")
|
||||
}
|
||||
|
||||
const role = userProfile.role || "student"
|
||||
const isStudent = role === "student"
|
||||
const isTeacher = role === "teacher"
|
||||
const permissions = session.user.permissions ?? []
|
||||
const isStudent = permissions.includes(Permissions.HOMEWORK_SUBMIT) && !permissions.includes(Permissions.EXAM_CREATE)
|
||||
const isTeacher = permissions.includes(Permissions.EXAM_CREATE)
|
||||
|
||||
const studentData =
|
||||
isStudent
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AdminSettingsView } from "@/modules/settings/components/admin-settings-
|
||||
import { StudentSettingsView } from "@/modules/settings/components/student-settings-view"
|
||||
import { TeacherSettingsView } from "@/modules/settings/components/teacher-settings-view"
|
||||
import { getUserProfile } from "@/modules/users/data-access"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -17,11 +18,9 @@ export default async function SettingsPage() {
|
||||
|
||||
if (!userProfile) redirect("/login")
|
||||
|
||||
const role = userProfile.role || "student"
|
||||
const permissions = session.user.permissions ?? []
|
||||
|
||||
if (role === "admin") return <AdminSettingsView user={userProfile} />
|
||||
if (role === "student") return <StudentSettingsView user={userProfile} />
|
||||
if (role === "teacher") return <TeacherSettingsView user={userProfile} />
|
||||
|
||||
redirect("/dashboard")
|
||||
if (permissions.includes(Permissions.SETTINGS_ADMIN)) return <AdminSettingsView user={userProfile} />
|
||||
if (permissions.includes(Permissions.HOMEWORK_SUBMIT) && !permissions.includes(Permissions.EXAM_CREATE)) return <StudentSettingsView user={userProfile} />
|
||||
return <TeacherSettingsView user={userProfile} />
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ExamDataTable } from "@/modules/exams/components/exam-data-table"
|
||||
import { examColumns } from "@/modules/exams/components/exam-columns"
|
||||
import { ExamFilters } from "@/modules/exams/components/exam-filters"
|
||||
import { getExams } from "@/modules/exams/data-access"
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { FileText, PlusCircle } from "lucide-react"
|
||||
|
||||
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||
@@ -19,6 +20,7 @@ const getParam = (params: SearchParams, key: string) => {
|
||||
|
||||
async function ExamsResults({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||
const params = await searchParams
|
||||
const { dataScope } = await getAuthContext()
|
||||
|
||||
const q = getParam(params, "q")
|
||||
const status = getParam(params, "status")
|
||||
@@ -28,6 +30,7 @@ async function ExamsResults({ searchParams }: { searchParams: Promise<SearchPara
|
||||
q,
|
||||
status,
|
||||
difficulty,
|
||||
scope: dataScope,
|
||||
})
|
||||
|
||||
const hasFilters = Boolean(q || (status && status !== "all") || (difficulty && difficulty !== "all"))
|
||||
|
||||
@@ -2,12 +2,14 @@ import { HomeworkAssignmentForm } from "@/modules/homework/components/homework-a
|
||||
import { getExams } from "@/modules/exams/data-access"
|
||||
import { getTeacherClasses } from "@/modules/classes/data-access"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { FileQuestion } from "lucide-react"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function CreateHomeworkAssignmentPage() {
|
||||
const [exams, classes] = await Promise.all([getExams({}), getTeacherClasses()])
|
||||
const { dataScope } = await getAuthContext()
|
||||
const [exams, classes] = await Promise.all([getExams({ scope: dataScope }), getTeacherClasses()])
|
||||
const options = exams.map((e) => ({ id: e.id, title: e.title }))
|
||||
|
||||
return (
|
||||
|
||||
@@ -166,6 +166,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced Motion */
|
||||
@layer base {
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Base Styles */
|
||||
@layer base {
|
||||
* {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import { ThemeProvider } from "@/shared/components/theme-provider";
|
||||
import { Toaster } from "@/shared/components/ui/sonner";
|
||||
import { NuqsAdapter } from 'nuqs/adapters/next/app'
|
||||
@@ -6,6 +7,12 @@ import { AuthSessionProvider } from "@/shared/components/auth-session-provider"
|
||||
import { OnboardingGate } from "@/shared/components/onboarding-gate"
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
variable: "--font-inter",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Next_Edu - K12 智慧教务系统",
|
||||
description: "Enterprise Grade K12 Education Management System",
|
||||
@@ -19,7 +26,7 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={`antialiased`}
|
||||
className={`${inter.variable} antialiased font-sans`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<ThemeProvider
|
||||
|
||||
28
src/auth.ts
28
src/auth.ts
@@ -1,6 +1,7 @@
|
||||
import { compare } from "bcryptjs"
|
||||
import NextAuth from "next-auth"
|
||||
import Credentials from "next-auth/providers/credentials"
|
||||
import { resolvePermissions } from "@/shared/lib/permissions"
|
||||
|
||||
const normalizeRole = (value: unknown) => {
|
||||
const role = String(value ?? "").trim().toLowerCase()
|
||||
@@ -64,13 +65,15 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||
.where(eq(usersToRoles.userId, user.id))
|
||||
|
||||
const resolvedRole = resolvePrimaryRole(roleRows.map((r) => r.name))
|
||||
const roleNames = roleRows.map((r) => r.name)
|
||||
const resolvedRole = resolvePrimaryRole(roleNames)
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name ?? undefined,
|
||||
email: user.email,
|
||||
role: resolvedRole,
|
||||
roles: roleNames,
|
||||
}
|
||||
},
|
||||
}),
|
||||
@@ -78,11 +81,17 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
callbacks: {
|
||||
jwt: async ({ token, user }) => {
|
||||
if (user) {
|
||||
token.id = (user as { id: string }).id
|
||||
token.role = normalizeRole((user as { role?: string }).role)
|
||||
token.name = (user as { name?: string }).name
|
||||
const u = user as { id: string; role?: string; roles?: string[]; name?: string }
|
||||
token.id = u.id
|
||||
token.role = normalizeRole(u.role)
|
||||
token.name = u.name ?? undefined
|
||||
// Store all roles (not just primary) and resolved permissions
|
||||
const allRoles = u.roles ?? [u.role ?? "student"]
|
||||
token.roles = allRoles
|
||||
token.permissions = resolvePermissions(allRoles)
|
||||
}
|
||||
|
||||
// Refresh roles/permissions from DB on each JWT refresh
|
||||
const userId = String(token.id ?? "").trim()
|
||||
if (userId) {
|
||||
const [{ eq }, { db }, { roles, users, usersToRoles }] = await Promise.all([
|
||||
@@ -93,8 +102,8 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
|
||||
const [fresh, roleRows] = await Promise.all([
|
||||
db.query.users.findFirst({
|
||||
where: eq(users.id, userId),
|
||||
columns: { name: true },
|
||||
where: eq(users.id, userId),
|
||||
columns: { name: true },
|
||||
}),
|
||||
db
|
||||
.select({ name: roles.name })
|
||||
@@ -104,8 +113,11 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
])
|
||||
|
||||
if (fresh) {
|
||||
token.role = resolvePrimaryRole(roleRows.map((r) => r.name))
|
||||
const allRoles = roleRows.map((r) => r.name)
|
||||
token.role = resolvePrimaryRole(allRoles)
|
||||
token.name = fresh.name ?? token.name
|
||||
token.roles = allRoles
|
||||
token.permissions = resolvePermissions(allRoles)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +127,8 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
if (session.user) {
|
||||
session.user.id = String(token.id ?? "")
|
||||
session.user.role = normalizeRole(token.role)
|
||||
session.user.roles = (token.roles ?? []) as string[]
|
||||
session.user.permissions = (token.permissions ?? []) as typeof token.permissions
|
||||
if (typeof token.name === "string") {
|
||||
session.user.name = token.name
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Exam, ExamSubmission } from "./types"
|
||||
import { Exam, ExamSubmission } from "../modules/exams/types"
|
||||
|
||||
export let MOCK_EXAMS: Exam[] = [
|
||||
{
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Question } from "./types";
|
||||
import { Question } from "../modules/questions/types";
|
||||
|
||||
export const MOCK_QUESTIONS: Question[] = [
|
||||
{
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
import "server-only"
|
||||
|
||||
import { cache } from "react"
|
||||
import { count, desc, eq, gt, inArray } from "drizzle-orm"
|
||||
import { count, desc, eq, gt, inArray, and } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
@@ -18,10 +18,61 @@ import {
|
||||
usersToRoles,
|
||||
} from "@/shared/db/schema"
|
||||
import type { AdminDashboardData } from "./types"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
|
||||
export const getAdminDashboardData = cache(async (): Promise<AdminDashboardData> => {
|
||||
export const getAdminDashboardData = cache(async (scope?: DataScope): Promise<AdminDashboardData> => {
|
||||
const now = new Date()
|
||||
|
||||
// Build scope-based conditions for exams
|
||||
const examConditions = []
|
||||
const homeworkConditions = []
|
||||
const submissionConditions = []
|
||||
|
||||
if (scope && scope.type !== "all") {
|
||||
if (scope.type === "owned") {
|
||||
examConditions.push(eq(exams.creatorId, scope.userId))
|
||||
homeworkConditions.push(eq(homeworkAssignments.creatorId, scope.userId))
|
||||
const ownedAssignmentIds = db
|
||||
.select({ id: homeworkAssignments.id })
|
||||
.from(homeworkAssignments)
|
||||
.where(eq(homeworkAssignments.creatorId, scope.userId))
|
||||
submissionConditions.push(inArray(homeworkSubmissions.assignmentId, ownedAssignmentIds))
|
||||
}
|
||||
if (scope.type === "grade_managed" && scope.gradeIds.length > 0) {
|
||||
examConditions.push(inArray(exams.gradeId, scope.gradeIds))
|
||||
const gradeExamIds = db
|
||||
.select({ id: exams.id })
|
||||
.from(exams)
|
||||
.where(inArray(exams.gradeId, scope.gradeIds))
|
||||
homeworkConditions.push(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
|
||||
const gradeAssignmentIds = db
|
||||
.select({ id: homeworkAssignments.id })
|
||||
.from(homeworkAssignments)
|
||||
.where(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
|
||||
submissionConditions.push(inArray(homeworkSubmissions.assignmentId, gradeAssignmentIds))
|
||||
}
|
||||
if (scope.type === "class_taught" && scope.classIds.length > 0) {
|
||||
const teacherGradeIds = await db
|
||||
.selectDistinct({ gradeId: classes.gradeId })
|
||||
.from(classes)
|
||||
.where(inArray(classes.id, scope.classIds))
|
||||
const gradeIds = teacherGradeIds.map(g => g.gradeId).filter(Boolean) as string[]
|
||||
if (gradeIds.length > 0) {
|
||||
examConditions.push(inArray(exams.gradeId, gradeIds))
|
||||
const gradeExamIds = db
|
||||
.select({ id: exams.id })
|
||||
.from(exams)
|
||||
.where(inArray(exams.gradeId, gradeIds))
|
||||
homeworkConditions.push(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
|
||||
const gradeAssignmentIds = db
|
||||
.select({ id: homeworkAssignments.id })
|
||||
.from(homeworkAssignments)
|
||||
.where(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
|
||||
submissionConditions.push(inArray(homeworkSubmissions.assignmentId, gradeAssignmentIds))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [
|
||||
activeSessionsRow,
|
||||
userCountRow,
|
||||
@@ -48,11 +99,19 @@ export const getAdminDashboardData = cache(async (): Promise<AdminDashboardData>
|
||||
db.select({ value: count() }).from(textbooks),
|
||||
db.select({ value: count() }).from(chapters),
|
||||
db.select({ value: count() }).from(questions),
|
||||
db.select({ value: count() }).from(exams),
|
||||
db.select({ value: count() }).from(homeworkAssignments),
|
||||
db.select({ value: count() }).from(homeworkAssignments).where(eq(homeworkAssignments.status, "published")),
|
||||
db.select({ value: count() }).from(homeworkSubmissions),
|
||||
db.select({ value: count() }).from(homeworkSubmissions).where(eq(homeworkSubmissions.status, "submitted")),
|
||||
db.select({ value: count() }).from(exams).where(examConditions.length ? and(...examConditions) : undefined),
|
||||
db.select({ value: count() }).from(homeworkAssignments).where(homeworkConditions.length ? and(...homeworkConditions) : undefined),
|
||||
db.select({ value: count() }).from(homeworkAssignments).where(
|
||||
homeworkConditions.length
|
||||
? and(eq(homeworkAssignments.status, "published"), ...homeworkConditions)
|
||||
: eq(homeworkAssignments.status, "published")
|
||||
),
|
||||
db.select({ value: count() }).from(homeworkSubmissions).where(submissionConditions.length ? and(...submissionConditions) : undefined),
|
||||
db.select({ value: count() }).from(homeworkSubmissions).where(
|
||||
submissionConditions.length
|
||||
? and(eq(homeworkSubmissions.status, "submitted"), ...submissionConditions)
|
||||
: eq(homeworkSubmissions.status, "submitted")
|
||||
),
|
||||
db
|
||||
.select({
|
||||
id: users.id,
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { ActionState } from "@/shared/types/action-state"
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { z } from "zod"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { db } from "@/shared/db"
|
||||
@@ -253,53 +255,61 @@ export async function createExamAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
const rawQuestions = formData.get("questionsJson") as string | null
|
||||
|
||||
const parsed = ExamCreateSchema.safeParse({
|
||||
title: getStringValue(formData, "title"),
|
||||
subject: getStringValue(formData, "subject"),
|
||||
grade: getStringValue(formData, "grade"),
|
||||
difficulty: getStringValue(formData, "difficulty"),
|
||||
totalScore: getStringValue(formData, "totalScore"),
|
||||
durationMin: getStringValue(formData, "durationMin"),
|
||||
scheduledAt: getStringValue(formData, "scheduledAt") ?? null,
|
||||
questions: rawQuestions ? JSON.parse(rawQuestions) : [],
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
return invalidFormState<string>(parsed.error, { useFirstMessage: false })
|
||||
}
|
||||
|
||||
const input = parsed.data
|
||||
const context = await prepareExamCreateContext({
|
||||
subject: input.subject,
|
||||
grade: input.grade,
|
||||
difficulty: input.difficulty,
|
||||
totalScore: input.totalScore,
|
||||
durationMin: input.durationMin,
|
||||
scheduledAt: input.scheduledAt,
|
||||
})
|
||||
const description = context.buildDescription()
|
||||
|
||||
try {
|
||||
const user = await getCurrentUser()
|
||||
await persistExamDraft({
|
||||
examId: context.examId,
|
||||
title: input.title,
|
||||
creatorId: user?.id ?? "user_teacher_math",
|
||||
subjectId: input.subject,
|
||||
gradeId: input.grade,
|
||||
scheduledAt: context.scheduled,
|
||||
description,
|
||||
const ctx = await requirePermission(Permissions.EXAM_CREATE)
|
||||
|
||||
const rawQuestions = formData.get("questionsJson") as string | null
|
||||
|
||||
const parsed = ExamCreateSchema.safeParse({
|
||||
title: getStringValue(formData, "title"),
|
||||
subject: getStringValue(formData, "subject"),
|
||||
grade: getStringValue(formData, "grade"),
|
||||
difficulty: getStringValue(formData, "difficulty"),
|
||||
totalScore: getStringValue(formData, "totalScore"),
|
||||
durationMin: getStringValue(formData, "durationMin"),
|
||||
scheduledAt: getStringValue(formData, "scheduledAt") ?? null,
|
||||
questions: rawQuestions ? JSON.parse(rawQuestions) : [],
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
return invalidFormState<string>(parsed.error, { useFirstMessage: false })
|
||||
}
|
||||
|
||||
const input = parsed.data
|
||||
const context = await prepareExamCreateContext({
|
||||
subject: input.subject,
|
||||
grade: input.grade,
|
||||
difficulty: input.difficulty,
|
||||
totalScore: input.totalScore,
|
||||
durationMin: input.durationMin,
|
||||
scheduledAt: input.scheduledAt,
|
||||
})
|
||||
const description = context.buildDescription()
|
||||
|
||||
try {
|
||||
await persistExamDraft({
|
||||
examId: context.examId,
|
||||
title: input.title,
|
||||
creatorId: ctx.userId,
|
||||
subjectId: input.subject,
|
||||
gradeId: input.grade,
|
||||
scheduledAt: context.scheduled,
|
||||
description,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to create exam:", error)
|
||||
return failState<string>("Database error: Failed to create exam")
|
||||
}
|
||||
|
||||
revalidatePath("/teacher/exams/all")
|
||||
|
||||
return successState(context.examId, "Exam created successfully.")
|
||||
} catch (error) {
|
||||
console.error("Failed to create exam:", error)
|
||||
return failState<string>("Database error: Failed to create exam")
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return failState<string>(error.message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
revalidatePath("/teacher/exams/all")
|
||||
|
||||
return successState(context.examId, "Exam created successfully.")
|
||||
}
|
||||
|
||||
const AiExamCreateSchema = ExamCreateSchema.extend({
|
||||
@@ -324,167 +334,193 @@ export async function createAiExamAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
const rawQuestions = formData.get("questionsJson") as string | null
|
||||
const rawAiQuestions = formData.get("aiQuestionsJson") as string | null
|
||||
const rawStructure = formData.get("structureJson") as string | null
|
||||
const aiSourceTextRaw = formData.get("aiSourceText")
|
||||
const aiQuestionCountRaw = formData.get("aiQuestionCount")
|
||||
const aiProviderIdRaw = formData.get("aiProviderId")
|
||||
|
||||
const parsed = AiExamCreateSchema.safeParse({
|
||||
title: getStringValue(formData, "title"),
|
||||
subject: getStringValue(formData, "subject"),
|
||||
grade: getStringValue(formData, "grade"),
|
||||
difficulty: getStringValue(formData, "difficulty"),
|
||||
totalScore: getStringValue(formData, "totalScore"),
|
||||
durationMin: getStringValue(formData, "durationMin"),
|
||||
scheduledAt: getStringValue(formData, "scheduledAt") ?? null,
|
||||
questions: rawQuestions ? JSON.parse(rawQuestions) : [],
|
||||
aiSourceText: typeof aiSourceTextRaw === "string" ? aiSourceTextRaw.trim() : undefined,
|
||||
aiQuestionCount: typeof aiQuestionCountRaw === "string" && aiQuestionCountRaw.trim().length > 0
|
||||
? aiQuestionCountRaw
|
||||
: undefined,
|
||||
aiProviderId: typeof aiProviderIdRaw === "string" && aiProviderIdRaw.trim().length > 0
|
||||
? aiProviderIdRaw
|
||||
: undefined,
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
return invalidFormState<string>(parsed.error)
|
||||
}
|
||||
|
||||
const input = parsed.data
|
||||
if (!rawAiQuestions && !input.aiSourceText) {
|
||||
return failState<string>("Please analyze and preview before creating")
|
||||
}
|
||||
const context = await prepareExamCreateContext({
|
||||
subject: input.subject,
|
||||
grade: input.grade,
|
||||
difficulty: input.difficulty,
|
||||
totalScore: input.totalScore,
|
||||
durationMin: input.durationMin,
|
||||
scheduledAt: input.scheduledAt,
|
||||
})
|
||||
|
||||
const user = await getCurrentUser()
|
||||
const aiDraftResult = await loadAiDraftQuestionsAndStructure({
|
||||
rawAiQuestions,
|
||||
rawStructure,
|
||||
title: input.title,
|
||||
subject: context.subjectName,
|
||||
grade: context.gradeName,
|
||||
difficulty: input.difficulty,
|
||||
totalScore: input.totalScore,
|
||||
durationMin: input.durationMin,
|
||||
aiSourceText: input.aiSourceText,
|
||||
aiQuestionCount: input.aiQuestionCount,
|
||||
aiProviderId: input.aiProviderId,
|
||||
})
|
||||
if (!aiDraftResult.ok) {
|
||||
return failState<string>(aiDraftResult.message)
|
||||
}
|
||||
const { generated, structure } = aiDraftResult
|
||||
|
||||
const questionCount = generated.length
|
||||
const description = context.buildDescription({ questionCount })
|
||||
|
||||
try {
|
||||
await persistAiGeneratedExamDraft({
|
||||
examId: context.examId,
|
||||
title: input.title,
|
||||
creatorId: user?.id ?? "user_teacher_math",
|
||||
subjectId: input.subject,
|
||||
gradeId: input.grade,
|
||||
scheduledAt: context.scheduled,
|
||||
description,
|
||||
structure,
|
||||
generated,
|
||||
const ctx = await requirePermission(Permissions.EXAM_AI_GENERATE)
|
||||
|
||||
const rawQuestions = formData.get("questionsJson") as string | null
|
||||
const rawAiQuestions = formData.get("aiQuestionsJson") as string | null
|
||||
const rawStructure = formData.get("structureJson") as string | null
|
||||
const aiSourceTextRaw = formData.get("aiSourceText")
|
||||
const aiQuestionCountRaw = formData.get("aiQuestionCount")
|
||||
const aiProviderIdRaw = formData.get("aiProviderId")
|
||||
|
||||
const parsed = AiExamCreateSchema.safeParse({
|
||||
title: getStringValue(formData, "title"),
|
||||
subject: getStringValue(formData, "subject"),
|
||||
grade: getStringValue(formData, "grade"),
|
||||
difficulty: getStringValue(formData, "difficulty"),
|
||||
totalScore: getStringValue(formData, "totalScore"),
|
||||
durationMin: getStringValue(formData, "durationMin"),
|
||||
scheduledAt: getStringValue(formData, "scheduledAt") ?? null,
|
||||
questions: rawQuestions ? JSON.parse(rawQuestions) : [],
|
||||
aiSourceText: typeof aiSourceTextRaw === "string" ? aiSourceTextRaw.trim() : undefined,
|
||||
aiQuestionCount: typeof aiQuestionCountRaw === "string" && aiQuestionCountRaw.trim().length > 0
|
||||
? aiQuestionCountRaw
|
||||
: undefined,
|
||||
aiProviderId: typeof aiProviderIdRaw === "string" && aiProviderIdRaw.trim().length > 0
|
||||
? aiProviderIdRaw
|
||||
: undefined,
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
return invalidFormState<string>(parsed.error)
|
||||
}
|
||||
|
||||
const input = parsed.data
|
||||
if (!rawAiQuestions && !input.aiSourceText) {
|
||||
return failState<string>("Please analyze and preview before creating")
|
||||
}
|
||||
const context = await prepareExamCreateContext({
|
||||
subject: input.subject,
|
||||
grade: input.grade,
|
||||
difficulty: input.difficulty,
|
||||
totalScore: input.totalScore,
|
||||
durationMin: input.durationMin,
|
||||
scheduledAt: input.scheduledAt,
|
||||
})
|
||||
|
||||
const aiDraftResult = await loadAiDraftQuestionsAndStructure({
|
||||
rawAiQuestions,
|
||||
rawStructure,
|
||||
title: input.title,
|
||||
subject: context.subjectName,
|
||||
grade: context.gradeName,
|
||||
difficulty: input.difficulty,
|
||||
totalScore: input.totalScore,
|
||||
durationMin: input.durationMin,
|
||||
aiSourceText: input.aiSourceText,
|
||||
aiQuestionCount: input.aiQuestionCount,
|
||||
aiProviderId: input.aiProviderId,
|
||||
})
|
||||
if (!aiDraftResult.ok) {
|
||||
return failState<string>(aiDraftResult.message)
|
||||
}
|
||||
const { generated, structure } = aiDraftResult
|
||||
|
||||
const questionCount = generated.length
|
||||
const description = context.buildDescription({ questionCount })
|
||||
|
||||
try {
|
||||
await persistAiGeneratedExamDraft({
|
||||
examId: context.examId,
|
||||
title: input.title,
|
||||
creatorId: ctx.userId,
|
||||
subjectId: input.subject,
|
||||
gradeId: input.grade,
|
||||
scheduledAt: context.scheduled,
|
||||
description,
|
||||
structure,
|
||||
generated,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to create exam:", error)
|
||||
return failState<string>("Database error: Failed to create exam")
|
||||
}
|
||||
|
||||
revalidatePath("/teacher/exams/all")
|
||||
|
||||
return successState(context.examId, "Exam created successfully.")
|
||||
} catch (error) {
|
||||
console.error("Failed to create exam:", error)
|
||||
return failState<string>("Database error: Failed to create exam")
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return failState<string>(error.message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
revalidatePath("/teacher/exams/all")
|
||||
|
||||
return successState(context.examId, "Exam created successfully.")
|
||||
}
|
||||
|
||||
export async function previewAiExamAction(
|
||||
prevState: ActionState<AiPreviewData> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<AiPreviewData>> {
|
||||
const aiSourceTextRaw = formData.get("aiSourceText")
|
||||
const aiQuestionCountRaw = formData.get("aiQuestionCount")
|
||||
const aiProviderIdRaw = formData.get("aiProviderId")
|
||||
try {
|
||||
await requirePermission(Permissions.EXAM_AI_GENERATE)
|
||||
|
||||
const sourceText = typeof aiSourceTextRaw === "string" ? aiSourceTextRaw.trim() : ""
|
||||
if (!sourceText) {
|
||||
return failState<AiPreviewData>("Please paste the full exam text first", {
|
||||
aiSourceText: ["Please paste the full exam text first"],
|
||||
const aiSourceTextRaw = formData.get("aiSourceText")
|
||||
const aiQuestionCountRaw = formData.get("aiQuestionCount")
|
||||
const aiProviderIdRaw = formData.get("aiProviderId")
|
||||
|
||||
const sourceText = typeof aiSourceTextRaw === "string" ? aiSourceTextRaw.trim() : ""
|
||||
if (!sourceText) {
|
||||
return failState<AiPreviewData>("Please paste the full exam text first", {
|
||||
aiSourceText: ["Please paste the full exam text first"],
|
||||
})
|
||||
}
|
||||
|
||||
const parsed = AiExamPreviewSchema.safeParse({
|
||||
title: getStringValue(formData, "title"),
|
||||
subject: getStringValue(formData, "subject"),
|
||||
grade: getStringValue(formData, "grade"),
|
||||
difficulty: getStringValue(formData, "difficulty"),
|
||||
totalScore: getStringValue(formData, "totalScore"),
|
||||
durationMin: getStringValue(formData, "durationMin"),
|
||||
aiSourceText: sourceText,
|
||||
aiQuestionCount: typeof aiQuestionCountRaw === "string" && aiQuestionCountRaw.trim().length > 0
|
||||
? aiQuestionCountRaw
|
||||
: undefined,
|
||||
aiProviderId: typeof aiProviderIdRaw === "string" && aiProviderIdRaw.trim().length > 0
|
||||
? aiProviderIdRaw
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const parsed = AiExamPreviewSchema.safeParse({
|
||||
title: getStringValue(formData, "title"),
|
||||
subject: getStringValue(formData, "subject"),
|
||||
grade: getStringValue(formData, "grade"),
|
||||
difficulty: getStringValue(formData, "difficulty"),
|
||||
totalScore: getStringValue(formData, "totalScore"),
|
||||
durationMin: getStringValue(formData, "durationMin"),
|
||||
aiSourceText: sourceText,
|
||||
aiQuestionCount: typeof aiQuestionCountRaw === "string" && aiQuestionCountRaw.trim().length > 0
|
||||
? aiQuestionCountRaw
|
||||
: undefined,
|
||||
aiProviderId: typeof aiProviderIdRaw === "string" && aiProviderIdRaw.trim().length > 0
|
||||
? aiProviderIdRaw
|
||||
: undefined,
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return invalidFormState<AiPreviewData>(parsed.error)
|
||||
}
|
||||
|
||||
if (!parsed.success) {
|
||||
return invalidFormState<AiPreviewData>(parsed.error)
|
||||
const input = parsed.data
|
||||
const previewRequest = await prepareAiPreviewRequest(input)
|
||||
const aiDraft = await generateAiPreviewData(previewRequest)
|
||||
if (!aiDraft.ok) {
|
||||
return failState<AiPreviewData>(aiDraft.message)
|
||||
}
|
||||
return successState({ ...aiDraft.data, rawOutput: aiDraft.rawOutput })
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return failState<AiPreviewData>(error.message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const input = parsed.data
|
||||
const previewRequest = await prepareAiPreviewRequest(input)
|
||||
const aiDraft = await generateAiPreviewData(previewRequest)
|
||||
if (!aiDraft.ok) {
|
||||
return failState<AiPreviewData>(aiDraft.message)
|
||||
}
|
||||
return successState({ ...aiDraft.data, rawOutput: aiDraft.rawOutput })
|
||||
}
|
||||
|
||||
export async function regenerateAiQuestionAction(
|
||||
prevState: ActionState<AiRewriteQuestionData> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<AiRewriteQuestionData>> {
|
||||
const parsedInput = parseRegenerateAiQuestionInput(formData)
|
||||
if (!parsedInput.ok) {
|
||||
return parsedInput.state
|
||||
}
|
||||
const { instruction, aiProviderId, sourceText, originalQuestion } = parsedInput
|
||||
|
||||
const originalDifficulty = originalQuestion.difficulty ?? 3
|
||||
const originalScore = originalQuestion.score ?? 0
|
||||
|
||||
try {
|
||||
const result = await regenerateAiQuestionByInstruction({
|
||||
instruction,
|
||||
originalQuestion,
|
||||
sourceText,
|
||||
aiProviderId,
|
||||
})
|
||||
if (!result.ok) {
|
||||
return failState<AiRewriteQuestionData>(result.message)
|
||||
await requirePermission(Permissions.EXAM_AI_GENERATE)
|
||||
|
||||
const parsedInput = parseRegenerateAiQuestionInput(formData)
|
||||
if (!parsedInput.ok) {
|
||||
return parsedInput.state
|
||||
}
|
||||
return successState({
|
||||
type: result.data.type,
|
||||
difficulty: result.data.difficulty ?? originalDifficulty,
|
||||
score: result.data.score ?? originalScore,
|
||||
content: result.data.content,
|
||||
})
|
||||
} catch {
|
||||
return failState<AiRewriteQuestionData>("AI question format invalid")
|
||||
const { instruction, aiProviderId, sourceText, originalQuestion } = parsedInput
|
||||
|
||||
const originalDifficulty = originalQuestion.difficulty ?? 3
|
||||
const originalScore = originalQuestion.score ?? 0
|
||||
|
||||
try {
|
||||
const result = await regenerateAiQuestionByInstruction({
|
||||
instruction,
|
||||
originalQuestion,
|
||||
sourceText,
|
||||
aiProviderId,
|
||||
})
|
||||
if (!result.ok) {
|
||||
return failState<AiRewriteQuestionData>(result.message)
|
||||
}
|
||||
return successState({
|
||||
type: result.data.type,
|
||||
difficulty: result.data.difficulty ?? originalDifficulty,
|
||||
score: result.data.score ?? originalScore,
|
||||
content: result.data.content,
|
||||
})
|
||||
} catch {
|
||||
return failState<AiRewriteQuestionData>("AI question format invalid")
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return failState<AiRewriteQuestionData>(error.message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,58 +542,78 @@ export async function updateExamAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
const rawQuestions = formData.get("questionsJson")
|
||||
const rawStructure = formData.get("structureJson")
|
||||
const hasQuestions = typeof rawQuestions === "string"
|
||||
const hasStructure = typeof rawStructure === "string"
|
||||
|
||||
const parsed = ExamUpdateSchema.safeParse({
|
||||
examId: formData.get("examId"),
|
||||
questions: hasQuestions ? JSON.parse(rawQuestions) : undefined,
|
||||
structure: hasStructure ? JSON.parse(rawStructure) : undefined,
|
||||
status: formData.get("status") ?? undefined,
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
return invalidFormState<string>(parsed.error, {
|
||||
fallbackMessage: "Invalid update data",
|
||||
useFirstMessage: false,
|
||||
})
|
||||
}
|
||||
|
||||
const { examId, questions, structure, status } = parsed.data
|
||||
|
||||
try {
|
||||
if (questions) {
|
||||
await db.delete(examQuestions).where(eq(examQuestions.examId, examId))
|
||||
if (questions.length > 0) {
|
||||
await db.insert(examQuestions).values(
|
||||
questions.map((q, idx) => ({
|
||||
examId,
|
||||
questionId: q.id,
|
||||
score: q.score ?? 0,
|
||||
order: idx,
|
||||
}))
|
||||
)
|
||||
const ctx = await requirePermission(Permissions.EXAM_UPDATE)
|
||||
|
||||
const rawQuestions = formData.get("questionsJson")
|
||||
const rawStructure = formData.get("structureJson")
|
||||
const hasQuestions = typeof rawQuestions === "string"
|
||||
const hasStructure = typeof rawStructure === "string"
|
||||
|
||||
const parsed = ExamUpdateSchema.safeParse({
|
||||
examId: formData.get("examId"),
|
||||
questions: hasQuestions ? JSON.parse(rawQuestions) : undefined,
|
||||
structure: hasStructure ? JSON.parse(rawStructure) : undefined,
|
||||
status: formData.get("status") ?? undefined,
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
return invalidFormState<string>(parsed.error, {
|
||||
fallbackMessage: "Invalid update data",
|
||||
useFirstMessage: false,
|
||||
})
|
||||
}
|
||||
|
||||
const { examId, questions, structure, status } = parsed.data
|
||||
|
||||
// Ownership check: non-admin users can only update their own exams
|
||||
if (ctx.dataScope.type !== "all") {
|
||||
const exam = await db.query.exams.findFirst({
|
||||
where: eq(exams.id, examId),
|
||||
columns: { creatorId: true },
|
||||
})
|
||||
if (!exam || exam.creatorId !== ctx.userId) {
|
||||
return failState<string>("You can only update exams you created")
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare update object
|
||||
const updateData: Partial<typeof exams.$inferInsert> = {}
|
||||
if (status) updateData.status = status
|
||||
if (structure !== undefined) updateData.structure = structure
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await db.update(exams).set(updateData).where(eq(exams.id, examId))
|
||||
|
||||
try {
|
||||
if (questions) {
|
||||
await db.delete(examQuestions).where(eq(examQuestions.examId, examId))
|
||||
if (questions.length > 0) {
|
||||
await db.insert(examQuestions).values(
|
||||
questions.map((q, idx) => ({
|
||||
examId,
|
||||
questionId: q.id,
|
||||
score: q.score ?? 0,
|
||||
order: idx,
|
||||
}))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare update object
|
||||
const updateData: Partial<typeof exams.$inferInsert> = {}
|
||||
if (status) updateData.status = status
|
||||
if (structure !== undefined) updateData.structure = structure
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await db.update(exams).set(updateData).where(eq(exams.id, examId))
|
||||
}
|
||||
|
||||
} catch {
|
||||
return failState<string>("Database error: Failed to update exam")
|
||||
}
|
||||
|
||||
} catch {
|
||||
return failState<string>("Database error: Failed to update exam")
|
||||
|
||||
revalidatePath("/teacher/exams/all")
|
||||
|
||||
return successState(examId, "Exam updated")
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return failState<string>(error.message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
revalidatePath("/teacher/exams/all")
|
||||
|
||||
return successState(examId, "Exam updated")
|
||||
}
|
||||
|
||||
const ExamDeleteSchema = z.object({
|
||||
@@ -568,28 +624,48 @@ export async function deleteExamAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
const parsed = ExamDeleteSchema.safeParse({
|
||||
examId: formData.get("examId"),
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
return invalidFormState<string>(parsed.error, {
|
||||
fallbackMessage: "Invalid delete data",
|
||||
useFirstMessage: false,
|
||||
})
|
||||
}
|
||||
|
||||
const { examId } = parsed.data
|
||||
|
||||
try {
|
||||
await db.delete(exams).where(eq(exams.id, examId))
|
||||
} catch {
|
||||
return failState<string>("Database error: Failed to delete exam")
|
||||
const ctx = await requirePermission(Permissions.EXAM_DELETE)
|
||||
|
||||
const parsed = ExamDeleteSchema.safeParse({
|
||||
examId: formData.get("examId"),
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
return invalidFormState<string>(parsed.error, {
|
||||
fallbackMessage: "Invalid delete data",
|
||||
useFirstMessage: false,
|
||||
})
|
||||
}
|
||||
|
||||
const { examId } = parsed.data
|
||||
|
||||
// Ownership check: non-admin users can only delete their own exams
|
||||
if (ctx.dataScope.type !== "all") {
|
||||
const exam = await db.query.exams.findFirst({
|
||||
where: eq(exams.id, examId),
|
||||
columns: { creatorId: true },
|
||||
})
|
||||
if (!exam || exam.creatorId !== ctx.userId) {
|
||||
return failState<string>("You can only delete exams you created")
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await db.delete(exams).where(eq(exams.id, examId))
|
||||
} catch {
|
||||
return failState<string>("Database error: Failed to delete exam")
|
||||
}
|
||||
|
||||
revalidatePath("/teacher/exams/all")
|
||||
|
||||
return successState(examId, "Exam deleted")
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return failState<string>(error.message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
revalidatePath("/teacher/exams/all")
|
||||
|
||||
return successState(examId, "Exam deleted")
|
||||
}
|
||||
|
||||
const ExamDuplicateSchema = z.object({
|
||||
@@ -600,124 +676,157 @@ export async function duplicateExamAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
const parsed = ExamDuplicateSchema.safeParse({
|
||||
examId: formData.get("examId"),
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
return invalidFormState<string>(parsed.error, {
|
||||
fallbackMessage: "Invalid duplicate data",
|
||||
useFirstMessage: false,
|
||||
})
|
||||
}
|
||||
|
||||
const { examId } = parsed.data
|
||||
|
||||
const source = await db.query.exams.findFirst({
|
||||
where: eq(exams.id, examId),
|
||||
with: {
|
||||
questions: {
|
||||
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!source) {
|
||||
return failState<string>("Exam not found")
|
||||
}
|
||||
|
||||
const newExamId = createId()
|
||||
const user = await getCurrentUser()
|
||||
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(exams).values({
|
||||
id: newExamId,
|
||||
title: `${source.title} (Copy)`,
|
||||
description: omitScheduledAtFromDescription(source.description),
|
||||
creatorId: user?.id ?? "user_teacher_math",
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
status: "draft",
|
||||
structure: source.structure,
|
||||
})
|
||||
const ctx = await requirePermission(Permissions.EXAM_DUPLICATE)
|
||||
|
||||
if (source.questions.length > 0) {
|
||||
await tx.insert(examQuestions).values(
|
||||
source.questions.map((q) => ({
|
||||
examId: newExamId,
|
||||
questionId: q.questionId,
|
||||
score: q.score ?? 0,
|
||||
order: q.order ?? 0,
|
||||
}))
|
||||
)
|
||||
}
|
||||
const parsed = ExamDuplicateSchema.safeParse({
|
||||
examId: formData.get("examId"),
|
||||
})
|
||||
} catch {
|
||||
return failState<string>("Database error: Failed to duplicate exam")
|
||||
|
||||
if (!parsed.success) {
|
||||
return invalidFormState<string>(parsed.error, {
|
||||
fallbackMessage: "Invalid duplicate data",
|
||||
useFirstMessage: false,
|
||||
})
|
||||
}
|
||||
|
||||
const { examId } = parsed.data
|
||||
|
||||
const source = await db.query.exams.findFirst({
|
||||
where: eq(exams.id, examId),
|
||||
with: {
|
||||
questions: {
|
||||
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!source) {
|
||||
return failState<string>("Exam not found")
|
||||
}
|
||||
|
||||
const newExamId = createId()
|
||||
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(exams).values({
|
||||
id: newExamId,
|
||||
title: `${source.title} (Copy)`,
|
||||
description: omitScheduledAtFromDescription(source.description),
|
||||
creatorId: ctx.userId,
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
status: "draft",
|
||||
structure: source.structure,
|
||||
})
|
||||
|
||||
if (source.questions.length > 0) {
|
||||
await tx.insert(examQuestions).values(
|
||||
source.questions.map((q) => ({
|
||||
examId: newExamId,
|
||||
questionId: q.questionId,
|
||||
score: q.score ?? 0,
|
||||
order: q.order ?? 0,
|
||||
}))
|
||||
)
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return failState<string>("Database error: Failed to duplicate exam")
|
||||
}
|
||||
|
||||
revalidatePath("/teacher/exams/all")
|
||||
|
||||
return successState(newExamId, "Exam duplicated")
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return failState<string>(error.message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
revalidatePath("/teacher/exams/all")
|
||||
|
||||
return successState(newExamId, "Exam duplicated")
|
||||
}
|
||||
|
||||
export async function getExamPreviewAction(
|
||||
examId: string
|
||||
): Promise<ActionState<{ structure: unknown; questions: Array<{ id: string }> }>> {
|
||||
try {
|
||||
const exam = await db.query.exams.findFirst({
|
||||
where: eq(exams.id, examId),
|
||||
with: {
|
||||
questions: {
|
||||
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
|
||||
with: {
|
||||
question: true
|
||||
await requirePermission(Permissions.EXAM_READ)
|
||||
|
||||
try {
|
||||
const exam = await db.query.exams.findFirst({
|
||||
where: eq(exams.id, examId),
|
||||
with: {
|
||||
questions: {
|
||||
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
|
||||
with: {
|
||||
question: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (!exam) {
|
||||
return failState<{ structure: unknown; questions: Array<{ id: string }> }>("Exam not found")
|
||||
if (!exam) {
|
||||
return failState<{ structure: unknown; questions: Array<{ id: string }> }>("Exam not found")
|
||||
}
|
||||
const questions = exam.questions.map((eq) => eq.question)
|
||||
return successState({
|
||||
structure: exam.structure,
|
||||
questions,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return failState<{ structure: unknown; questions: Array<{ id: string }> }>("Failed to load exam preview")
|
||||
}
|
||||
const questions = exam.questions.map((eq) => eq.question)
|
||||
return successState({
|
||||
structure: exam.structure,
|
||||
questions,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return failState<{ structure: unknown; questions: Array<{ id: string }> }>("Failed to load exam preview")
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return failState<{ structure: unknown; questions: Array<{ id: string }> }>(error.message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSubjectsAction(): Promise<ActionState<{ id: string; name: string }[]>> {
|
||||
try {
|
||||
const allSubjects = await db.query.subjects.findMany({
|
||||
orderBy: (subjects, { asc }) => [asc(subjects.order), asc(subjects.name)],
|
||||
})
|
||||
await requirePermission(Permissions.EXAM_READ)
|
||||
|
||||
return successState(allSubjects.map((s) => ({ id: s.id, name: s.name })))
|
||||
try {
|
||||
const allSubjects = await db.query.subjects.findMany({
|
||||
orderBy: (subjects, { asc }) => [asc(subjects.order), asc(subjects.name)],
|
||||
})
|
||||
|
||||
return successState(allSubjects.map((s) => ({ id: s.id, name: s.name })))
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch subjects:", error)
|
||||
return failState<{ id: string; name: string }[]>("Failed to load subjects")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch subjects:", error)
|
||||
return failState<{ id: string; name: string }[]>("Failed to load subjects")
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return failState<{ id: string; name: string }[]>(error.message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGradesAction(): Promise<ActionState<{ id: string; name: string }[]>> {
|
||||
try {
|
||||
const allGrades = await db.query.grades.findMany({
|
||||
orderBy: (grades, { asc }) => [asc(grades.order), asc(grades.name)],
|
||||
})
|
||||
await requirePermission(Permissions.EXAM_READ)
|
||||
|
||||
return successState(allGrades.map((g) => ({ id: g.id, name: g.name })))
|
||||
try {
|
||||
const allGrades = await db.query.grades.findMany({
|
||||
orderBy: (grades, { asc }) => [asc(grades.order), asc(grades.name)],
|
||||
})
|
||||
|
||||
return successState(allGrades.map((g) => ({ id: g.id, name: g.name })))
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch grades:", error)
|
||||
return failState<{ id: string; name: string }[]>("Failed to load grades")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch grades:", error)
|
||||
return failState<{ id: string; name: string }[]>("Failed to load grades")
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return failState<{ id: string; name: string }[]>(error.message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function getCurrentUser() {
|
||||
return { id: "user_teacher_math", role: "teacher" }
|
||||
}
|
||||
|
||||
|
||||
@@ -155,21 +155,22 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleView()
|
||||
}}
|
||||
title="Preview Exam"
|
||||
aria-label="Preview exam"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<Button variant="ghost" className="h-8 w-8 p-0" aria-label="Open menu">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
223
src/modules/exams/components/exam-ai-generator.tsx
Normal file
223
src/modules/exams/components/exam-ai-generator.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
"use client"
|
||||
|
||||
import type { Control, UseFormReturn } from "react-hook-form"
|
||||
import { Settings } from "lucide-react"
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
FormDescription,
|
||||
} from "@/shared/components/ui/form"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { AiProviderSettingsCard } from "@/modules/settings/components/ai-provider-settings-card"
|
||||
import type { AiProviderSummary } from "@/modules/settings/actions"
|
||||
import type { ExamFormValues, PreviewBackgroundTask } from "./exam-form-types"
|
||||
import { aiProviderLabels } from "./exam-form-types"
|
||||
|
||||
type ExamAiGeneratorProps = {
|
||||
form: UseFormReturn<ExamFormValues>
|
||||
control: Control<ExamFormValues>
|
||||
aiProviders: AiProviderSummary[]
|
||||
setAiProviders: (providers: AiProviderSummary[]) => void
|
||||
loadingAiProviders: boolean
|
||||
providerDialogOpen: boolean
|
||||
setProviderDialogOpen: (open: boolean) => void
|
||||
providerDialogKey: number
|
||||
setProviderDialogKey: (key: number | ((prev: number) => number)) => void
|
||||
handlePreview: () => void
|
||||
handleBackgroundPreview: () => void
|
||||
previewLoading: boolean
|
||||
previewTasks: PreviewBackgroundTask[]
|
||||
handleOpenPreviewTask: (taskId: string) => void
|
||||
activePreviewTaskCount: number
|
||||
runningPreviewTaskCount: number
|
||||
queuedPreviewTaskCount: number
|
||||
}
|
||||
|
||||
export function ExamAiGenerator({
|
||||
form,
|
||||
control,
|
||||
aiProviders,
|
||||
setAiProviders,
|
||||
loadingAiProviders,
|
||||
providerDialogOpen,
|
||||
setProviderDialogOpen,
|
||||
providerDialogKey,
|
||||
setProviderDialogKey,
|
||||
handlePreview,
|
||||
handleBackgroundPreview,
|
||||
previewLoading,
|
||||
previewTasks,
|
||||
handleOpenPreviewTask,
|
||||
activePreviewTaskCount,
|
||||
runningPreviewTaskCount,
|
||||
queuedPreviewTaskCount,
|
||||
}: ExamAiGeneratorProps) {
|
||||
const formatTaskTime = (value: number) => new Date(value).toLocaleString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>AI Generation</CardTitle>
|
||||
<CardDescription>
|
||||
Paste the exam text and generate a structured preview.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<FormField
|
||||
control={control}
|
||||
name="aiProviderId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<FormLabel>AI Provider</FormLabel>
|
||||
<Dialog
|
||||
open={providerDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setProviderDialogOpen(open)
|
||||
if (open) {
|
||||
setProviderDialogKey((value) => value + 1)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-muted-foreground hover:text-foreground">
|
||||
<Settings className="mr-1 h-3.5 w-3.5" />
|
||||
新建配置
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[960px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>AI Provider Settings</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new provider or update existing configuration.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<AiProviderSettingsCard
|
||||
key={providerDialogKey}
|
||||
initialMode="new"
|
||||
onProvidersChanged={(rows) => {
|
||||
setAiProviders(rows)
|
||||
const preferred = rows.find((item) => item.isDefault) ?? rows[0]
|
||||
if (preferred) {
|
||||
form.setValue("aiProviderId", preferred.id)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<Select value={field.value} onValueChange={field.onChange} disabled={loadingAiProviders}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={loadingAiProviders ? "Loading providers..." : "Select provider"} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{aiProviders.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{aiProviderLabels[item.provider] ?? item.provider} · {item.model}{item.isDefault ? " (Default)" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Select the AI configuration for this generation.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleBackgroundPreview}>
|
||||
{`加入后台队列(运行 ${runningPreviewTaskCount}/3,排队 ${queuedPreviewTaskCount})`}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={handlePreview} disabled={previewLoading || activePreviewTaskCount > 0}>
|
||||
{previewLoading ? "Generating..." : "立即预览"}
|
||||
</Button>
|
||||
</div>
|
||||
<FormField
|
||||
control={control}
|
||||
name="aiSourceText"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Source Exam Text</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Paste the full exam text to parse into questions."
|
||||
className="min-h-[200px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
AI will extract questions and structure from this text.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{previewTasks.length > 0 ? (
|
||||
<div className="rounded-md border p-3 space-y-2">
|
||||
<div className="text-sm font-medium">后台生成记录</div>
|
||||
<div className="space-y-2">
|
||||
{previewTasks.slice(0, 6).map((task) => (
|
||||
<div key={task.id} className="rounded-md border p-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-sm font-medium truncate">{task.title}</div>
|
||||
<div className="text-xs text-muted-foreground">{formatTaskTime(task.createdAt)}</div>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{task.status === "queued"
|
||||
? "排队中"
|
||||
: task.status === "running"
|
||||
? "生成中"
|
||||
: task.status === "success"
|
||||
? "已完成"
|
||||
: `失败:${task.message || "生成失败"}`}
|
||||
</div>
|
||||
{task.status === "success" && task.result ? (
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => handleOpenPreviewTask(task.id)}>
|
||||
打开预览
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
190
src/modules/exams/components/exam-basic-info-form.tsx
Normal file
190
src/modules/exams/components/exam-basic-info-form.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
"use client"
|
||||
|
||||
import type { Control } from "react-hook-form"
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
FormDescription,
|
||||
} from "@/shared/components/ui/form"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card"
|
||||
import type { ExamFormValues } from "./exam-form-types"
|
||||
|
||||
type ExamBasicInfoFormProps = {
|
||||
control: Control<ExamFormValues>
|
||||
subjects: { id: string; name: string }[]
|
||||
grades: { id: string; name: string }[]
|
||||
loadingSubjects: boolean
|
||||
loadingGrades: boolean
|
||||
}
|
||||
|
||||
export function ExamBasicInfoForm({
|
||||
control,
|
||||
subjects,
|
||||
grades,
|
||||
loadingSubjects,
|
||||
loadingGrades,
|
||||
}: ExamBasicInfoFormProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Exam Details</CardTitle>
|
||||
<CardDescription>
|
||||
Define the core information for your exam.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-6">
|
||||
<FormField
|
||||
control={control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g. Midterm Mathematics Exam" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={control}
|
||||
name="subject"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Subject</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value} disabled={loadingSubjects}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={loadingSubjects ? "Loading subjects..." : "Select subject"} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{subjects.map((subject) => (
|
||||
<SelectItem key={subject.id} value={subject.id}>
|
||||
{subject.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="grade"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Grade Level</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value} disabled={loadingGrades}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={loadingGrades ? "Loading grades..." : "Select grade level"} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{grades.map((grade) => (
|
||||
<SelectItem key={grade.id} value={grade.id}>
|
||||
{grade.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<FormField
|
||||
control={control}
|
||||
name="difficulty"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Difficulty</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select level" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">Level 1 (Easy)</SelectItem>
|
||||
<SelectItem value="2">Level 2</SelectItem>
|
||||
<SelectItem value="3">Level 3 (Medium)</SelectItem>
|
||||
<SelectItem value="4">Level 4</SelectItem>
|
||||
<SelectItem value="5">Level 5 (Hard)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="totalScore"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Total Score</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="durationMin"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Duration (min)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="scheduledAt"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Schedule Start Time (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="datetime-local" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
If set, this exam will be scheduled for a specific time.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
46
src/modules/exams/components/exam-form-types.test.ts
Normal file
46
src/modules/exams/components/exam-form-types.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { formSchema } from "./exam-form-types"
|
||||
|
||||
describe("formSchema", () => {
|
||||
it("should validate manual mode with required fields", () => {
|
||||
const result = formSchema.safeParse({
|
||||
mode: "manual",
|
||||
title: "Test Exam",
|
||||
subject: "math",
|
||||
grade: "g1",
|
||||
difficulty: "3",
|
||||
totalScore: 100,
|
||||
durationMin: 90,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("should reject manual mode without title", () => {
|
||||
const result = formSchema.safeParse({
|
||||
mode: "manual",
|
||||
title: "",
|
||||
subject: "math",
|
||||
grade: "g1",
|
||||
difficulty: "3",
|
||||
totalScore: 100,
|
||||
durationMin: 90,
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("should validate AI mode with source text", () => {
|
||||
const result = formSchema.safeParse({
|
||||
mode: "ai",
|
||||
aiSourceText: "Some exam text content here",
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("should reject AI mode without source text", () => {
|
||||
const result = formSchema.safeParse({
|
||||
mode: "ai",
|
||||
aiSourceText: "",
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
133
src/modules/exams/components/exam-form-types.ts
Normal file
133
src/modules/exams/components/exam-form-types.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import * as z from "zod"
|
||||
import type { Question } from "@/modules/questions/types"
|
||||
import type { AiProviderSummary } from "@/modules/settings/actions"
|
||||
import type { ExamNode } from "./assembly/selected-question-list"
|
||||
|
||||
export const formSchema = z.object({
|
||||
title: z.string().optional(),
|
||||
subject: z.string().optional(),
|
||||
grade: z.string().optional(),
|
||||
difficulty: z.string().optional(),
|
||||
totalScore: z.coerce.number().min(1, "Total score must be at least 1.").optional(),
|
||||
durationMin: z.coerce.number().min(10, "Duration must be at least 10 minutes.").optional(),
|
||||
scheduledAt: z.string().optional(),
|
||||
mode: z.enum(["manual", "ai"]),
|
||||
aiSourceText: z.string().optional(),
|
||||
aiQuestionCount: z.coerce.number().min(1).max(200).optional(),
|
||||
aiProviderId: z.string().optional(),
|
||||
}).superRefine((data, ctx) => {
|
||||
if (data.mode === "ai") {
|
||||
if (!data.aiSourceText?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["aiSourceText"],
|
||||
message: "Source exam text is required for AI generation.",
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!data.title?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["title"],
|
||||
message: "Title must be at least 2 characters.",
|
||||
})
|
||||
}
|
||||
if (!data.subject?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["subject"],
|
||||
message: "Subject is required.",
|
||||
})
|
||||
}
|
||||
if (!data.grade?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["grade"],
|
||||
message: "Grade is required.",
|
||||
})
|
||||
}
|
||||
if (!data.difficulty?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["difficulty"],
|
||||
message: "Difficulty is required.",
|
||||
})
|
||||
}
|
||||
if (typeof data.totalScore !== "number") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["totalScore"],
|
||||
message: "Total score must be at least 1.",
|
||||
})
|
||||
}
|
||||
if (typeof data.durationMin !== "number") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["durationMin"],
|
||||
message: "Duration must be at least 10 minutes.",
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export type ExamFormValues = z.infer<typeof formSchema>
|
||||
|
||||
export type PreviewQuestion = {
|
||||
id: string
|
||||
type: Question["type"]
|
||||
difficulty: number
|
||||
score: number
|
||||
content: Question["content"]
|
||||
}
|
||||
|
||||
export type EditableQuestionContent = {
|
||||
text: string
|
||||
options: Array<{ id: string; text: string; isCorrect: boolean }>
|
||||
subQuestions: Array<{ id: string; text: string; answer?: string; score?: number }>
|
||||
}
|
||||
|
||||
export type PreviewSnapshotMeta = {
|
||||
subject: string
|
||||
grade: string
|
||||
durationMin: number
|
||||
totalScore: number
|
||||
}
|
||||
|
||||
export type PreviewBackgroundTask = {
|
||||
id: string
|
||||
createdAt: number
|
||||
status: "queued" | "running" | "success" | "failed"
|
||||
title: string
|
||||
signature: string
|
||||
message?: string
|
||||
result?: {
|
||||
title: string
|
||||
nodes: ExamNode[]
|
||||
rawOutput: string
|
||||
meta: PreviewSnapshotMeta
|
||||
formValues: Pick<ExamFormValues, "title" | "subject" | "grade" | "difficulty" | "totalScore" | "durationMin" | "aiSourceText" | "aiQuestionCount" | "aiProviderId">
|
||||
}
|
||||
}
|
||||
|
||||
export const aiProviderLabels: Record<AiProviderSummary["provider"], string> = {
|
||||
zhipu: "智谱",
|
||||
openai: "OpenAI",
|
||||
gemini: "Gemini",
|
||||
custom: "Custom",
|
||||
}
|
||||
|
||||
export const defaultValues: Partial<ExamFormValues> = {
|
||||
title: "",
|
||||
subject: "",
|
||||
grade: "",
|
||||
difficulty: "3",
|
||||
totalScore: 100,
|
||||
durationMin: 90,
|
||||
mode: "manual",
|
||||
scheduledAt: "",
|
||||
aiSourceText: "",
|
||||
aiQuestionCount: undefined,
|
||||
aiProviderId: "",
|
||||
}
|
||||
|
||||
export const previewTaskStorageKey = "exam-preview-background-tasks:v1"
|
||||
File diff suppressed because it is too large
Load Diff
85
src/modules/exams/components/exam-mode-selector.tsx
Normal file
85
src/modules/exams/components/exam-mode-selector.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
"use client"
|
||||
|
||||
import { Loader2, Sparkles, BookOpen } from "lucide-react"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card"
|
||||
|
||||
type ExamModeSelectorProps = {
|
||||
mode: "manual" | "ai"
|
||||
setMode: (mode: "manual" | "ai") => void
|
||||
isPending: boolean
|
||||
handleCreateClick: () => void
|
||||
}
|
||||
|
||||
export function ExamModeSelector({
|
||||
mode,
|
||||
setMode,
|
||||
isPending,
|
||||
handleCreateClick,
|
||||
}: ExamModeSelectorProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Assembly Mode</CardTitle>
|
||||
<CardDescription>
|
||||
Choose how to build the exam structure.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"relative flex cursor-pointer flex-col rounded-lg border p-4 shadow-sm outline-none transition-all hover:bg-accent hover:text-accent-foreground text-left",
|
||||
mode === "manual" ? "border-primary ring-1 ring-primary" : "border-border"
|
||||
)}
|
||||
onClick={() => setMode("manual")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="h-4 w-4 text-primary" />
|
||||
<span className="font-medium">Manual Assembly</span>
|
||||
</div>
|
||||
<span className="mt-1 text-xs text-muted-foreground">
|
||||
Manually select questions from the bank and organize structure.
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"relative flex cursor-pointer flex-col rounded-lg border p-4 shadow-sm outline-none transition-all hover:bg-accent hover:text-accent-foreground text-left",
|
||||
mode === "ai" ? "border-primary ring-1 ring-primary" : "border-border"
|
||||
)}
|
||||
onClick={() => setMode("ai")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
<span className="font-medium">AI Generation</span>
|
||||
</div>
|
||||
<span className="mt-1 text-xs text-muted-foreground">
|
||||
Automatically generate a draft exam based on your input.
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button type="button" className="w-full" disabled={isPending} onClick={handleCreateClick}>
|
||||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{isPending
|
||||
? "Creating Draft..."
|
||||
: mode === "ai"
|
||||
? "后台生成试卷"
|
||||
: "Create & Start Building"}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
185
src/modules/exams/components/exam-preview-dialog.tsx
Normal file
185
src/modules/exams/components/exam-preview-dialog.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
"use client"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import type { ExamNode } from "./assembly/selected-question-list"
|
||||
import type { EditableQuestionContent, PreviewSnapshotMeta } from "./exam-form-types"
|
||||
import { ExamPreviewQuestionEditor } from "./exam-preview-question-editor"
|
||||
|
||||
type ExamPreviewDialogProps = {
|
||||
previewOpen: boolean
|
||||
setPreviewOpen: (open: boolean) => void
|
||||
previewLoading: boolean
|
||||
previewNodes: ExamNode[]
|
||||
previewTitle: string
|
||||
previewRawOutput: string
|
||||
previewMeta: PreviewSnapshotMeta | null
|
||||
selectedQuestionId: string
|
||||
setSelectedQuestionId: (id: string) => void
|
||||
rewriteInstruction: string
|
||||
setRewriteInstruction: (value: string) => void
|
||||
rewritingQuestion: boolean
|
||||
previewQuestionRows: Array<{ node: ExamNode; sectionTitle?: string }>
|
||||
selectedPreviewQuestion: ExamNode | null
|
||||
selectedPreviewContent: EditableQuestionContent | null
|
||||
activePreviewMeta: PreviewSnapshotMeta
|
||||
updatePreviewQuestionNode: (questionId: string, updater: (node: ExamNode) => ExamNode) => void
|
||||
parseEditableContent: (raw: unknown) => EditableQuestionContent
|
||||
handleRewriteSelectedQuestion: () => void
|
||||
handleConfirmCreate: () => void
|
||||
previewTitleValue?: string
|
||||
}
|
||||
|
||||
export function ExamPreviewDialog({
|
||||
previewOpen,
|
||||
setPreviewOpen,
|
||||
previewLoading,
|
||||
previewNodes,
|
||||
previewTitle,
|
||||
previewRawOutput,
|
||||
selectedQuestionId,
|
||||
setSelectedQuestionId,
|
||||
rewriteInstruction,
|
||||
setRewriteInstruction,
|
||||
rewritingQuestion,
|
||||
previewQuestionRows,
|
||||
selectedPreviewQuestion,
|
||||
selectedPreviewContent,
|
||||
activePreviewMeta,
|
||||
updatePreviewQuestionNode,
|
||||
parseEditableContent,
|
||||
handleRewriteSelectedQuestion,
|
||||
handleConfirmCreate,
|
||||
previewTitleValue,
|
||||
}: ExamPreviewDialogProps) {
|
||||
const renderSelectablePreview = (nodes: ExamNode[]) => {
|
||||
let questionCounter = 0
|
||||
const renderNode = (node: ExamNode, depth: number = 0): ReactNode => {
|
||||
if (node.type === "group") {
|
||||
return (
|
||||
<div key={node.id} className="space-y-3 mb-6">
|
||||
<h3 className={cn("font-semibold text-foreground/90", depth === 0 ? "text-base" : "text-sm")}>
|
||||
{node.title || "Section"}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{(node.children ?? []).map((child) => renderNode(child, depth + 1))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (node.type === "question" && node.question && node.questionId) {
|
||||
questionCounter += 1
|
||||
const content = parseEditableContent(node.question.content)
|
||||
const active = node.questionId === selectedQuestionId
|
||||
return (
|
||||
<button
|
||||
key={node.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedQuestionId(node.questionId ?? "")}
|
||||
className={cn(
|
||||
"w-full rounded-md border p-3 text-left transition-colors",
|
||||
active ? "border-primary bg-primary/5" : "border-border hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<span className="font-semibold text-foreground min-w-[28px]">{questionCounter}.</span>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="text-foreground/90 leading-relaxed whitespace-pre-wrap">
|
||||
{content.text || "未命名题目"}
|
||||
<span className="text-muted-foreground text-xs ml-2">({node.score ?? 0}分)</span>
|
||||
</div>
|
||||
{content.options.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
{content.options.map((opt) => (
|
||||
<div key={`${node.id}-${opt.id}`} className="text-sm text-foreground/80 flex gap-2">
|
||||
<span className="min-w-[28px]">{opt.id}.</span>
|
||||
<span className="whitespace-pre-wrap">{opt.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{content.subQuestions.length > 0 ? (
|
||||
<div className="space-y-1.5 rounded-md bg-muted/40 p-2">
|
||||
{content.subQuestions.map((item, index) => (
|
||||
<div key={`${node.id}-sub-${index}`} className="text-sm text-foreground/80 flex gap-2">
|
||||
<span className="min-w-[28px]">{item.id}.</span>
|
||||
<span className="whitespace-pre-wrap">{item.text || "未命名子题"}</span>
|
||||
{item.score ? <span className="text-xs text-muted-foreground">({item.score}分)</span> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{nodes.map((node) => renderNode(node))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
|
||||
<DialogContent className="max-w-7xl h-[90vh] flex flex-col p-0 gap-0">
|
||||
<div className="p-4 border-b shrink-0 flex items-center justify-between">
|
||||
<DialogTitle className="text-lg font-semibold tracking-tight">
|
||||
{previewTitle || previewTitleValue || "Exam Preview"}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
{previewLoading ? (
|
||||
<div className="flex-1 py-20 text-center text-muted-foreground">Generating preview...</div>
|
||||
) : previewNodes.length > 0 ? (
|
||||
<div className="flex-1 grid grid-cols-12 min-h-0">
|
||||
<div className="col-span-5 border-r min-h-0 flex flex-col">
|
||||
<div className="p-4 border-b">
|
||||
<div className="text-sm font-medium">完整试卷预览</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{previewQuestionRows.length} 题 · 科目 {activePreviewMeta.subject} · 年级 {activePreviewMeta.grade} · {activePreviewMeta.durationMin} 分钟 · 总分 {activePreviewMeta.totalScore}
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4">
|
||||
{renderSelectablePreview(previewNodes)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="col-span-7 min-h-0 flex flex-col">
|
||||
<ExamPreviewQuestionEditor
|
||||
selectedQuestion={selectedPreviewQuestion}
|
||||
selectedContent={selectedPreviewContent}
|
||||
selectedQuestionId={selectedQuestionId}
|
||||
updatePreviewQuestionNode={updatePreviewQuestionNode}
|
||||
parseEditableContent={parseEditableContent}
|
||||
rewriteInstruction={rewriteInstruction}
|
||||
setRewriteInstruction={setRewriteInstruction}
|
||||
rewritingQuestion={rewritingQuestion}
|
||||
handleRewriteSelectedQuestion={handleRewriteSelectedQuestion}
|
||||
previewRawOutput={previewRawOutput}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 py-20 text-center text-muted-foreground">No preview available</div>
|
||||
)}
|
||||
<div className="border-t p-4 flex justify-end">
|
||||
<Button type="button" disabled={previewLoading || previewNodes.length === 0} onClick={handleConfirmCreate}>
|
||||
Confirm & Create
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
173
src/modules/exams/components/exam-preview-question-editor.tsx
Normal file
173
src/modules/exams/components/exam-preview-question-editor.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
"use client"
|
||||
|
||||
import { Loader2, Wand2 } from "lucide-react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import type { ExamNode } from "./assembly/selected-question-list"
|
||||
import type { Question } from "@/modules/questions/types"
|
||||
import type { EditableQuestionContent } from "./exam-form-types"
|
||||
import { QuestionOptionsEditor } from "./question-options-editor"
|
||||
import { QuestionSubQuestionsEditor } from "./question-sub-questions-editor"
|
||||
|
||||
type ExamPreviewQuestionEditorProps = {
|
||||
selectedQuestion: ExamNode | null
|
||||
selectedContent: EditableQuestionContent | null
|
||||
selectedQuestionId: string
|
||||
updatePreviewQuestionNode: (questionId: string, updater: (node: ExamNode) => ExamNode) => void
|
||||
parseEditableContent: (raw: unknown) => EditableQuestionContent
|
||||
rewriteInstruction: string
|
||||
setRewriteInstruction: (value: string) => void
|
||||
rewritingQuestion: boolean
|
||||
handleRewriteSelectedQuestion: () => void
|
||||
previewRawOutput: string
|
||||
}
|
||||
|
||||
export function ExamPreviewQuestionEditor({
|
||||
selectedQuestion,
|
||||
selectedContent,
|
||||
selectedQuestionId,
|
||||
updatePreviewQuestionNode,
|
||||
parseEditableContent,
|
||||
rewriteInstruction,
|
||||
setRewriteInstruction,
|
||||
rewritingQuestion,
|
||||
handleRewriteSelectedQuestion,
|
||||
previewRawOutput,
|
||||
}: ExamPreviewQuestionEditorProps) {
|
||||
if (!selectedQuestion?.question || !selectedContent) {
|
||||
return <div className="flex-1 py-20 text-center text-muted-foreground">请选择左侧题目后进行编辑</div>
|
||||
}
|
||||
|
||||
const isChoiceQuestion = selectedQuestion.question.type === "single_choice" || selectedQuestion.question.type === "multiple_choice"
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div className="p-4 border-b flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium">题目编辑</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">直接修改或通过 AI 指令重写当前题目</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>题型</Label>
|
||||
<Select
|
||||
value={selectedQuestion.question.type}
|
||||
onValueChange={(value) => {
|
||||
updatePreviewQuestionNode(selectedQuestionId, (node) => {
|
||||
if (!node.question) return node
|
||||
return { ...node, question: { ...node.question, type: value as Question["type"] } }
|
||||
})
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="single_choice">single_choice</SelectItem>
|
||||
<SelectItem value="multiple_choice">multiple_choice</SelectItem>
|
||||
<SelectItem value="judgment">judgment</SelectItem>
|
||||
<SelectItem value="text">text</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>难度</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={5}
|
||||
value={selectedQuestion.question.difficulty ?? 3}
|
||||
onChange={(event) => {
|
||||
const next = Number.parseInt(event.target.value || "3", 10)
|
||||
updatePreviewQuestionNode(selectedQuestionId, (node) => {
|
||||
if (!node.question) return node
|
||||
return { ...node, question: { ...node.question, difficulty: Number.isFinite(next) ? next : 3 } }
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>分值</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={selectedQuestion.score ?? 0}
|
||||
onChange={(event) => {
|
||||
const next = Number.parseInt(event.target.value || "0", 10)
|
||||
updatePreviewQuestionNode(selectedQuestionId, (node) => ({ ...node, score: Number.isFinite(next) ? next : 0 }))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>题干</Label>
|
||||
<Textarea
|
||||
className="min-h-[140px]"
|
||||
value={selectedContent.text}
|
||||
onChange={(event) => {
|
||||
const text = event.target.value
|
||||
updatePreviewQuestionNode(selectedQuestionId, (node) => {
|
||||
if (!node.question) return node
|
||||
const current = parseEditableContent(node.question.content)
|
||||
return { ...node, question: { ...node.question, content: { ...current, text } } }
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isChoiceQuestion ? (
|
||||
<QuestionOptionsEditor
|
||||
selectedQuestionId={selectedQuestionId}
|
||||
selectedContent={selectedContent}
|
||||
questionType={selectedQuestion.question.type}
|
||||
updatePreviewQuestionNode={updatePreviewQuestionNode}
|
||||
parseEditableContent={parseEditableContent}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<QuestionSubQuestionsEditor
|
||||
selectedQuestionId={selectedQuestionId}
|
||||
selectedContent={selectedContent}
|
||||
updatePreviewQuestionNode={updatePreviewQuestionNode}
|
||||
parseEditableContent={parseEditableContent}
|
||||
/>
|
||||
|
||||
<div className="rounded-md border p-3 space-y-2">
|
||||
<Label>AI 重写指令</Label>
|
||||
<Textarea
|
||||
className="min-h-[90px]"
|
||||
placeholder="例如:把这题改成更难、增加一个干扰项、保持总分不变。"
|
||||
value={rewriteInstruction}
|
||||
onChange={(event) => setRewriteInstruction(event.target.value)}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" variant="outline" onClick={handleRewriteSelectedQuestion} disabled={rewritingQuestion}>
|
||||
{rewritingQuestion ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Wand2 className="mr-2 h-4 w-4" />}
|
||||
AI 重写当前题目
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{previewRawOutput ? (
|
||||
<div className="rounded-md border bg-muted/30 p-3">
|
||||
<div className="text-xs font-medium mb-2">模型原始输出</div>
|
||||
<pre className="whitespace-pre-wrap text-xs text-muted-foreground">{previewRawOutput}</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
205
src/modules/exams/components/exam-preview-utils.ts
Normal file
205
src/modules/exams/components/exam-preview-utils.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import type { AiPreviewData } from "../actions"
|
||||
import type { ExamNode } from "./assembly/selected-question-list"
|
||||
import type { Question } from "@/modules/questions/types"
|
||||
import type { EditableQuestionContent, ExamFormValues, PreviewSnapshotMeta } from "./exam-form-types"
|
||||
|
||||
export function buildPreviewNodes(data: AiPreviewData): ExamNode[] {
|
||||
const now = new Date()
|
||||
const toQuestionNode = (q: { id: string; type: Question["type"]; difficulty: number; score: number; content: Question["content"] }): ExamNode => ({
|
||||
id: q.id,
|
||||
type: "question",
|
||||
questionId: q.id,
|
||||
score: q.score,
|
||||
question: {
|
||||
id: q.id,
|
||||
content: q.content,
|
||||
type: q.type,
|
||||
difficulty: q.difficulty,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
author: null,
|
||||
knowledgePoints: [],
|
||||
} satisfies Question,
|
||||
})
|
||||
|
||||
if (data.sections && data.sections.length > 0) {
|
||||
return data.sections.map((section) => ({
|
||||
id: section.id || createId(),
|
||||
type: "group",
|
||||
title: section.title,
|
||||
children: section.questions.map((q) => toQuestionNode(q)),
|
||||
}))
|
||||
}
|
||||
|
||||
return (data.questions ?? []).map((q) => toQuestionNode(q))
|
||||
}
|
||||
|
||||
export function parseEditableContent(raw: unknown): EditableQuestionContent {
|
||||
const parseFromObject = (value: unknown): EditableQuestionContent => {
|
||||
if (!value || typeof value !== "object") return { text: "", options: [], subQuestions: [] }
|
||||
const record = value as { text?: unknown; options?: unknown; subQuestions?: unknown }
|
||||
const text = typeof record.text === "string" ? record.text : ""
|
||||
const options = Array.isArray(record.options)
|
||||
? record.options.map((opt, index) => {
|
||||
const item = opt && typeof opt === "object" ? opt as { id?: unknown; text?: unknown; isCorrect?: unknown } : {}
|
||||
return {
|
||||
id: typeof item.id === "string" && item.id.trim().length > 0 ? item.id : String.fromCharCode(65 + index),
|
||||
text: typeof item.text === "string" ? item.text : "",
|
||||
isCorrect: typeof item.isCorrect === "boolean" ? item.isCorrect : false,
|
||||
}
|
||||
})
|
||||
: []
|
||||
const subQuestions = Array.isArray(record.subQuestions)
|
||||
? record.subQuestions.map((item, index) => {
|
||||
const row = item && typeof item === "object"
|
||||
? item as { id?: unknown; text?: unknown; answer?: unknown; score?: unknown }
|
||||
: {}
|
||||
const rawScore = typeof row.score === "number" ? row.score : Number.parseInt(String(row.score ?? ""), 10)
|
||||
return {
|
||||
id: typeof row.id === "string" && row.id.trim().length > 0 ? row.id : String(index + 1),
|
||||
text: typeof row.text === "string" ? row.text : "",
|
||||
answer: typeof row.answer === "string" ? row.answer : "",
|
||||
score: Number.isFinite(rawScore) ? rawScore : undefined,
|
||||
}
|
||||
})
|
||||
: []
|
||||
return { text, options, subQuestions }
|
||||
}
|
||||
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
return parseFromObject(JSON.parse(raw))
|
||||
} catch {
|
||||
return { text: raw, options: [], subQuestions: [] }
|
||||
}
|
||||
}
|
||||
return parseFromObject(raw)
|
||||
}
|
||||
|
||||
export function flattenPreviewQuestions(nodes: ExamNode[]) {
|
||||
const rows: Array<{ node: ExamNode; sectionTitle?: string }> = []
|
||||
const walk = (items: ExamNode[], sectionTitle?: string) => {
|
||||
items.forEach((node) => {
|
||||
if (node.type === "question" && node.questionId && node.question) {
|
||||
rows.push({ node, sectionTitle })
|
||||
return
|
||||
}
|
||||
if (node.type === "group" && node.children) {
|
||||
walk(node.children, node.title || sectionTitle)
|
||||
}
|
||||
})
|
||||
}
|
||||
walk(nodes)
|
||||
return rows
|
||||
}
|
||||
|
||||
export function findPreviewQuestionNode(nodes: ExamNode[], questionId: string): ExamNode | null {
|
||||
for (const node of nodes) {
|
||||
if (node.type === "question" && node.questionId === questionId && node.question) {
|
||||
return node
|
||||
}
|
||||
if (node.type === "group" && node.children) {
|
||||
const found = findPreviewQuestionNode(node.children, questionId)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function updatePreviewQuestionNodeInList(questionId: string, items: ExamNode[], updater: (node: ExamNode) => ExamNode): ExamNode[] {
|
||||
return items.map((node) => {
|
||||
if (node.type === "question" && node.questionId === questionId && node.question) {
|
||||
return updater(node)
|
||||
}
|
||||
if (node.type === "group" && node.children) {
|
||||
return { ...node, children: updatePreviewQuestionNodeInList(questionId, node.children, updater) }
|
||||
}
|
||||
return node
|
||||
})
|
||||
}
|
||||
|
||||
export function buildPreviewSignature(values: ExamFormValues) {
|
||||
return JSON.stringify({
|
||||
sourceText: values.aiSourceText?.trim() || "",
|
||||
questionCount: values.aiQuestionCount ?? null,
|
||||
providerId: values.aiProviderId ?? "",
|
||||
title: values.title?.trim() || "",
|
||||
subject: values.subject?.trim() || "",
|
||||
grade: values.grade?.trim() || "",
|
||||
difficulty: values.difficulty ?? "",
|
||||
totalScore: values.totalScore ?? "",
|
||||
durationMin: values.durationMin ?? "",
|
||||
})
|
||||
}
|
||||
|
||||
export function buildPreviewPayload(nodes: ExamNode[]) {
|
||||
const questions: Array<{
|
||||
id: string
|
||||
type: Question["type"]
|
||||
difficulty: number
|
||||
score: number
|
||||
content: Question["content"]
|
||||
}> = []
|
||||
const seen = new Set<string>()
|
||||
const collect = (items: ExamNode[]) => {
|
||||
items.forEach((node) => {
|
||||
if (node.type === "question" && node.questionId && node.question) {
|
||||
if (!seen.has(node.questionId)) {
|
||||
seen.add(node.questionId)
|
||||
questions.push({
|
||||
id: node.questionId,
|
||||
type: node.question.type,
|
||||
difficulty: node.question.difficulty ?? 3,
|
||||
score: node.score ?? 0,
|
||||
content: node.question.content,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
if (node.type === "group" && node.children) collect(node.children)
|
||||
})
|
||||
}
|
||||
collect(nodes)
|
||||
|
||||
const cleanStructure = (items: ExamNode[]): Array<Omit<ExamNode, "question"> & { children?: unknown[] }> => {
|
||||
return items.map((node) => {
|
||||
const { question, ...rest } = node
|
||||
void question
|
||||
if (node.type === "group") {
|
||||
return { ...rest, children: cleanStructure(node.children || []) }
|
||||
}
|
||||
return rest
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
questions,
|
||||
structure: cleanStructure(nodes),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPreviewRequestData(values: ExamFormValues) {
|
||||
const sourceText = values.aiSourceText?.trim()
|
||||
if (!sourceText) return null
|
||||
const formData = new FormData()
|
||||
if (values.title?.trim()) formData.append("title", values.title.trim())
|
||||
if (values.subject?.trim()) formData.append("subject", values.subject.trim())
|
||||
if (values.grade?.trim()) formData.append("grade", values.grade.trim())
|
||||
const previewDifficulty = Number.parseInt(String(values.difficulty ?? "3"), 10)
|
||||
const previewTotalScore = typeof values.totalScore === "number" && values.totalScore > 0 ? values.totalScore : 100
|
||||
const previewDurationMin = typeof values.durationMin === "number" && values.durationMin > 0 ? values.durationMin : 90
|
||||
formData.append("difficulty", Number.isFinite(previewDifficulty) && previewDifficulty >= 1 && previewDifficulty <= 5 ? String(previewDifficulty) : "3")
|
||||
formData.append("totalScore", String(previewTotalScore))
|
||||
formData.append("durationMin", String(previewDurationMin))
|
||||
formData.append("aiSourceText", sourceText)
|
||||
if (values.aiQuestionCount) formData.append("aiQuestionCount", values.aiQuestionCount.toString())
|
||||
if (values.aiProviderId) formData.append("aiProviderId", values.aiProviderId)
|
||||
const meta: PreviewSnapshotMeta = {
|
||||
subject: values.subject?.trim() || "—",
|
||||
grade: values.grade?.trim() || "—",
|
||||
durationMin: previewDurationMin,
|
||||
totalScore: previewTotalScore,
|
||||
}
|
||||
return { formData, meta, signature: buildPreviewSignature(values) }
|
||||
}
|
||||
130
src/modules/exams/components/question-options-editor.tsx
Normal file
130
src/modules/exams/components/question-options-editor.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
"use client"
|
||||
|
||||
import { Plus, Trash2 } from "lucide-react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||
import type { ExamNode } from "./assembly/selected-question-list"
|
||||
import type { EditableQuestionContent } from "./exam-form-types"
|
||||
|
||||
type QuestionOptionsEditorProps = {
|
||||
selectedQuestionId: string
|
||||
selectedContent: EditableQuestionContent
|
||||
questionType: string
|
||||
updatePreviewQuestionNode: (questionId: string, updater: (node: ExamNode) => ExamNode) => void
|
||||
parseEditableContent: (raw: unknown) => EditableQuestionContent
|
||||
}
|
||||
|
||||
export function QuestionOptionsEditor({
|
||||
selectedQuestionId,
|
||||
selectedContent,
|
||||
questionType,
|
||||
updatePreviewQuestionNode,
|
||||
parseEditableContent,
|
||||
}: QuestionOptionsEditorProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>选项</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
updatePreviewQuestionNode(selectedQuestionId, (node) => {
|
||||
if (!node.question) return node
|
||||
const current = parseEditableContent(node.question.content)
|
||||
const nextId = String.fromCharCode(65 + current.options.length)
|
||||
return {
|
||||
...node,
|
||||
question: {
|
||||
...node.question,
|
||||
content: {
|
||||
...current,
|
||||
options: [...current.options, { id: nextId, text: "", isCorrect: false }],
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
新增选项
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{selectedContent.options.map((option, optionIndex) => (
|
||||
<div key={`${option.id}-${optionIndex}`} className="flex items-center gap-2 rounded-md border p-2">
|
||||
<Input
|
||||
className="w-16"
|
||||
value={option.id}
|
||||
onChange={(event) => {
|
||||
const nextId = event.target.value
|
||||
updatePreviewQuestionNode(selectedQuestionId, (node) => {
|
||||
if (!node.question) return node
|
||||
const current = parseEditableContent(node.question.content)
|
||||
const options = current.options.map((item, idx) => idx === optionIndex ? { ...item, id: nextId } : item)
|
||||
return { ...node, question: { ...node.question, content: { ...current, options } } }
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
className="flex-1"
|
||||
value={option.text}
|
||||
onChange={(event) => {
|
||||
const text = event.target.value
|
||||
updatePreviewQuestionNode(selectedQuestionId, (node) => {
|
||||
if (!node.question) return node
|
||||
const current = parseEditableContent(node.question.content)
|
||||
const options = current.options.map((item, idx) => idx === optionIndex ? { ...item, text } : item)
|
||||
return { ...node, question: { ...node.question, content: { ...current, options } } }
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-2 px-2">
|
||||
<Checkbox
|
||||
aria-label={`标记选项 ${option.id} 为正确答案`}
|
||||
checked={option.isCorrect}
|
||||
onCheckedChange={(checked) => {
|
||||
const isCorrect = Boolean(checked)
|
||||
updatePreviewQuestionNode(selectedQuestionId, (node) => {
|
||||
if (!node.question) return node
|
||||
const current = parseEditableContent(node.question.content)
|
||||
const options = current.options.map((item, idx) => {
|
||||
if (idx !== optionIndex) {
|
||||
if (questionType === "single_choice") {
|
||||
return { ...item, isCorrect: false }
|
||||
}
|
||||
return item
|
||||
}
|
||||
return { ...item, isCorrect }
|
||||
})
|
||||
return { ...node, question: { ...node.question, content: { ...current, options } } }
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">正确</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="删除选项"
|
||||
onClick={() => {
|
||||
updatePreviewQuestionNode(selectedQuestionId, (node) => {
|
||||
if (!node.question) return node
|
||||
const current = parseEditableContent(node.question.content)
|
||||
const options = current.options.filter((_, idx) => idx !== optionIndex)
|
||||
return { ...node, question: { ...node.question, content: { ...current, options } } }
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
137
src/modules/exams/components/question-sub-questions-editor.tsx
Normal file
137
src/modules/exams/components/question-sub-questions-editor.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
"use client"
|
||||
|
||||
import { Plus, Trash2 } from "lucide-react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import type { ExamNode } from "./assembly/selected-question-list"
|
||||
import type { EditableQuestionContent } from "./exam-form-types"
|
||||
|
||||
type QuestionSubQuestionsEditorProps = {
|
||||
selectedQuestionId: string
|
||||
selectedContent: EditableQuestionContent
|
||||
updatePreviewQuestionNode: (questionId: string, updater: (node: ExamNode) => ExamNode) => void
|
||||
parseEditableContent: (raw: unknown) => EditableQuestionContent
|
||||
}
|
||||
|
||||
export function QuestionSubQuestionsEditor({
|
||||
selectedQuestionId,
|
||||
selectedContent,
|
||||
updatePreviewQuestionNode,
|
||||
parseEditableContent,
|
||||
}: QuestionSubQuestionsEditorProps) {
|
||||
return (
|
||||
<div className="space-y-2 rounded-md border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>子题</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
updatePreviewQuestionNode(selectedQuestionId, (node) => {
|
||||
if (!node.question) return node
|
||||
const current = parseEditableContent(node.question.content)
|
||||
return {
|
||||
...node,
|
||||
question: {
|
||||
...node.question,
|
||||
content: {
|
||||
...current,
|
||||
subQuestions: [
|
||||
...current.subQuestions,
|
||||
{ id: String(current.subQuestions.length + 1), text: "", answer: "", score: undefined },
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
新增子题
|
||||
</Button>
|
||||
</div>
|
||||
{selectedContent.subQuestions.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{selectedContent.subQuestions.map((item, subIndex) => (
|
||||
<div key={`${item.id}-${subIndex}`} className="grid grid-cols-[64px_1fr_1fr_84px_36px] items-center gap-2 rounded-md border p-2">
|
||||
<Input
|
||||
value={item.id}
|
||||
onChange={(event) => {
|
||||
const nextId = event.target.value
|
||||
updatePreviewQuestionNode(selectedQuestionId, (node) => {
|
||||
if (!node.question) return node
|
||||
const current = parseEditableContent(node.question.content)
|
||||
const subQuestions = current.subQuestions.map((row, idx) => idx === subIndex ? { ...row, id: nextId } : row)
|
||||
return { ...node, question: { ...node.question, content: { ...current, subQuestions } } }
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
value={item.text}
|
||||
placeholder="子题内容"
|
||||
onChange={(event) => {
|
||||
const text = event.target.value
|
||||
updatePreviewQuestionNode(selectedQuestionId, (node) => {
|
||||
if (!node.question) return node
|
||||
const current = parseEditableContent(node.question.content)
|
||||
const subQuestions = current.subQuestions.map((row, idx) => idx === subIndex ? { ...row, text } : row)
|
||||
return { ...node, question: { ...node.question, content: { ...current, subQuestions } } }
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
value={item.answer ?? ""}
|
||||
placeholder="参考答案"
|
||||
onChange={(event) => {
|
||||
const answer = event.target.value
|
||||
updatePreviewQuestionNode(selectedQuestionId, (node) => {
|
||||
if (!node.question) return node
|
||||
const current = parseEditableContent(node.question.content)
|
||||
const subQuestions = current.subQuestions.map((row, idx) => idx === subIndex ? { ...row, answer } : row)
|
||||
return { ...node, question: { ...node.question, content: { ...current, subQuestions } } }
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={typeof item.score === "number" ? item.score : ""}
|
||||
placeholder="分值"
|
||||
onChange={(event) => {
|
||||
const next = Number.parseInt(event.target.value, 10)
|
||||
updatePreviewQuestionNode(selectedQuestionId, (node) => {
|
||||
if (!node.question) return node
|
||||
const current = parseEditableContent(node.question.content)
|
||||
const subQuestions = current.subQuestions.map((row, idx) => idx === subIndex
|
||||
? { ...row, score: Number.isFinite(next) ? next : undefined }
|
||||
: row)
|
||||
return { ...node, question: { ...node.question, content: { ...current, subQuestions } } }
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
updatePreviewQuestionNode(selectedQuestionId, (node) => {
|
||||
if (!node.question) return node
|
||||
const current = parseEditableContent(node.question.content)
|
||||
const subQuestions = current.subQuestions.filter((_, idx) => idx !== subIndex)
|
||||
return { ...node, question: { ...node.question, content: { ...current, subQuestions } } }
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">当前题目没有子题</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { db } from "@/shared/db"
|
||||
import { exams, examQuestions, questions, subjects, grades } from "@/shared/db/schema"
|
||||
import { eq, desc, like, and, or } from "drizzle-orm"
|
||||
import { exams, examQuestions, questions, subjects, grades, classes } from "@/shared/db/schema"
|
||||
import { eq, desc, like, and, or, inArray } from "drizzle-orm"
|
||||
import { cache } from "react"
|
||||
|
||||
import type { Exam, ExamDifficulty, ExamStatus } from "./types"
|
||||
import type { AiGeneratedQuestion, AiGeneratedStructureNode } from "./ai-pipeline"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
|
||||
export type GetExamsParams = {
|
||||
q?: string
|
||||
@@ -49,7 +50,7 @@ const toExamDifficulty = (n: number | undefined): ExamDifficulty => {
|
||||
}
|
||||
|
||||
|
||||
export const getExams = cache(async (params: GetExamsParams) => {
|
||||
export const getExams = cache(async (params: GetExamsParams & { scope: DataScope }) => {
|
||||
const conditions = []
|
||||
|
||||
if (params.q) {
|
||||
@@ -61,7 +62,28 @@ export const getExams = cache(async (params: GetExamsParams) => {
|
||||
conditions.push(eq(exams.status, params.status))
|
||||
}
|
||||
|
||||
// Note: Difficulty is stored in JSON description field in current schema,
|
||||
// Data scope filtering
|
||||
if (params.scope.type === "owned") {
|
||||
conditions.push(eq(exams.creatorId, params.scope.userId))
|
||||
}
|
||||
if (params.scope.type === "class_taught" && params.scope.classIds.length > 0) {
|
||||
// Teacher can see exams for grades their classes belong to
|
||||
const teacherGradeIds = await db
|
||||
.selectDistinct({ gradeId: classes.gradeId })
|
||||
.from(classes)
|
||||
.where(inArray(classes.id, params.scope.classIds))
|
||||
const gradeIds = teacherGradeIds.map(g => g.gradeId).filter(Boolean) as string[]
|
||||
if (gradeIds.length > 0) {
|
||||
conditions.push(inArray(exams.gradeId, gradeIds))
|
||||
}
|
||||
}
|
||||
if (params.scope.type === "grade_managed" && params.scope.gradeIds.length > 0) {
|
||||
conditions.push(inArray(exams.gradeId, params.scope.gradeIds))
|
||||
}
|
||||
// "all" type: no filtering
|
||||
// "class_members": student sees published exams for their grade (would need student's gradeId)
|
||||
|
||||
// Note: Difficulty is stored in JSON description field in current schema,
|
||||
// so we might need to filter in memory or adjust schema.
|
||||
// For now, let's fetch and filter in memory if difficulty is needed,
|
||||
// or just ignore strict DB filtering for JSON fields to keep it simple.
|
||||
@@ -104,7 +126,7 @@ export const getExams = cache(async (params: GetExamsParams) => {
|
||||
return result
|
||||
})
|
||||
|
||||
export const getExamById = cache(async (id: string) => {
|
||||
export const getExamById = cache(async (id: string, scope?: DataScope) => {
|
||||
const exam = await db.query.exams.findFirst({
|
||||
where: eq(exams.id, id),
|
||||
with: {
|
||||
@@ -121,6 +143,26 @@ export const getExamById = cache(async (id: string) => {
|
||||
|
||||
if (!exam) return null
|
||||
|
||||
// Data scope verification for single-item fetch
|
||||
if (scope && scope.type !== "all") {
|
||||
if (scope.type === "owned" && exam.creatorId !== scope.userId) {
|
||||
return null
|
||||
}
|
||||
if (scope.type === "grade_managed" && scope.gradeIds.length > 0 && !scope.gradeIds.includes(exam.gradeId ?? "")) {
|
||||
return null
|
||||
}
|
||||
if (scope.type === "class_taught" && scope.classIds.length > 0) {
|
||||
const teacherGradeIds = await db
|
||||
.selectDistinct({ gradeId: classes.gradeId })
|
||||
.from(classes)
|
||||
.where(inArray(classes.id, scope.classIds))
|
||||
const gradeIds = teacherGradeIds.map(g => g.gradeId).filter(Boolean) as string[]
|
||||
if (gradeIds.length > 0 && !gradeIds.includes(exam.gradeId ?? "")) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const meta = parseExamMeta(exam.description || null)
|
||||
|
||||
return {
|
||||
|
||||
295
src/modules/exams/hooks/use-exam-preview.ts
Normal file
295
src/modules/exams/hooks/use-exam-preview.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import type { UseFormReturn } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
import PQueue from "p-queue"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
|
||||
import { previewAiExamAction, regenerateAiQuestionAction, type AiPreviewData, type AiRewriteQuestionData } from "../actions"
|
||||
import type { ExamNode } from "../components/assembly/selected-question-list"
|
||||
import {
|
||||
type ExamFormValues,
|
||||
type PreviewSnapshotMeta,
|
||||
type PreviewBackgroundTask,
|
||||
previewTaskStorageKey,
|
||||
} from "../components/exam-form-types"
|
||||
import {
|
||||
buildPreviewNodes,
|
||||
parseEditableContent,
|
||||
flattenPreviewQuestions,
|
||||
findPreviewQuestionNode,
|
||||
updatePreviewQuestionNodeInList,
|
||||
buildPreviewSignature,
|
||||
buildPreviewPayload,
|
||||
buildPreviewRequestData,
|
||||
} from "../components/exam-preview-utils"
|
||||
|
||||
export function useExamPreview(form: UseFormReturn<ExamFormValues>) {
|
||||
const [previewOpen, setPreviewOpen] = useState(false)
|
||||
const [previewLoading, setPreviewLoading] = useState(false)
|
||||
const [previewNodes, setPreviewNodes] = useState<ExamNode[]>([])
|
||||
const [previewTitle, setPreviewTitle] = useState("")
|
||||
const [previewRawOutput, setPreviewRawOutput] = useState("")
|
||||
const [previewSignature, setPreviewSignature] = useState("")
|
||||
const [previewMeta, setPreviewMeta] = useState<PreviewSnapshotMeta | null>(null)
|
||||
const [previewTasks, setPreviewTasks] = useState<PreviewBackgroundTask[]>([])
|
||||
const [selectedQuestionId, setSelectedQuestionId] = useState<string>("")
|
||||
const [rewriteInstruction, setRewriteInstruction] = useState("")
|
||||
const [rewritingQuestion, setRewritingQuestion] = useState(false)
|
||||
|
||||
const previewQueueRef = useRef<PQueue | null>(null)
|
||||
if (!previewQueueRef.current) {
|
||||
previewQueueRef.current = new PQueue({ concurrency: 3 })
|
||||
}
|
||||
const previewQueue = previewQueueRef.current
|
||||
|
||||
const persistPreviewTasks = (tasks: PreviewBackgroundTask[]) => {
|
||||
try {
|
||||
window.localStorage.setItem(previewTaskStorageKey, JSON.stringify(tasks.slice(0, 20)))
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(previewTaskStorageKey)
|
||||
if (!raw) return
|
||||
const parsed = JSON.parse(raw) as PreviewBackgroundTask[]
|
||||
if (!Array.isArray(parsed)) return
|
||||
const restoredTasks = parsed
|
||||
.filter((task) => task && typeof task.id === "string")
|
||||
.map((task) => {
|
||||
if (task.status === "queued" || task.status === "running") {
|
||||
return {
|
||||
...task,
|
||||
status: "failed" as const,
|
||||
message: "页面刷新后任务已中断,请重新生成",
|
||||
}
|
||||
}
|
||||
return task
|
||||
})
|
||||
setPreviewTasks(restoredTasks)
|
||||
if (restoredTasks.length > 0) {
|
||||
form.setValue("mode", "ai")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
setPreviewTasks([])
|
||||
}
|
||||
}, [form])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
previewQueue.clear()
|
||||
}
|
||||
}, [previewQueue])
|
||||
|
||||
useEffect(() => {
|
||||
persistPreviewTasks(previewTasks)
|
||||
}, [previewTasks])
|
||||
|
||||
const updatePreviewQuestionNode = (questionId: string, updater: (node: ExamNode) => ExamNode) => {
|
||||
setPreviewNodes((prev) => updatePreviewQuestionNodeInList(questionId, prev, updater))
|
||||
}
|
||||
|
||||
const updateSelectedQuestionFromAi = (questionId: string, data: AiRewriteQuestionData) => {
|
||||
updatePreviewQuestionNode(questionId, (node) => {
|
||||
if (!node.question) return node
|
||||
return {
|
||||
...node,
|
||||
score: data.score,
|
||||
question: {
|
||||
...node.question,
|
||||
type: data.type,
|
||||
difficulty: data.difficulty,
|
||||
content: data.content,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const applyPreviewResult = (input: { data: AiPreviewData; signature: string; meta: PreviewSnapshotMeta }) => {
|
||||
setPreviewTitle(input.data.title)
|
||||
const nextNodes = buildPreviewNodes(input.data)
|
||||
setPreviewNodes(nextNodes)
|
||||
const firstQuestion = flattenPreviewQuestions(nextNodes)[0]
|
||||
setSelectedQuestionId(firstQuestion?.node.questionId ?? "")
|
||||
setPreviewRawOutput(input.data.rawOutput ?? "")
|
||||
setPreviewSignature(input.signature)
|
||||
setPreviewMeta(input.meta)
|
||||
setRewriteInstruction("")
|
||||
setPreviewOpen(true)
|
||||
}
|
||||
|
||||
const handlePreview = async () => {
|
||||
const values = form.getValues()
|
||||
const requestData = buildPreviewRequestData(values)
|
||||
if (!requestData) {
|
||||
toast.error("Please paste the full exam text first")
|
||||
return
|
||||
}
|
||||
|
||||
setPreviewOpen(false)
|
||||
setPreviewLoading(true)
|
||||
setPreviewNodes([])
|
||||
setPreviewRawOutput("")
|
||||
setPreviewSignature("")
|
||||
setSelectedQuestionId("")
|
||||
setRewriteInstruction("")
|
||||
try {
|
||||
const result = await previewAiExamAction(null, requestData.formData)
|
||||
if (result.success && result.data) {
|
||||
applyPreviewResult({
|
||||
data: result.data,
|
||||
signature: requestData.signature,
|
||||
meta: requestData.meta,
|
||||
})
|
||||
} else {
|
||||
toast.error(result.message || "Failed to generate preview")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to generate preview")
|
||||
} finally {
|
||||
setPreviewLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBackgroundPreview = () => {
|
||||
const values = form.getValues()
|
||||
const requestData = buildPreviewRequestData(values)
|
||||
if (!requestData) {
|
||||
toast.error("Please paste the full exam text first")
|
||||
return
|
||||
}
|
||||
const taskId = createId()
|
||||
const taskTitle = values.title?.trim() || "未命名试卷"
|
||||
setPreviewTasks((prev) => {
|
||||
const next = [{ id: taskId, createdAt: Date.now(), status: "queued" as const, title: taskTitle, signature: requestData.signature }, ...prev]
|
||||
persistPreviewTasks(next)
|
||||
return next
|
||||
})
|
||||
toast.success("已加入后台队列,可继续编辑页面")
|
||||
void previewQueue.add(async () => {
|
||||
setPreviewTasks((prev) => prev.map((task) => task.id === taskId
|
||||
? { ...task, status: "running" }
|
||||
: task))
|
||||
try {
|
||||
const result = await previewAiExamAction(null, requestData.formData)
|
||||
const data = result.data
|
||||
if (result.success && data) {
|
||||
const nextNodes = buildPreviewNodes(data)
|
||||
setPreviewTasks((prev) => prev.map((task) => task.id === taskId
|
||||
? {
|
||||
...task,
|
||||
status: "success",
|
||||
result: {
|
||||
title: data.title,
|
||||
nodes: nextNodes,
|
||||
rawOutput: data.rawOutput ?? "",
|
||||
meta: requestData.meta,
|
||||
formValues: { title: values.title, subject: values.subject, grade: values.grade, difficulty: values.difficulty, totalScore: values.totalScore, durationMin: values.durationMin, aiSourceText: values.aiSourceText, aiQuestionCount: values.aiQuestionCount, aiProviderId: values.aiProviderId },
|
||||
},
|
||||
}
|
||||
: task))
|
||||
toast.success(`后台生成完成:${taskTitle}`)
|
||||
return
|
||||
}
|
||||
setPreviewTasks((prev) => prev.map((task) => task.id === taskId
|
||||
? { ...task, status: "failed", message: result.message || "Failed to generate preview" }
|
||||
: task))
|
||||
toast.error(`后台生成失败:${taskTitle}`)
|
||||
} catch {
|
||||
setPreviewTasks((prev) => prev.map((task) => task.id === taskId
|
||||
? { ...task, status: "failed", message: "Failed to generate preview" }
|
||||
: task))
|
||||
toast.error(`后台生成失败:${taskTitle}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleOpenPreviewTask = (taskId: string) => {
|
||||
const task = previewTasks.find((item) => item.id === taskId)
|
||||
if (!task || task.status !== "success" || !task.result) return
|
||||
const tv = task.result.formValues
|
||||
const fields = ["title", "subject", "grade", "difficulty", "totalScore", "durationMin", "aiSourceText", "aiQuestionCount", "aiProviderId"] as const
|
||||
fields.forEach((key) => {
|
||||
if (typeof tv[key] !== "undefined") form.setValue(key, tv[key])
|
||||
})
|
||||
setPreviewTitle(task.result.title)
|
||||
setPreviewNodes(task.result.nodes)
|
||||
setPreviewRawOutput(task.result.rawOutput)
|
||||
setPreviewSignature(task.signature)
|
||||
setPreviewMeta(task.result.meta)
|
||||
const firstQuestion = flattenPreviewQuestions(task.result.nodes)[0]
|
||||
setSelectedQuestionId(firstQuestion?.node.questionId ?? "")
|
||||
setRewriteInstruction("")
|
||||
setPreviewOpen(true)
|
||||
}
|
||||
|
||||
const handleRewriteSelectedQuestion = async () => {
|
||||
if (!selectedQuestionId) {
|
||||
toast.error("请先选择一个题目")
|
||||
return
|
||||
}
|
||||
const selected = findPreviewQuestionNode(previewNodes, selectedQuestionId)
|
||||
if (!selected?.question) {
|
||||
toast.error("未找到选中的题目")
|
||||
return
|
||||
}
|
||||
const instruction = rewriteInstruction.trim()
|
||||
if (!instruction) {
|
||||
toast.error("请输入重写指令")
|
||||
return
|
||||
}
|
||||
setRewritingQuestion(true)
|
||||
try {
|
||||
const content = parseEditableContent(selected.question.content)
|
||||
const questionPayload = {
|
||||
type: selected.question.type,
|
||||
difficulty: selected.question.difficulty ?? 3,
|
||||
score: selected.score ?? 0,
|
||||
content: {
|
||||
text: content.text,
|
||||
options: content.options.map((opt) => ({
|
||||
id: opt.id, text: opt.text, isCorrect: opt.isCorrect,
|
||||
})),
|
||||
subQuestions: content.subQuestions.map((item) => ({
|
||||
id: item.id, text: item.text, answer: item.answer, score: item.score,
|
||||
})),
|
||||
},
|
||||
}
|
||||
const formData = new FormData()
|
||||
formData.append("instruction", instruction)
|
||||
formData.append("questionJson", JSON.stringify(questionPayload))
|
||||
const providerId = form.getValues("aiProviderId")
|
||||
const sourceText = form.getValues("aiSourceText")
|
||||
if (providerId) formData.append("aiProviderId", providerId)
|
||||
if (sourceText) formData.append("sourceText", sourceText)
|
||||
const result = await regenerateAiQuestionAction(null, formData)
|
||||
if (!result.success || !result.data) {
|
||||
toast.error(result.message || "AI 重写失败")
|
||||
return
|
||||
}
|
||||
updateSelectedQuestionFromAi(selectedQuestionId, result.data)
|
||||
setRewriteInstruction("")
|
||||
toast.success("题目已按指令重写")
|
||||
} catch {
|
||||
toast.error("AI 重写失败")
|
||||
} finally {
|
||||
setRewritingQuestion(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
previewOpen, setPreviewOpen, previewLoading, previewNodes, setPreviewNodes,
|
||||
previewTitle, previewRawOutput, previewSignature, previewMeta, previewTasks,
|
||||
selectedQuestionId, setSelectedQuestionId, rewriteInstruction, setRewriteInstruction,
|
||||
rewritingQuestion, buildPreviewNodes, parseEditableContent, flattenPreviewQuestions,
|
||||
findPreviewQuestionNode, updatePreviewQuestionNode, buildPreviewPayload,
|
||||
buildPreviewRequestData, buildPreviewSignature, handlePreview, handleBackgroundPreview,
|
||||
handleOpenPreviewTask, handleRewriteSelectedQuestion,
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { and, count, eq, inArray } from "drizzle-orm"
|
||||
import { and, count, eq } from "drizzle-orm"
|
||||
|
||||
import { auth } from "@/auth"
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
classes,
|
||||
@@ -16,51 +17,11 @@ import {
|
||||
homeworkAssignmentTargets,
|
||||
homeworkAssignments,
|
||||
homeworkSubmissions,
|
||||
roles,
|
||||
users,
|
||||
usersToRoles,
|
||||
} from "@/shared/db/schema"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
|
||||
import { CreateHomeworkAssignmentSchema, GradeHomeworkSchema } from "./schema"
|
||||
|
||||
type TeacherRole = "admin" | "teacher"
|
||||
type StudentRole = "student"
|
||||
|
||||
const getSessionUserId = async (): Promise<string | null> => {
|
||||
const session = await auth()
|
||||
const userId = String(session?.user?.id ?? "").trim()
|
||||
return userId.length > 0 ? userId : null
|
||||
}
|
||||
|
||||
async function ensureTeacher(): Promise<{ id: string; role: TeacherRole }> {
|
||||
const userId = await getSessionUserId()
|
||||
if (!userId) throw new Error("Unauthorized")
|
||||
const [row] = await db
|
||||
.select({ id: users.id, role: roles.name })
|
||||
.from(users)
|
||||
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||
.where(and(eq(users.id, userId), inArray(roles.name, ["teacher", "admin"])))
|
||||
.limit(1)
|
||||
if (!row) throw new Error("Unauthorized")
|
||||
return { id: row.id, role: row.role as TeacherRole }
|
||||
}
|
||||
|
||||
async function ensureStudent(): Promise<{ id: string; role: StudentRole }> {
|
||||
const userId = await getSessionUserId()
|
||||
if (!userId) throw new Error("Unauthorized")
|
||||
const [row] = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||
.where(and(eq(users.id, userId), eq(roles.name, "student")))
|
||||
.limit(1)
|
||||
if (!row) throw new Error("Unauthorized")
|
||||
return { id: row.id, role: "student" }
|
||||
}
|
||||
|
||||
const parseStudentIds = (raw: string): string[] => {
|
||||
return raw
|
||||
.split(/[,\n\r\t ]+/g)
|
||||
@@ -73,7 +34,7 @@ export async function createHomeworkAssignmentAction(
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
const user = await ensureTeacher()
|
||||
const ctx = await requirePermission(Permissions.HOMEWORK_CREATE)
|
||||
|
||||
const targetStudentIdsJson = formData.get("targetStudentIdsJson")
|
||||
const targetStudentIdsText = formData.get("targetStudentIdsText")
|
||||
@@ -126,11 +87,11 @@ export async function createHomeworkAssignmentAction(
|
||||
|
||||
if (!exam) return { success: false, message: "Exam not found" }
|
||||
|
||||
if (user.role !== "admin" && classRow.teacherId !== user.id) {
|
||||
if (ctx.dataScope.type !== "all" && classRow.teacherId !== ctx.userId) {
|
||||
const assignedSubjectRows = await db
|
||||
.select({ subjectId: classSubjectTeachers.subjectId })
|
||||
.from(classSubjectTeachers)
|
||||
.where(and(eq(classSubjectTeachers.classId, input.classId), eq(classSubjectTeachers.teacherId, user.id)))
|
||||
.where(and(eq(classSubjectTeachers.classId, input.classId), eq(classSubjectTeachers.teacherId, ctx.userId)))
|
||||
if (assignedSubjectRows.length === 0) {
|
||||
return { success: false, message: "Not assigned to this class" }
|
||||
}
|
||||
@@ -150,10 +111,10 @@ export async function createHomeworkAssignmentAction(
|
||||
const lateDueAt = input.lateDueAt ? new Date(input.lateDueAt) : null
|
||||
|
||||
const classScope =
|
||||
user.role === "admin"
|
||||
ctx.dataScope.type === "all"
|
||||
? eq(classes.id, input.classId)
|
||||
: classRow.teacherId === user.id
|
||||
? eq(classes.teacherId, user.id)
|
||||
: classRow.teacherId === ctx.userId
|
||||
? eq(classes.teacherId, ctx.userId)
|
||||
: eq(classes.id, input.classId)
|
||||
|
||||
const classStudentIds = (
|
||||
@@ -185,7 +146,7 @@ export async function createHomeworkAssignmentAction(
|
||||
description: input.description ?? null,
|
||||
structure: publish ? (exam.structure as unknown) : null,
|
||||
status: publish ? "published" : "draft",
|
||||
creatorId: user.id,
|
||||
creatorId: ctx.userId,
|
||||
availableAt,
|
||||
dueAt,
|
||||
allowLate: input.allowLate ?? false,
|
||||
@@ -218,8 +179,11 @@ export async function createHomeworkAssignmentAction(
|
||||
revalidatePath("/teacher/homework/submissions")
|
||||
|
||||
return { success: true, message: "Assignment created", data: assignmentId }
|
||||
} catch (error) {
|
||||
if (error instanceof Error) return { success: false, message: error.message }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
@@ -229,7 +193,7 @@ export async function startHomeworkSubmissionAction(
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
const user = await ensureStudent()
|
||||
const ctx = await requirePermission(Permissions.HOMEWORK_SUBMIT)
|
||||
const assignmentId = formData.get("assignmentId")
|
||||
if (typeof assignmentId !== "string" || assignmentId.length === 0) return { success: false, message: "Missing assignmentId" }
|
||||
|
||||
@@ -240,7 +204,7 @@ export async function startHomeworkSubmissionAction(
|
||||
if (assignment.status !== "published") return { success: false, message: "Assignment not available" }
|
||||
|
||||
const target = await db.query.homeworkAssignmentTargets.findFirst({
|
||||
where: and(eq(homeworkAssignmentTargets.assignmentId, assignmentId), eq(homeworkAssignmentTargets.studentId, user.id)),
|
||||
where: and(eq(homeworkAssignmentTargets.assignmentId, assignmentId), eq(homeworkAssignmentTargets.studentId, ctx.userId)),
|
||||
})
|
||||
if (!target) return { success: false, message: "Not assigned" }
|
||||
|
||||
@@ -249,7 +213,7 @@ export async function startHomeworkSubmissionAction(
|
||||
const [attemptRow] = await db
|
||||
.select({ c: count() })
|
||||
.from(homeworkSubmissions)
|
||||
.where(and(eq(homeworkSubmissions.assignmentId, assignmentId), eq(homeworkSubmissions.studentId, user.id)))
|
||||
.where(and(eq(homeworkSubmissions.assignmentId, assignmentId), eq(homeworkSubmissions.studentId, ctx.userId)))
|
||||
|
||||
const attemptNo = (attemptRow?.c ?? 0) + 1
|
||||
if (attemptNo > assignment.maxAttempts) return { success: false, message: "No attempts left" }
|
||||
@@ -258,7 +222,7 @@ export async function startHomeworkSubmissionAction(
|
||||
await db.insert(homeworkSubmissions).values({
|
||||
id: submissionId,
|
||||
assignmentId,
|
||||
studentId: user.id,
|
||||
studentId: ctx.userId,
|
||||
attemptNo,
|
||||
status: "started",
|
||||
startedAt: new Date(),
|
||||
@@ -267,8 +231,11 @@ export async function startHomeworkSubmissionAction(
|
||||
revalidatePath("/student/learning/assignments")
|
||||
|
||||
return { success: true, message: "Started", data: submissionId }
|
||||
} catch (error) {
|
||||
if (error instanceof Error) return { success: false, message: error.message }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
@@ -278,7 +245,7 @@ export async function saveHomeworkAnswerAction(
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
const user = await ensureStudent()
|
||||
const ctx = await requirePermission(Permissions.HOMEWORK_SUBMIT)
|
||||
const submissionId = formData.get("submissionId")
|
||||
const questionId = formData.get("questionId")
|
||||
const answerJson = formData.get("answerJson")
|
||||
@@ -290,7 +257,7 @@ export async function saveHomeworkAnswerAction(
|
||||
with: { assignment: true },
|
||||
})
|
||||
if (!submission) return { success: false, message: "Submission not found" }
|
||||
if (submission.studentId !== user.id) return { success: false, message: "Unauthorized" }
|
||||
if (submission.studentId !== ctx.userId) return { success: false, message: "Unauthorized" }
|
||||
if (submission.status !== "started") return { success: false, message: "Submission is locked" }
|
||||
|
||||
const payload = typeof answerJson === "string" && answerJson.length > 0 ? JSON.parse(answerJson) : null
|
||||
@@ -316,8 +283,11 @@ export async function saveHomeworkAnswerAction(
|
||||
})
|
||||
|
||||
return { success: true, message: "Saved", data: submissionId }
|
||||
} catch (error) {
|
||||
if (error instanceof Error) return { success: false, message: error.message }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
@@ -327,7 +297,7 @@ export async function submitHomeworkAction(
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
const user = await ensureStudent()
|
||||
const ctx = await requirePermission(Permissions.HOMEWORK_SUBMIT)
|
||||
const submissionId = formData.get("submissionId")
|
||||
if (typeof submissionId !== "string" || submissionId.length === 0) return { success: false, message: "Missing submissionId" }
|
||||
|
||||
@@ -336,7 +306,7 @@ export async function submitHomeworkAction(
|
||||
with: { assignment: true },
|
||||
})
|
||||
if (!submission) return { success: false, message: "Submission not found" }
|
||||
if (submission.studentId !== user.id) return { success: false, message: "Unauthorized" }
|
||||
if (submission.studentId !== ctx.userId) return { success: false, message: "Unauthorized" }
|
||||
if (submission.status !== "started") return { success: false, message: "Already submitted" }
|
||||
|
||||
const now = new Date()
|
||||
@@ -358,8 +328,11 @@ export async function submitHomeworkAction(
|
||||
revalidatePath("/student/learning/assignments")
|
||||
|
||||
return { success: true, message: "Submitted", data: submissionId }
|
||||
} catch (error) {
|
||||
if (error instanceof Error) return { success: false, message: error.message }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
@@ -369,7 +342,7 @@ export async function gradeHomeworkSubmissionAction(
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
await ensureTeacher()
|
||||
await requirePermission(Permissions.HOMEWORK_GRADE)
|
||||
|
||||
const rawAnswers = formData.get("answersJson") as string | null
|
||||
const parsed = GradeHomeworkSchema.safeParse({
|
||||
@@ -404,8 +377,11 @@ export async function gradeHomeworkSubmissionAction(
|
||||
revalidatePath("/teacher/homework/submissions")
|
||||
|
||||
return { success: true, message: "Grading saved" }
|
||||
} catch (error) {
|
||||
if (error instanceof Error) return { success: false, message: error.message }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import type {
|
||||
StudentRanking,
|
||||
TeacherGradeTrendItem,
|
||||
} from "./types"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
|
||||
export const getTeacherGradeTrends = cache(async (teacherId: string, limit: number = 5): Promise<TeacherGradeTrendItem[]> => {
|
||||
const recentAssignments = await db.query.homeworkAssignments.findMany({
|
||||
@@ -122,7 +123,7 @@ const getAssignmentMaxScoreById = async (assignmentIds: string[]): Promise<Map<s
|
||||
return map
|
||||
}
|
||||
|
||||
export const getHomeworkAssignments = cache(async (params?: { creatorId?: string; ids?: string[]; classId?: string }) => {
|
||||
export const getHomeworkAssignments = cache(async (params?: { creatorId?: string; ids?: string[]; classId?: string; scope?: DataScope }) => {
|
||||
const conditions = []
|
||||
|
||||
if (params?.creatorId) conditions.push(eq(homeworkAssignments.creatorId, params.creatorId))
|
||||
@@ -141,6 +142,37 @@ export const getHomeworkAssignments = cache(async (params?: { creatorId?: string
|
||||
conditions.push(inArray(homeworkAssignments.id, targetAssignmentIds))
|
||||
}
|
||||
|
||||
// Data scope filtering
|
||||
if (params?.scope) {
|
||||
if (params.scope.type === "owned") {
|
||||
conditions.push(eq(homeworkAssignments.creatorId, params.scope.userId))
|
||||
}
|
||||
if (params.scope.type === "class_taught" && params.scope.classIds.length > 0) {
|
||||
// Filter homework by assignments targeting students in teacher's classes
|
||||
const classStudentIds = db
|
||||
.select({ studentId: classEnrollments.studentId })
|
||||
.from(classEnrollments)
|
||||
.where(inArray(classEnrollments.classId, params.scope.classIds))
|
||||
|
||||
const targetAssignmentIds = db
|
||||
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
|
||||
.from(homeworkAssignmentTargets)
|
||||
.where(inArray(homeworkAssignmentTargets.studentId, classStudentIds))
|
||||
|
||||
conditions.push(inArray(homeworkAssignments.id, targetAssignmentIds))
|
||||
}
|
||||
if (params.scope.type === "grade_managed" && params.scope.gradeIds.length > 0) {
|
||||
// Homework links to exam via sourceExamId, exam has gradeId
|
||||
const gradeExamIds = db
|
||||
.select({ id: exams.id })
|
||||
.from(exams)
|
||||
.where(inArray(exams.gradeId, params.scope.gradeIds))
|
||||
|
||||
conditions.push(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
|
||||
}
|
||||
// "all" type: no filtering
|
||||
}
|
||||
|
||||
const data = await db.query.homeworkAssignments.findMany({
|
||||
where: conditions.length ? and(...conditions) : undefined,
|
||||
orderBy: [desc(homeworkAssignments.createdAt)],
|
||||
@@ -168,12 +200,42 @@ export const getHomeworkAssignments = cache(async (params?: { creatorId?: string
|
||||
})
|
||||
})
|
||||
|
||||
export const getHomeworkAssignmentReviewList = cache(async (params: { creatorId: string }) => {
|
||||
export const getHomeworkAssignmentReviewList = cache(async (params: { creatorId: string; scope?: DataScope }) => {
|
||||
const creatorId = params.creatorId.trim()
|
||||
if (!creatorId) return []
|
||||
|
||||
const conditions = [eq(homeworkAssignments.creatorId, creatorId)]
|
||||
|
||||
// Data scope filtering
|
||||
if (params.scope) {
|
||||
if (params.scope.type === "owned") {
|
||||
// Already filtered by creatorId above
|
||||
}
|
||||
if (params.scope.type === "class_taught" && params.scope.classIds.length > 0) {
|
||||
const classStudentIds = db
|
||||
.select({ studentId: classEnrollments.studentId })
|
||||
.from(classEnrollments)
|
||||
.where(inArray(classEnrollments.classId, params.scope.classIds))
|
||||
|
||||
const targetAssignmentIds = db
|
||||
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
|
||||
.from(homeworkAssignmentTargets)
|
||||
.where(inArray(homeworkAssignmentTargets.studentId, classStudentIds))
|
||||
|
||||
conditions.push(inArray(homeworkAssignments.id, targetAssignmentIds))
|
||||
}
|
||||
if (params.scope.type === "grade_managed" && params.scope.gradeIds.length > 0) {
|
||||
const gradeExamIds = db
|
||||
.select({ id: exams.id })
|
||||
.from(exams)
|
||||
.where(inArray(exams.gradeId, params.scope.gradeIds))
|
||||
|
||||
conditions.push(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
|
||||
}
|
||||
}
|
||||
|
||||
const assignments = await db.query.homeworkAssignments.findMany({
|
||||
where: eq(homeworkAssignments.creatorId, creatorId),
|
||||
where: and(...conditions),
|
||||
orderBy: [desc(homeworkAssignments.createdAt)],
|
||||
with: { sourceExam: true },
|
||||
})
|
||||
@@ -239,7 +301,7 @@ export const getHomeworkAssignmentReviewList = cache(async (params: { creatorId:
|
||||
})
|
||||
})
|
||||
|
||||
export const getHomeworkSubmissions = cache(async (params?: { assignmentId?: string; classId?: string; creatorId?: string }) => {
|
||||
export const getHomeworkSubmissions = cache(async (params?: { assignmentId?: string; classId?: string; creatorId?: string; scope?: DataScope }) => {
|
||||
const conditions = []
|
||||
if (params?.assignmentId) conditions.push(eq(homeworkSubmissions.assignmentId, params.assignmentId))
|
||||
if (params?.classId) {
|
||||
@@ -265,6 +327,39 @@ export const getHomeworkSubmissions = cache(async (params?: { assignmentId?: str
|
||||
conditions.push(inArray(homeworkSubmissions.assignmentId, creatorAssignmentIds))
|
||||
}
|
||||
|
||||
// Data scope filtering
|
||||
if (params?.scope) {
|
||||
if (params.scope.type === "owned") {
|
||||
const creatorAssignmentIds = db
|
||||
.select({ assignmentId: homeworkAssignments.id })
|
||||
.from(homeworkAssignments)
|
||||
.where(eq(homeworkAssignments.creatorId, params.scope.userId))
|
||||
|
||||
conditions.push(inArray(homeworkSubmissions.assignmentId, creatorAssignmentIds))
|
||||
}
|
||||
if (params.scope.type === "class_taught" && params.scope.classIds.length > 0) {
|
||||
const classStudentIds = db
|
||||
.select({ studentId: classEnrollments.studentId })
|
||||
.from(classEnrollments)
|
||||
.where(inArray(classEnrollments.classId, params.scope.classIds))
|
||||
|
||||
conditions.push(inArray(homeworkSubmissions.studentId, classStudentIds))
|
||||
}
|
||||
if (params.scope.type === "grade_managed" && params.scope.gradeIds.length > 0) {
|
||||
const gradeExamIds = db
|
||||
.select({ id: exams.id })
|
||||
.from(exams)
|
||||
.where(inArray(exams.gradeId, params.scope.gradeIds))
|
||||
|
||||
const gradeAssignmentIds = db
|
||||
.select({ assignmentId: homeworkAssignments.id })
|
||||
.from(homeworkAssignments)
|
||||
.where(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
|
||||
|
||||
conditions.push(inArray(homeworkSubmissions.assignmentId, gradeAssignmentIds))
|
||||
}
|
||||
}
|
||||
|
||||
const data = await db.query.homeworkSubmissions.findMany({
|
||||
where: conditions.length ? and(...conditions) : undefined,
|
||||
orderBy: [desc(homeworkSubmissions.updatedAt)],
|
||||
@@ -289,7 +384,7 @@ export const getHomeworkSubmissions = cache(async (params?: { assignmentId?: str
|
||||
})
|
||||
})
|
||||
|
||||
export const getHomeworkAssignmentById = cache(async (id: string) => {
|
||||
export const getHomeworkAssignmentById = cache(async (id: string, scope?: DataScope) => {
|
||||
const assignment = await db.query.homeworkAssignments.findFirst({
|
||||
where: eq(homeworkAssignments.id, id),
|
||||
with: {
|
||||
@@ -299,6 +394,41 @@ export const getHomeworkAssignmentById = cache(async (id: string) => {
|
||||
|
||||
if (!assignment) return null
|
||||
|
||||
// Data scope verification for single-item fetch
|
||||
if (scope && scope.type !== "all") {
|
||||
if (scope.type === "owned" && assignment.creatorId !== scope.userId) {
|
||||
return null
|
||||
}
|
||||
if (scope.type === "grade_managed" && scope.gradeIds.length > 0) {
|
||||
const gradeExamIds = await db
|
||||
.select({ id: exams.id })
|
||||
.from(exams)
|
||||
.where(inArray(exams.gradeId, scope.gradeIds))
|
||||
const examIds = gradeExamIds.map(e => e.id)
|
||||
if (!examIds.includes(assignment.sourceExamId)) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
if (scope.type === "class_taught" && scope.classIds.length > 0) {
|
||||
const classStudentIds = await db
|
||||
.select({ studentId: classEnrollments.studentId })
|
||||
.from(classEnrollments)
|
||||
.where(inArray(classEnrollments.classId, scope.classIds))
|
||||
const studentIds = classStudentIds.map(s => s.studentId)
|
||||
if (studentIds.length > 0) {
|
||||
const target = await db.query.homeworkAssignmentTargets.findFirst({
|
||||
where: and(
|
||||
eq(homeworkAssignmentTargets.assignmentId, id),
|
||||
inArray(homeworkAssignmentTargets.studentId, studentIds)
|
||||
),
|
||||
})
|
||||
if (!target) return null
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [targetsRow] = await db
|
||||
.select({ c: count() })
|
||||
.from(homeworkAssignmentTargets)
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import * as React from "react"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { useSession } from "next-auth/react"
|
||||
import { ChevronRight } from "lucide-react"
|
||||
|
||||
import {
|
||||
@@ -19,6 +18,8 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/shared/components/ui/tooltip"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { usePermission } from "@/shared/hooks"
|
||||
import { Permissions, type Permission } from "@/shared/types/permissions"
|
||||
import { useSidebar } from "./sidebar-provider"
|
||||
import { NAV_CONFIG, Role } from "../config/navigation"
|
||||
|
||||
@@ -29,10 +30,31 @@ interface AppSidebarProps {
|
||||
export function AppSidebar({ mode }: AppSidebarProps) {
|
||||
const { expanded, toggleSidebar, isMobile } = useSidebar()
|
||||
const pathname = usePathname()
|
||||
const { data } = useSession()
|
||||
const currentRole = (data?.user?.role ?? "teacher") as Role
|
||||
const { permissions, hasRole } = usePermission()
|
||||
|
||||
const navItems = NAV_CONFIG[currentRole] ?? NAV_CONFIG.teacher
|
||||
// Determine which role's nav config to use based on permissions
|
||||
let currentRole: Role = "teacher"
|
||||
if (permissions.includes(Permissions.SCHOOL_MANAGE)) {
|
||||
currentRole = "admin"
|
||||
} else if (permissions.includes(Permissions.HOMEWORK_SUBMIT) && !permissions.includes(Permissions.EXAM_CREATE)) {
|
||||
currentRole = "student"
|
||||
} else if (hasRole("parent")) {
|
||||
currentRole = "parent"
|
||||
}
|
||||
|
||||
const allNavItems = NAV_CONFIG[currentRole] ?? NAV_CONFIG.teacher
|
||||
|
||||
// Filter nav items by permission
|
||||
const navItems = allNavItems.filter((item) => {
|
||||
if (!item.permission) return true
|
||||
return permissions.includes(item.permission as Permission)
|
||||
}).map((item) => ({
|
||||
...item,
|
||||
items: item.items?.filter((subItem) => {
|
||||
if (!subItem.permission) return true
|
||||
return permissions.includes(subItem.permission as Permission)
|
||||
}),
|
||||
}))
|
||||
|
||||
// Ensure consistent state for hydration
|
||||
if (!expanded && mode === 'mobile') return null
|
||||
|
||||
@@ -15,12 +15,14 @@ import {
|
||||
Briefcase
|
||||
} from "lucide-react"
|
||||
import type { LucideIcon } from "lucide-react"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
export type NavItem = {
|
||||
title: string
|
||||
icon: LucideIcon
|
||||
href: string
|
||||
items?: { title: string; href: string }[]
|
||||
permission?: string
|
||||
items?: { title: string; href: string; permission?: string }[]
|
||||
}
|
||||
|
||||
export type Role = "admin" | "teacher" | "student" | "parent"
|
||||
@@ -31,11 +33,13 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
|
||||
title: "Dashboard",
|
||||
icon: LayoutDashboard,
|
||||
href: "/admin/dashboard",
|
||||
permission: Permissions.SCHOOL_MANAGE,
|
||||
},
|
||||
{
|
||||
title: "School Management",
|
||||
icon: Shield,
|
||||
href: "/admin/school",
|
||||
permission: Permissions.SCHOOL_MANAGE,
|
||||
items: [
|
||||
{ title: "Schools", href: "/admin/school/schools" },
|
||||
{ title: "Grades", href: "/admin/school/grades" },
|
||||
@@ -49,6 +53,7 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
|
||||
title: "Users",
|
||||
icon: Users,
|
||||
href: "/admin/users",
|
||||
permission: Permissions.USER_MANAGE,
|
||||
items: [
|
||||
{ title: "Teachers", href: "/admin/users/teachers" },
|
||||
{ title: "Students", href: "/admin/users/students" },
|
||||
@@ -79,6 +84,7 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
|
||||
title: "Settings",
|
||||
icon: Settings,
|
||||
href: "/settings",
|
||||
permission: Permissions.SETTINGS_ADMIN,
|
||||
},
|
||||
],
|
||||
teacher: [
|
||||
@@ -91,20 +97,23 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
|
||||
title: "Textbooks",
|
||||
icon: Library,
|
||||
href: "/teacher/textbooks",
|
||||
permission: Permissions.TEXTBOOK_READ,
|
||||
},
|
||||
{
|
||||
title: "Exams",
|
||||
icon: FileQuestion,
|
||||
href: "/teacher/exams",
|
||||
permission: Permissions.EXAM_CREATE,
|
||||
items: [
|
||||
{ title: "All Exams", href: "/teacher/exams/all" },
|
||||
{ title: "Create Exam", href: "/teacher/exams/create" },
|
||||
{ title: "Create Exam", href: "/teacher/exams/create", permission: Permissions.EXAM_CREATE },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Homework",
|
||||
icon: PenTool,
|
||||
href: "/teacher/homework",
|
||||
permission: Permissions.HOMEWORK_CREATE,
|
||||
items: [
|
||||
{ title: "Assignments", href: "/teacher/homework/assignments" },
|
||||
{ title: "Submissions", href: "/teacher/homework/submissions" },
|
||||
@@ -114,21 +123,24 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
|
||||
title: "Question Bank",
|
||||
icon: ClipboardList,
|
||||
href: "/teacher/questions",
|
||||
permission: Permissions.QUESTION_READ,
|
||||
},
|
||||
{
|
||||
title: "Class Management",
|
||||
icon: Users,
|
||||
href: "/teacher/classes",
|
||||
permission: Permissions.CLASS_READ,
|
||||
items: [
|
||||
{ title: "My Classes", href: "/teacher/classes/my" },
|
||||
{ title: "Students", href: "/teacher/classes/students" },
|
||||
{ title: "Schedule", href: "/teacher/classes/schedule" },
|
||||
{ title: "Schedule", href: "/teacher/classes/schedule", permission: Permissions.CLASS_SCHEDULE },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Management",
|
||||
icon: Briefcase,
|
||||
href: "/management",
|
||||
permission: Permissions.GRADE_MANAGE,
|
||||
items: [
|
||||
{ title: "Grade Insights", href: "/management/grade/insights" },
|
||||
]
|
||||
@@ -144,16 +156,18 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
|
||||
title: "My Learning",
|
||||
icon: BookOpen,
|
||||
href: "/student/learning",
|
||||
permission: Permissions.HOMEWORK_SUBMIT,
|
||||
items: [
|
||||
{ title: "Courses", href: "/student/learning/courses" },
|
||||
{ title: "Assignments", href: "/student/learning/assignments" },
|
||||
{ title: "Textbooks", href: "/student/learning/textbooks" },
|
||||
{ title: "Assignments", href: "/student/learning/assignments", permission: Permissions.HOMEWORK_SUBMIT },
|
||||
{ title: "Textbooks", href: "/student/learning/textbooks", permission: Permissions.TEXTBOOK_READ },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Schedule",
|
||||
icon: Calendar,
|
||||
href: "/student/schedule",
|
||||
permission: Permissions.CLASS_SCHEDULE,
|
||||
},
|
||||
],
|
||||
parent: [
|
||||
|
||||
@@ -1,52 +1,18 @@
|
||||
"use server";
|
||||
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard";
|
||||
import { Permissions } from "@/shared/types/permissions";
|
||||
import { db } from "@/shared/db";
|
||||
import { chapters, knowledgePoints, questions, questionsToKnowledgePoints, textbooks, roles, users, usersToRoles } from "@/shared/db/schema";
|
||||
import { chapters, knowledgePoints, questions, questionsToKnowledgePoints, textbooks } from "@/shared/db/schema";
|
||||
import { CreateQuestionSchema } from "./schema";
|
||||
import type { CreateQuestionInput } from "./schema";
|
||||
import { ActionState } from "@/shared/types/action-state";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { getQuestions, type GetQuestionsParams } from "./data-access";
|
||||
import type { KnowledgePointOption } from "./types";
|
||||
import { auth } from "@/auth";
|
||||
|
||||
const getSessionUserId = async (): Promise<string | null> => {
|
||||
const session = await auth();
|
||||
const userId = String(session?.user?.id ?? "").trim();
|
||||
return userId.length > 0 ? userId : null;
|
||||
};
|
||||
|
||||
async function ensureTeacher() {
|
||||
const userId = await getSessionUserId();
|
||||
if (!userId) {
|
||||
const [fallback] = await db
|
||||
.select({ id: users.id, role: roles.name })
|
||||
.from(users)
|
||||
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||
.where(inArray(roles.name, ["teacher", "admin"]))
|
||||
.orderBy(asc(users.createdAt))
|
||||
.limit(1);
|
||||
if (!fallback) {
|
||||
throw new Error("Unauthorized: Only teachers can perform this action.");
|
||||
}
|
||||
return { id: fallback.id, role: fallback.role as "teacher" | "admin" };
|
||||
}
|
||||
const [row] = await db
|
||||
.select({ id: users.id, role: roles.name })
|
||||
.from(users)
|
||||
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||
.where(and(eq(users.id, userId), inArray(roles.name, ["teacher", "admin"])))
|
||||
.limit(1);
|
||||
if (!row) {
|
||||
throw new Error("Unauthorized: Only teachers can perform this action.");
|
||||
}
|
||||
return { id: row.id, role: row.role as "teacher" | "admin" };
|
||||
}
|
||||
|
||||
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0]
|
||||
|
||||
@@ -90,10 +56,10 @@ export async function createNestedQuestion(
|
||||
formData: FormData | CreateQuestionInput
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
const user = await ensureTeacher();
|
||||
const ctx = await requirePermission(Permissions.QUESTION_CREATE);
|
||||
|
||||
let rawInput: unknown = formData;
|
||||
|
||||
|
||||
if (formData instanceof FormData) {
|
||||
const jsonString = formData.get("json");
|
||||
if (typeof jsonString === "string") {
|
||||
@@ -116,7 +82,7 @@ export async function createNestedQuestion(
|
||||
const input = validatedFields.data;
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await insertQuestionWithRelations(tx, input, user.id, null);
|
||||
await insertQuestionWithRelations(tx, input, ctx.userId, null);
|
||||
});
|
||||
|
||||
revalidatePath("/teacher/questions");
|
||||
@@ -126,11 +92,14 @@ export async function createNestedQuestion(
|
||||
message: "Question created successfully",
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
if (e instanceof Error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || "Database error occurred",
|
||||
message: e.message || "Database error occurred",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -154,8 +123,8 @@ export async function updateQuestionAction(
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
const user = await ensureTeacher();
|
||||
const canEditAll = user.role === "admin";
|
||||
const ctx = await requirePermission(Permissions.QUESTION_UPDATE);
|
||||
const canEditAll = ctx.dataScope.type === "all";
|
||||
|
||||
const jsonString = formData.get("json");
|
||||
if (typeof jsonString !== "string") {
|
||||
@@ -182,7 +151,7 @@ export async function updateQuestionAction(
|
||||
content: input.content,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(canEditAll ? eq(questions.id, input.id) : and(eq(questions.id, input.id), eq(questions.authorId, user.id)));
|
||||
.where(canEditAll ? eq(questions.id, input.id) : and(eq(questions.id, input.id), eq(questions.authorId, ctx.userId)));
|
||||
|
||||
await tx
|
||||
.delete(questionsToKnowledgePoints)
|
||||
@@ -201,9 +170,12 @@ export async function updateQuestionAction(
|
||||
revalidatePath("/teacher/questions");
|
||||
|
||||
return { success: true, message: "Question updated successfully" };
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return { success: false, message: error.message };
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
if (e instanceof Error) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
return { success: false, message: "An unexpected error occurred" };
|
||||
}
|
||||
@@ -227,8 +199,8 @@ export async function deleteQuestionAction(
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
const user = await ensureTeacher();
|
||||
const canDeleteAll = user.role === "admin";
|
||||
const ctx = await requirePermission(Permissions.QUESTION_DELETE);
|
||||
const canDeleteAll = ctx.dataScope.type === "all";
|
||||
|
||||
const questionId = formData.get("questionId");
|
||||
if (typeof questionId !== "string") {
|
||||
@@ -244,7 +216,7 @@ export async function deleteQuestionAction(
|
||||
throw new Error("Question not found");
|
||||
}
|
||||
|
||||
if (!canDeleteAll && q.authorId !== user.id) {
|
||||
if (!canDeleteAll && q.authorId !== ctx.userId) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
@@ -254,21 +226,32 @@ export async function deleteQuestionAction(
|
||||
revalidatePath("/teacher/questions");
|
||||
|
||||
return { success: true, message: "Question deleted successfully" };
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return { success: false, message: error.message };
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
if (e instanceof Error) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
return { success: false, message: "Failed to delete question" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getQuestionsAction(params: GetQuestionsParams) {
|
||||
await ensureTeacher();
|
||||
return await getQuestions(params);
|
||||
try {
|
||||
await requirePermission(Permissions.QUESTION_READ);
|
||||
return await getQuestions(params);
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
throw e;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getKnowledgePointOptionsAction(): Promise<KnowledgePointOption[]> {
|
||||
await ensureTeacher();
|
||||
try {
|
||||
await requirePermission(Permissions.QUESTION_READ);
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
@@ -302,4 +285,10 @@ export async function getKnowledgePointOptionsAction(): Promise<KnowledgePointOp
|
||||
subject: row.subject ?? null,
|
||||
grade: row.grade ?? null,
|
||||
}));
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
throw e;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard";
|
||||
import { Permissions } from "@/shared/types/permissions";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import {
|
||||
createTextbook,
|
||||
@@ -24,10 +26,14 @@ export async function reorderChaptersAction(
|
||||
textbookId: string
|
||||
): Promise<ActionState> {
|
||||
try {
|
||||
await requirePermission(Permissions.TEXTBOOK_UPDATE);
|
||||
await reorderChapters(chapterId, newIndex, parentId);
|
||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||
return { success: true, message: "Chapters reordered successfully" };
|
||||
} catch {
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
return { success: false, message: "Failed to reorder chapters" };
|
||||
}
|
||||
}
|
||||
@@ -60,13 +66,17 @@ export async function createTextbookAction(
|
||||
}
|
||||
|
||||
try {
|
||||
await requirePermission(Permissions.TEXTBOOK_CREATE);
|
||||
await createTextbook(rawData);
|
||||
revalidatePath("/teacher/textbooks");
|
||||
return {
|
||||
success: true,
|
||||
message: "Textbook created successfully.",
|
||||
};
|
||||
} catch {
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to create textbook.",
|
||||
@@ -95,13 +105,17 @@ export async function updateTextbookAction(
|
||||
}
|
||||
|
||||
try {
|
||||
await requirePermission(Permissions.TEXTBOOK_UPDATE);
|
||||
await updateTextbook(rawData);
|
||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||
return {
|
||||
success: true,
|
||||
message: "Textbook updated successfully.",
|
||||
};
|
||||
} catch {
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to update textbook.",
|
||||
@@ -113,13 +127,17 @@ export async function deleteTextbookAction(
|
||||
textbookId: string
|
||||
): Promise<ActionState> {
|
||||
try {
|
||||
await requirePermission(Permissions.TEXTBOOK_DELETE);
|
||||
await deleteTextbook(textbookId);
|
||||
revalidatePath("/teacher/textbooks");
|
||||
return {
|
||||
success: true,
|
||||
message: "Textbook deleted successfully.",
|
||||
};
|
||||
} catch {
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to delete textbook.",
|
||||
@@ -138,6 +156,7 @@ export async function createChapterAction(
|
||||
if (!title) return { success: false, message: "Title is required" };
|
||||
|
||||
try {
|
||||
await requirePermission(Permissions.TEXTBOOK_CREATE);
|
||||
await createChapter({
|
||||
textbookId,
|
||||
title,
|
||||
@@ -146,7 +165,10 @@ export async function createChapterAction(
|
||||
});
|
||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||
return { success: true, message: "Chapter created successfully" };
|
||||
} catch {
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
return { success: false, message: "Failed to create chapter" };
|
||||
}
|
||||
}
|
||||
@@ -157,10 +179,14 @@ export async function updateChapterContentAction(
|
||||
textbookId: string
|
||||
): Promise<ActionState> {
|
||||
try {
|
||||
await requirePermission(Permissions.TEXTBOOK_UPDATE);
|
||||
await updateChapterContent({ chapterId, content });
|
||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||
return { success: true, message: "Content updated successfully" };
|
||||
} catch {
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
return { success: false, message: "Failed to update content" };
|
||||
}
|
||||
}
|
||||
@@ -170,10 +196,14 @@ export async function deleteChapterAction(
|
||||
textbookId: string
|
||||
): Promise<ActionState> {
|
||||
try {
|
||||
await requirePermission(Permissions.TEXTBOOK_DELETE);
|
||||
await deleteChapter(chapterId);
|
||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||
return { success: true, message: "Chapter deleted successfully" };
|
||||
} catch {
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
return { success: false, message: "Failed to delete chapter" };
|
||||
}
|
||||
}
|
||||
@@ -191,10 +221,14 @@ export async function createKnowledgePointAction(
|
||||
if (!name) return { success: false, message: "Name is required" };
|
||||
|
||||
try {
|
||||
await requirePermission(Permissions.TEXTBOOK_CREATE);
|
||||
await createKnowledgePoint({ name, description, anchorText, chapterId });
|
||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||
return { success: true, message: "Knowledge point created successfully" };
|
||||
} catch {
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
return { success: false, message: "Failed to create knowledge point" };
|
||||
}
|
||||
}
|
||||
@@ -204,10 +238,14 @@ export async function deleteKnowledgePointAction(
|
||||
textbookId: string
|
||||
): Promise<ActionState> {
|
||||
try {
|
||||
await requirePermission(Permissions.TEXTBOOK_DELETE);
|
||||
await deleteKnowledgePoint(kpId);
|
||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||
return { success: true, message: "Knowledge point deleted successfully" };
|
||||
} catch {
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
return { success: false, message: "Failed to delete knowledge point" };
|
||||
}
|
||||
}
|
||||
@@ -225,10 +263,14 @@ export async function updateKnowledgePointAction(
|
||||
if (!name) return { success: false, message: "Name is required" };
|
||||
|
||||
try {
|
||||
await requirePermission(Permissions.TEXTBOOK_UPDATE);
|
||||
await updateKnowledgePoint({ id: kpId, name, description, anchorText });
|
||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||
return { success: true, message: "Knowledge point updated successfully" };
|
||||
} catch {
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
return { success: false, message: "Failed to update knowledge point" };
|
||||
}
|
||||
}
|
||||
|
||||
181
src/modules/textbooks/components/knowledge-graph.tsx
Normal file
181
src/modules/textbooks/components/knowledge-graph.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
|
||||
import type { KnowledgePoint } from "../types"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
|
||||
interface GraphNode extends KnowledgePoint {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
interface GraphEdge {
|
||||
id: string
|
||||
x1: number
|
||||
y1: number
|
||||
x2: number
|
||||
y2: number
|
||||
}
|
||||
|
||||
interface GraphLayout {
|
||||
nodes: GraphNode[]
|
||||
edges: GraphEdge[]
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
function computeGraphLayout(knowledgePoints: KnowledgePoint[]): GraphLayout {
|
||||
if (knowledgePoints.length === 0) {
|
||||
return { nodes: [], edges: [], width: 0, height: 0 }
|
||||
}
|
||||
|
||||
const byId = new Map<string, KnowledgePoint>()
|
||||
for (const kp of knowledgePoints) byId.set(kp.id, kp)
|
||||
|
||||
const children = new Map<string, string[]>()
|
||||
const roots: string[] = []
|
||||
|
||||
for (const kp of knowledgePoints) {
|
||||
if (kp.parentId && byId.has(kp.parentId)) {
|
||||
const arr = children.get(kp.parentId) ?? []
|
||||
arr.push(kp.id)
|
||||
children.set(kp.parentId, arr)
|
||||
} else {
|
||||
roots.push(kp.id)
|
||||
}
|
||||
}
|
||||
|
||||
const levelMap = new Map<string, number>()
|
||||
const levels: string[][] = []
|
||||
const queue = [...roots].map((id) => ({ id, level: 0 }))
|
||||
|
||||
if (queue.length === 0) {
|
||||
for (const kp of knowledgePoints) queue.push({ id: kp.id, level: 0 })
|
||||
}
|
||||
|
||||
while (queue.length > 0) {
|
||||
const item = queue.shift()
|
||||
if (!item) continue
|
||||
if (levelMap.has(item.id)) continue
|
||||
levelMap.set(item.id, item.level)
|
||||
if (!levels[item.level]) levels[item.level] = []
|
||||
levels[item.level].push(item.id)
|
||||
const kids = children.get(item.id) ?? []
|
||||
for (const kid of kids) {
|
||||
if (!levelMap.has(kid)) queue.push({ id: kid, level: item.level + 1 })
|
||||
}
|
||||
}
|
||||
|
||||
for (const kp of knowledgePoints) {
|
||||
if (!levelMap.has(kp.id)) {
|
||||
const level = levels.length
|
||||
levelMap.set(kp.id, level)
|
||||
if (!levels[level]) levels[level] = []
|
||||
levels[level].push(kp.id)
|
||||
}
|
||||
}
|
||||
|
||||
const nodeWidth = 160
|
||||
const nodeHeight = 52
|
||||
const gapX = 40
|
||||
const gapY = 90
|
||||
const maxCount = Math.max(...levels.map((l) => l.length), 1)
|
||||
const width = maxCount * (nodeWidth + gapX) + gapX
|
||||
const height = levels.length * (nodeHeight + gapY) + gapY
|
||||
|
||||
const positions = new Map<string, { x: number; y: number }>()
|
||||
levels.forEach((ids, level) => {
|
||||
ids.forEach((id, index) => {
|
||||
const x = gapX + index * (nodeWidth + gapX)
|
||||
const y = gapY + level * (nodeHeight + gapY)
|
||||
positions.set(id, { x, y })
|
||||
})
|
||||
})
|
||||
|
||||
const nodes = knowledgePoints.map((kp) => {
|
||||
const pos = positions.get(kp.id) ?? { x: gapX, y: gapY }
|
||||
return { ...kp, x: pos.x, y: pos.y }
|
||||
})
|
||||
|
||||
const edges = knowledgePoints
|
||||
.filter((kp) => kp.parentId && positions.has(kp.parentId))
|
||||
.map((kp) => {
|
||||
const parentPos = positions.get(kp.parentId as string)!
|
||||
const childPos = positions.get(kp.id)!
|
||||
return {
|
||||
id: `${kp.parentId}-${kp.id}`,
|
||||
x1: parentPos.x + nodeWidth / 2,
|
||||
y1: parentPos.y + nodeHeight,
|
||||
x2: childPos.x + nodeWidth / 2,
|
||||
y2: childPos.y,
|
||||
}
|
||||
})
|
||||
|
||||
return { nodes, edges, width, height }
|
||||
}
|
||||
|
||||
interface KnowledgeGraphProps {
|
||||
knowledgePoints: KnowledgePoint[]
|
||||
selectedId: string | null
|
||||
onHighlight: (id: string) => void
|
||||
}
|
||||
|
||||
export function KnowledgeGraph({ knowledgePoints, selectedId, onHighlight }: KnowledgeGraphProps) {
|
||||
const graphLayout = useMemo(() => computeGraphLayout(knowledgePoints), [knowledgePoints])
|
||||
|
||||
if (knowledgePoints.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
|
||||
该章节暂无知识点。
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="flex-1 h-full px-2">
|
||||
<div
|
||||
className="relative"
|
||||
style={{ width: graphLayout.width, height: graphLayout.height }}
|
||||
>
|
||||
<svg
|
||||
width={graphLayout.width}
|
||||
height={graphLayout.height}
|
||||
className="absolute inset-0"
|
||||
>
|
||||
{graphLayout.edges.map((edge) => (
|
||||
<line
|
||||
key={edge.id}
|
||||
x1={edge.x1}
|
||||
y1={edge.y1}
|
||||
x2={edge.x2}
|
||||
y2={edge.y2}
|
||||
stroke="hsl(var(--border))"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
{graphLayout.nodes.map((node) => (
|
||||
<button
|
||||
key={node.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"absolute rounded-lg border bg-card px-3 py-2 text-left text-sm shadow-sm hover:bg-accent/50",
|
||||
selectedId === node.id && "border-primary bg-primary/5"
|
||||
)}
|
||||
style={{ left: node.x, top: node.y, width: 160, height: 52 }}
|
||||
onClick={() => onHighlight(node.id)}
|
||||
>
|
||||
<div className="font-medium truncate">{node.name}</div>
|
||||
{node.description && (
|
||||
<div className="text-[10px] text-muted-foreground truncate">
|
||||
{node.description}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
148
src/modules/textbooks/components/knowledge-point-dialogs.tsx
Normal file
148
src/modules/textbooks/components/knowledge-point-dialogs.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
"use client"
|
||||
|
||||
import type { KnowledgePoint } from "../types"
|
||||
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 { Textarea } from "@/shared/components/ui/textarea"
|
||||
import { CreateQuestionDialog } from "@/modules/questions/components/create-question-dialog"
|
||||
|
||||
interface KnowledgePointDialogsProps {
|
||||
// Create KP dialog
|
||||
createDialogOpen: boolean
|
||||
setCreateDialogOpen: (open: boolean) => void
|
||||
selectedText: string
|
||||
isCreating: boolean
|
||||
onCreateKnowledgePoint: (formData: FormData) => Promise<boolean | void>
|
||||
|
||||
// Edit KP dialog
|
||||
editKpDialogOpen: boolean
|
||||
setEditKpDialogOpen: (open: boolean) => void
|
||||
editingKp: KnowledgePoint | null
|
||||
isUpdatingKp: boolean
|
||||
onUpdateKnowledgePoint: (formData: FormData) => Promise<void>
|
||||
|
||||
// Question dialog
|
||||
questionDialogOpen: boolean
|
||||
setQuestionDialogOpen: (open: boolean) => void
|
||||
targetKpForQuestion: KnowledgePoint | null
|
||||
}
|
||||
|
||||
export function KnowledgePointDialogs({
|
||||
createDialogOpen,
|
||||
setCreateDialogOpen,
|
||||
selectedText,
|
||||
isCreating,
|
||||
onCreateKnowledgePoint,
|
||||
editKpDialogOpen,
|
||||
setEditKpDialogOpen,
|
||||
editingKp,
|
||||
isUpdatingKp,
|
||||
onUpdateKnowledgePoint,
|
||||
questionDialogOpen,
|
||||
setQuestionDialogOpen,
|
||||
targetKpForQuestion,
|
||||
}: KnowledgePointDialogsProps) {
|
||||
return (
|
||||
<>
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加知识点</DialogTitle>
|
||||
<DialogDescription>
|
||||
从选中的文本创建知识点。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form action={onCreateKnowledgePoint as (formData: FormData) => void}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">名称</Label>
|
||||
<Input id="name" name="name" defaultValue={selectedText} required />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">描述(可选)</Label>
|
||||
<Textarea id="description" name="description" placeholder="请输入描述..." />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setCreateDialogOpen(false)} disabled={isCreating}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={isCreating}>
|
||||
{isCreating ? "创建中..." : "创建"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={editKpDialogOpen} onOpenChange={setEditKpDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑知识点</DialogTitle>
|
||||
<DialogDescription>
|
||||
修改知识点的名称和描述。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form action={onUpdateKnowledgePoint}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-name">显示名称</Label>
|
||||
<Input id="edit-name" name="name" defaultValue={editingKp?.name} required />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-description">描述(可选)</Label>
|
||||
<Textarea id="edit-description" name="description" defaultValue={editingKp?.description || ""} placeholder="请输入描述..." />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 border rounded-md p-3 bg-muted/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="edit-anchorText" className="text-muted-foreground text-xs flex items-center gap-1">
|
||||
高级:关联文本 (影响文中高亮)
|
||||
</Label>
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<Input
|
||||
key={editingKp?.id}
|
||||
id="edit-anchorText"
|
||||
name="anchorText"
|
||||
defaultValue={editingKp?.anchorText || editingKp?.name}
|
||||
className="text-sm font-mono"
|
||||
required
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
修改此字段会改变文中被高亮匹配的文字。通常保持与原文一致。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setEditKpDialogOpen(false)} disabled={isUpdatingKp}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={isUpdatingKp}>
|
||||
{isUpdatingKp ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<CreateQuestionDialog
|
||||
open={questionDialogOpen}
|
||||
onOpenChange={setQuestionDialogOpen}
|
||||
defaultKnowledgePointIds={targetKpForQuestion ? [targetKpForQuestion.id] : []}
|
||||
defaultContent={targetKpForQuestion ? `Please explain the knowledge point: ${targetKpForQuestion.name}` : ""}
|
||||
defaultType="text"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
107
src/modules/textbooks/components/knowledge-point-list.tsx
Normal file
107
src/modules/textbooks/components/knowledge-point-list.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client"
|
||||
|
||||
import { PlusCircle, Pencil, Trash2 } from "lucide-react"
|
||||
|
||||
import type { KnowledgePoint } from "../types"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
|
||||
interface KnowledgePointListProps {
|
||||
knowledgePoints: KnowledgePoint[]
|
||||
canEdit: boolean
|
||||
highlightedKpId: string | null
|
||||
onHighlight: (id: string) => void
|
||||
onEdit: (kp: KnowledgePoint) => void
|
||||
onDelete: (kpId: string, e: React.MouseEvent) => void
|
||||
onCreateQuestion: (kp: KnowledgePoint) => void
|
||||
}
|
||||
|
||||
export function KnowledgePointList({
|
||||
knowledgePoints,
|
||||
canEdit,
|
||||
highlightedKpId,
|
||||
onHighlight,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onCreateQuestion,
|
||||
}: KnowledgePointListProps) {
|
||||
if (knowledgePoints.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
|
||||
该章节暂无知识点。
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="flex-1 h-full px-2">
|
||||
<div className="space-y-2 pb-4">
|
||||
{knowledgePoints.map((kp) => (
|
||||
<button
|
||||
key={kp.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full text-left p-3 rounded-lg border bg-card hover:bg-accent/50 transition-colors cursor-pointer",
|
||||
highlightedKpId === kp.id && "border-primary bg-primary/5"
|
||||
)}
|
||||
onClick={() => onHighlight(kp.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h4 className="text-sm font-medium leading-none">{kp.name}</h4>
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge variant="outline" className="text-[10px] h-5 px-1">Lv.{kp.level}</Badge>
|
||||
{canEdit && (
|
||||
<div className="flex items-center gap-1 ml-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onCreateQuestion(kp)
|
||||
}}
|
||||
title="创建相关题目"
|
||||
aria-label="创建相关题目"
|
||||
>
|
||||
<PlusCircle className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEdit(kp)
|
||||
}}
|
||||
title="编辑知识点"
|
||||
aria-label="编辑知识点"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 text-muted-foreground hover:text-destructive"
|
||||
onClick={(e) => onDelete(kp.id, e)}
|
||||
title="删除知识点"
|
||||
aria-label="删除知识点"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{kp.description && (
|
||||
<p className="mt-2 text-xs text-muted-foreground line-clamp-2">
|
||||
{kp.description}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
170
src/modules/textbooks/components/textbook-content-panel.tsx
Normal file
170
src/modules/textbooks/components/textbook-content-panel.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
"use client"
|
||||
|
||||
import ReactMarkdown from "react-markdown"
|
||||
import remarkBreaks from "remark-breaks"
|
||||
import remarkGfm from "remark-gfm"
|
||||
import rehypeSanitize from "rehype-sanitize"
|
||||
import { Edit2, Save, Plus } from "lucide-react"
|
||||
|
||||
import type { Chapter, KnowledgePoint } from "../types"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "@/shared/components/ui/context-menu"
|
||||
import { RichTextEditor } from "@/shared/components/ui/rich-text-editor"
|
||||
|
||||
interface TextbookContentPanelProps {
|
||||
selected: Chapter | null
|
||||
isEditing: boolean
|
||||
editContent: string
|
||||
setEditContent: (content: string) => void
|
||||
canEdit: boolean
|
||||
knowledgePoints: KnowledgePoint[]
|
||||
highlightedKpId: string | null
|
||||
onHighlight: (id: string) => void
|
||||
onSwitchToKnowledgeTab: () => void
|
||||
contentRef: React.RefObject<HTMLDivElement | null>
|
||||
onPointerDown: (e: React.PointerEvent) => void
|
||||
onContextMenuChange: (open: boolean) => void
|
||||
selectedText: string
|
||||
createDialogOpen: boolean
|
||||
setCreateDialogOpen: (open: boolean) => void
|
||||
isCreating: boolean
|
||||
onCreateKnowledgePoint: (formData: FormData) => Promise<boolean | void>
|
||||
startEditing: () => void
|
||||
cancelEditing: () => void
|
||||
saveContent: () => void
|
||||
isSaving: boolean
|
||||
processedContent: string
|
||||
}
|
||||
|
||||
export function TextbookContentPanel({
|
||||
selected,
|
||||
isEditing,
|
||||
editContent,
|
||||
setEditContent,
|
||||
canEdit,
|
||||
highlightedKpId,
|
||||
onHighlight,
|
||||
onSwitchToKnowledgeTab,
|
||||
contentRef,
|
||||
onPointerDown,
|
||||
onContextMenuChange,
|
||||
selectedText,
|
||||
setCreateDialogOpen,
|
||||
startEditing,
|
||||
cancelEditing,
|
||||
saveContent,
|
||||
isSaving,
|
||||
processedContent,
|
||||
}: TextbookContentPanelProps) {
|
||||
if (!selected) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground px-2">
|
||||
请选择一个章节开始阅读。
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-4 pb-2 border-b px-2 shrink-0">
|
||||
<h2 className="text-xl font-bold tracking-tight line-clamp-1">{selected.title}</h2>
|
||||
{canEdit && (
|
||||
<div className="flex gap-2">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button size="sm" variant="ghost" onClick={cancelEditing} disabled={isSaving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button size="sm" onClick={saveContent} disabled={isSaving}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{isSaving ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button size="sm" variant="outline" onClick={startEditing}>
|
||||
<Edit2 className="mr-2 h-4 w-4" />
|
||||
编辑内容
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ScrollArea className="flex-1 min-h-0 px-2">
|
||||
{isEditing ? (
|
||||
<div className="h-full">
|
||||
<RichTextEditor
|
||||
value={editContent}
|
||||
onChange={setEditContent}
|
||||
className="min-h-[500px] border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ContextMenu onOpenChange={onContextMenuChange}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className="p-4 min-h-full"
|
||||
ref={contentRef}
|
||||
onPointerDown={onPointerDown}
|
||||
>
|
||||
{selected.content ? (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
rehypePlugins={[rehypeSanitize]}
|
||||
components={{
|
||||
a: ({ href, children, ...props }) => {
|
||||
if (href?.startsWith("#kp-")) {
|
||||
const id = href.replace("#kp-", "")
|
||||
const isHighlighted = highlightedKpId === id
|
||||
return (
|
||||
<span
|
||||
data-kp-id={id}
|
||||
className={cn(
|
||||
"font-medium text-primary cursor-pointer hover:underline decoration-dashed underline-offset-4 transition-all duration-300",
|
||||
isHighlighted && "bg-yellow-300 dark:bg-yellow-600 text-black dark:text-white rounded px-1 py-0.5 shadow-sm scale-110 inline-block mx-0.5 font-bold ring-2 ring-yellow-400/50 dark:ring-yellow-500/50"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
onHighlight(id)
|
||||
onSwitchToKnowledgeTab()
|
||||
}}
|
||||
title="点击查看知识点详情"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return <a href={href} {...props}>{children}</a>
|
||||
}
|
||||
}}
|
||||
>
|
||||
{processedContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground italic py-8 text-center">暂无内容</div>
|
||||
)}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
disabled={!selectedText}
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
添加知识点
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,42 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState, useEffect, useRef } from "react"
|
||||
import ReactMarkdown from "react-markdown"
|
||||
import remarkBreaks from "remark-breaks"
|
||||
import remarkGfm from "remark-gfm"
|
||||
import { useMemo, useState, useEffect } from "react"
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
import { Tag, List, Plus, Edit2, Save, Trash2, Pencil, PlusCircle, Share2 } from "lucide-react"
|
||||
import { Tag, List, Share2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import type { Chapter, KnowledgePoint } from "../types"
|
||||
import { createKnowledgePointAction, updateChapterContentAction, deleteKnowledgePointAction, updateKnowledgePointAction } from "../actions"
|
||||
import { CreateQuestionDialog } from "@/modules/questions/components/create-question-dialog"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { updateChapterContentAction } from "../actions"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
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 { Textarea } from "@/shared/components/ui/textarea"
|
||||
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "@/shared/components/ui/context-menu"
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/components/ui/alert-dialog"
|
||||
|
||||
import { ChapterSidebarList } from "./chapter-sidebar-list"
|
||||
import { RichTextEditor } from "@/shared/components/ui/rich-text-editor"
|
||||
import { KnowledgeGraph } from "./knowledge-graph"
|
||||
import { KnowledgePointList } from "./knowledge-point-list"
|
||||
import { TextbookContentPanel } from "./textbook-content-panel"
|
||||
import { KnowledgePointDialogs } from "./knowledge-point-dialogs"
|
||||
import { useTextSelection } from "../hooks/use-text-selection"
|
||||
import { useKnowledgePointActions } from "../hooks/use-knowledge-point-actions"
|
||||
|
||||
function buildChapterIndex(chapters: Chapter[]) {
|
||||
const index = new Map<string, Chapter>()
|
||||
@@ -52,109 +43,81 @@ function buildChapterIndex(chapters: Chapter[]) {
|
||||
return index
|
||||
}
|
||||
|
||||
export function TextbookReader({ chapters, knowledgePoints = [], canEdit = false, textbookId }: { chapters: Chapter[]; knowledgePoints?: KnowledgePoint[]; canEdit?: boolean; textbookId?: string }) {
|
||||
export function TextbookReader({
|
||||
chapters,
|
||||
knowledgePoints = [],
|
||||
canEdit = false,
|
||||
textbookId,
|
||||
}: {
|
||||
chapters: Chapter[]
|
||||
knowledgePoints?: KnowledgePoint[]
|
||||
canEdit?: boolean
|
||||
textbookId?: string
|
||||
}) {
|
||||
const [chapterId, setChapterId] = useQueryState("chapterId", parseAsString.withDefault(""))
|
||||
const [activeTab, setActiveTab] = useState("chapters")
|
||||
const [highlightedKpId, setHighlightedKpId] = useState<string | null>(null)
|
||||
|
||||
// Selection & Creation State
|
||||
const [selectedText, setSelectedText] = useState("")
|
||||
const selectionRef = useRef("") // Store selection temporarily to avoid re-renders on pointer down
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Editing State
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editContent, setEditContent] = useState("")
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
// Knowledge Point Edit State
|
||||
const [editingKp, setEditingKp] = useState<KnowledgePoint | null>(null)
|
||||
const [editKpDialogOpen, setEditKpDialogOpen] = useState(false)
|
||||
const [isUpdatingKp, setIsUpdatingKp] = useState(false)
|
||||
|
||||
// Question Creation State
|
||||
const [questionDialogOpen, setQuestionDialogOpen] = useState(false)
|
||||
const [targetKpForQuestion, setTargetKpForQuestion] = useState<KnowledgePoint | null>(null)
|
||||
const {
|
||||
selectedText,
|
||||
setSelectedText,
|
||||
contentRef,
|
||||
createDialogOpen,
|
||||
setCreateDialogOpen,
|
||||
isCreating,
|
||||
setIsCreating,
|
||||
handleContentPointerDown,
|
||||
handleContextMenuChange,
|
||||
} = useTextSelection()
|
||||
|
||||
const index = useMemo(() => buildChapterIndex(chapters), [chapters])
|
||||
const selected = chapterId ? index.get(chapterId) ?? null : null
|
||||
const selectedId = selected?.id ?? null
|
||||
|
||||
const handleSelect = (chapter: Chapter) => {
|
||||
setChapterId(chapter.id)
|
||||
setIsEditing(false)
|
||||
}
|
||||
const currentChapterKPs = useMemo(() => {
|
||||
if (!selectedId) return []
|
||||
return knowledgePoints.filter((kp) => kp.chapterId === selectedId)
|
||||
}, [knowledgePoints, selectedId])
|
||||
|
||||
// Handle Text Selection via Context Menu
|
||||
// We capture selection on PointerDown (Right Click) to ensure we get the state before any context menu logic runs.
|
||||
// Using onContextMenu directly caused conflicts with Radix UI's ContextMenuTrigger in some cases.
|
||||
const handleContentPointerDown = (e: React.PointerEvent) => {
|
||||
// Only capture on right click (button 2)
|
||||
if (e.button !== 2) return
|
||||
const {
|
||||
editingKp,
|
||||
setEditingKp,
|
||||
editKpDialogOpen,
|
||||
setEditKpDialogOpen,
|
||||
isUpdatingKp,
|
||||
questionDialogOpen,
|
||||
setQuestionDialogOpen,
|
||||
targetKpForQuestion,
|
||||
setTargetKpForQuestion,
|
||||
deleteConfirmOpen,
|
||||
setDeleteConfirmOpen,
|
||||
handleCreateKnowledgePoint,
|
||||
requestDeleteKnowledgePoint,
|
||||
confirmDeleteKnowledgePoint,
|
||||
handleUpdateKnowledgePoint,
|
||||
} = useKnowledgePointActions(
|
||||
textbookId,
|
||||
selectedId,
|
||||
selected?.textbookId,
|
||||
highlightedKpId,
|
||||
setHighlightedKpId,
|
||||
() => {
|
||||
setCreateDialogOpen(false)
|
||||
setActiveTab("knowledge")
|
||||
setSelectedText("")
|
||||
},
|
||||
)
|
||||
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.isCollapsed) {
|
||||
selectionRef.current = ""
|
||||
return
|
||||
}
|
||||
|
||||
// Check if selection is within content area
|
||||
if (contentRef.current && contentRef.current.contains(selection.anchorNode)) {
|
||||
// Store in ref, don't trigger re-render yet
|
||||
selectionRef.current = selection.toString().trim()
|
||||
} else {
|
||||
selectionRef.current = ""
|
||||
}
|
||||
}
|
||||
const [localContent, setLocalContent] = useState<string | null>(null)
|
||||
|
||||
const handleContextMenuChange = (open: boolean) => {
|
||||
if (!open) return
|
||||
|
||||
// When menu opens, sync ref to state to update UI
|
||||
if (selectionRef.current) {
|
||||
setSelectedText(selectionRef.current)
|
||||
} else {
|
||||
// Fallback: If pointer down didn't capture (e.g. keyboard), try now
|
||||
const selection = window.getSelection()
|
||||
if (selection && !selection.isCollapsed && contentRef.current && contentRef.current.contains(selection.anchorNode)) {
|
||||
const text = selection.toString().trim()
|
||||
selectionRef.current = text
|
||||
setSelectedText(text)
|
||||
} else {
|
||||
setSelectedText("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateKnowledgePoint = async (formData: FormData) => {
|
||||
if (!selectedId || !selected) return
|
||||
const onCreateKnowledgePoint = async (formData: FormData) => {
|
||||
setIsCreating(true)
|
||||
|
||||
try {
|
||||
const result = await createKnowledgePointAction(
|
||||
selectedId,
|
||||
selected.textbookId,
|
||||
null,
|
||||
formData
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
toast.success("知识点已创建")
|
||||
setCreateDialogOpen(false)
|
||||
setActiveTab("knowledge")
|
||||
// Clear selection
|
||||
window.getSelection()?.removeAllRanges()
|
||||
setSelectedText("")
|
||||
} else {
|
||||
toast.error(result.message || "创建知识点失败")
|
||||
}
|
||||
} catch {
|
||||
toast.error("发生错误")
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
await handleCreateKnowledgePoint(formData)
|
||||
setIsCreating(false)
|
||||
}
|
||||
|
||||
const handleSaveContent = async () => {
|
||||
@@ -162,23 +125,11 @@ export function TextbookReader({ chapters, knowledgePoints = [], canEdit = false
|
||||
setIsSaving(true)
|
||||
const result = await updateChapterContentAction(selectedId, editContent, textbookId)
|
||||
setIsSaving(false)
|
||||
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
setIsEditing(false)
|
||||
// Optimistic update might be tricky here without full reload, but let's assume parent revalidates or we rely on router refresh
|
||||
// For now, we manually update the local state if needed, but since we use `chapters` prop which comes from server,
|
||||
// we ideally want to trigger a refresh.
|
||||
// However, for this component, we can just let the user see the new content if we render `editContent` or rely on props update.
|
||||
// But `chapters` prop won't update automatically unless we router.refresh().
|
||||
// Let's rely on the fact that `selected` comes from `chapters` which might be stale until refresh.
|
||||
// A full solution would use `router.refresh()`.
|
||||
// For now, we can update the `selected.content` in place? No, it's a prop.
|
||||
// We will rely on router refresh in the parent or just simple UI feedback.
|
||||
// Actually, let's trigger a router refresh if possible, but we don't have router here.
|
||||
// We'll just exit edit mode. The content might look old until refresh.
|
||||
// To fix this, we can locally override content.
|
||||
if (selected) selected.content = editContent
|
||||
setLocalContent(editContent)
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
@@ -191,180 +142,33 @@ export function TextbookReader({ chapters, knowledgePoints = [], canEdit = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteKnowledgePoint = async (kpId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!confirm("确定要删除这个知识点吗?")) return
|
||||
|
||||
if (!textbookId) return
|
||||
|
||||
try {
|
||||
const result = await deleteKnowledgePointAction(kpId, textbookId)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
if (highlightedKpId === kpId) {
|
||||
setHighlightedKpId(null)
|
||||
}
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
} catch {
|
||||
toast.error("删除失败")
|
||||
}
|
||||
const handleSelect = (chapter: Chapter) => {
|
||||
setChapterId(chapter.id)
|
||||
setIsEditing(false)
|
||||
setLocalContent(null)
|
||||
}
|
||||
|
||||
const handleUpdateKnowledgePoint = async (formData: FormData) => {
|
||||
if (!editingKp || !textbookId) return
|
||||
setIsUpdatingKp(true)
|
||||
|
||||
try {
|
||||
const result = await updateKnowledgePointAction(editingKp.id, textbookId, null, formData)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
setEditKpDialogOpen(false)
|
||||
setEditingKp(null)
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
} catch {
|
||||
toast.error("更新失败")
|
||||
} finally {
|
||||
setIsUpdatingKp(false)
|
||||
}
|
||||
}
|
||||
const effectiveContent = localContent ?? selected?.content
|
||||
|
||||
// Filter KPs for the current chapter
|
||||
const currentChapterKPs = useMemo(() => {
|
||||
if (!selectedId) return []
|
||||
return knowledgePoints.filter(kp => kp.chapterId === selectedId)
|
||||
}, [knowledgePoints, selectedId])
|
||||
|
||||
const graphLayout = useMemo(() => {
|
||||
if (currentChapterKPs.length === 0) {
|
||||
return { nodes: [], edges: [], width: 0, height: 0 }
|
||||
}
|
||||
|
||||
const byId = new Map<string, KnowledgePoint>()
|
||||
for (const kp of currentChapterKPs) byId.set(kp.id, kp)
|
||||
|
||||
const children = new Map<string, string[]>()
|
||||
const roots: string[] = []
|
||||
|
||||
for (const kp of currentChapterKPs) {
|
||||
if (kp.parentId && byId.has(kp.parentId)) {
|
||||
const arr = children.get(kp.parentId) ?? []
|
||||
arr.push(kp.id)
|
||||
children.set(kp.parentId, arr)
|
||||
} else {
|
||||
roots.push(kp.id)
|
||||
}
|
||||
}
|
||||
|
||||
const levelMap = new Map<string, number>()
|
||||
const levels: string[][] = []
|
||||
const queue = [...roots].map((id) => ({ id, level: 0 }))
|
||||
|
||||
if (queue.length === 0) {
|
||||
for (const kp of currentChapterKPs) queue.push({ id: kp.id, level: 0 })
|
||||
}
|
||||
|
||||
while (queue.length > 0) {
|
||||
const item = queue.shift()
|
||||
if (!item) continue
|
||||
if (levelMap.has(item.id)) continue
|
||||
levelMap.set(item.id, item.level)
|
||||
if (!levels[item.level]) levels[item.level] = []
|
||||
levels[item.level].push(item.id)
|
||||
const kids = children.get(item.id) ?? []
|
||||
for (const kid of kids) {
|
||||
if (!levelMap.has(kid)) queue.push({ id: kid, level: item.level + 1 })
|
||||
}
|
||||
}
|
||||
|
||||
for (const kp of currentChapterKPs) {
|
||||
if (!levelMap.has(kp.id)) {
|
||||
const level = levels.length
|
||||
levelMap.set(kp.id, level)
|
||||
if (!levels[level]) levels[level] = []
|
||||
levels[level].push(kp.id)
|
||||
}
|
||||
}
|
||||
|
||||
const nodeWidth = 160
|
||||
const nodeHeight = 52
|
||||
const gapX = 40
|
||||
const gapY = 90
|
||||
const maxCount = Math.max(...levels.map((l) => l.length), 1)
|
||||
const width = maxCount * (nodeWidth + gapX) + gapX
|
||||
const height = levels.length * (nodeHeight + gapY) + gapY
|
||||
|
||||
const positions = new Map<string, { x: number; y: number }>()
|
||||
levels.forEach((ids, level) => {
|
||||
ids.forEach((id, index) => {
|
||||
const x = gapX + index * (nodeWidth + gapX)
|
||||
const y = gapY + level * (nodeHeight + gapY)
|
||||
positions.set(id, { x, y })
|
||||
})
|
||||
})
|
||||
|
||||
const nodes = currentChapterKPs.map((kp) => {
|
||||
const pos = positions.get(kp.id) ?? { x: gapX, y: gapY }
|
||||
return { ...kp, x: pos.x, y: pos.y }
|
||||
})
|
||||
|
||||
const edges = currentChapterKPs
|
||||
.filter((kp) => kp.parentId && positions.has(kp.parentId))
|
||||
.map((kp) => {
|
||||
const parentPos = positions.get(kp.parentId as string)!
|
||||
const childPos = positions.get(kp.id)!
|
||||
return {
|
||||
id: `${kp.parentId}-${kp.id}`,
|
||||
x1: parentPos.x + nodeWidth / 2,
|
||||
y1: parentPos.y + nodeHeight,
|
||||
x2: childPos.x + nodeWidth / 2,
|
||||
y2: childPos.y,
|
||||
}
|
||||
})
|
||||
|
||||
return { nodes, edges, width, height }
|
||||
}, [currentChapterKPs])
|
||||
|
||||
// Pre-process content to mark knowledge points
|
||||
const processedContent = useMemo(() => {
|
||||
if (!selected?.content) return ""
|
||||
let content = selected.content
|
||||
|
||||
// Sort KPs by name length descending to handle overlapping names
|
||||
if (!effectiveContent) return ""
|
||||
let content = effectiveContent
|
||||
const sortedKPs = [...currentChapterKPs].sort((a, b) => b.name.length - a.name.length)
|
||||
|
||||
// We use a temporary replacement strategy to avoid nested replacements
|
||||
// This is simple but works for most cases
|
||||
// We replace "Name" with "[Name](kp://id)"
|
||||
|
||||
for (const kp of sortedKPs) {
|
||||
// Escape regex special characters
|
||||
const escapedName = kp.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
// Case insensitive match, but preserve original text casing
|
||||
// We use a simplified lookahead to avoid replacing inside existing links if possible,
|
||||
// but perfect markdown parsing is hard with regex.
|
||||
// For now, we assume KPs don't overlap in a way that breaks things often.
|
||||
const regex = new RegExp(`(${escapedName})`, 'gi')
|
||||
|
||||
// We only replace if not already part of a link (simplified check)
|
||||
// A robust parser would be better, but regex is acceptable for this level
|
||||
content = content.replace(regex, `[$1](#kp-${kp.id})`)
|
||||
}
|
||||
|
||||
return content
|
||||
}, [selected?.content, currentChapterKPs])
|
||||
|
||||
// Scroll to highlighted KP
|
||||
for (const kp of sortedKPs) {
|
||||
const escapedName = kp.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
const regex = new RegExp(`(${escapedName})`, "gi")
|
||||
content = content.replace(regex, `[$1](#kp-${kp.id})`)
|
||||
}
|
||||
|
||||
return content
|
||||
}, [effectiveContent, currentChapterKPs])
|
||||
|
||||
useEffect(() => {
|
||||
if (highlightedKpId) {
|
||||
// Find first element by data attribute
|
||||
const el = document.querySelector(`[data-kp-id="${highlightedKpId}"]`)
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" })
|
||||
// Add temporary highlight effect
|
||||
el.classList.add("ring-2", "ring-primary", "ring-offset-2")
|
||||
setTimeout(() => {
|
||||
el.classList.remove("ring-2", "ring-primary", "ring-offset-2")
|
||||
@@ -387,7 +191,9 @@ export function TextbookReader({ chapters, knowledgePoints = [], canEdit = false
|
||||
<Tag className="h-4 w-4" />
|
||||
知识点
|
||||
{currentChapterKPs.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1 px-1 py-0 h-5 text-[10px]">{currentChapterKPs.length}</Badge>
|
||||
<Badge variant="secondary" className="ml-1 px-1 py-0 h-5 text-[10px]">
|
||||
{currentChapterKPs.length}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="graph" className="gap-2" disabled={!selectedId}>
|
||||
@@ -396,97 +202,43 @@ export function TextbookReader({ chapters, knowledgePoints = [], canEdit = false
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
|
||||
<TabsContent value="chapters" className="flex-1 min-h-0 mt-0">
|
||||
<ScrollArea className="flex-1 h-full px-2">
|
||||
<div className="space-y-1 pb-4">
|
||||
<ChapterSidebarList
|
||||
chapters={chapters}
|
||||
selectedChapterId={selectedId || undefined}
|
||||
onSelectChapter={handleSelect}
|
||||
textbookId={textbookId || ""}
|
||||
canEdit={canEdit}
|
||||
<ChapterSidebarList
|
||||
chapters={chapters}
|
||||
selectedChapterId={selectedId || undefined}
|
||||
onSelectChapter={handleSelect}
|
||||
textbookId={textbookId || ""}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="knowledge" className="flex-1 min-h-0 mt-0">
|
||||
{!selectedId ? (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
|
||||
请选择一个章节查看知识点。
|
||||
</div>
|
||||
) : currentChapterKPs.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
|
||||
该章节暂无知识点。
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="flex-1 h-full px-2">
|
||||
<div className="space-y-2 pb-4">
|
||||
{currentChapterKPs.map((kp) => (
|
||||
<div
|
||||
key={kp.id}
|
||||
className={cn(
|
||||
"p-3 rounded-lg border bg-card hover:bg-accent/50 transition-colors cursor-pointer",
|
||||
highlightedKpId === kp.id && "border-primary bg-primary/5"
|
||||
)}
|
||||
onClick={() => setHighlightedKpId(kp.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h4 className="text-sm font-medium leading-none">{kp.name}</h4>
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge variant="outline" className="text-[10px] h-5 px-1">Lv.{kp.level}</Badge>
|
||||
{canEdit && (
|
||||
<div className="flex items-center gap-1 ml-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setTargetKpForQuestion(kp)
|
||||
setQuestionDialogOpen(true)
|
||||
}}
|
||||
title="创建相关题目"
|
||||
>
|
||||
<PlusCircle className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setEditingKp(kp)
|
||||
setEditKpDialogOpen(true)
|
||||
}}
|
||||
title="编辑知识点"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 text-muted-foreground hover:text-destructive"
|
||||
onClick={(e) => handleDeleteKnowledgePoint(kp.id, e)}
|
||||
title="删除知识点"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{kp.description && (
|
||||
<p className="mt-2 text-xs text-muted-foreground line-clamp-2">
|
||||
{kp.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
{!selectedId ? (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
|
||||
请选择一个章节查看知识点。
|
||||
</div>
|
||||
) : (
|
||||
<KnowledgePointList
|
||||
knowledgePoints={currentChapterKPs}
|
||||
canEdit={canEdit}
|
||||
highlightedKpId={highlightedKpId}
|
||||
onHighlight={setHighlightedKpId}
|
||||
onEdit={(kp) => {
|
||||
setEditingKp(kp)
|
||||
setEditKpDialogOpen(true)
|
||||
}}
|
||||
onDelete={requestDeleteKnowledgePoint}
|
||||
onCreateQuestion={(kp) => {
|
||||
setTargetKpForQuestion(kp)
|
||||
setQuestionDialogOpen(true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="graph" className="flex-1 min-h-0 mt-0">
|
||||
@@ -494,250 +246,73 @@ export function TextbookReader({ chapters, knowledgePoints = [], canEdit = false
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
|
||||
请选择一个章节查看知识图谱。
|
||||
</div>
|
||||
) : currentChapterKPs.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
|
||||
该章节暂无知识点。
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="flex-1 h-full px-2">
|
||||
<div
|
||||
className="relative"
|
||||
style={{ width: graphLayout.width, height: graphLayout.height }}
|
||||
>
|
||||
<svg
|
||||
width={graphLayout.width}
|
||||
height={graphLayout.height}
|
||||
className="absolute inset-0"
|
||||
>
|
||||
{graphLayout.edges.map((edge) => (
|
||||
<line
|
||||
key={edge.id}
|
||||
x1={edge.x1}
|
||||
y1={edge.y1}
|
||||
x2={edge.x2}
|
||||
y2={edge.y2}
|
||||
stroke="hsl(var(--border))"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
{graphLayout.nodes.map((node) => (
|
||||
<button
|
||||
key={node.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"absolute rounded-lg border bg-card px-3 py-2 text-left text-sm shadow-sm hover:bg-accent/50",
|
||||
highlightedKpId === node.id && "border-primary bg-primary/5"
|
||||
)}
|
||||
style={{ left: node.x, top: node.y, width: 160, height: 52 }}
|
||||
onClick={() => setHighlightedKpId(node.id)}
|
||||
>
|
||||
<div className="font-medium truncate">{node.name}</div>
|
||||
{node.description && (
|
||||
<div className="text-[10px] text-muted-foreground truncate">
|
||||
{node.description}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<KnowledgeGraph
|
||||
knowledgePoints={currentChapterKPs}
|
||||
selectedId={highlightedKpId}
|
||||
onHighlight={setHighlightedKpId}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-8 flex flex-col min-h-0 relative">
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加知识点</DialogTitle>
|
||||
<DialogDescription>
|
||||
从选中的文本创建知识点。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form action={handleCreateKnowledgePoint}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">名称</Label>
|
||||
<Input id="name" name="name" defaultValue={selectedText} required />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">描述(可选)</Label>
|
||||
<Textarea id="description" name="description" placeholder="请输入描述..." />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setCreateDialogOpen(false)} disabled={isCreating}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={isCreating}>
|
||||
{isCreating ? "创建中..." : "创建"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除这个知识点吗?此操作无法撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmDeleteKnowledgePoint}>删除</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<Dialog open={editKpDialogOpen} onOpenChange={setEditKpDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑知识点</DialogTitle>
|
||||
<DialogDescription>
|
||||
修改知识点的名称和描述。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form action={handleUpdateKnowledgePoint}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-name">显示名称</Label>
|
||||
<Input id="edit-name" name="name" defaultValue={editingKp?.name} required />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-description">描述(可选)</Label>
|
||||
<Textarea id="edit-description" name="description" defaultValue={editingKp?.description || ""} placeholder="请输入描述..." />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 border rounded-md p-3 bg-muted/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="edit-anchorText" className="text-muted-foreground text-xs flex items-center gap-1">
|
||||
高级:关联文本 (影响文中高亮)
|
||||
</Label>
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<Input
|
||||
key={editingKp?.id} // Force re-render when kp changes
|
||||
id="edit-anchorText"
|
||||
name="anchorText"
|
||||
defaultValue={editingKp?.anchorText || editingKp?.name}
|
||||
className="text-sm font-mono"
|
||||
required
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
修改此字段会改变文中被高亮匹配的文字。通常保持与原文一致。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setEditKpDialogOpen(false)} disabled={isUpdatingKp}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={isUpdatingKp}>
|
||||
{isUpdatingKp ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<CreateQuestionDialog
|
||||
open={questionDialogOpen}
|
||||
onOpenChange={setQuestionDialogOpen}
|
||||
defaultKnowledgePointIds={targetKpForQuestion ? [targetKpForQuestion.id] : []}
|
||||
defaultContent={targetKpForQuestion ? `Please explain the knowledge point: ${targetKpForQuestion.name}` : ""}
|
||||
defaultType="text"
|
||||
<KnowledgePointDialogs
|
||||
createDialogOpen={createDialogOpen}
|
||||
setCreateDialogOpen={setCreateDialogOpen}
|
||||
selectedText={selectedText}
|
||||
isCreating={isCreating}
|
||||
onCreateKnowledgePoint={onCreateKnowledgePoint}
|
||||
editKpDialogOpen={editKpDialogOpen}
|
||||
setEditKpDialogOpen={setEditKpDialogOpen}
|
||||
editingKp={editingKp}
|
||||
isUpdatingKp={isUpdatingKp}
|
||||
onUpdateKnowledgePoint={handleUpdateKnowledgePoint}
|
||||
questionDialogOpen={questionDialogOpen}
|
||||
setQuestionDialogOpen={setQuestionDialogOpen}
|
||||
targetKpForQuestion={targetKpForQuestion}
|
||||
/>
|
||||
|
||||
{selected ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-4 pb-2 border-b px-2 shrink-0">
|
||||
<h2 className="text-xl font-bold tracking-tight line-clamp-1">{selected.title}</h2>
|
||||
{canEdit && (
|
||||
<div className="flex gap-2">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button size="sm" variant="ghost" onClick={() => setIsEditing(false)} disabled={isSaving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSaveContent} disabled={isSaving}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{isSaving ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button size="sm" variant="outline" onClick={startEditing}>
|
||||
<Edit2 className="mr-2 h-4 w-4" />
|
||||
编辑内容
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ScrollArea className="flex-1 min-h-0 px-2">
|
||||
{isEditing ? (
|
||||
<div className="h-full">
|
||||
<RichTextEditor
|
||||
value={editContent}
|
||||
onChange={setEditContent}
|
||||
className="min-h-[500px] border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ContextMenu onOpenChange={handleContextMenuChange}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className="p-4 min-h-full"
|
||||
ref={contentRef}
|
||||
onPointerDown={handleContentPointerDown}
|
||||
>
|
||||
{selected.content ? (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
components={{
|
||||
a: ({ href, children, ...props }) => {
|
||||
if (href?.startsWith("#kp-")) {
|
||||
const id = href.replace("#kp-", "")
|
||||
const isHighlighted = highlightedKpId === id
|
||||
return (
|
||||
<span
|
||||
data-kp-id={id}
|
||||
className={cn(
|
||||
"font-medium text-primary cursor-pointer hover:underline decoration-dashed underline-offset-4 transition-all duration-300",
|
||||
isHighlighted && "bg-yellow-300 dark:bg-yellow-600 text-black dark:text-white rounded px-1 py-0.5 shadow-sm scale-110 inline-block mx-0.5 font-bold ring-2 ring-yellow-400/50 dark:ring-yellow-500/50"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setHighlightedKpId(id)
|
||||
setActiveTab("knowledge")
|
||||
}}
|
||||
title="点击查看知识点详情"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return <a href={href} {...props}>{children}</a>
|
||||
}
|
||||
}}
|
||||
>
|
||||
{processedContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground italic py-8 text-center">暂无内容</div>
|
||||
)}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
disabled={!selectedText}
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
添加知识点
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground px-2">
|
||||
请选择一个章节开始阅读。
|
||||
</div>
|
||||
)}
|
||||
<TextbookContentPanel
|
||||
selected={selected}
|
||||
isEditing={isEditing}
|
||||
editContent={editContent}
|
||||
setEditContent={setEditContent}
|
||||
canEdit={canEdit}
|
||||
knowledgePoints={currentChapterKPs}
|
||||
highlightedKpId={highlightedKpId}
|
||||
onHighlight={setHighlightedKpId}
|
||||
onSwitchToKnowledgeTab={() => setActiveTab("knowledge")}
|
||||
contentRef={contentRef}
|
||||
onPointerDown={handleContentPointerDown}
|
||||
onContextMenuChange={handleContextMenuChange}
|
||||
selectedText={selectedText}
|
||||
createDialogOpen={createDialogOpen}
|
||||
setCreateDialogOpen={setCreateDialogOpen}
|
||||
isCreating={isCreating}
|
||||
onCreateKnowledgePoint={onCreateKnowledgePoint}
|
||||
startEditing={startEditing}
|
||||
cancelEditing={() => setIsEditing(false)}
|
||||
saveContent={handleSaveContent}
|
||||
isSaving={isSaving}
|
||||
processedContent={processedContent}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
121
src/modules/textbooks/hooks/use-knowledge-point-actions.ts
Normal file
121
src/modules/textbooks/hooks/use-knowledge-point-actions.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import type { KnowledgePoint } from "../types"
|
||||
import {
|
||||
createKnowledgePointAction,
|
||||
deleteKnowledgePointAction,
|
||||
updateKnowledgePointAction,
|
||||
} from "../actions"
|
||||
|
||||
export function useKnowledgePointActions(
|
||||
textbookId: string | undefined,
|
||||
selectedChapterId: string | null,
|
||||
selectedChapterTextbookId: string | undefined,
|
||||
highlightedKpId: string | null,
|
||||
setHighlightedKpId: (id: string | null) => void,
|
||||
onKpCreated?: () => void,
|
||||
) {
|
||||
const [editingKp, setEditingKp] = useState<KnowledgePoint | null>(null)
|
||||
const [editKpDialogOpen, setEditKpDialogOpen] = useState(false)
|
||||
const [isUpdatingKp, setIsUpdatingKp] = useState(false)
|
||||
|
||||
const [questionDialogOpen, setQuestionDialogOpen] = useState(false)
|
||||
const [targetKpForQuestion, setTargetKpForQuestion] = useState<KnowledgePoint | null>(null)
|
||||
|
||||
const handleCreateKnowledgePoint = async (formData: FormData) => {
|
||||
if (!selectedChapterId || !selectedChapterTextbookId) return
|
||||
|
||||
try {
|
||||
const result = await createKnowledgePointAction(
|
||||
selectedChapterId,
|
||||
selectedChapterTextbookId,
|
||||
null,
|
||||
formData,
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
toast.success("知识点已创建")
|
||||
onKpCreated?.()
|
||||
window.getSelection()?.removeAllRanges()
|
||||
return true
|
||||
} else {
|
||||
toast.error(result.message || "创建知识点失败")
|
||||
return false
|
||||
}
|
||||
} catch {
|
||||
toast.error("发生错误")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
|
||||
const [pendingDeleteKpId, setPendingDeleteKpId] = useState<string | null>(null)
|
||||
|
||||
const requestDeleteKnowledgePoint = (kpId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setPendingDeleteKpId(kpId)
|
||||
setDeleteConfirmOpen(true)
|
||||
}
|
||||
|
||||
const confirmDeleteKnowledgePoint = async () => {
|
||||
if (!pendingDeleteKpId || !textbookId) return
|
||||
setDeleteConfirmOpen(false)
|
||||
|
||||
try {
|
||||
const result = await deleteKnowledgePointAction(pendingDeleteKpId, textbookId)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
if (highlightedKpId === pendingDeleteKpId) {
|
||||
setHighlightedKpId(null)
|
||||
}
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
} catch {
|
||||
toast.error("删除失败")
|
||||
} finally {
|
||||
setPendingDeleteKpId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateKnowledgePoint = async (formData: FormData) => {
|
||||
if (!editingKp || !textbookId) return
|
||||
setIsUpdatingKp(true)
|
||||
|
||||
try {
|
||||
const result = await updateKnowledgePointAction(editingKp.id, textbookId, null, formData)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
setEditKpDialogOpen(false)
|
||||
setEditingKp(null)
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
} catch {
|
||||
toast.error("更新失败")
|
||||
} finally {
|
||||
setIsUpdatingKp(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
editingKp,
|
||||
setEditingKp,
|
||||
editKpDialogOpen,
|
||||
setEditKpDialogOpen,
|
||||
isUpdatingKp,
|
||||
questionDialogOpen,
|
||||
setQuestionDialogOpen,
|
||||
targetKpForQuestion,
|
||||
setTargetKpForQuestion,
|
||||
deleteConfirmOpen,
|
||||
setDeleteConfirmOpen,
|
||||
handleCreateKnowledgePoint,
|
||||
requestDeleteKnowledgePoint,
|
||||
confirmDeleteKnowledgePoint,
|
||||
handleUpdateKnowledgePoint,
|
||||
}
|
||||
}
|
||||
57
src/modules/textbooks/hooks/use-text-selection.ts
Normal file
57
src/modules/textbooks/hooks/use-text-selection.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef } from "react"
|
||||
|
||||
export function useTextSelection() {
|
||||
const [selectedText, setSelectedText] = useState("")
|
||||
const selectionRef = useRef("")
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
|
||||
const handleContentPointerDown = (e: React.PointerEvent) => {
|
||||
if (e.button !== 2) return
|
||||
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.isCollapsed) {
|
||||
selectionRef.current = ""
|
||||
return
|
||||
}
|
||||
|
||||
if (contentRef.current && contentRef.current.contains(selection.anchorNode)) {
|
||||
selectionRef.current = selection.toString().trim()
|
||||
} else {
|
||||
selectionRef.current = ""
|
||||
}
|
||||
}
|
||||
|
||||
const handleContextMenuChange = (open: boolean) => {
|
||||
if (!open) return
|
||||
|
||||
if (selectionRef.current) {
|
||||
setSelectedText(selectionRef.current)
|
||||
} else {
|
||||
const selection = window.getSelection()
|
||||
if (selection && !selection.isCollapsed && contentRef.current && contentRef.current.contains(selection.anchorNode)) {
|
||||
const text = selection.toString().trim()
|
||||
selectionRef.current = text
|
||||
setSelectedText(text)
|
||||
} else {
|
||||
setSelectedText("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
selectedText,
|
||||
setSelectedText,
|
||||
selectionRef,
|
||||
contentRef,
|
||||
createDialogOpen,
|
||||
setCreateDialogOpen,
|
||||
isCreating,
|
||||
setIsCreating,
|
||||
handleContentPointerDown,
|
||||
handleContextMenuChange,
|
||||
}
|
||||
}
|
||||
9
src/next-auth.d.ts
vendored
9
src/next-auth.d.ts
vendored
@@ -1,10 +1,13 @@
|
||||
import type { DefaultSession } from "next-auth"
|
||||
import type { Permission } from "@/shared/types/permissions"
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: DefaultSession["user"] & {
|
||||
id: string
|
||||
role: string
|
||||
role: string // kept for backward compatibility
|
||||
roles: string[]
|
||||
permissions: Permission[]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +15,8 @@ declare module "next-auth" {
|
||||
declare module "next-auth/jwt" {
|
||||
interface JWT {
|
||||
id: string
|
||||
role: string
|
||||
role: string // kept for backward compatibility
|
||||
roles: string[]
|
||||
permissions: Permission[]
|
||||
}
|
||||
}
|
||||
|
||||
97
src/proxy.ts
97
src/proxy.ts
@@ -1,53 +1,80 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import type { NextAuthRequest } from "next-auth"
|
||||
import type { NextRequest } from "next/server"
|
||||
import { getToken } from "next-auth/jwt"
|
||||
|
||||
import { auth } from "./auth"
|
||||
|
||||
function normalizeRole(value: unknown) {
|
||||
const role = String(value ?? "").trim().toLowerCase()
|
||||
if (role === "admin" || role === "student" || role === "teacher" || role === "parent") return role
|
||||
return "student"
|
||||
// Route prefix → minimum required permission
|
||||
const ROUTE_PERMISSIONS: Record<string, string> = {
|
||||
"/admin": "school:manage",
|
||||
"/teacher": "exam:read",
|
||||
"/student": "homework:submit",
|
||||
"/parent": "exam:read",
|
||||
"/management": "grade:manage",
|
||||
}
|
||||
|
||||
function roleHome(role: string) {
|
||||
if (role === "admin") return "/admin/dashboard"
|
||||
if (role === "student") return "/student/dashboard"
|
||||
if (role === "parent") return "/parent/dashboard"
|
||||
return "/teacher/dashboard"
|
||||
// API route prefix → required permission
|
||||
const API_PERMISSIONS: Record<string, string> = {
|
||||
"/api/ai/chat": "ai:chat",
|
||||
}
|
||||
|
||||
export default auth((req: NextAuthRequest) => {
|
||||
const { pathname } = req.nextUrl
|
||||
const session = req.auth
|
||||
function resolveDefaultPath(roles: string[]): string {
|
||||
if (roles.includes("admin")) return "/admin/dashboard"
|
||||
if (roles.includes("grade_head") || roles.includes("teaching_head")) return "/teacher/dashboard"
|
||||
if (roles.includes("teacher")) return "/teacher/dashboard"
|
||||
if (roles.includes("student")) return "/student/dashboard"
|
||||
if (roles.includes("parent")) return "/parent/dashboard"
|
||||
return "/dashboard"
|
||||
}
|
||||
|
||||
if (!session?.user) {
|
||||
const url = req.nextUrl.clone()
|
||||
url.pathname = "/login"
|
||||
url.searchParams.set("callbackUrl", pathname)
|
||||
return NextResponse.redirect(url)
|
||||
export async function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl
|
||||
|
||||
// Skip static assets and auth pages
|
||||
if (
|
||||
pathname.startsWith("/_next") ||
|
||||
pathname.startsWith("/api/auth") ||
|
||||
pathname === "/login" ||
|
||||
pathname === "/register" ||
|
||||
pathname === "/favicon.ico"
|
||||
) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
const role = normalizeRole(session.user.role)
|
||||
const token = await getToken({ req: request })
|
||||
|
||||
if (pathname.startsWith("/admin/") && role !== "admin") {
|
||||
return NextResponse.redirect(new URL(roleHome(role), req.url))
|
||||
// Not authenticated → redirect to login
|
||||
if (!token) {
|
||||
const loginUrl = new URL("/login", request.url)
|
||||
loginUrl.searchParams.set("callbackUrl", request.url)
|
||||
return NextResponse.redirect(loginUrl)
|
||||
}
|
||||
if (pathname.startsWith("/teacher/") && role !== "teacher") {
|
||||
return NextResponse.redirect(new URL(roleHome(role), req.url))
|
||||
|
||||
const permissions: string[] = (token.permissions as string[]) ?? []
|
||||
const roles: string[] = (token.roles as string[]) ?? []
|
||||
|
||||
// Check API route permissions
|
||||
for (const [prefix, requiredPerm] of Object.entries(API_PERMISSIONS)) {
|
||||
if (pathname.startsWith(prefix)) {
|
||||
if (!permissions.includes(requiredPerm)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if (pathname.startsWith("/student/") && role !== "student") {
|
||||
return NextResponse.redirect(new URL(roleHome(role), req.url))
|
||||
}
|
||||
if (pathname.startsWith("/parent/") && role !== "parent") {
|
||||
return NextResponse.redirect(new URL(roleHome(role), req.url))
|
||||
}
|
||||
if (pathname.startsWith("/management/") && role !== "admin" && role !== "teacher") {
|
||||
return NextResponse.redirect(new URL(roleHome(role), req.url))
|
||||
|
||||
// Check page route permissions
|
||||
for (const [prefix, requiredPerm] of Object.entries(ROUTE_PERMISSIONS)) {
|
||||
if (pathname.startsWith(prefix)) {
|
||||
if (!permissions.includes(requiredPerm)) {
|
||||
const defaultPath = resolveDefaultPath(roles)
|
||||
return NextResponse.redirect(new URL(defaultPath, request.url))
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.next()
|
||||
})
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/dashboard", "/admin/:path*", "/teacher/:path*", "/student/:path*", "/parent/:path*", "/management/:path*", "/settings/:path*", "/profile"],
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Label } from "@/shared/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
type Role = "student" | "teacher" | "parent" | "admin"
|
||||
|
||||
@@ -27,7 +28,6 @@ export function OnboardingGate() {
|
||||
const router = useRouter()
|
||||
const { status, data: session, update } = useSession()
|
||||
const [required, setRequired] = useState(false)
|
||||
const [currentRole, setCurrentRole] = useState<Role>("student")
|
||||
const [open, setOpen] = useState(false)
|
||||
const [step, setStep] = useState(0)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
@@ -53,7 +53,6 @@ export function OnboardingGate() {
|
||||
const required = Boolean(json.required)
|
||||
const role = String(json.role ?? "student") as Role
|
||||
setRequired(required)
|
||||
setCurrentRole(role)
|
||||
setRole(role === "admin" ? "admin" : role)
|
||||
setName(String(session?.user?.name ?? "").trim())
|
||||
if (required) {
|
||||
@@ -88,6 +87,12 @@ export function OnboardingGate() {
|
||||
const canNextFromStep0 = role.length > 0
|
||||
const canNextFromStep1 = name.trim().length > 0 && phone.trim().length > 0
|
||||
|
||||
const permissions = (session?.user?.permissions ?? []) as string[]
|
||||
const isAdmin = permissions.includes(Permissions.SETTINGS_ADMIN)
|
||||
const isTeacher = permissions.includes(Permissions.EXAM_CREATE)
|
||||
const isStudent = permissions.includes(Permissions.HOMEWORK_SUBMIT) && !permissions.includes(Permissions.EXAM_CREATE)
|
||||
const isParent = !permissions.includes(Permissions.EXAM_CREATE) && !permissions.includes(Permissions.HOMEWORK_SUBMIT) && permissions.includes(Permissions.EXAM_READ)
|
||||
|
||||
const onNext = async () => {
|
||||
if (step === 0) {
|
||||
if (!canNextFromStep0) return
|
||||
@@ -99,7 +104,7 @@ export function OnboardingGate() {
|
||||
toast.error("请填写姓名与电话")
|
||||
return
|
||||
}
|
||||
if (role === "admin") {
|
||||
if (isAdmin) {
|
||||
setStep(3)
|
||||
} else {
|
||||
setStep(2)
|
||||
@@ -181,7 +186,7 @@ export function OnboardingGate() {
|
||||
{step === 0 ? (
|
||||
<div className="grid gap-2">
|
||||
<Label>Role</Label>
|
||||
{currentRole === "admin" ? (
|
||||
{isAdmin ? (
|
||||
<div className="rounded-md border px-3 py-2 text-sm">admin</div>
|
||||
) : (
|
||||
<Select value={role} onValueChange={(v) => setRole(v as Role)}>
|
||||
@@ -217,7 +222,7 @@ export function OnboardingGate() {
|
||||
|
||||
{step === 2 ? (
|
||||
<div className="grid gap-4">
|
||||
{role === "teacher" ? (
|
||||
{isTeacher ? (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="onb_codes_teacher">班级代码(可多个)</Label>
|
||||
@@ -242,7 +247,7 @@ export function OnboardingGate() {
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{role === "student" ? (
|
||||
{isStudent ? (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="onb_codes_student">班级代码</Label>
|
||||
<Textarea
|
||||
@@ -254,7 +259,7 @@ export function OnboardingGate() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{role === "parent" ? (
|
||||
{isParent ? (
|
||||
<div className="rounded-md border px-3 py-2 text-sm text-muted-foreground">
|
||||
家长角色暂不需要配置,可跳过
|
||||
</div>
|
||||
|
||||
@@ -108,6 +108,15 @@ export const usersToRoles = mysqlTable("users_to_roles", {
|
||||
userIdIdx: index("user_id_idx").on(table.userId),
|
||||
}));
|
||||
|
||||
// Role -> Permissions (fine-grained RBAC)
|
||||
export const rolePermissions = mysqlTable("role_permissions", {
|
||||
roleId: varchar("role_id", { length: 128 }).notNull().references(() => roles.id, { onDelete: "cascade" }),
|
||||
permission: varchar("permission", { length: 100 }).notNull(),
|
||||
}, (table) => ({
|
||||
pk: primaryKey({ columns: [table.roleId, table.permission] }),
|
||||
roleIdIdx: index("role_permissions_role_idx").on(table.roleId),
|
||||
}));
|
||||
|
||||
// --- 2. Knowledge Points (Tree Structure) ---
|
||||
|
||||
export const knowledgePoints = mysqlTable("knowledge_points", {
|
||||
|
||||
5
src/shared/hooks/index.ts
Normal file
5
src/shared/hooks/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { useActionWithToast } from "./use-action-with-toast"
|
||||
export { useDebounce } from "./use-debounce"
|
||||
export { useMediaQuery } from "./use-media-query"
|
||||
export { useLocalStorage } from "./use-local-storage"
|
||||
export { usePermission } from "./use-permission"
|
||||
22
src/shared/hooks/use-action-with-toast.ts
Normal file
22
src/shared/hooks/use-action-with-toast.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
"use client"
|
||||
|
||||
import { useTransition } from "react"
|
||||
import { toast } from "sonner"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
|
||||
export function useActionWithToast<T>() {
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const execute = async (action: () => Promise<ActionState<T>>) => {
|
||||
startTransition(async () => {
|
||||
const result = await action()
|
||||
if (result.success) {
|
||||
toast.success(result.message || "操作成功")
|
||||
} else {
|
||||
toast.error(result.message || "操作失败")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return { isPending, execute }
|
||||
}
|
||||
28
src/shared/hooks/use-debounce.test.ts
Normal file
28
src/shared/hooks/use-debounce.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, it, expect, vi } from "vitest"
|
||||
import { renderHook, act } from "@testing-library/react"
|
||||
import { useDebounce } from "./use-debounce"
|
||||
|
||||
describe("useDebounce", () => {
|
||||
it("should return initial value immediately", () => {
|
||||
const { result } = renderHook(() => useDebounce("hello", 500))
|
||||
expect(result.current).toBe("hello")
|
||||
})
|
||||
|
||||
it("should debounce value changes", () => {
|
||||
vi.useFakeTimers()
|
||||
const { result, rerender } = renderHook(
|
||||
({ value, delay }) => useDebounce(value, delay),
|
||||
{ initialProps: { value: "hello", delay: 300 } }
|
||||
)
|
||||
|
||||
rerender({ value: "world", delay: 300 })
|
||||
expect(result.current).toBe("hello")
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
expect(result.current).toBe("world")
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
14
src/shared/hooks/use-debounce.ts
Normal file
14
src/shared/hooks/use-debounce.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
|
||||
export function useDebounce<T>(value: T, delay: number = 300): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedValue(value), delay)
|
||||
return () => clearTimeout(timer)
|
||||
}, [value, delay])
|
||||
|
||||
return debouncedValue
|
||||
}
|
||||
35
src/shared/hooks/use-local-storage.test.ts
Normal file
35
src/shared/hooks/use-local-storage.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest"
|
||||
import { renderHook, act } from "@testing-library/react"
|
||||
import { useLocalStorage } from "./use-local-storage"
|
||||
|
||||
describe("useLocalStorage", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it("should return initial value when localStorage is empty", () => {
|
||||
const { result } = renderHook(() => useLocalStorage("test-key", "default"))
|
||||
expect(result.current[0]).toBe("default")
|
||||
})
|
||||
|
||||
it("should persist value to localStorage", () => {
|
||||
const { result } = renderHook(() => useLocalStorage("test-key", "default"))
|
||||
|
||||
act(() => {
|
||||
result.current[1]("updated")
|
||||
})
|
||||
|
||||
expect(result.current[0]).toBe("updated")
|
||||
expect(localStorage.getItem("test-key")).toBe(JSON.stringify("updated"))
|
||||
})
|
||||
|
||||
it("should support functional updates", () => {
|
||||
const { result } = renderHook(() => useLocalStorage("test-key", 0))
|
||||
|
||||
act(() => {
|
||||
result.current[1]((prev) => prev + 1)
|
||||
})
|
||||
|
||||
expect(result.current[0]).toBe(1)
|
||||
})
|
||||
})
|
||||
30
src/shared/hooks/use-local-storage.ts
Normal file
30
src/shared/hooks/use-local-storage.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useCallback } from "react"
|
||||
|
||||
function getStorageItem<T>(key: string, initialValue: T): T {
|
||||
try {
|
||||
const item = window.localStorage.getItem(key)
|
||||
return item ? (JSON.parse(item) as T) : initialValue
|
||||
} catch {
|
||||
return initialValue
|
||||
}
|
||||
}
|
||||
|
||||
export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void] {
|
||||
const [localValue, setLocalValue] = useState<T>(() => getStorageItem(key, initialValue))
|
||||
|
||||
const setValue = useCallback((newValue: T | ((prev: T) => T)) => {
|
||||
setLocalValue((prev) => {
|
||||
const nextValue = newValue instanceof Function ? newValue(prev) : newValue
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(nextValue))
|
||||
} catch (error) {
|
||||
console.error(`Error setting localStorage key "${key}":`, error)
|
||||
}
|
||||
return nextValue
|
||||
})
|
||||
}, [key])
|
||||
|
||||
return [localValue, setValue]
|
||||
}
|
||||
14
src/shared/hooks/use-media-query.ts
Normal file
14
src/shared/hooks/use-media-query.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
"use client"
|
||||
|
||||
import { useSyncExternalStore } from "react"
|
||||
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
return useSyncExternalStore(
|
||||
(callback) => {
|
||||
const media = window.matchMedia(query)
|
||||
media.addEventListener("change", callback)
|
||||
return () => media.removeEventListener("change", callback)
|
||||
},
|
||||
() => window.matchMedia(query).matches,
|
||||
)
|
||||
}
|
||||
28
src/shared/hooks/use-permission.ts
Normal file
28
src/shared/hooks/use-permission.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import { useSession } from "next-auth/react"
|
||||
import type { Permission } from "@/shared/types/permissions"
|
||||
|
||||
export function usePermission() {
|
||||
const { data: session } = useSession()
|
||||
const permissions = (session?.user?.permissions ?? []) as Permission[]
|
||||
const roles = (session?.user?.roles ?? []) as string[]
|
||||
|
||||
const hasPermission = (permission: Permission): boolean => {
|
||||
return permissions.includes(permission)
|
||||
}
|
||||
|
||||
const hasAnyPermission = (...perms: Permission[]): boolean => {
|
||||
return perms.some((p) => permissions.includes(p))
|
||||
}
|
||||
|
||||
const hasAllPermissions = (...perms: Permission[]): boolean => {
|
||||
return perms.every((p) => permissions.includes(p))
|
||||
}
|
||||
|
||||
const hasRole = (role: string): boolean => {
|
||||
return roles.includes(role)
|
||||
}
|
||||
|
||||
return { permissions, roles, hasPermission, hasAnyPermission, hasAllPermissions, hasRole }
|
||||
}
|
||||
133
src/shared/lib/auth-guard.ts
Normal file
133
src/shared/lib/auth-guard.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { auth } from "@/auth"
|
||||
import type { Permission, DataScope, AuthContext } from "@/shared/types/permissions"
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
classes,
|
||||
classSubjectTeachers,
|
||||
grades,
|
||||
} from "@/shared/db/schema"
|
||||
import { eq, or } from "drizzle-orm"
|
||||
|
||||
export class PermissionDeniedError extends Error {
|
||||
constructor(permission: string) {
|
||||
super(`Permission denied: ${permission}`)
|
||||
this.name = "PermissionDeniedError"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full authentication context for the current user.
|
||||
* Throws if not authenticated.
|
||||
*/
|
||||
export async function getAuthContext(): Promise<AuthContext> {
|
||||
const session = await auth()
|
||||
const userId = session?.user?.id
|
||||
if (!userId) throw new PermissionDeniedError("auth_required")
|
||||
|
||||
// Prefer session data (already resolved in JWT callback)
|
||||
const roleNames = (session.user.roles ?? []) as string[]
|
||||
const permissions = (session.user.permissions ?? []) as Permission[]
|
||||
|
||||
// Resolve data scope from DB (not cached in JWT since it can change)
|
||||
const dataScope = await resolveDataScope(userId, roleNames)
|
||||
|
||||
return { userId, roles: roleNames, permissions, dataScope }
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert the current user has the specified permission.
|
||||
* Returns AuthContext on success, throws PermissionDeniedError on failure.
|
||||
*/
|
||||
export async function requirePermission(permission: Permission): Promise<AuthContext> {
|
||||
const ctx = await getAuthContext()
|
||||
if (!ctx.permissions.includes(permission)) {
|
||||
throw new PermissionDeniedError(permission)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* Check permission without throwing. Useful for conditional logic.
|
||||
*/
|
||||
export async function checkPermission(
|
||||
permission: Permission
|
||||
): Promise<{ allowed: boolean; ctx: AuthContext }> {
|
||||
const ctx = await getAuthContext()
|
||||
return { allowed: ctx.permissions.includes(permission), ctx }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the data scope for a user based on their roles.
|
||||
* Queries the DB for resource ownership information.
|
||||
*/
|
||||
async function resolveDataScope(userId: string, roleNames: string[]): Promise<DataScope> {
|
||||
// Admin sees everything
|
||||
if (roleNames.includes("admin")) {
|
||||
return { type: "all" }
|
||||
}
|
||||
|
||||
// Grade head / teaching head: can manage their grades
|
||||
if (roleNames.includes("grade_head") || roleNames.includes("teaching_head")) {
|
||||
const managedGrades = await db
|
||||
.select({ id: grades.id })
|
||||
.from(grades)
|
||||
.where(or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId)))
|
||||
|
||||
if (managedGrades.length > 0) {
|
||||
return { type: "grade_managed", gradeIds: managedGrades.map((g) => g.id) }
|
||||
}
|
||||
}
|
||||
|
||||
// Teacher: can see their own classes
|
||||
if (roleNames.includes("teacher")) {
|
||||
// Classes where user is the homeroom teacher
|
||||
const homeroomClasses = await db
|
||||
.select({ id: classes.id })
|
||||
.from(classes)
|
||||
.where(eq(classes.teacherId, userId))
|
||||
|
||||
// Classes where user is a subject teacher
|
||||
const subjectClasses = await db
|
||||
.selectDistinct({ classId: classSubjectTeachers.classId, subjectId: classSubjectTeachers.subjectId })
|
||||
.from(classSubjectTeachers)
|
||||
.where(eq(classSubjectTeachers.teacherId, userId))
|
||||
|
||||
const classIds = [
|
||||
...new Set([
|
||||
...homeroomClasses.map((c) => c.id),
|
||||
...subjectClasses.map((c) => c.classId),
|
||||
]),
|
||||
]
|
||||
const subjectIds = subjectClasses
|
||||
.map((c) => c.subjectId)
|
||||
.filter((s): s is string => s !== null)
|
||||
|
||||
return {
|
||||
type: "class_taught",
|
||||
classIds,
|
||||
subjectIds: subjectIds.length > 0 ? subjectIds : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// Student: can see data from their enrolled classes
|
||||
if (roleNames.includes("student")) {
|
||||
return { type: "class_members" }
|
||||
}
|
||||
|
||||
// Parent: can see their children's data
|
||||
if (roleNames.includes("parent")) {
|
||||
// TODO: implement parent-child relationship lookup
|
||||
return { type: "children", childrenIds: [] }
|
||||
}
|
||||
|
||||
// Fallback: only own data
|
||||
return { type: "owned", userId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: assert the user is authenticated (has any role).
|
||||
* Returns AuthContext on success.
|
||||
*/
|
||||
export async function requireAuth(): Promise<AuthContext> {
|
||||
return getAuthContext()
|
||||
}
|
||||
130
src/shared/lib/permissions.ts
Normal file
130
src/shared/lib/permissions.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { Permissions, type Permission } from "@/shared/types/permissions"
|
||||
|
||||
// Role → Permission mapping
|
||||
// New roles only need to add an entry here + seed the DB
|
||||
export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
|
||||
admin: [
|
||||
Permissions.EXAM_CREATE,
|
||||
Permissions.EXAM_READ,
|
||||
Permissions.EXAM_UPDATE,
|
||||
Permissions.EXAM_DELETE,
|
||||
Permissions.EXAM_DUPLICATE,
|
||||
Permissions.EXAM_PUBLISH,
|
||||
Permissions.EXAM_AI_GENERATE,
|
||||
Permissions.HOMEWORK_CREATE,
|
||||
Permissions.HOMEWORK_GRADE,
|
||||
Permissions.QUESTION_CREATE,
|
||||
Permissions.QUESTION_READ,
|
||||
Permissions.QUESTION_UPDATE,
|
||||
Permissions.QUESTION_DELETE,
|
||||
Permissions.TEXTBOOK_CREATE,
|
||||
Permissions.TEXTBOOK_READ,
|
||||
Permissions.TEXTBOOK_UPDATE,
|
||||
Permissions.TEXTBOOK_DELETE,
|
||||
Permissions.CLASS_CREATE,
|
||||
Permissions.CLASS_READ,
|
||||
Permissions.CLASS_UPDATE,
|
||||
Permissions.CLASS_DELETE,
|
||||
Permissions.CLASS_ENROLL,
|
||||
Permissions.CLASS_SCHEDULE,
|
||||
Permissions.SCHOOL_MANAGE,
|
||||
Permissions.GRADE_MANAGE,
|
||||
Permissions.USER_MANAGE,
|
||||
Permissions.AI_CHAT,
|
||||
Permissions.AI_CONFIGURE,
|
||||
Permissions.SETTINGS_ADMIN,
|
||||
],
|
||||
teacher: [
|
||||
Permissions.EXAM_CREATE,
|
||||
Permissions.EXAM_READ,
|
||||
Permissions.EXAM_UPDATE,
|
||||
Permissions.EXAM_DELETE,
|
||||
Permissions.EXAM_DUPLICATE,
|
||||
Permissions.EXAM_PUBLISH,
|
||||
Permissions.EXAM_AI_GENERATE,
|
||||
Permissions.HOMEWORK_CREATE,
|
||||
Permissions.HOMEWORK_GRADE,
|
||||
Permissions.QUESTION_CREATE,
|
||||
Permissions.QUESTION_READ,
|
||||
Permissions.QUESTION_UPDATE,
|
||||
Permissions.QUESTION_DELETE,
|
||||
Permissions.TEXTBOOK_CREATE,
|
||||
Permissions.TEXTBOOK_READ,
|
||||
Permissions.TEXTBOOK_UPDATE,
|
||||
Permissions.CLASS_READ,
|
||||
Permissions.CLASS_ENROLL,
|
||||
Permissions.CLASS_SCHEDULE,
|
||||
Permissions.AI_CHAT,
|
||||
],
|
||||
student: [
|
||||
Permissions.EXAM_READ,
|
||||
Permissions.HOMEWORK_SUBMIT,
|
||||
Permissions.QUESTION_READ,
|
||||
Permissions.TEXTBOOK_READ,
|
||||
Permissions.CLASS_READ,
|
||||
Permissions.AI_CHAT,
|
||||
],
|
||||
parent: [
|
||||
Permissions.EXAM_READ,
|
||||
Permissions.TEXTBOOK_READ,
|
||||
Permissions.CLASS_READ,
|
||||
],
|
||||
grade_head: [
|
||||
Permissions.EXAM_CREATE,
|
||||
Permissions.EXAM_READ,
|
||||
Permissions.EXAM_UPDATE,
|
||||
Permissions.EXAM_DELETE,
|
||||
Permissions.EXAM_DUPLICATE,
|
||||
Permissions.EXAM_PUBLISH,
|
||||
Permissions.EXAM_AI_GENERATE,
|
||||
Permissions.HOMEWORK_CREATE,
|
||||
Permissions.HOMEWORK_GRADE,
|
||||
Permissions.QUESTION_CREATE,
|
||||
Permissions.QUESTION_READ,
|
||||
Permissions.QUESTION_UPDATE,
|
||||
Permissions.QUESTION_DELETE,
|
||||
Permissions.TEXTBOOK_CREATE,
|
||||
Permissions.TEXTBOOK_READ,
|
||||
Permissions.TEXTBOOK_UPDATE,
|
||||
Permissions.CLASS_CREATE,
|
||||
Permissions.CLASS_READ,
|
||||
Permissions.CLASS_UPDATE,
|
||||
Permissions.CLASS_ENROLL,
|
||||
Permissions.CLASS_SCHEDULE,
|
||||
Permissions.GRADE_MANAGE,
|
||||
Permissions.AI_CHAT,
|
||||
],
|
||||
teaching_head: [
|
||||
Permissions.EXAM_CREATE,
|
||||
Permissions.EXAM_READ,
|
||||
Permissions.EXAM_UPDATE,
|
||||
Permissions.EXAM_DELETE,
|
||||
Permissions.EXAM_DUPLICATE,
|
||||
Permissions.EXAM_PUBLISH,
|
||||
Permissions.EXAM_AI_GENERATE,
|
||||
Permissions.HOMEWORK_CREATE,
|
||||
Permissions.HOMEWORK_GRADE,
|
||||
Permissions.QUESTION_CREATE,
|
||||
Permissions.QUESTION_READ,
|
||||
Permissions.QUESTION_UPDATE,
|
||||
Permissions.QUESTION_DELETE,
|
||||
Permissions.TEXTBOOK_CREATE,
|
||||
Permissions.TEXTBOOK_READ,
|
||||
Permissions.TEXTBOOK_UPDATE,
|
||||
Permissions.CLASS_READ,
|
||||
Permissions.GRADE_MANAGE,
|
||||
Permissions.AI_CHAT,
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge permissions from all roles (deduplicated)
|
||||
*/
|
||||
export function resolvePermissions(roleNames: string[]): Permission[] {
|
||||
const set = new Set<Permission>()
|
||||
for (const name of roleNames) {
|
||||
const perms = ROLE_PERMISSIONS[name] ?? []
|
||||
for (const p of perms) set.add(p)
|
||||
}
|
||||
return Array.from(set)
|
||||
}
|
||||
39
src/shared/lib/utils.test.ts
Normal file
39
src/shared/lib/utils.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { cn, formatDate } from "./utils"
|
||||
|
||||
describe("cn", () => {
|
||||
it("should merge class names", () => {
|
||||
expect(cn("foo", "bar")).toBe("foo bar")
|
||||
})
|
||||
|
||||
it("should handle conditional classes", () => {
|
||||
expect(cn("foo", false && "bar", "baz")).toBe("foo baz")
|
||||
})
|
||||
|
||||
it("should resolve tailwind conflicts", () => {
|
||||
expect(cn("px-4", "px-6")).toBe("px-6")
|
||||
})
|
||||
|
||||
it("should handle undefined and null", () => {
|
||||
expect(cn("foo", undefined, null, "bar")).toBe("foo bar")
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatDate", () => {
|
||||
it("should format a date string with default zh-CN locale", () => {
|
||||
const result = formatDate("2024-01-15")
|
||||
expect(result).toContain("2024")
|
||||
expect(result).toContain("1")
|
||||
})
|
||||
|
||||
it("should format a date string with en-US locale", () => {
|
||||
const result = formatDate("2024-01-15", "en-US")
|
||||
expect(result).toContain("2024")
|
||||
expect(result).toContain("Jan")
|
||||
})
|
||||
|
||||
it("should format a Date object", () => {
|
||||
const result = formatDate(new Date(2024, 5, 15))
|
||||
expect(result).toContain("2024")
|
||||
})
|
||||
})
|
||||
@@ -5,10 +5,10 @@ export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function formatDate(date: string | Date) {
|
||||
return new Date(date).toLocaleDateString("en-US", {
|
||||
export function formatDate(date: string | Date, locale: string = "zh-CN") {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
}).format(new Date(date))
|
||||
}
|
||||
|
||||
33
src/shared/types/action-state.test.ts
Normal file
33
src/shared/types/action-state.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import type { ActionState } from "./action-state"
|
||||
|
||||
describe("ActionState", () => {
|
||||
it("should create a success state", () => {
|
||||
const state: ActionState<string> = {
|
||||
success: true,
|
||||
message: "Operation succeeded",
|
||||
data: "result",
|
||||
}
|
||||
expect(state.success).toBe(true)
|
||||
expect(state.data).toBe("result")
|
||||
})
|
||||
|
||||
it("should create an error state", () => {
|
||||
const state: ActionState = {
|
||||
success: false,
|
||||
message: "Operation failed",
|
||||
errors: { field: ["Error message"] },
|
||||
}
|
||||
expect(state.success).toBe(false)
|
||||
expect(state.errors).toBeDefined()
|
||||
})
|
||||
|
||||
it("should create a void state", () => {
|
||||
const state: ActionState = {
|
||||
success: true,
|
||||
message: "Done",
|
||||
}
|
||||
expect(state.success).toBe(true)
|
||||
expect(state.data).toBeUndefined()
|
||||
})
|
||||
})
|
||||
68
src/shared/types/permissions.ts
Normal file
68
src/shared/types/permissions.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
// Permission definitions: resource:action naming convention
|
||||
// Used by requirePermission() on server and usePermission() on client
|
||||
|
||||
export const Permissions = {
|
||||
// Exam
|
||||
EXAM_CREATE: "exam:create",
|
||||
EXAM_READ: "exam:read",
|
||||
EXAM_UPDATE: "exam:update",
|
||||
EXAM_DELETE: "exam:delete",
|
||||
EXAM_DUPLICATE: "exam:duplicate",
|
||||
EXAM_PUBLISH: "exam:publish",
|
||||
EXAM_AI_GENERATE: "exam:ai_generate",
|
||||
|
||||
// Homework
|
||||
HOMEWORK_CREATE: "homework:create",
|
||||
HOMEWORK_GRADE: "homework:grade",
|
||||
HOMEWORK_SUBMIT: "homework:submit",
|
||||
|
||||
// Question
|
||||
QUESTION_CREATE: "question:create",
|
||||
QUESTION_READ: "question:read",
|
||||
QUESTION_UPDATE: "question:update",
|
||||
QUESTION_DELETE: "question:delete",
|
||||
|
||||
// Textbook
|
||||
TEXTBOOK_CREATE: "textbook:create",
|
||||
TEXTBOOK_READ: "textbook:read",
|
||||
TEXTBOOK_UPDATE: "textbook:update",
|
||||
TEXTBOOK_DELETE: "textbook:delete",
|
||||
|
||||
// Class
|
||||
CLASS_CREATE: "class:create",
|
||||
CLASS_READ: "class:read",
|
||||
CLASS_UPDATE: "class:update",
|
||||
CLASS_DELETE: "class:delete",
|
||||
CLASS_ENROLL: "class:enroll",
|
||||
CLASS_SCHEDULE: "class:schedule",
|
||||
|
||||
// School management
|
||||
SCHOOL_MANAGE: "school:manage",
|
||||
GRADE_MANAGE: "grade:manage",
|
||||
USER_MANAGE: "user:manage",
|
||||
|
||||
// AI
|
||||
AI_CHAT: "ai:chat",
|
||||
AI_CONFIGURE: "ai:configure",
|
||||
|
||||
// Settings
|
||||
SETTINGS_ADMIN: "settings:admin",
|
||||
} as const
|
||||
|
||||
export type Permission = (typeof Permissions)[keyof typeof Permissions]
|
||||
|
||||
// Data scope for row-level security
|
||||
export type DataScope =
|
||||
| { type: "all" }
|
||||
| { type: "owned"; userId: string }
|
||||
| { type: "class_members" }
|
||||
| { type: "grade_managed"; gradeIds: string[] }
|
||||
| { type: "class_taught"; classIds: string[]; subjectIds?: string[] }
|
||||
| { type: "children"; childrenIds: string[] }
|
||||
|
||||
export interface AuthContext {
|
||||
userId: string
|
||||
roles: string[]
|
||||
permissions: Permission[]
|
||||
dataScope: DataScope
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import tailwindcssAnimate from "tailwindcss-animate";
|
||||
import typography from "@tailwindcss/typography";
|
||||
|
||||
const config: Config = {
|
||||
darkMode: "class",
|
||||
content: [
|
||||
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
@@ -11,67 +10,6 @@ const config: Config = {
|
||||
"./src/modules/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/shared/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
chart: {
|
||||
"1": "hsl(var(--chart-1))",
|
||||
"2": "hsl(var(--chart-2))",
|
||||
"3": "hsl(var(--chart-3))",
|
||||
"4": "hsl(var(--chart-4))",
|
||||
"5": "hsl(var(--chart-5))",
|
||||
},
|
||||
sidebar: {
|
||||
DEFAULT: "hsl(var(--sidebar-background))",
|
||||
foreground: "hsl(var(--sidebar-foreground))",
|
||||
primary: "hsl(var(--sidebar-primary))",
|
||||
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
|
||||
accent: "hsl(var(--sidebar-accent))",
|
||||
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
|
||||
border: "hsl(var(--sidebar-border))",
|
||||
ring: "hsl(var(--sidebar-ring))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [tailwindcssAnimate, typography],
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
authMock: vi.fn(),
|
||||
getUserProfileMock: vi.fn(),
|
||||
redirectMock: vi.fn((target: string) => {
|
||||
throw new Error(`REDIRECT:${target}`)
|
||||
}),
|
||||
@@ -12,10 +11,6 @@ vi.mock("@/auth", () => ({
|
||||
auth: mocks.authMock,
|
||||
}))
|
||||
|
||||
vi.mock("@/modules/users/data-access", () => ({
|
||||
getUserProfile: mocks.getUserProfileMock,
|
||||
}))
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: mocks.redirectMock,
|
||||
}))
|
||||
@@ -35,33 +30,36 @@ describe("dashboard route dispatcher", () => {
|
||||
await expect(DashboardPage()).rejects.toThrow("REDIRECT:/login")
|
||||
})
|
||||
|
||||
it("redirects to login when user profile is missing", async () => {
|
||||
mocks.authMock.mockResolvedValue({ user: { id: "u_1" } })
|
||||
mocks.getUserProfileMock.mockResolvedValue(null)
|
||||
it("redirects to login when user is missing", async () => {
|
||||
mocks.authMock.mockResolvedValue({ user: null })
|
||||
await expect(DashboardPage()).rejects.toThrow("REDIRECT:/login")
|
||||
})
|
||||
|
||||
it("redirects admin to admin dashboard", async () => {
|
||||
mocks.authMock.mockResolvedValue({ user: { id: "u_admin" } })
|
||||
mocks.getUserProfileMock.mockResolvedValue({ role: "admin" })
|
||||
it("redirects admin (school:manage) to admin dashboard", async () => {
|
||||
mocks.authMock.mockResolvedValue({
|
||||
user: { id: "u_admin", roles: ["admin"], permissions: ["school:manage"] },
|
||||
})
|
||||
await expect(DashboardPage()).rejects.toThrow("REDIRECT:/admin/dashboard")
|
||||
})
|
||||
|
||||
it("redirects student to student dashboard", async () => {
|
||||
mocks.authMock.mockResolvedValue({ user: { id: "u_student" } })
|
||||
mocks.getUserProfileMock.mockResolvedValue({ role: "student" })
|
||||
it("redirects student (homework:submit without exam:create) to student dashboard", async () => {
|
||||
mocks.authMock.mockResolvedValue({
|
||||
user: { id: "u_student", roles: ["student"], permissions: ["homework:submit"] },
|
||||
})
|
||||
await expect(DashboardPage()).rejects.toThrow("REDIRECT:/student/dashboard")
|
||||
})
|
||||
|
||||
it("redirects parent to parent dashboard", async () => {
|
||||
mocks.authMock.mockResolvedValue({ user: { id: "u_parent" } })
|
||||
mocks.getUserProfileMock.mockResolvedValue({ role: "parent" })
|
||||
mocks.authMock.mockResolvedValue({
|
||||
user: { id: "u_parent", roles: ["parent"], permissions: ["exam:read"] },
|
||||
})
|
||||
await expect(DashboardPage()).rejects.toThrow("REDIRECT:/parent/dashboard")
|
||||
})
|
||||
|
||||
it("falls back to student dashboard when role is unknown", async () => {
|
||||
mocks.authMock.mockResolvedValue({ user: { id: "u_teacher" } })
|
||||
mocks.getUserProfileMock.mockResolvedValue({ role: "" })
|
||||
await expect(DashboardPage()).rejects.toThrow("REDIRECT:/student/dashboard")
|
||||
it("redirects teacher (with exam:create) to teacher dashboard", async () => {
|
||||
mocks.authMock.mockResolvedValue({
|
||||
user: { id: "u_teacher", roles: ["teacher"], permissions: ["exam:create", "exam:read"] },
|
||||
})
|
||||
await expect(DashboardPage()).rejects.toThrow("REDIRECT:/teacher/dashboard")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const authMock = vi.fn()
|
||||
const requirePermissionMock = vi.fn()
|
||||
const revalidatePathMock = vi.fn()
|
||||
const createIdMock = vi.fn()
|
||||
|
||||
@@ -38,7 +38,7 @@ const mocks = vi.hoisted(() => {
|
||||
)
|
||||
|
||||
return {
|
||||
authMock,
|
||||
requirePermissionMock,
|
||||
revalidatePathMock,
|
||||
createIdMock,
|
||||
ensureLimitMock,
|
||||
@@ -60,8 +60,14 @@ const mocks = vi.hoisted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("@/auth", () => ({
|
||||
auth: mocks.authMock,
|
||||
vi.mock("@/shared/lib/auth-guard", () => ({
|
||||
requirePermission: mocks.requirePermissionMock,
|
||||
PermissionDeniedError: class PermissionDeniedError extends Error {
|
||||
constructor(permission: string) {
|
||||
super(`Permission denied: ${permission}`)
|
||||
this.name = "PermissionDeniedError"
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("next/cache", () => ({
|
||||
@@ -105,9 +111,6 @@ vi.mock("@/shared/db/schema", () => ({
|
||||
updatedAt: "updatedAt",
|
||||
score: "score",
|
||||
},
|
||||
roles: { id: "id", name: "name" },
|
||||
users: { id: "id" },
|
||||
usersToRoles: { userId: "userId", roleId: "roleId" },
|
||||
}))
|
||||
|
||||
import {
|
||||
@@ -117,14 +120,31 @@ import {
|
||||
submitHomeworkAction,
|
||||
} from "@/modules/homework/actions"
|
||||
|
||||
function studentCtx(userId = "u_student") {
|
||||
return {
|
||||
userId,
|
||||
roles: ["student"],
|
||||
permissions: ["homework:submit"],
|
||||
dataScope: { type: "class_members" as const },
|
||||
}
|
||||
}
|
||||
|
||||
function teacherCtx(userId = "u_teacher") {
|
||||
return {
|
||||
userId,
|
||||
roles: ["teacher"],
|
||||
permissions: ["homework:grade"],
|
||||
dataScope: { type: "class_taught" as const, classIds: [] },
|
||||
}
|
||||
}
|
||||
|
||||
describe("homework action flow", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
it("starts submission for assigned student", async () => {
|
||||
mocks.authMock.mockResolvedValue({ user: { id: "u_student" } })
|
||||
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_student" }])
|
||||
mocks.requirePermissionMock.mockResolvedValue(studentCtx())
|
||||
mocks.assignmentFindFirstMock.mockResolvedValue({
|
||||
id: "a_1",
|
||||
status: "published",
|
||||
@@ -153,8 +173,7 @@ describe("homework action flow", () => {
|
||||
})
|
||||
|
||||
it("blocks submission when assignment is past due", async () => {
|
||||
mocks.authMock.mockResolvedValue({ user: { id: "u_student" } })
|
||||
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_student" }])
|
||||
mocks.requirePermissionMock.mockResolvedValue(studentCtx())
|
||||
mocks.submissionFindFirstMock.mockResolvedValue({
|
||||
id: "sub_1",
|
||||
studentId: "u_student",
|
||||
@@ -174,8 +193,7 @@ describe("homework action flow", () => {
|
||||
})
|
||||
|
||||
it("submits started homework before due time", async () => {
|
||||
mocks.authMock.mockResolvedValue({ user: { id: "u_student" } })
|
||||
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_student" }])
|
||||
mocks.requirePermissionMock.mockResolvedValue(studentCtx())
|
||||
mocks.submissionFindFirstMock.mockResolvedValue({
|
||||
id: "sub_2",
|
||||
studentId: "u_student",
|
||||
@@ -198,8 +216,7 @@ describe("homework action flow", () => {
|
||||
})
|
||||
|
||||
it("blocks start when attempts are exhausted", async () => {
|
||||
mocks.authMock.mockResolvedValue({ user: { id: "u_student" } })
|
||||
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_student" }])
|
||||
mocks.requirePermissionMock.mockResolvedValue(studentCtx())
|
||||
mocks.assignmentFindFirstMock.mockResolvedValue({
|
||||
id: "a_2",
|
||||
status: "published",
|
||||
@@ -217,8 +234,7 @@ describe("homework action flow", () => {
|
||||
})
|
||||
|
||||
it("grades submission and writes total score", async () => {
|
||||
mocks.authMock.mockResolvedValue({ user: { id: "u_teacher" } })
|
||||
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_teacher", role: "teacher" }])
|
||||
mocks.requirePermissionMock.mockResolvedValue(teacherCtx())
|
||||
|
||||
const formData = new FormData()
|
||||
formData.set("submissionId", "sub_1")
|
||||
@@ -239,8 +255,7 @@ describe("homework action flow", () => {
|
||||
})
|
||||
|
||||
it("saves new answer for started submission", async () => {
|
||||
mocks.authMock.mockResolvedValue({ user: { id: "u_student" } })
|
||||
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_student" }])
|
||||
mocks.requirePermissionMock.mockResolvedValue(studentCtx())
|
||||
mocks.submissionFindFirstMock.mockResolvedValue({
|
||||
id: "sub_3",
|
||||
studentId: "u_student",
|
||||
@@ -267,8 +282,7 @@ describe("homework action flow", () => {
|
||||
})
|
||||
|
||||
it("updates existing answer for started submission", async () => {
|
||||
mocks.authMock.mockResolvedValue({ user: { id: "u_student" } })
|
||||
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_student" }])
|
||||
mocks.requirePermissionMock.mockResolvedValue(studentCtx())
|
||||
mocks.submissionFindFirstMock.mockResolvedValue({
|
||||
id: "sub_4",
|
||||
studentId: "u_student",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const authMock = vi.fn()
|
||||
const requirePermissionMock = vi.fn()
|
||||
const revalidatePathMock = vi.fn()
|
||||
const createIdMock = vi.fn()
|
||||
|
||||
@@ -27,13 +27,10 @@ const mocks = vi.hoisted(() => {
|
||||
homeworkAssignmentTargets: { assignmentId: "assignmentId", studentId: "studentId" },
|
||||
homeworkAssignments: { id: "id" },
|
||||
homeworkSubmissions: { id: "id" },
|
||||
roles: { id: "id", name: "name" },
|
||||
users: { id: "id" },
|
||||
usersToRoles: { userId: "userId", roleId: "roleId" },
|
||||
}
|
||||
|
||||
return {
|
||||
authMock,
|
||||
requirePermissionMock,
|
||||
revalidatePathMock,
|
||||
createIdMock,
|
||||
ensureLimitMock,
|
||||
@@ -48,8 +45,14 @@ const mocks = vi.hoisted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("@/auth", () => ({
|
||||
auth: mocks.authMock,
|
||||
vi.mock("@/shared/lib/auth-guard", () => ({
|
||||
requirePermission: mocks.requirePermissionMock,
|
||||
PermissionDeniedError: class PermissionDeniedError extends Error {
|
||||
constructor(permission: string) {
|
||||
super(`Permission denied: ${permission}`)
|
||||
this.name = "PermissionDeniedError"
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("next/cache", () => ({
|
||||
@@ -118,14 +121,33 @@ vi.mock("@/shared/db", () => ({
|
||||
|
||||
import { createHomeworkAssignmentAction } from "@/modules/homework/actions"
|
||||
|
||||
/** Helper to create a default admin auth context */
|
||||
function adminCtx() {
|
||||
return {
|
||||
userId: "u_admin",
|
||||
roles: ["admin"],
|
||||
permissions: ["homework:create"],
|
||||
dataScope: { type: "all" as const },
|
||||
}
|
||||
}
|
||||
|
||||
/** Helper to create a teacher auth context */
|
||||
function teacherCtx(userId = "u_teacher") {
|
||||
return {
|
||||
userId,
|
||||
roles: ["teacher"],
|
||||
permissions: ["homework:create"],
|
||||
dataScope: { type: "class_taught" as const, classIds: ["class_5"], subjectIds: ["subject_science"] },
|
||||
}
|
||||
}
|
||||
|
||||
describe("createHomeworkAssignmentAction", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
it("creates published assignment from exam with targets", async () => {
|
||||
mocks.authMock.mockResolvedValue({ user: { id: "u_admin" } })
|
||||
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_admin", role: "admin" }])
|
||||
mocks.requirePermissionMock.mockResolvedValue(adminCtx())
|
||||
mocks.classLimitMock.mockResolvedValue([{ id: "class_1", teacherId: "teacher_1" }])
|
||||
mocks.examFindFirstMock.mockResolvedValue({
|
||||
id: "exam_1",
|
||||
@@ -150,8 +172,7 @@ describe("createHomeworkAssignmentAction", () => {
|
||||
})
|
||||
|
||||
it("returns not found when source exam does not exist", async () => {
|
||||
mocks.authMock.mockResolvedValue({ user: { id: "u_admin" } })
|
||||
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_admin", role: "admin" }])
|
||||
mocks.requirePermissionMock.mockResolvedValue(adminCtx())
|
||||
mocks.classLimitMock.mockResolvedValue([{ id: "class_1", teacherId: "teacher_1" }])
|
||||
mocks.examFindFirstMock.mockResolvedValue(null)
|
||||
|
||||
@@ -166,8 +187,7 @@ describe("createHomeworkAssignmentAction", () => {
|
||||
})
|
||||
|
||||
it("blocks publish when class has no active students", async () => {
|
||||
mocks.authMock.mockResolvedValue({ user: { id: "u_admin" } })
|
||||
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_admin", role: "admin" }])
|
||||
mocks.requirePermissionMock.mockResolvedValue(adminCtx())
|
||||
mocks.classLimitMock.mockResolvedValue([{ id: "class_2", teacherId: "teacher_2" }])
|
||||
mocks.examFindFirstMock.mockResolvedValue({
|
||||
id: "exam_2",
|
||||
@@ -189,8 +209,8 @@ describe("createHomeworkAssignmentAction", () => {
|
||||
})
|
||||
|
||||
it("blocks teacher when not assigned to class", async () => {
|
||||
mocks.authMock.mockResolvedValue({ user: { id: "u_teacher" } })
|
||||
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_teacher", role: "teacher" }])
|
||||
const ctx = teacherCtx("u_teacher")
|
||||
mocks.requirePermissionMock.mockResolvedValue(ctx)
|
||||
mocks.classLimitMock.mockResolvedValue([{ id: "class_3", teacherId: "owner_teacher" }])
|
||||
mocks.examFindFirstMock.mockResolvedValue({
|
||||
id: "exam_3",
|
||||
@@ -212,8 +232,8 @@ describe("createHomeworkAssignmentAction", () => {
|
||||
})
|
||||
|
||||
it("blocks teacher when exam subject is not assigned", async () => {
|
||||
mocks.authMock.mockResolvedValue({ user: { id: "u_teacher" } })
|
||||
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_teacher", role: "teacher" }])
|
||||
const ctx = teacherCtx("u_teacher")
|
||||
mocks.requirePermissionMock.mockResolvedValue(ctx)
|
||||
mocks.classLimitMock.mockResolvedValue([{ id: "class_4", teacherId: "owner_teacher" }])
|
||||
mocks.examFindFirstMock.mockResolvedValue({
|
||||
id: "exam_4",
|
||||
@@ -235,8 +255,8 @@ describe("createHomeworkAssignmentAction", () => {
|
||||
})
|
||||
|
||||
it("allows teacher assigned subject to publish", async () => {
|
||||
mocks.authMock.mockResolvedValue({ user: { id: "u_teacher" } })
|
||||
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_teacher", role: "teacher" }])
|
||||
const ctx = teacherCtx("u_teacher")
|
||||
mocks.requirePermissionMock.mockResolvedValue(ctx)
|
||||
mocks.classLimitMock.mockResolvedValue([{ id: "class_5", teacherId: "owner_teacher" }])
|
||||
mocks.examFindFirstMock.mockResolvedValue({
|
||||
id: "exam_5",
|
||||
@@ -260,8 +280,8 @@ describe("createHomeworkAssignmentAction", () => {
|
||||
})
|
||||
|
||||
it("returns exam subject missing for teacher-assigned class", async () => {
|
||||
mocks.authMock.mockResolvedValue({ user: { id: "u_teacher" } })
|
||||
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_teacher", role: "teacher" }])
|
||||
const ctx = teacherCtx("u_teacher")
|
||||
mocks.requirePermissionMock.mockResolvedValue(ctx)
|
||||
mocks.classLimitMock.mockResolvedValue([{ id: "class_6", teacherId: "owner_teacher" }])
|
||||
mocks.examFindFirstMock.mockResolvedValue({
|
||||
id: "exam_6",
|
||||
@@ -283,8 +303,7 @@ describe("createHomeworkAssignmentAction", () => {
|
||||
})
|
||||
|
||||
it("returns class not found when class is missing", async () => {
|
||||
mocks.authMock.mockResolvedValue({ user: { id: "u_admin" } })
|
||||
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_admin", role: "admin" }])
|
||||
mocks.requirePermissionMock.mockResolvedValue(adminCtx())
|
||||
mocks.classLimitMock.mockResolvedValue([])
|
||||
|
||||
const formData = new FormData()
|
||||
|
||||
@@ -1,45 +1,60 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
|
||||
vi.mock("@/auth", () => ({
|
||||
auth: (handler: (req: unknown) => unknown) => handler,
|
||||
const { getTokenMock } = vi.hoisted(() => ({
|
||||
getTokenMock: vi.fn(),
|
||||
}))
|
||||
|
||||
import proxy from "@/proxy"
|
||||
vi.mock("next-auth/jwt", () => ({
|
||||
getToken: getTokenMock,
|
||||
}))
|
||||
|
||||
type SessionRole = "admin" | "teacher" | "student" | "parent"
|
||||
import { middleware } from "@/proxy"
|
||||
|
||||
const createRequest = (pathname: string, role?: SessionRole) => ({
|
||||
const createRequest = (pathname: string) => ({
|
||||
nextUrl: {
|
||||
pathname,
|
||||
clone: () => new URL(`http://localhost${pathname}`),
|
||||
},
|
||||
auth: role ? { user: { role } } : null,
|
||||
url: `http://localhost${pathname}`,
|
||||
})
|
||||
|
||||
describe("proxy route guard", () => {
|
||||
it("redirects unauthenticated requests to login with callback", async () => {
|
||||
const response = await proxy(createRequest("/teacher/dashboard") as never)
|
||||
getTokenMock.mockResolvedValue(null)
|
||||
const response = await middleware(createRequest("/teacher/dashboard") as never)
|
||||
expect(response.status).toBe(307)
|
||||
const location = response.headers.get("location") ?? ""
|
||||
expect(location).toContain("/login")
|
||||
expect(location).toContain("callbackUrl=%2Fteacher%2Fdashboard")
|
||||
expect(location).toContain("callbackUrl=")
|
||||
expect(decodeURIComponent(location)).toContain("/teacher/dashboard")
|
||||
})
|
||||
|
||||
it("redirects student away from admin routes", async () => {
|
||||
const response = await proxy(createRequest("/admin/dashboard", "student") as never)
|
||||
it("redirects user without school:manage permission away from admin routes", async () => {
|
||||
getTokenMock.mockResolvedValue({
|
||||
permissions: ["homework:submit"],
|
||||
roles: ["student"],
|
||||
})
|
||||
const response = await middleware(createRequest("/admin/dashboard") as never)
|
||||
expect(response.status).toBe(307)
|
||||
expect(response.headers.get("location")).toContain("/student/dashboard")
|
||||
})
|
||||
|
||||
it("redirects parent away from management routes", async () => {
|
||||
const response = await proxy(createRequest("/management/grade/insights", "parent") as never)
|
||||
it("redirects user without grade:manage permission away from management routes", async () => {
|
||||
getTokenMock.mockResolvedValue({
|
||||
permissions: ["exam:read"],
|
||||
roles: ["parent"],
|
||||
})
|
||||
const response = await middleware(createRequest("/management/grade/insights") as never)
|
||||
expect(response.status).toBe(307)
|
||||
expect(response.headers.get("location")).toContain("/parent/dashboard")
|
||||
})
|
||||
|
||||
it("allows teacher access to management routes", async () => {
|
||||
const response = await proxy(createRequest("/management/grade/insights", "teacher") as never)
|
||||
it("allows user with grade:manage permission to access management routes", async () => {
|
||||
getTokenMock.mockResolvedValue({
|
||||
permissions: ["exam:read", "grade:manage"],
|
||||
roles: ["teacher"],
|
||||
})
|
||||
const response = await middleware(createRequest("/management/grade/insights") as never)
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get("location")).toBeNull()
|
||||
})
|
||||
|
||||
18
vitest.unit.config.ts
Normal file
18
vitest.unit.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import path from "node:path"
|
||||
import { defineConfig } from "vitest/config"
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
name: "unit",
|
||||
environment: "jsdom",
|
||||
include: ["src/**/*.test.{ts,tsx}"],
|
||||
clearMocks: true,
|
||||
restoreMocks: true,
|
||||
mockReset: true,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user