sync-docs-and-fixes
All checks were successful
CI / build-deploy (push) Successful in 4m39s

This commit is contained in:
SpecialX
2026-03-03 17:32:26 +08:00
parent 538805bad0
commit eb08c0ab68
73 changed files with 2218 additions and 422 deletions

View File

@@ -136,10 +136,10 @@ This release extends the Classes domain to support school-level sorting and per-
#### 2.2 Table: `class_subject_teachers`
* **Action**: `CREATE TABLE`
* **Primary Key**: (`class_id`, `subject`)
* **Primary Key**: (`class_id`, `subject_id`)
* **Columns**:
* `class_id` (varchar(128), FK -> `classes.id`, cascade delete)
* `subject` (enum: `语文/数学/英语/美术/体育/科学/社会/音乐`)
* `subject_id` (varchar(128), FK -> `subjects.id`, cascade delete)
* `teacher_id` (varchar(128), FK -> `users.id`, set null on delete)
* `created_at`, `updated_at`
* **Reason**: Maintain a stable default “subject list” per class while allowing admin/teacher to assign the actual teacher per subject.

View File

@@ -240,3 +240,20 @@ Seed 脚本已覆盖班级相关数据,以便在开发环境快速验证页面
- **移除 Insights**: 经评估,`src/app/(dashboard)/teacher/classes/insights` 模块功能冗余,已全量移除。
- **保留核心数据**: 保留 `data-access.ts` 中的 `getClassHomeworkInsights` 函数,继续服务于班级详情页的统计卡片与图表。
- **导航更新**: 从 `NAV_CONFIG` 中移除 Insights 入口。
---
## 9. 教师加入班级学科分配逻辑修复 (2026-03-03)
**日期**: 2026-03-03
**范围**: 教师通过邀请码加入班级(含学科选择)的校验与分配
### 9.1 行为调整
- 教师已在班级中但选择学科加入时,不再直接返回成功,继续执行学科占用校验。
- 班级未创建该学科映射时,先补齐映射再分配,避免误报“该班级不提供该学科”。
- 学科已被其他老师占用时,返回明确提示。
### 9.2 影响代码
- [data-access.ts](file:///e:/Desktop/CICD/src/modules/classes/data-access.ts)

View File

@@ -130,3 +130,21 @@ type QuestionContent = {
### 6.6 校验
- `npm run lint`0 errors仓库其他位置仍存在 warnings
- `npm run typecheck`:通过
---
## 7. 实现更新2026-03-03
### 7.1 登录态与权限校验
- 题库创建/更新/知识点加载统一使用会话身份;缺失会话时回退到首个教师账号以保持演示可用。
- 主要修改:
- [actions.ts](file:///c:/Users/xiner/Desktop/CICD/src/modules/questions/actions.ts)
### 7.2 弹窗稳定性
- Create Question 弹窗在打开时仅在默认知识点变化时更新,避免重复 setState 造成循环更新。
- 主要修改:
- [create-question-dialog.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/questions/components/create-question-dialog.tsx)
### 7.3 校验
- `npm run lint`:通过
- `npm run typecheck`:通过

View File

@@ -162,6 +162,12 @@ type ExamNode = {
## 7. 变更记录
**日期**2026-03-03
- **题库列表稳定性**:
- 题库卡片对题目 content/type 做解析兜底,避免异常数据导致运行时崩溃。
- 主要修改: [question-bank-list.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/exams/components/assembly/question-bank-list.tsx)
**日期**2026-01-12 (当前)
- **列表页优化 (`/teacher/exams/all`)**:

View File

@@ -304,3 +304,18 @@
- 问题:`isCorrect` 字段可能为 `boolean | null`,直接用于 JSX 渲染导致 "Type 'unknown' is not assignable to type 'ReactNode'" 错误。
- 修复:增加显式布尔值检查 `opt.isCorrect === true`,确保 React 条件渲染接收到合法的 boolean 值。
---
## 14. 实现更新2026-03-03
### 14.1 学生作业状态修复
- 提交作业与学生列表查询改为使用真实登录用户,避免提交后仍显示未开始。
- 学生列表优先展示最近一次已提交/已评分记录,提升状态准确性。
- 主要修改:
- [actions.ts](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/actions.ts)
- [data-access.ts](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/data-access.ts)
### 14.2 校验
- `npm run lint`:通过
- `npm run typecheck`:通过

View File

@@ -0,0 +1,313 @@
# 教师端页面实现分析文档
**日期**: 2026-03-03
**范围**: Teacher 路由与页面实现(`src/app/(dashboard)/teacher`
---
## 1. 总览
教师端页面采用服务端组件为主、按页面聚合数据的方式实现,页面负责:
- 读取数据(模块 data-access
- 组装 UI模块 components
- 处理空状态与跳转
所有页面路由位于 `src/app/(dashboard)/teacher`,各业务能力落在 `src/modules/*` 中。
---
## 2. 路由总表
### 2.1 教师工作台
- `/teacher/dashboard`
实现:[dashboard/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/dashboard/page.tsx)
### 2.2 班级
- `/teacher/classes``/teacher/classes/my`
实现:[classes/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/page.tsx)
- `/teacher/classes/my`
实现:[classes/my/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/my/page.tsx)
- `/teacher/classes/my/[id]`
实现:[classes/my/[id]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/my/%5Bid%5D/page.tsx)
- `/teacher/classes/students`
实现:[classes/students/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/students/page.tsx)
- `/teacher/classes/schedule`
实现:[classes/schedule/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/schedule/page.tsx)
### 2.3 作业
- `/teacher/homework``/teacher/homework/assignments`
实现:[homework/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/page.tsx)
- `/teacher/homework/assignments`
实现:[homework/assignments/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/page.tsx)
- `/teacher/homework/assignments/create`
实现:[homework/assignments/create/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/create/page.tsx)
- `/teacher/homework/assignments/[id]`
实现:[homework/assignments/[id]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/%5Bid%5D/page.tsx)
- `/teacher/homework/assignments/[id]/submissions`
实现:[homework/assignments/[id]/submissions/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/%5Bid%5D/submissions/page.tsx)
- `/teacher/homework/submissions`
实现:[homework/submissions/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/submissions/page.tsx)
- `/teacher/homework/submissions/[submissionId]`
实现:[homework/submissions/[submissionId]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/submissions/%5BsubmissionId%5D/page.tsx)
### 2.4 考试
- `/teacher/exams``/teacher/exams/all`
实现:[exams/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/page.tsx)
- `/teacher/exams/all`
实现:[exams/all/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/all/page.tsx)
- `/teacher/exams/create`
实现:[exams/create/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/create/page.tsx)
- `/teacher/exams/[id]/build`
实现:[exams/[id]/build/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/%5Bid%5D/build/page.tsx)
- `/teacher/exams/grading``/teacher/homework/submissions`
实现:[exams/grading/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/grading/page.tsx)
- `/teacher/exams/grading/[submissionId]``/teacher/homework/submissions`
实现:[exams/grading/[submissionId]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/grading/%5BsubmissionId%5D/page.tsx)
### 2.5 题库
- `/teacher/questions`
实现:[questions/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/questions/page.tsx)
### 2.6 教材
- `/teacher/textbooks`
实现:[textbooks/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/textbooks/page.tsx)
- `/teacher/textbooks/[id]`
实现:[textbooks/[id]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/textbooks/%5Bid%5D/page.tsx)
---
## 3. 页面详解(逐页)
### 3.1 教师工作台 `/teacher/dashboard`
实现:[dashboard/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/dashboard/page.tsx)
- **目的**: 教师总览工作台,展示班级、课表、作业、提交、成绩趋势与教师姓名。
- **数据来源**:
- 班级与课表:`getTeacherClasses``getClassSchedule`
- 作业与提交:`getHomeworkAssignments``getHomeworkSubmissions`
- 教师姓名:`users` 表查询
- 成绩趋势:`getTeacherGradeTrends`
- **关键组件**: `TeacherDashboardView`
- **渲染策略**: `dynamic = "force-dynamic"`
### 3.2 班级入口 `/teacher/classes`
实现:[classes/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/page.tsx)
- **目的**: 跳转入口,统一导向“我的班级”。
- **行为**: `redirect("/teacher/classes/my")`
### 3.3 我的班级 `/teacher/classes/my`
实现:[classes/my/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/my/page.tsx)
- **目的**: 展示教师负责班级的卡片列表,并支持学科筛选/加入。
- **数据来源**:
- 班级:`getTeacherClasses`
- 学科选项:`getClassSubjects`
- **关键组件**: `MyClassesGrid`
- **渲染策略**: `dynamic = "force-dynamic"`
### 3.4 班级详情 `/teacher/classes/my/[id]`
实现:[classes/my/[id]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/my/%5Bid%5D/page.tsx)
- **目的**: 展示班级概览(作业趋势、学生、课表、作业摘要)。
- **数据来源**:
- 班级作业洞察:`getClassHomeworkInsights`
- 学生列表:`getClassStudents`
- 课表:`getClassSchedule`
- 学科成绩:`getClassStudentSubjectScoresV2`
- **关键组件**:
- `ClassHeader`
- `ClassOverviewStats`
- `ClassTrendsWidget`
- `ClassStudentsWidget`
- `ClassScheduleWidget`
- `ClassAssignmentsWidget`
- **空状态**: `insights` 缺失返回 `notFound()`
- **渲染策略**: `dynamic = "force-dynamic"`
### 3.5 学生列表 `/teacher/classes/students`
实现:[classes/students/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/students/page.tsx)
- **目的**: 按班级、关键词、状态筛选学生,并显示学科成绩。
- **数据来源**:
- 教师班级:`getTeacherClasses`
- 学生列表:`getClassStudents`
- 学科成绩:`getStudentsSubjectScores`
- **关键组件**:
- `StudentsFilters`
- `StudentsTable`
- `EmptyState` / `Skeleton`
- **筛选逻辑**: 未显式选择班级时默认第一班级
- **渲染策略**: `dynamic = "force-dynamic"`
### 3.6 课表 `/teacher/classes/schedule`
实现:[classes/schedule/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/schedule/page.tsx)
- **目的**: 按班级查看课表。
- **数据来源**:
- 班级:`getTeacherClasses`
- 课表:`getClassSchedule`
- **关键组件**:
- `ScheduleFilters`
- `ScheduleView`
- `EmptyState` / `Skeleton`
- **渲染策略**: `dynamic = "force-dynamic"`
### 3.7 作业入口 `/teacher/homework`
实现:[homework/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/page.tsx)
- **目的**: 统一导向作业列表。
- **行为**: `redirect("/teacher/homework/assignments")`
### 3.8 作业列表 `/teacher/homework/assignments`
实现:[homework/assignments/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/page.tsx)
- **目的**: 查看作业列表与状态,支持按班级筛选,提供创建入口。
- **数据来源**:
- 作业列表:`getHomeworkAssignments`
- 班级列表(用于显示名称):`getTeacherClasses`
- **关键组件**: `Table``Badge``EmptyState`
- **空状态**: 无作业时提示创建
- **渲染策略**: `dynamic = "force-dynamic"`
### 3.9 创建作业 `/teacher/homework/assignments/create`
实现:[homework/assignments/create/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/create/page.tsx)
- **目的**: 从 Exam 派发作业。
- **数据来源**:
- 可派发的 Exam`getExams`
- 班级列表:`getTeacherClasses`
- **关键组件**: `HomeworkAssignmentForm`
- **空状态**:
- 无 Exam提示先创建考试
- 无班级:提示先创建班级
### 3.10 作业详情 `/teacher/homework/assignments/[id]`
实现:[homework/assignments/[id]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/%5Bid%5D/page.tsx)
- **目的**: 展示作业详情、错题概览与试卷内容。
- **数据来源**: `getHomeworkAssignmentAnalytics`
- **关键组件**:
- `HomeworkAssignmentQuestionErrorOverviewCard`
- `HomeworkAssignmentExamContentCard`
- **空状态**: 查不到作业时 `notFound()`
### 3.11 作业提交列表 `/teacher/homework/assignments/[id]/submissions`
实现:[homework/assignments/[id]/submissions/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/%5Bid%5D/submissions/page.tsx)
- **目的**: 按作业查看提交与评分进度。
- **数据来源**:
- 作业信息:`getHomeworkAssignmentById`
- 提交列表:`getHomeworkSubmissions`
- **关键组件**: `Table``Badge`
- **空状态**: 作业不存在时 `notFound()`
### 3.12 全部提交 `/teacher/homework/submissions`
实现:[homework/submissions/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/submissions/page.tsx)
- **目的**: 按作业汇总查看所有提交与批改入口。
- **数据来源**:
- 教师身份:`getTeacherIdForMutations`
- 作业审阅列表:`getHomeworkAssignmentReviewList`
- **关键组件**: `Table``Badge``EmptyState`
### 3.13 提交批改 `/teacher/homework/submissions/[submissionId]`
实现:[homework/submissions/[submissionId]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/submissions/%5BsubmissionId%5D/page.tsx)
- **目的**: 作业批改视图,按题打分与反馈。
- **数据来源**: `getHomeworkSubmissionDetails`
- **关键组件**: `HomeworkGradingView`
- **空状态**: 查不到提交时 `notFound()`
### 3.14 考试入口 `/teacher/exams`
实现:[exams/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/page.tsx)
- **目的**: 统一导向考试列表。
- **行为**: `redirect("/teacher/exams/all")`
### 3.15 考试列表 `/teacher/exams/all`
实现:[exams/all/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/all/page.tsx)
- **目的**: 列出考试并支持筛选、统计、创建入口。
- **数据来源**: `getExams`(按关键词/状态/难度过滤)
- **关键组件**:
- `ExamFilters`
- `ExamDataTable`
- `EmptyState` / `Skeleton`
- **统计**: 对列表结果进行状态数量统计draft/published/archived
### 3.16 创建考试 `/teacher/exams/create`
实现:[exams/create/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/create/page.tsx)
- **目的**: 创建考试基础信息。
- **关键组件**: `ExamForm`
### 3.17 组卷 `/teacher/exams/[id]/build`
实现:[exams/[id]/build/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/%5Bid%5D/build/page.tsx)
- **目的**: 从题库选择题目并构建考试结构。
- **数据来源**:
- 考试数据:`getExamById`
- 题库数据:`getQuestions`
- **关键组件**: `ExamAssembly`
- **关键逻辑**:
- 读取已选题并初始化 `initialSelected`
- 将题目数据映射为 `Question` 类型
- 归一化 `structure` 并保证节点 `id` 唯一
### 3.18 阅卷入口 `/teacher/exams/grading*`
实现:[exams/grading/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/grading/page.tsx)、[exams/grading/[submissionId]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/grading/%5BsubmissionId%5D/page.tsx)
- **目的**: 统一重定向至作业批改视图。
- **行为**: `redirect("/teacher/homework/submissions")`
### 3.19 题库 `/teacher/questions`
实现:[questions/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/questions/page.tsx)
- **目的**: 题库管理与筛选。
- **数据来源**: `getQuestions`(按关键词/题型/难度过滤)
- **关键组件**:
- `QuestionFilters`
- `QuestionDataTable`
- `CreateQuestionButton`
- `EmptyState` / `Skeleton`
### 3.20 教材列表 `/teacher/textbooks`
实现:[textbooks/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/textbooks/page.tsx)
- **目的**: 教材管理与筛选,创建入口。
- **数据来源**: `getTextbooks`
- **关键组件**:
- `TextbookFilters`
- `TextbookCard`
- `TextbookFormDialog`
- `EmptyState`
- **渲染策略**: `dynamic = "force-dynamic"`
### 3.21 教材详情 `/teacher/textbooks/[id]`
实现:[textbooks/[id]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/textbooks/%5Bid%5D/page.tsx)
- **目的**: 教材章节与知识点结构阅读与维护。
- **数据来源**:
- 教材:`getTextbookById`
- 章节:`getChaptersByTextbookId`
- 知识点:`getKnowledgePointsByTextbookId`
- **关键组件**:
- `TextbookReader`
- `TextbookSettingsDialog`
- **空状态**: 教材不存在时 `notFound()`
---
## 4. 依赖模块清单
教师端页面主要依赖以下模块:
- 班级与排课:`src/modules/classes`
- 作业:`src/modules/homework`
- 考试:`src/modules/exams`
- 题库:`src/modules/questions`
- 教材:`src/modules/textbooks`
- 工作台:`src/modules/dashboard`

View File

@@ -0,0 +1,150 @@
# 功能实现对比文档(已实现 vs 规划)
**日期**: 2026-03-03
**范围**: 基于 PRD 与现有设计文档的功能落地对比
---
## 1. 依据与来源
本对比基于以下文档:
- 产品规划PRD`docs/product_requirements.md`
- 已实现模块:
- 教师仪表盘与班级管理:[002_teacher_dashboard_implementation.md](file:///e:/Desktop/CICD/docs/design/002_teacher_dashboard_implementation.md)
- 教材模块:[003_textbooks_module_implementation.md](file:///e:/Desktop/CICD/docs/design/003_textbooks_module_implementation.md)
- 题库模块:[004_question_bank_implementation.md](file:///e:/Desktop/CICD/docs/design/004_question_bank_implementation.md)
- 考试模块:[005_exam_module_implementation.md](file:///e:/Desktop/CICD/docs/design/005_exam_module_implementation.md)
- 作业模块:[006_homework_module_implementation.md](file:///e:/Desktop/CICD/docs/design/006_homework_module_implementation.md)
- 学校管理模块:[007_school_module_implementation.md](file:///e:/Desktop/CICD/docs/design/007_school_module_implementation.md)
---
## 2. 便利贴视图(按 Roles / Page / 功能)
> **🟨 Roles**
>
> - **教师Teacher**:备课、出卷、作业批改与班级管理
> - **学生Student**:作业完成与教材阅读(只读)
> - **管理端Admin/校长/年级主任/教研组长/班主任)**:规划中,未完全落地
> **🟨 Page**
>
> - **教师工作台**`/teacher/dashboard`
> - **班级**`/teacher/classes/*`
> - **作业**`/teacher/homework/*`
> - **考试**`/teacher/exams/*`
> - **题库**`/teacher/questions`
> - **教材**`/teacher/textbooks/*`
> **🟨 功能(规划目标)**
>
> - **权限与角色**:多角色矩阵 + RLS 行级隔离
> - **智能题库**:嵌套题、知识点关联
> - **知识图谱**:知识点树 + 题目/章节关联
> - **教材映射**:章节 ↔ 知识点
> - **组卷引擎**:筛选/分组/结构化试卷
> - **作业闭环**:派发-提交-批改-统计
> - **通知中心**:分级提醒策略
---
## 3. 已实现页面功能清单(简述)
> **🟨 教师工作台**
>
> - `/teacher/dashboard`
> 功能:汇总班级、课表、作业、提交与成绩趋势
> 目标:快速掌握教学全局
> **🟨 班级**
>
> - `/teacher/classes`
> 功能:班级入口重定向
> 目标:统一进入我的班级
> - `/teacher/classes/my`
> 功能:班级列表与学科选择
> 目标:管理所教班级
> - `/teacher/classes/my/[id]`
> 功能:班级详情概览、作业趋势、学生与课表
> 目标:掌握班级学习情况
> - `/teacher/classes/students`
> 功能:学生筛选与成绩查看
> 目标:定位学生画像与状态
> - `/teacher/classes/schedule`
> 功能:班级课表查看
> 目标:排课信息可视化
> **🟨 作业**
>
> - `/teacher/homework/assignments`
> 功能:作业列表与状态
> 目标:管理作业发布
> - `/teacher/homework/assignments/create`
> 功能:从考试派发作业
> 目标:快速生成作业
> - `/teacher/homework/assignments/[id]`
> 功能:作业详情与错题概览
> 目标:定位薄弱题型
> - `/teacher/homework/assignments/[id]/submissions`
> 功能:作业提交列表
> 目标:查看班级完成度
> - `/teacher/homework/submissions`
> 功能:所有作业提交汇总
> 目标:统一批改入口
> - `/teacher/homework/submissions/[submissionId]`
> 功能:作业批改与反馈
> 目标:完成评分与讲评
> **🟨 考试**
>
> - `/teacher/exams/all`
> 功能:考试列表与筛选
> 目标:管理考试资产
> - `/teacher/exams/create`
> 功能:创建考试基础信息
> 目标:建立试卷草稿
> - `/teacher/exams/[id]/build`
> 功能:题库选题与结构化组卷
> 目标:完成试卷构建
> **🟨 题库**
>
> - `/teacher/questions`
> 功能:题库检索与管理
> 目标:积累与复用题目
> **🟨 教材**
>
> - `/teacher/textbooks`
> 功能:教材管理与筛选
> 目标:组织课程资源
> - `/teacher/textbooks/[id]`
> 功能:章节与知识点维护
> 目标:构建教材结构
---
## 4. 差距清单(简述)
> **🟨 权限与治理**
>
> - 多角色 RBAC 细化权限未落地
> - RLS 数据隔离策略未落地
> **🟨 教学质量与推荐**
>
> - 章节 → 知识点 → 题库的自动推荐链路未落地
> - 知识点图谱深层能力未落地
> - 学科维度与权重/标签机制未落地
> **🟨 组卷与作业高级能力**
>
> - AB 卷与乱序策略未落地
> - 作业分层与交集筛选未落地
> - 学习画像/成长档案层的评估闭环尚未体现
> **🟨 通知与消息闭环**
>
> - 分级通知体系未落地

View File

@@ -4,6 +4,7 @@ import { users, exams, questions, knowledgePoints, examSubmissions, examQuestion
import { createId } from "@paralleldrive/cuid2"
import { faker } from "@faker-js/faker"
import { eq } from "drizzle-orm"
import { hash } from "bcryptjs"
/**
* Seed Script for Next_Edu
@@ -19,6 +20,7 @@ const DIFFICULTY = [1, 2, 3, 4, 5]
async function seed() {
console.log("🌱 Starting seed process...")
const passwordHash = await hash("123456", 10)
// 1. Create a Teacher User if not exists
const teacherEmail = "teacher@example.com"
@@ -36,6 +38,7 @@ async function seed() {
email: teacherEmail,
role: "teacher",
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Teacher",
password: passwordHash,
})
} else {
teacherId = existingTeacher.id
@@ -54,6 +57,7 @@ async function seed() {
email: faker.internet.email(),
role: "student",
image: `https://api.dicebear.com/7.x/avataaars/svg?seed=${sId}`,
password: passwordHash,
})
}

View File

@@ -1,5 +1,17 @@
# Work Log
## 2026-03-03
### 1. 教师加入班级学科分配逻辑修复
- 修复教师已在班级中但选择学科加入时被直接返回成功的问题。
- 班级未创建该学科映射时,先补齐映射再分配。
- 学科已被其他老师占用时,返回明确提示。
- 主要修改:
- [data-access.ts](file:///e:/Desktop/CICD/src/modules/classes/data-access.ts)
### 2. 验证
- 质量检查:`npm run lint``npm run typecheck` 均通过。
## 2026-03-02
### 1. 班级详情访问修复(基于会话身份)

View File

@@ -0,0 +1,88 @@
SET @has_exams_subject := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'exams'
AND COLUMN_NAME = 'subject_id'
);--> statement-breakpoint
SET @sql := IF(@has_exams_subject = 0, 'ALTER TABLE `exams` ADD `subject_id` varchar(128);', 'SELECT 1');--> statement-breakpoint
PREPARE stmt FROM @sql;--> statement-breakpoint
EXECUTE stmt;--> statement-breakpoint
DEALLOCATE PREPARE stmt;--> statement-breakpoint
SET @has_exams_grade := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'exams'
AND COLUMN_NAME = 'grade_id'
);--> statement-breakpoint
SET @sql := IF(@has_exams_grade = 0, 'ALTER TABLE `exams` ADD `grade_id` varchar(128);', 'SELECT 1');--> statement-breakpoint
PREPARE stmt FROM @sql;--> statement-breakpoint
EXECUTE stmt;--> statement-breakpoint
DEALLOCATE PREPARE stmt;--> statement-breakpoint
SET @has_kp_anchor := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'knowledge_points'
AND COLUMN_NAME = 'anchor_text'
);--> statement-breakpoint
SET @sql := IF(@has_kp_anchor = 0, 'ALTER TABLE `knowledge_points` ADD `anchor_text` varchar(255);', 'SELECT 1');--> statement-breakpoint
PREPARE stmt FROM @sql;--> statement-breakpoint
EXECUTE stmt;--> statement-breakpoint
DEALLOCATE PREPARE stmt;--> statement-breakpoint
SET @has_exams_subject_fk := (
SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'exams'
AND CONSTRAINT_NAME = 'exams_subject_id_subjects_id_fk'
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
);--> statement-breakpoint
SET @sql := IF(@has_exams_subject_fk = 0, 'ALTER TABLE `exams` ADD CONSTRAINT `exams_subject_id_subjects_id_fk` FOREIGN KEY (`subject_id`) REFERENCES `subjects`(`id`) ON DELETE no action ON UPDATE no action;', 'SELECT 1');--> statement-breakpoint
PREPARE stmt FROM @sql;--> statement-breakpoint
EXECUTE stmt;--> statement-breakpoint
DEALLOCATE PREPARE stmt;--> statement-breakpoint
SET @has_exams_grade_fk := (
SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'exams'
AND CONSTRAINT_NAME = 'exams_grade_id_grades_id_fk'
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
);--> statement-breakpoint
SET @sql := IF(@has_exams_grade_fk = 0, 'ALTER TABLE `exams` ADD CONSTRAINT `exams_grade_id_grades_id_fk` FOREIGN KEY (`grade_id`) REFERENCES `grades`(`id`) ON DELETE no action ON UPDATE no action;', 'SELECT 1');--> statement-breakpoint
PREPARE stmt FROM @sql;--> statement-breakpoint
EXECUTE stmt;--> statement-breakpoint
DEALLOCATE PREPARE stmt;--> statement-breakpoint
SET @has_exams_subject_idx := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'exams'
AND INDEX_NAME = 'exams_subject_idx'
);--> statement-breakpoint
SET @sql := IF(@has_exams_subject_idx = 0, 'CREATE INDEX `exams_subject_idx` ON `exams` (`subject_id`);', 'SELECT 1');--> statement-breakpoint
PREPARE stmt FROM @sql;--> statement-breakpoint
EXECUTE stmt;--> statement-breakpoint
DEALLOCATE PREPARE stmt;--> statement-breakpoint
SET @has_exams_grade_idx := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'exams'
AND INDEX_NAME = 'exams_grade_idx'
);--> statement-breakpoint
SET @sql := IF(@has_exams_grade_idx = 0, 'CREATE INDEX `exams_grade_idx` ON `exams` (`grade_id`);', 'SELECT 1');--> statement-breakpoint
PREPARE stmt FROM @sql;--> statement-breakpoint
EXECUTE stmt;--> statement-breakpoint
DEALLOCATE PREPARE stmt;--> statement-breakpoint
INSERT IGNORE INTO `roles` (`id`, `name`, `created_at`, `updated_at`)
SELECT UUID(), LOWER(TRIM(`role`)), NOW(), NOW()
FROM `users`
WHERE `role` IS NOT NULL AND TRIM(`role`) <> '';--> statement-breakpoint
INSERT IGNORE INTO `users_to_roles` (`user_id`, `role_id`)
SELECT `users`.`id`, `roles`.`id`
FROM `users`
INNER JOIN `roles` ON `roles`.`name` = LOWER(TRIM(`users`.`role`))
WHERE `users`.`role` IS NOT NULL AND TRIM(`users`.`role`) <> '';--> statement-breakpoint
ALTER TABLE `users` DROP COLUMN `role`;

View File

@@ -0,0 +1,110 @@
SET @has_subject_id := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'class_subject_teachers'
AND COLUMN_NAME = 'subject_id'
);--> statement-breakpoint
SET @sql := IF(@has_subject_id = 0, 'ALTER TABLE `class_subject_teachers` ADD COLUMN `subject_id` VARCHAR(128) NULL;', 'SELECT 1');--> statement-breakpoint
PREPARE stmt FROM @sql;--> statement-breakpoint
EXECUTE stmt;--> statement-breakpoint
DEALLOCATE PREPARE stmt;--> statement-breakpoint
INSERT INTO `subjects` (`id`, `name`, `code`)
SELECT UUID(), '语文', 'CHINESE' FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM `subjects` WHERE `name` = '语文' OR `code` = 'CHINESE')
UNION ALL
SELECT UUID(), '数学', 'MATH' FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM `subjects` WHERE `name` = '数学' OR `code` = 'MATH')
UNION ALL
SELECT UUID(), '英语', 'ENG' FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM `subjects` WHERE `name` = '英语' OR `code` = 'ENG')
UNION ALL
SELECT UUID(), '美术', 'ART' FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM `subjects` WHERE `name` = '美术' OR `code` = 'ART')
UNION ALL
SELECT UUID(), '体育', 'PE' FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM `subjects` WHERE `name` = '体育' OR `code` = 'PE')
UNION ALL
SELECT UUID(), '科学', 'SCI' FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM `subjects` WHERE `name` = '科学' OR `code` = 'SCI')
UNION ALL
SELECT UUID(), '社会', 'SOC' FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM `subjects` WHERE `name` = '社会' OR `code` = 'SOC')
UNION ALL
SELECT UUID(), '音乐', 'MUSIC' FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM `subjects` WHERE `name` = '音乐' OR `code` = 'MUSIC');--> statement-breakpoint
SET @has_subject_col := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'class_subject_teachers'
AND COLUMN_NAME = 'subject'
);--> statement-breakpoint
SET @sql := IF(@has_subject_col = 1, '
UPDATE `class_subject_teachers` cst
JOIN `subjects` s ON (
s.`name` = cst.`subject`
OR s.`code` = CASE cst.`subject`
WHEN ''语文'' THEN ''CHINESE''
WHEN ''数学'' THEN ''MATH''
WHEN ''英语'' THEN ''ENG''
WHEN ''美术'' THEN ''ART''
WHEN ''体育'' THEN ''PE''
WHEN ''科学'' THEN ''SCI''
WHEN ''社会'' THEN ''SOC''
WHEN ''音乐'' THEN ''MUSIC''
ELSE NULL
END
)
SET cst.`subject_id` = s.`id`
WHERE cst.`subject_id` IS NULL;
', 'SELECT 1');--> statement-breakpoint
PREPARE stmt FROM @sql;--> statement-breakpoint
EXECUTE stmt;--> statement-breakpoint
DEALLOCATE PREPARE stmt;--> statement-breakpoint
SET @subject_id_nulls := (
SELECT COUNT(*) FROM `class_subject_teachers`
WHERE `subject_id` IS NULL
);--> statement-breakpoint
SET @sql := IF(@subject_id_nulls = 0, 'ALTER TABLE `class_subject_teachers` MODIFY COLUMN `subject_id` VARCHAR(128) NOT NULL;', 'SELECT 1');--> statement-breakpoint
PREPARE stmt FROM @sql;--> statement-breakpoint
EXECUTE stmt;--> statement-breakpoint
DEALLOCATE PREPARE stmt;--> statement-breakpoint
SET @sql := IF(@subject_id_nulls = 0, 'ALTER TABLE `class_subject_teachers` DROP PRIMARY KEY;', 'SELECT 1');--> statement-breakpoint
PREPARE stmt FROM @sql;--> statement-breakpoint
EXECUTE stmt;--> statement-breakpoint
DEALLOCATE PREPARE stmt;--> statement-breakpoint
SET @sql := IF(@subject_id_nulls = 0, 'ALTER TABLE `class_subject_teachers` ADD PRIMARY KEY (`class_id`, `subject_id`);', 'SELECT 1');--> statement-breakpoint
PREPARE stmt FROM @sql;--> statement-breakpoint
EXECUTE stmt;--> statement-breakpoint
DEALLOCATE PREPARE stmt;--> statement-breakpoint
SET @sql := IF(@subject_id_nulls = 0 AND @has_subject_col = 1, 'ALTER TABLE `class_subject_teachers` DROP COLUMN `subject`;', 'SELECT 1');--> statement-breakpoint
PREPARE stmt FROM @sql;--> statement-breakpoint
EXECUTE stmt;--> statement-breakpoint
DEALLOCATE PREPARE stmt;--> statement-breakpoint
SET @has_subject_id_idx := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'class_subject_teachers'
AND INDEX_NAME = 'class_subject_teachers_subject_id_idx'
);--> statement-breakpoint
SET @sql := IF(@subject_id_nulls = 0 AND @has_subject_id_idx = 0, 'CREATE INDEX `class_subject_teachers_subject_id_idx` ON `class_subject_teachers` (`subject_id`);', 'SELECT 1');--> statement-breakpoint
PREPARE stmt FROM @sql;--> statement-breakpoint
EXECUTE stmt;--> statement-breakpoint
DEALLOCATE PREPARE stmt;--> statement-breakpoint
SET @has_subject_fk := (
SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'class_subject_teachers'
AND CONSTRAINT_NAME = 'cst_s_fk'
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
);--> statement-breakpoint
SET @sql := IF(@subject_id_nulls = 0 AND @has_subject_fk = 0, 'ALTER TABLE `class_subject_teachers` ADD CONSTRAINT `cst_s_fk` FOREIGN KEY (`subject_id`) REFERENCES `subjects`(`id`) ON DELETE CASCADE;', 'SELECT 1');--> statement-breakpoint
PREPARE stmt FROM @sql;--> statement-breakpoint
EXECUTE stmt;--> statement-breakpoint
DEALLOCATE PREPARE stmt;

View File

@@ -2,7 +2,7 @@
"version": "5",
"dialect": "mysql",
"id": "2cf4c7e4-f538-499e-a5a5-9281d556bc9d",
"prevId": "5eaf9185-8a1e-4e35-8144-553aec7ff31f",
"prevId": "a6d95d47-4400-464e-bc53-45735dd6e3e3",
"tables": {
"academic_years": {
"name": "academic_years",
@@ -3068,4 +3068,4 @@
"tables": {},
"indexes": {}
}
}
}

View File

@@ -1,8 +1,8 @@
{
"version": "5",
"dialect": "mysql",
"id": "5eaf9185-8a1e-4e35-8144-553aec7ff31f",
"prevId": "3b23e056-3d79-4ea9-a03e-d1b5d56bafda",
"id": "551f3408-945e-4f1d-984c-bfd35fe9d0ea",
"prevId": "2cf4c7e4-f538-499e-a5a5-9281d556bc9d",
"tables": {
"academic_years": {
"name": "academic_years",
@@ -1180,6 +1180,20 @@
"notNull": true,
"autoincrement": false
},
"subject_id": {
"name": "subject_id",
"type": "varchar(128)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"grade_id": {
"name": "grade_id",
"type": "varchar(128)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"start_time": {
"name": "start_time",
"type": "timestamp",
@@ -1220,7 +1234,22 @@
"default": "(now())"
}
},
"indexes": {},
"indexes": {
"exams_subject_idx": {
"name": "exams_subject_idx",
"columns": [
"subject_id"
],
"isUnique": false
},
"exams_grade_idx": {
"name": "exams_grade_idx",
"columns": [
"grade_id"
],
"isUnique": false
}
},
"foreignKeys": {
"exams_creator_id_users_id_fk": {
"name": "exams_creator_id_users_id_fk",
@@ -1234,6 +1263,32 @@
],
"onDelete": "no action",
"onUpdate": "no action"
},
"exams_subject_id_subjects_id_fk": {
"name": "exams_subject_id_subjects_id_fk",
"tableFrom": "exams",
"tableTo": "subjects",
"columnsFrom": [
"subject_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"exams_grade_id_grades_id_fk": {
"name": "exams_grade_id_grades_id_fk",
"tableFrom": "exams",
"tableTo": "grades",
"columnsFrom": [
"grade_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
@@ -2009,6 +2064,13 @@
"notNull": false,
"autoincrement": false
},
"anchor_text": {
"name": "anchor_text",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"parent_id": {
"name": "parent_id",
"type": "varchar(128)",
@@ -2765,14 +2827,6 @@
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'student'"
},
"password": {
"name": "password",
"type": "varchar(255)",
@@ -3006,4 +3060,4 @@
"tables": {},
"indexes": {}
}
}
}

View File

@@ -64,6 +64,20 @@
"when": 1768470966367,
"tag": "0008_thin_madrox",
"breakpoints": true
},
{
"idx": 9,
"version": "5",
"when": 1772162908476,
"tag": "0009_smart_mephistopheles",
"breakpoints": true
},
{
"idx": 10,
"version": "5",
"when": 1772439600000,
"tag": "0010_subject_id_switch",
"breakpoints": true
}
]
}
}

View File

@@ -0,0 +1,38 @@
import { config } from "dotenv"
import mysql from "mysql2/promise"
async function main() {
config()
const url = process.env.DATABASE_URL
if (!url) {
console.error("Missing DATABASE_URL")
process.exit(1)
return
}
const conn = await mysql.createConnection(url)
try {
const [rows] = await conn.query("SHOW COLUMNS FROM class_subject_teachers")
const [keys] = await conn.query("SHOW KEYS FROM class_subject_teachers")
let migrations: Array<{ id: number | string; hash: string; created_at: number | string }> | null = null
try {
const [m] = await conn.query("SELECT id, hash, created_at FROM __drizzle_migrations ORDER BY id DESC LIMIT 5")
migrations = m as Array<{ id: number | string; hash: string; created_at: number | string }>
} catch (error: unknown) {
console.error(error)
}
const columns = rows as Array<{ Field: string; Type: string; Null: string; Key: string }>
const indexes = keys as Array<{ Key_name: string; Column_name: string }>
console.log(columns.map((r) => `${r.Field}:${r.Type}:${r.Null}:${r.Key}`).join("\n"))
console.log(indexes.map((r) => `${r.Key_name}:${r.Column_name}`).join("\n"))
if (migrations) {
console.log(migrations.map((r) => `${r.id}:${r.hash}:${r.created_at}`).join("\n"))
}
} finally {
await conn.end()
}
}
main().catch((e) => {
console.error(e)
process.exit(1)
})

View File

@@ -0,0 +1,58 @@
import { config } from "dotenv"
import { db } from "@/shared/db"
import { sql } from "drizzle-orm"
async function main() {
config()
// 1) add subject_id column if not exists (nullable first)
await db.execute(sql`ALTER TABLE class_subject_teachers ADD COLUMN IF NOT EXISTS subject_id VARCHAR(128) NULL;`)
// 2) backfill subject_id from subjects.name matching existing 'subject' column
// This assumes existing data uses subjects.name 值;若不匹配,将在 NOT NULL 约束处失败
await db.execute(sql`
UPDATE class_subject_teachers cst
JOIN subjects s ON (
s.name = cst.subject
OR s.code = CASE cst.subject
WHEN '语文' THEN 'CHINESE'
WHEN '数学' THEN 'MATH'
WHEN '英语' THEN 'ENG'
WHEN '美术' THEN 'ART'
WHEN '体育' THEN 'PE'
WHEN '科学' THEN 'SCI'
WHEN '社会' THEN 'SOC'
WHEN '音乐' THEN 'MUSIC'
ELSE NULL
END
)
SET cst.subject_id = s.id
WHERE cst.subject_id IS NULL
`)
// 3) enforce NOT NULL
await db.execute(sql`ALTER TABLE class_subject_teachers MODIFY COLUMN subject_id VARCHAR(128) NOT NULL;`)
// 4) drop old PK and create new PK (class_id, subject_id)
try { await db.execute(sql`ALTER TABLE class_subject_teachers DROP PRIMARY KEY;`) } catch {}
await db.execute(sql`ALTER TABLE class_subject_teachers ADD PRIMARY KEY (class_id, subject_id);`)
// 5) drop old subject column if exists
await db.execute(sql`ALTER TABLE class_subject_teachers DROP COLUMN IF EXISTS subject;`)
// 6) add index and FK
try { await db.execute(sql`CREATE INDEX class_subject_teachers_subject_id_idx ON class_subject_teachers (subject_id);`) } catch {}
try { await db.execute(sql`ALTER TABLE class_subject_teachers DROP FOREIGN KEY cst_s_fk;`) } catch {}
await db.execute(sql`
ALTER TABLE class_subject_teachers
ADD CONSTRAINT cst_s_fk
FOREIGN KEY (subject_id) REFERENCES subjects(id)
ON DELETE CASCADE
`)
console.log("Migration completed: class_subject_teachers now uses subject_id mapping.")
}
main().catch((err) => {
console.error(err)
process.exit(1)
})

View File

@@ -18,6 +18,7 @@ import {
import { createId } from "@paralleldrive/cuid2";
import { faker } from "@faker-js/faker";
import { sql } from "drizzle-orm";
import { hash } from "bcryptjs";
/**
* Enterprise-Grade Seed Script for Next_Edu
@@ -77,27 +78,31 @@ async function seed() {
]);
// Users
const passwordHash = await hash("123456", 10);
const usersData = [
{
id: "user_admin",
name: "Admin User",
email: "admin@next-edu.com",
role: "admin", // Legacy field
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Admin"
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Admin",
password: passwordHash
},
{
id: "user_teacher_math",
name: "Mr. Math",
email: "math@next-edu.com",
role: "teacher",
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Math"
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Math",
password: passwordHash
},
{
id: "user_student_1",
name: "Alice Student",
email: "alice@next-edu.com",
role: "student",
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Alice"
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Alice",
password: passwordHash
}
];
@@ -122,6 +127,7 @@ async function seed() {
email: faker.internet.email().toLowerCase(),
role: "student",
image: `https://api.dicebear.com/7.x/avataaars/svg?seed=${studentId}`,
password: passwordHash,
});
await db.insert(usersToRoles).values({ userId: studentId, roleId: roleMap.student });
}
@@ -136,13 +142,14 @@ async function seed() {
// --- Seeding Subjects ---
await db.insert(subjects).values([
{ id: createId(), name: "Mathematics", code: "MATH", order: 1 },
{ id: createId(), name: "Physics", code: "PHYS", order: 2 },
{ id: createId(), name: "Chemistry", code: "CHEM", order: 3 },
{ id: createId(), name: "English", code: "ENG", order: 4 },
{ id: createId(), name: "History", code: "HIST", order: 5 },
{ id: createId(), name: "Geography", code: "GEO", order: 6 },
{ id: createId(), name: "Biology", code: "BIO", order: 7 },
{ id: createId(), name: "语文", code: "CHINESE", order: 1 },
{ id: createId(), name: "数学", code: "MATH", order: 2 },
{ id: createId(), name: "英语", code: "ENG", order: 3 },
{ id: createId(), name: "美术", code: "ART", order: 4 },
{ id: createId(), name: "体育", code: "PE", order: 5 },
{ id: createId(), name: "科学", code: "SCI", order: 6 },
{ id: createId(), name: "社会", code: "SOC", order: 7 },
{ id: createId(), name: "音乐", code: "MUSIC", order: 8 },
])
await db.insert(grades).values([

View File

@@ -25,7 +25,7 @@ export default function RegisterPage() {
if (!databaseUrl) return { success: false, message: "DATABASE_URL 未配置" }
try {
const [{ db }, { users }] = await Promise.all([
const [{ db }, { roles, users, usersToRoles }] = await Promise.all([
import("@/shared/db"),
import("@/shared/db/schema"),
])
@@ -45,13 +45,25 @@ export default function RegisterPage() {
if (existing) return { success: false, message: "该邮箱已注册" }
const hashedPassword = normalizeBcryptHash(await hash(password, 10))
const userId = createId()
await db.insert(users).values({
id: createId(),
id: userId,
name: name.length ? name : null,
email,
password: hashedPassword,
role: "student",
})
const roleRow = await db.query.roles.findFirst({
where: eq(roles.name, "student"),
columns: { id: true },
})
if (!roleRow) {
await db.insert(roles).values({ name: "student" })
}
const resolvedRole = roleRow
?? (await db.query.roles.findFirst({ where: eq(roles.name, "student"), columns: { id: true } }))
if (resolvedRole?.id) {
await db.insert(usersToRoles).values({ userId, roleId: resolvedRole.id })
}
return { success: true, message: "账户创建成功" }
} catch (error) {

View File

@@ -1,19 +1,18 @@
import { redirect } from "next/navigation"
import { auth } from "@/auth"
import { getUserProfile } from "@/modules/users/data-access"
export const dynamic = "force-dynamic"
const normalizeRole = (value: unknown) => {
const role = String(value ?? "").trim().toLowerCase()
if (role === "admin" || role === "student" || role === "teacher" || role === "parent") return role
return "student"
}
export default async function DashboardPage() {
const session = await auth()
if (!session?.user) redirect("/login")
const role = normalizeRole(session.user.role)
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"
if (role === "admin") redirect("/admin/dashboard")
if (role === "student") redirect("/student/dashboard")

View File

@@ -2,7 +2,7 @@ import Link from "next/link"
import { redirect } from "next/navigation"
import { auth } from "@/auth"
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"
import { getStudentClasses, getStudentSchedule, getTeacherClasses, getTeacherTeachingSubjects } from "@/modules/classes/data-access"
import { StudentGradesCard } from "@/modules/dashboard/components/student-dashboard/student-grades-card"
import { StudentStatsGrid } from "@/modules/dashboard/components/student-dashboard/student-stats-grid"
import { StudentTodayScheduleCard } from "@/modules/dashboard/components/student-dashboard/student-today-schedule-card"
@@ -44,6 +44,7 @@ export default async function ProfilePage() {
const role = userProfile.role || "student"
const isStudent = role === "student"
const isTeacher = role === "teacher"
const studentData =
isStudent
@@ -107,6 +108,14 @@ export default async function ProfilePage() {
})()
: null
const teacherData =
isTeacher
? await (async () => {
const [subjects, classes] = await Promise.all([getTeacherTeachingSubjects(), getTeacherClasses()])
return { subjects, classes }
})()
: null
return (
<div className="flex h-full flex-col gap-8 p-8">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
@@ -231,6 +240,65 @@ export default async function ProfilePage() {
</div>
</div>
) : null}
{teacherData ? (
<div className="space-y-6">
<Separator />
<div className="space-y-1">
<h2 className="text-xl font-semibold tracking-tight">Teacher Overview</h2>
<div className="text-sm text-muted-foreground">Your teaching subjects and classes.</div>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Teaching Subjects</CardTitle>
<CardDescription>Subjects you are currently assigned to teach.</CardDescription>
</CardHeader>
<CardContent>
{teacherData.subjects.length === 0 ? (
<div className="text-sm text-muted-foreground">No subjects assigned yet.</div>
) : (
<div className="flex flex-wrap gap-2">
{teacherData.subjects.map((subject) => (
<Badge key={subject} variant="secondary">
{subject}
</Badge>
))}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Teaching Classes</CardTitle>
<CardDescription>Classes you are currently managing.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{teacherData.classes.length === 0 ? (
<div className="text-sm text-muted-foreground">No classes assigned yet.</div>
) : (
teacherData.classes.map((cls) => (
<div key={cls.id} className="flex items-center justify-between gap-4 rounded-md border px-3 py-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{cls.name}</div>
<div className="text-xs text-muted-foreground">
{cls.grade}
{cls.homeroom ? `${cls.homeroom}` : ""}
</div>
</div>
<Button asChild variant="outline" size="sm">
<Link href={`/teacher/classes/my/${encodeURIComponent(cls.id)}`}>View</Link>
</Button>
</div>
))
)}
</CardContent>
</Card>
</div>
</div>
) : null}
</div>
)
}

View File

@@ -1,4 +1,3 @@
import Link from "next/link"
import { notFound } from "next/navigation"
import { BookOpen, Inbox } from "lucide-react"

View File

@@ -5,8 +5,6 @@ import { TextbookCard } from "@/modules/textbooks/components/textbook-card"
import { TextbookFilters } from "@/modules/textbooks/components/textbook-filters"
import { getDemoStudentUser } from "@/modules/homework/data-access"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Button } from "@/shared/components/ui/button"
import Link from "next/link"
export const dynamic = "force-dynamic"
@@ -27,12 +25,6 @@ export default async function StudentTextbooksPage({
if (!student) {
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">Textbooks</h2>
<p className="text-muted-foreground">Browse your course textbooks.</p>
</div>
</div>
<EmptyState title="No user found" description="Create a student user to see textbooks." icon={Inbox} />
</div>
)
@@ -47,7 +39,7 @@ export default async function StudentTextbooksPage({
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
{/* <div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">Textbooks</h2>
<p className="text-muted-foreground">Browse your course textbooks.</p>
@@ -55,7 +47,7 @@ export default async function StudentTextbooksPage({
<Button asChild variant="outline">
<Link href="/student/dashboard">Back</Link>
</Button>
</div>
</div> */}
<TextbookFilters />

View File

@@ -5,26 +5,21 @@ import { ClassAssignmentsWidget } from "@/modules/classes/components/class-detai
import { ClassTrendsWidget } from "@/modules/classes/components/class-detail/class-trends-widget"
import { ClassHeader } from "@/modules/classes/components/class-detail/class-header"
import { ClassOverviewStats } from "@/modules/classes/components/class-detail/class-overview-stats"
import { ClassQuickActions } from "@/modules/classes/components/class-detail/class-quick-actions"
import { ClassScheduleWidget } from "@/modules/classes/components/class-detail/class-schedule-widget"
import { ClassStudentsWidget } from "@/modules/classes/components/class-detail/class-students-widget"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
export default async function ClassDetailPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>
searchParams: Promise<SearchParams>
}) {
const { id } = await params
// Parallel data fetching
const [insights, students, schedule] = await Promise.all([
getClassHomeworkInsights({ classId: id, limit: 20 }), // Limit increased to 20 for better list view
getClassHomeworkInsights({ classId: id, limit: 20 }),
getClassStudents({ classId: id }),
getClassSchedule({ classId: id }),
])
@@ -32,7 +27,7 @@ export default async function ClassDetailPage({
if (!insights) return notFound()
// Fetch subject scores
const studentScores = await getClassStudentSubjectScoresV2(id)
const studentScores = await getClassStudentSubjectScoresV2({ classId: id })
// Data mapping for widgets
const assignmentSummaries = insights.assignments.map(a => ({
@@ -91,10 +86,7 @@ export default async function ClassDetailPage({
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Main Content Area (Left 2/3) */}
<div className="space-y-6 lg:col-span-2">
<ClassTrendsWidget
classId={insights.class.id}
assignments={assignmentSummaries}
/>
<ClassTrendsWidget assignments={assignmentSummaries} />
<ClassStudentsWidget
classId={insights.class.id}
students={studentSummaries}

View File

@@ -1,9 +1,5 @@
import { eq } from "drizzle-orm"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { getClassSubjects, getTeacherClasses } from "@/modules/classes/data-access"
import { MyClassesGrid } from "@/modules/classes/components/my-classes-grid"
import { auth } from "@/auth"
import { db } from "@/shared/db"
import { grades } from "@/shared/db/schema"
export const dynamic = "force-dynamic"
@@ -12,11 +8,11 @@ export default function MyClassesPage() {
}
async function MyClassesPageImpl() {
const classes = await getTeacherClasses()
const [classes, subjectOptions] = await Promise.all([getTeacherClasses(), getClassSubjects()])
return (
<div className="flex h-full flex-col space-y-4 p-8">
<MyClassesGrid classes={classes} canCreateClass={false} />
<MyClassesGrid classes={classes} subjectOptions={subjectOptions} />
</div>
)
}

View File

@@ -82,7 +82,6 @@ function StudentsResultsFallback() {
export default async function StudentsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const classes = await getTeacherClasses()
const params = await searchParams
// Logic to determine default class (first one available)
const defaultClassId = classes.length > 0 ? classes[0].id : undefined

View File

@@ -1,37 +1,9 @@
import { ExamForm } from "@/modules/exams/components/exam-form"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/shared/components/ui/breadcrumb"
export default function CreateExamPage() {
return (
<div className="flex flex-col space-y-8 p-8 max-w-[1200px] mx-auto">
<div className="space-y-4">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/teacher/exams/all">Exams</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Create</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div>
<h1 className="text-3xl font-bold tracking-tight">Create Exam</h1>
<p className="text-muted-foreground mt-2">
Set up a new exam draft and choose your assembly method.
</p>
</div>
</div>
<div className="flex justify-center items-center min-h-[calc(100vh-160px)] p-8 max-w-[1200px] mx-auto">
<ExamForm />
</div>
)

View File

@@ -5,7 +5,6 @@ import { HomeworkAssignmentExamContentCard } from "@/modules/homework/components
import { HomeworkAssignmentQuestionErrorOverviewCard } from "@/modules/homework/components/homework-assignment-question-error-overview-card"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { formatDate } from "@/shared/lib/utils"
import { ChevronLeft, Users, Calendar, BarChart3, CheckCircle2 } from "lucide-react"

View File

@@ -14,6 +14,7 @@ import { formatDate } from "@/shared/lib/utils"
import { getHomeworkAssignments } from "@/modules/homework/data-access"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { PenTool, PlusCircle } from "lucide-react"
import { getTeacherIdForMutations } from "@/modules/classes/data-access"
export const dynamic = "force-dynamic"
@@ -27,9 +28,10 @@ const getParam = (params: SearchParams, key: string) => {
export default async function AssignmentsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const sp = await searchParams
const classId = getParam(sp, "classId") || undefined
const creatorId = await getTeacherIdForMutations()
const [assignments, classes] = await Promise.all([
getHomeworkAssignments({ classId: classId && classId !== "all" ? classId : undefined }),
getHomeworkAssignments({ creatorId, classId: classId && classId !== "all" ? classId : undefined }),
classId && classId !== "all" ? getTeacherClasses() : Promise.resolve([]),
])
const hasAssignments = assignments.length > 0

View File

@@ -23,6 +23,7 @@ async function QuestionBankResults({ searchParams }: { searchParams: Promise<Sea
const q = getParam(params, "q")
const type = getParam(params, "type")
const difficulty = getParam(params, "difficulty")
const knowledgePointId = getParam(params, "kp")
const questionType: QuestionType | undefined =
type === "single_choice" ||
@@ -37,10 +38,16 @@ async function QuestionBankResults({ searchParams }: { searchParams: Promise<Sea
q: q || undefined,
type: questionType,
difficulty: difficulty && difficulty !== "all" ? Number(difficulty) : undefined,
knowledgePointId: knowledgePointId && knowledgePointId !== "all" ? knowledgePointId : undefined,
pageSize: 200,
})
const hasFilters = Boolean(q || (type && type !== "all") || (difficulty && difficulty !== "all"))
const hasFilters = Boolean(
q ||
(type && type !== "all") ||
(difficulty && difficulty !== "all") ||
(knowledgePointId && knowledgePointId !== "all")
)
if (questions.length === 0) {
return (

View File

@@ -3,7 +3,7 @@ import { eq, inArray } from "drizzle-orm"
import { auth } from "@/auth"
import { db } from "@/shared/db"
import { classes, classSubjectTeachers, users } from "@/shared/db/schema"
import { classes, classSubjectTeachers, roles, users, usersToRoles, subjects } from "@/shared/db/schema"
import { DEFAULT_CLASS_SUBJECTS, type ClassSubject } from "@/modules/classes/types"
import { enrollStudentByInvitationCode } from "@/modules/classes/data-access"
@@ -34,13 +34,14 @@ export async function POST(req: Request) {
const role = (allowedRoles as readonly string[]).includes(roleRaw) ? roleRaw : null
if (!role) return NextResponse.json({ success: false, message: "Invalid role" }, { status: 400 })
const current = await db.query.users.findFirst({
where: eq(users.id, userId),
columns: { role: true },
})
const currentRole = String(current?.role ?? "student")
const currentRoleRows = await db
.select({ name: roles.name })
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(eq(usersToRoles.userId, userId))
const currentMapped = currentRoleRows.map((r) => String(r.name ?? "").trim().toLowerCase())
if (role === "admin" && currentRole !== "admin") {
if (role === "admin" && !currentMapped.includes("admin")) {
return NextResponse.json({ success: false, message: "Forbidden" }, { status: 403 })
}
@@ -58,16 +59,33 @@ export async function POST(req: Request) {
.map((s) => String(s).trim())
.filter((s): s is ClassSubject => DEFAULT_CLASS_SUBJECTS.includes(s as ClassSubject))
const roleRow = await db.query.roles.findFirst({
where: eq(roles.name, role),
columns: { id: true },
})
if (!roleRow) {
await db.insert(roles).values({ name: role })
}
const resolvedRole = roleRow
?? (await db.query.roles.findFirst({ where: eq(roles.name, role), columns: { id: true } }))
const roleId = resolvedRole?.id
await db
.update(users)
.set({
role,
name,
phone: phone.length ? phone : null,
address: address.length ? address : null,
})
.where(eq(users.id, userId))
if (roleId) {
await db
.insert(usersToRoles)
.values({ userId, roleId })
.onDuplicateKeyUpdate({ set: { roleId } })
}
if (role === "student" && codes.length) {
for (const code of codes) {
await enrollStudentByInvitationCode(userId, code)
@@ -87,14 +105,26 @@ export async function POST(req: Request) {
}
}
// Resolve subject ids when possible (by name exact match)
const subjectsFound = await db
.select({ id: subjects.id, name: subjects.name })
.from(subjects)
.where(inArray(subjects.name, teacherSubjects))
const subjectIdByName = new Map<string, string>()
for (const s of subjectsFound) {
if (s.name && s.id) subjectIdByName.set(String(s.name), String(s.id))
}
for (const code of codes) {
const classId = byCode.get(code)
if (!classId) continue
for (const subject of teacherSubjects) {
const subjectId = subjectIdByName.get(subject)
if (!subjectId) continue
await db
.insert(classSubjectTeachers)
.values({ classId, subject, teacherId: userId })
.onDuplicateKeyUpdate({ set: { teacherId: userId, updatedAt: new Date() } })
.values({ classId, subjectId, teacherId: userId })
.onDuplicateKeyUpdate({ set: { teacherId: userId, subjectId, updatedAt: new Date() } })
}
}
}
@@ -106,4 +136,3 @@ export async function POST(req: Request) {
return NextResponse.json({ success: true })
}

View File

@@ -3,7 +3,7 @@ import { eq } from "drizzle-orm"
import { auth } from "@/auth"
import { db } from "@/shared/db"
import { users } from "@/shared/db/schema"
import { roles, users, usersToRoles } from "@/shared/db/schema"
export const dynamic = "force-dynamic"
@@ -14,12 +14,32 @@ export async function GET() {
return NextResponse.json({ required: false })
}
const row = await db.query.users.findFirst({
where: eq(users.id, userId),
columns: { onboardedAt: true, role: true },
})
const [row, roleRows] = await Promise.all([
db.query.users.findFirst({
where: eq(users.id, userId),
columns: { onboardedAt: true },
}),
db
.select({ name: roles.name })
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(eq(usersToRoles.userId, userId)),
])
const normalizeRole = (value: string) => {
const role = value.trim().toLowerCase()
if (role === "grade_head" || role === "teaching_head") return "teacher"
if (role === "admin" || role === "student" || role === "teacher" || role === "parent") return role
return ""
}
const mappedRoles = roleRows.map((r) => normalizeRole(r.name)).filter(Boolean)
const resolvedRole = mappedRoles.find((r) => r === "admin")
?? mappedRoles.find((r) => r === "teacher")
?? mappedRoles.find((r) => r === "parent")
?? mappedRoles.find((r) => r === "student")
?? "student"
const required = !row?.onboardedAt
return NextResponse.json({ required, role: row?.role ?? "student" })
return NextResponse.json({ required, role: resolvedRole })
}

View File

@@ -1,13 +1,23 @@
import { compare, hash } from "bcryptjs"
import { compare } from "bcryptjs"
import NextAuth from "next-auth"
import Credentials from "next-auth/providers/credentials"
const normalizeRole = (value: unknown) => {
const role = String(value ?? "").trim().toLowerCase()
if (role === "grade_head" || role === "teaching_head") return "teacher"
if (role === "admin" || role === "student" || role === "teacher" || role === "parent") return role
return "student"
}
const resolvePrimaryRole = (roleNames: string[]) => {
const mapped = roleNames.map((name) => normalizeRole(name)).filter(Boolean)
if (mapped.includes("admin")) return "admin"
if (mapped.includes("teacher")) return "teacher"
if (mapped.includes("parent")) return "parent"
if (mapped.includes("student")) return "student"
return "student"
}
const normalizeBcryptHash = (value: string) => {
if (value.startsWith("$2")) return value
if (value.startsWith("$")) return `$2b${value}`
@@ -30,7 +40,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
const password = String(credentials?.password ?? "")
if (!email || !password) return null
const [{ eq }, { db }, { users }] = await Promise.all([
const [{ eq }, { db }, { roles, users, usersToRoles }] = await Promise.all([
import("drizzle-orm"),
import("@/shared/db"),
import("@/shared/db/schema"),
@@ -48,11 +58,19 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
const ok = await compare(password, normalizedPassword)
if (!ok) return null
const roleRows = await db
.select({ name: roles.name })
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(eq(usersToRoles.userId, user.id))
const resolvedRole = resolvePrimaryRole(roleRows.map((r) => r.name))
return {
id: user.id,
name: user.name ?? undefined,
email: user.email,
role: normalizeRole(user.role),
role: resolvedRole,
}
},
}),
@@ -67,19 +85,26 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
const userId = String(token.id ?? "").trim()
if (userId) {
const [{ eq }, { db }, { users }] = await Promise.all([
const [{ eq }, { db }, { roles, users, usersToRoles }] = await Promise.all([
import("drizzle-orm"),
import("@/shared/db"),
import("@/shared/db/schema"),
])
const fresh = await db.query.users.findFirst({
const [fresh, roleRows] = await Promise.all([
db.query.users.findFirst({
where: eq(users.id, userId),
columns: { role: true, name: true },
})
columns: { name: true },
}),
db
.select({ name: roles.name })
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(eq(usersToRoles.userId, userId)),
])
if (fresh) {
token.role = normalizeRole(fresh.role ?? token.role)
token.role = resolvePrimaryRole(roleRows.map((r) => r.name))
token.name = fresh.name ?? token.name
}
}

View File

@@ -1,7 +1,7 @@
"use server";
import { revalidatePath } from "next/cache"
import { and, eq, sql, or, inArray } from "drizzle-orm"
import { and, eq, sql, or } from "drizzle-orm"
import { auth } from "@/auth"
import { db } from "@/shared/db"
@@ -16,6 +16,7 @@ import {
deleteTeacherClass,
enrollStudentByEmail,
enrollStudentByInvitationCode,
enrollTeacherByInvitationCode,
ensureClassInvitationCode,
regenerateClassInvitationCode,
setClassSubjectTeachers,
@@ -371,8 +372,18 @@ export async function joinClassByInvitationCodeAction(
return { success: false, message: "Unauthorized" }
}
const subjectValue = formData.get("subject")
const subject = role === "teacher" && typeof subjectValue === "string" ? subjectValue.trim() : null
if (role === "teacher" && (!subject || subject.length === 0)) {
return { success: false, message: "Subject is required" }
}
try {
const classId = await enrollStudentByInvitationCode(session.user.id, code)
const classId =
role === "teacher"
? await enrollTeacherByInvitationCode(session.user.id, code, subject)
: await enrollStudentByInvitationCode(session.user.id, code)
if (role === "student") {
revalidatePath("/student/learning/courses")
revalidatePath("/student/schedule")

View File

@@ -1,6 +1,6 @@
import Link from "next/link"
import { Calendar, FilePlus, Mail, MessageSquare, Settings } from "lucide-react"
import { Calendar, FilePlus, MessageSquare, Settings } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"

View File

@@ -42,7 +42,7 @@ export function ClassScheduleGrid({ schedule, compact = false }: { schedule: Cla
return (
<div className="grid grid-cols-5 gap-1 text-center h-full grid-rows-[auto_1fr]">
{WEEKDAYS.slice(0, 5).map((day, i) => (
{WEEKDAYS.slice(0, 5).map((day) => (
<div key={day} className="text-[10px] font-medium text-muted-foreground uppercase py-0.5 border-b bg-muted/20 h-fit">
{day}
</div>

View File

@@ -6,7 +6,6 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avat
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"
import { formatDate } from "@/shared/lib/utils"
interface StudentSummary {
id: string

View File

@@ -1,13 +1,12 @@
"use client"
import { useState, useMemo } from "react"
import { useState } from "react"
import { Area, AreaChart, CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
import { ChevronDown } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/shared/components/ui/chart"
import { Tabs, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
import { cn } from "@/shared/lib/utils"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
@@ -31,7 +30,6 @@ interface AssignmentSummary {
}
interface ClassTrendsWidgetProps {
classId: string
assignments: AssignmentSummary[]
compact?: boolean
className?: string
@@ -121,7 +119,7 @@ export function ClassSubmissionTrendChart({
)
}
export function ClassTrendsWidget({ classId, assignments, compact, className }: ClassTrendsWidgetProps) {
export function ClassTrendsWidget({ assignments, compact, className }: ClassTrendsWidgetProps) {
const [chartTab, setChartTab] = useState<"submission" | "score">("submission")
const [selectedSubject, setSelectedSubject] = useState<string>("all")

View File

@@ -1,7 +1,7 @@
"use client"
import Link from "next/link"
import { useMemo, useState } from "react"
import { useState } from "react"
import { useRouter } from "next/navigation"
import {
Plus,
@@ -10,11 +10,9 @@ import {
Users,
MapPin,
GraduationCap,
Search,
} from "lucide-react"
import { toast } from "sonner"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button"
import { Badge } from "@/shared/components/ui/badge"
import { EmptyState } from "@/shared/components/ui/empty-state"
@@ -30,30 +28,35 @@ import {
} from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/components/ui/tooltip"
import type { TeacherClass, ClassScheduleItem } from "../types"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import type { TeacherClass } from "../types"
import {
ensureClassInvitationCodeAction,
regenerateClassInvitationCodeAction,
joinClassByInvitationCodeAction,
} from "../actions"
const GRADIENTS = [
"bg-card border-border",
"bg-card border-border",
"bg-card border-border",
"bg-card border-border",
"bg-card border-border",
]
function getClassGradient(id: string) {
return "bg-card border-border shadow-sm hover:shadow-md"
const getSeededValue = (seed: string, index: number) => {
let h = 2166136261
const str = `${seed}:${index}`
for (let i = 0; i < str.length; i += 1) {
h ^= str.charCodeAt(i)
h = Math.imul(h, 16777619)
}
return (h >>> 0) / 4294967296
}
export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherClass[]; canCreateClass: boolean }) {
export function MyClassesGrid({
classes,
subjectOptions,
}: {
classes: TeacherClass[]
subjectOptions: string[]
}) {
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const [joinOpen, setJoinOpen] = useState(false)
const [joinSubject, setJoinSubject] = useState("")
const handleJoin = async (formData: FormData) => {
setIsWorking(true)
@@ -62,6 +65,7 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
if (res.success) {
toast.success(res.message || "Joined class successfully")
setJoinOpen(false)
setJoinSubject("")
router.refresh()
} else {
toast.error(res.message || "Failed to join class")
@@ -83,6 +87,7 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
onOpenChange={(open) => {
if (isWorking) return
setJoinOpen(open)
if (!open) setJoinSubject("")
}}
>
<DialogTrigger asChild>
@@ -140,12 +145,30 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
Ask your administrator for the code if you don&apos;t have one.
</p>
</div>
<div className="space-y-3">
<Label htmlFor="join-subject" className="text-sm font-medium">
</Label>
<Select value={joinSubject} onValueChange={(v) => setJoinSubject(v)}>
<SelectTrigger id="join-subject" className="h-12">
<SelectValue placeholder={subjectOptions.length === 0 ? "暂无可选科目" : "选择教学科目"} />
</SelectTrigger>
<SelectContent>
{subjectOptions.map((subject) => (
<SelectItem key={subject} value={subject}>
{subject}
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="subject" value={joinSubject} />
</div>
</div>
<DialogFooter className="p-6 pt-2 bg-muted/5 border-t border-border/50">
<Button type="button" variant="ghost" onClick={() => setJoinOpen(false)} disabled={isWorking}>
Cancel
</Button>
<Button type="submit" disabled={isWorking} className="min-w-[100px]">
<Button type="submit" disabled={isWorking || !joinSubject || subjectOptions.length === 0} className="min-w-[100px]">
{isWorking ? "Joining..." : "Join Class"}
</Button>
</DialogFooter>
@@ -167,7 +190,7 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
/>
) : (
classes.map((c) => (
<ClassTicket key={c.id} c={c} onWorkingChange={setIsWorking} isWorking={isWorking} />
<ClassTicket key={c.id} c={c} onWorkingChange={setIsWorking} />
))
)}
</div>
@@ -182,11 +205,9 @@ import { ClassTrendsWidget } from "./class-detail/class-trends-widget"
function ClassTicket({
c,
isWorking,
onWorkingChange,
}: {
c: TeacherClass
isWorking: boolean
onWorkingChange: (v: boolean) => void
}) {
const router = useRouter()
@@ -256,7 +277,11 @@ function ClassTicket({
{/* Decorative Barcode Strip */}
<div className="absolute left-0 top-0 bottom-0 w-1.5 bg-primary/10 flex flex-col justify-between py-2 pointer-events-none">
{Array.from({ length: 20 }).map((_, i) => (
<div key={i} className="w-full h-px bg-primary/20" style={{ marginBottom: Math.random() * 8 + 2 + 'px' }}></div>
<div
key={i}
className="w-full h-px bg-primary/20"
style={{ marginBottom: `${2 + getSeededValue(c.id, i) * 8}px` }}
></div>
))}
</div>
@@ -320,7 +345,7 @@ function ClassTicket({
<div className="absolute right-10 top-1/2 -translate-y-1/2 opacity-[0.03]">
<div className="w-8 h-8 bg-current grid grid-cols-4 grid-rows-4 gap-px">
{Array.from({ length: 16 }).map((_, i) => (
<div key={i} className={cn("bg-transparent", Math.random() > 0.5 && "bg-black")}></div>
<div key={i} className={cn("bg-transparent", getSeededValue(`${c.id}-qr`, i) > 0.5 && "bg-black")}></div>
))}
</div>
</div>
@@ -373,12 +398,7 @@ function ClassTicket({
{/* Real Chart */}
<div className="h-[140px] w-full">
<ClassTrendsWidget
classId={c.id}
assignments={recentAssignments}
compact
className="h-full w-full"
/>
<ClassTrendsWidget assignments={recentAssignments} compact className="h-full w-full" />
</div>
</div>

View File

@@ -2,11 +2,9 @@
import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { Clock, MapPin, MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
import { toast } from "sonner"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { cn } from "@/shared/lib/utils"
import { Button } from "@/shared/components/ui/button"
import {
@@ -518,4 +516,4 @@ export function ScheduleView({
</AlertDialog>
</div>
)
}
}

View File

@@ -1,9 +1,9 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { useQueryState, parseAsString } from "nuqs"
import { Search, UserPlus, X, ChevronDown, Check } from "lucide-react"
import { Search, UserPlus, ChevronDown, Check } from "lucide-react"
import { toast } from "sonner"
import { Input } from "@/shared/components/ui/input"
@@ -33,7 +33,6 @@ import {
SelectValue,
} from "@/shared/components/ui/select"
import { Label } from "@/shared/components/ui/label"
import { cn } from "@/shared/lib/utils"
import type { TeacherClass } from "../types"
import { enrollStudentByEmailAction } from "../actions"
@@ -78,8 +77,6 @@ export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherC
const statusLabel = status === "all" ? "All Status" : (status === "active" ? "Active" : "Inactive")
const hasFilters = search || classId !== "all" || status !== "all"
return (
<div className="flex items-center justify-between py-2">
<div className="flex items-center gap-2">

View File

@@ -5,11 +5,10 @@ import { useRouter } from "next/navigation"
import { MoreHorizontal, UserCheck, UserX } from "lucide-react"
import { toast } from "sonner"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
import { Card, CardContent, CardFooter, CardHeader } from "@/shared/components/ui/card"
import { cn, formatDate } from "@/shared/lib/utils"
import { cn } from "@/shared/lib/utils"
import {
DropdownMenu,
DropdownMenuContent,

View File

@@ -2,9 +2,10 @@ import "server-only";
import { randomInt } from "node:crypto"
import { cache } from "react"
import { and, asc, desc, eq, inArray, or, sql, type SQL } from "drizzle-orm"
import { and, asc, desc, eq, inArray, isNull, or, sql, type SQL } from "drizzle-orm"
import { createId } from "@paralleldrive/cuid2"
import { auth } from "@/auth"
import { db } from "@/shared/db"
import {
classes,
@@ -19,7 +20,9 @@ import {
schools,
subjects,
exams,
roles,
users,
usersToRoles,
} from "@/shared/db/schema"
import { DEFAULT_CLASS_SUBJECTS } from "./types"
import type {
@@ -43,16 +46,22 @@ import type {
UpdateTeacherClassInput,
} from "./types"
const getDefaultTeacherId = cache(async () => {
const [row] = await db
const getSessionTeacherId = async (): Promise<string | null> => {
const session = await auth()
const userId = String(session?.user?.id ?? "").trim()
if (!userId) return null
const [teacher] = await db
.select({ id: users.id })
.from(users)
.where(eq(users.role, "teacher"))
.orderBy(asc(users.createdAt))
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(and(eq(users.id, userId), eq(roles.name, "teacher")))
.limit(1)
return teacher?.id ?? null
}
return row?.id
})
// Strict subjectId-based mapping: no aliasing
const isDuplicateInvitationCodeError = (err: unknown) => {
if (!err) return false
@@ -80,11 +89,20 @@ const generateUniqueInvitationCode = async (): Promise<string> => {
}
export const getTeacherIdForMutations = async (): Promise<string> => {
const teacherId = await getDefaultTeacherId()
if (!teacherId) throw new Error("No teacher available")
const teacherId = await getSessionTeacherId()
if (!teacherId) throw new Error("Teacher not found")
return teacherId
}
export const getClassSubjects = async (): Promise<string[]> => {
const rows = await db.query.subjects.findMany({
orderBy: (subjects, { asc }) => [asc(subjects.order), asc(subjects.name)],
})
const names = rows.map((r) => r.name.trim()).filter((n) => n.length > 0)
return Array.from(new Set(names))
}
const normalizeSortText = (v: string | null | undefined) => (typeof v === "string" ? v.trim().toLowerCase() : "")
const parseFirstInt = (v: string) => {
@@ -118,23 +136,30 @@ const compareClassLike = (
return normalizeSortText(a.room).localeCompare(normalizeSortText(b.room))
}
const getAccessibleClassIdsForTeacher = async (teacherId: string): Promise<string[]> => {
const ownedIds = await db.select({ id: classes.id }).from(classes).where(eq(classes.teacherId, teacherId))
const assignedIds = await db
.select({ id: classSubjectTeachers.classId })
.from(classSubjectTeachers)
.where(eq(classSubjectTeachers.teacherId, teacherId))
return Array.from(new Set([...ownedIds.map((x) => x.id), ...assignedIds.map((x) => x.id)]))
}
const getTeacherSubjectIdsForClass = async (teacherId: string, classId: string): Promise<string[]> => {
const rows = await db
.select({ subjectId: classSubjectTeachers.subjectId })
.from(classSubjectTeachers)
.where(and(eq(classSubjectTeachers.teacherId, teacherId), eq(classSubjectTeachers.classId, classId)))
return Array.from(new Set(rows.map((r) => String(r.subjectId))))
}
export const getTeacherClasses = cache(async (params?: { teacherId?: string }): Promise<TeacherClass[]> => {
const teacherId = params?.teacherId ?? (await getDefaultTeacherId())
const teacherId = params?.teacherId ?? (await getSessionTeacherId())
if (!teacherId) return []
const rows = await (async () => {
try {
const ownedIds = await db
.select({ id: classes.id })
.from(classes)
.where(eq(classes.teacherId, teacherId))
const enrolledIds = await db
.select({ id: classEnrollments.classId })
.from(classEnrollments)
.where(and(eq(classEnrollments.studentId, teacherId), eq(classEnrollments.status, "active")))
const allIds = Array.from(new Set([...ownedIds.map((x) => x.id), ...enrolledIds.map((x) => x.id)]))
const allIds = await getAccessibleClassIdsForTeacher(teacherId)
if (allIds.length === 0) return []
@@ -206,7 +231,9 @@ export const getTeacherOptions = cache(async (): Promise<TeacherOption[]> => {
const rows = await db
.select({ id: users.id, name: users.name, email: users.email })
.from(users)
.where(eq(users.role, "teacher"))
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(eq(roles.name, "teacher"))
.orderBy(asc(users.createdAt))
return rows.map((r) => ({
@@ -216,6 +243,23 @@ export const getTeacherOptions = cache(async (): Promise<TeacherOption[]> => {
}))
})
export const getTeacherTeachingSubjects = cache(async (): Promise<ClassSubject[]> => {
const teacherId = await getSessionTeacherId()
if (!teacherId) return []
const rows = await db
.select({ subject: subjects.name })
.from(classSubjectTeachers)
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
.where(eq(classSubjectTeachers.teacherId, teacherId))
.groupBy(subjects.name)
.orderBy(asc(subjects.name))
return rows
.map((r) => r.subject as ClassSubject)
.filter((s) => DEFAULT_CLASS_SUBJECTS.includes(s))
})
export const getAdminClasses = cache(async (): Promise<AdminClassListItem[]> => {
const [rows, subjectRows] = await Promise.all([
(async () => {
@@ -304,14 +348,15 @@ export const getAdminClasses = cache(async (): Promise<AdminClassListItem[]> =>
db
.select({
classId: classSubjectTeachers.classId,
subject: classSubjectTeachers.subject,
subject: subjects.name,
teacherId: users.id,
teacherName: users.name,
teacherEmail: users.email,
})
.from(classSubjectTeachers)
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
.leftJoin(users, eq(users.id, classSubjectTeachers.teacherId))
.orderBy(asc(classSubjectTeachers.classId), asc(classSubjectTeachers.subject)),
.orderBy(asc(classSubjectTeachers.classId), asc(subjects.name)),
])
const subjectsByClassId = new Map<string, Map<ClassSubject, TeacherOption | null>>()
@@ -425,16 +470,17 @@ export const getGradeManagedClasses = cache(async (userId: string): Promise<Admi
db
.select({
classId: classSubjectTeachers.classId,
subject: classSubjectTeachers.subject,
subject: subjects.name,
teacherId: users.id,
teacherName: users.name,
teacherEmail: users.email,
})
.from(classSubjectTeachers)
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
.innerJoin(classes, eq(classes.id, classSubjectTeachers.classId))
.leftJoin(users, eq(users.id, classSubjectTeachers.teacherId))
.where(inArray(classes.gradeId, gradeIds))
.orderBy(asc(classSubjectTeachers.classId), asc(classSubjectTeachers.subject)),
.orderBy(asc(classSubjectTeachers.classId), asc(subjects.name)),
])
const subjectsByClassId = new Map<string, Map<ClassSubject, TeacherOption | null>>()
@@ -589,14 +635,17 @@ export const getStudentSchedule = cache(async (studentId: string): Promise<Stude
export const getClassStudents = cache(
async (params?: { classId?: string; q?: string; status?: string; teacherId?: string }): Promise<ClassStudent[]> => {
const teacherId = params?.teacherId ?? (await getDefaultTeacherId())
const teacherId = params?.teacherId ?? (await getSessionTeacherId())
if (!teacherId) return []
const classId = params?.classId?.trim()
const q = params?.q?.trim().toLowerCase()
const status = params?.status?.trim().toLowerCase()
const conditions: SQL[] = [eq(classes.teacherId, teacherId)]
const accessibleIds = await getAccessibleClassIdsForTeacher(teacherId)
if (accessibleIds.length === 0) return []
const conditions: SQL[] = [inArray(classes.id, accessibleIds)]
if (classId) {
conditions.push(eq(classes.id, classId))
@@ -647,12 +696,15 @@ export const getClassStudents = cache(
export const getClassSchedule = cache(
async (params?: { classId?: string; teacherId?: string }): Promise<ClassScheduleItem[]> => {
const teacherId = params?.teacherId ?? (await getDefaultTeacherId())
const teacherId = params?.teacherId ?? (await getSessionTeacherId())
if (!teacherId) return []
const classId = params?.classId?.trim()
const conditions: SQL[] = [eq(classes.teacherId, teacherId)]
const accessibleIds = await getAccessibleClassIdsForTeacher(teacherId)
if (accessibleIds.length === 0) return []
const conditions: SQL[] = [inArray(classes.id, accessibleIds)]
if (classId) conditions.push(eq(classSchedule.classId, classId))
const rows = await db
@@ -707,11 +759,13 @@ const toScoreStats = (scores: number[]): ScoreStats => {
export const getClassHomeworkInsights = cache(
async (params: { classId: string; teacherId?: string; limit?: number }): Promise<ClassHomeworkInsights | null> => {
const teacherId = params.teacherId ?? (await getDefaultTeacherId())
const teacherId = params.teacherId ?? (await getSessionTeacherId())
if (!teacherId) return null
const classId = params.classId.trim()
if (!classId) return null
const accessibleIds = await getAccessibleClassIdsForTeacher(teacherId)
if (accessibleIds.length === 0 || !accessibleIds.includes(classId)) return null
const [classRow] = await db
.select({
@@ -721,12 +775,15 @@ export const getClassHomeworkInsights = cache(
homeroom: classes.homeroom,
room: classes.room,
invitationCode: classes.invitationCode,
teacherId: classes.teacherId,
})
.from(classes)
.where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId)))
.where(and(eq(classes.id, classId), inArray(classes.id, accessibleIds)))
.limit(1)
if (!classRow) return null
const isHomeroomTeacher = classRow.teacherId === teacherId
const subjectIdFilter = isHomeroomTeacher ? [] : await getTeacherSubjectIdsForClass(teacherId, classId)
const enrollments = await db
.select({
@@ -735,12 +792,29 @@ export const getClassHomeworkInsights = cache(
})
.from(classEnrollments)
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
.where(and(eq(classes.teacherId, teacherId), eq(classEnrollments.classId, classId)))
.where(and(inArray(classes.id, accessibleIds), eq(classEnrollments.classId, classId)))
const activeStudentIds = enrollments.filter((e) => e.status === "active").map((e) => e.studentId)
const inactiveStudentIds = enrollments.filter((e) => e.status !== "active").map((e) => e.studentId)
const studentIds = enrollments.map((e) => e.studentId)
if (!isHomeroomTeacher && subjectIdFilter.length === 0) {
return {
class: {
id: classRow.id,
name: classRow.name,
grade: classRow.grade,
homeroom: classRow.homeroom,
room: classRow.room,
invitationCode: classRow.invitationCode ?? null,
},
studentCounts: { total: studentIds.length, active: activeStudentIds.length, inactive: inactiveStudentIds.length },
assignments: [],
latest: null,
overallScores: { count: 0, avg: null, median: null, min: null, max: null },
}
}
if (studentIds.length === 0) {
return {
class: {
@@ -782,6 +856,10 @@ export const getClassHomeworkInsights = cache(
}
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 50
const assignmentConditions: SQL[] = [inArray(homeworkAssignments.id, assignmentIds)]
if (subjectIdFilter.length > 0) {
assignmentConditions.push(inArray(exams.subjectId, subjectIdFilter))
}
const assignments = await db
.select({
id: homeworkAssignments.id,
@@ -795,7 +873,7 @@ export const getClassHomeworkInsights = cache(
.from(homeworkAssignments)
.innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id))
.leftJoin(subjects, eq(exams.subjectId, subjects.id))
.where(and(inArray(homeworkAssignments.id, assignmentIds), eq(homeworkAssignments.creatorId, teacherId)))
.where(and(...assignmentConditions))
.orderBy(desc(homeworkAssignments.createdAt))
.limit(limit)
@@ -1239,6 +1317,12 @@ export async function createTeacherClass(data: CreateTeacherClassInput): Promise
for (let attempt = 0; attempt < 20; attempt += 1) {
const invitationCode = await generateUniqueInvitationCode()
try {
const subjectRows = await db
.select({ id: subjects.id, name: subjects.name })
.from(subjects)
.where(inArray(subjects.name, DEFAULT_CLASS_SUBJECTS))
const idByName = new Map(subjectRows.map((r) => [r.name as ClassSubject, r.id]))
await db.transaction(async (tx) => {
await tx.insert(classes).values({
id,
@@ -1253,13 +1337,14 @@ export async function createTeacherClass(data: CreateTeacherClassInput): Promise
teacherId,
})
await tx.insert(classSubjectTeachers).values(
DEFAULT_CLASS_SUBJECTS.map((subject) => ({
const values = DEFAULT_CLASS_SUBJECTS
.filter((name) => idByName.has(name))
.map((name) => ({
classId: id,
subject,
subjectId: idByName.get(name)!,
teacherId: null,
}))
)
await tx.insert(classSubjectTeachers).values(values)
})
return id
} catch (err) {
@@ -1291,13 +1376,21 @@ export async function createAdminClass(data: CreateTeacherClassInput & { teacher
const [teacher] = await db
.select({ id: users.id })
.from(users)
.where(and(eq(users.id, teacherId), eq(users.role, "teacher")))
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(and(eq(users.id, teacherId), eq(roles.name, "teacher")))
.limit(1)
if (!teacher) throw new Error("Teacher not found")
for (let attempt = 0; attempt < 20; attempt += 1) {
const invitationCode = await generateUniqueInvitationCode()
try {
const subjectRows = await db
.select({ id: subjects.id, name: subjects.name })
.from(subjects)
.where(inArray(subjects.name, DEFAULT_CLASS_SUBJECTS))
const idByName = new Map(subjectRows.map((r) => [r.name as ClassSubject, r.id]))
await db.transaction(async (tx) => {
await tx.insert(classes).values({
id,
@@ -1312,13 +1405,14 @@ export async function createAdminClass(data: CreateTeacherClassInput & { teacher
teacherId,
})
await tx.insert(classSubjectTeachers).values(
DEFAULT_CLASS_SUBJECTS.map((subject) => ({
const values = DEFAULT_CLASS_SUBJECTS
.filter((name) => idByName.has(name))
.map((name) => ({
classId: id,
subject,
subjectId: idByName.get(name)!,
teacherId: null,
}))
)
await tx.insert(classSubjectTeachers).values(values)
})
return id
} catch (err) {
@@ -1410,6 +1504,123 @@ export async function enrollStudentByInvitationCode(studentId: string, invitatio
return cls.id
}
export async function enrollTeacherByInvitationCode(
teacherId: string,
invitationCode: string,
subject: string | null
): Promise<string> {
const tid = teacherId.trim()
const code = invitationCode.trim()
if (!tid) throw new Error("Missing teacher id")
if (!/^\d{6}$/.test(code)) throw new Error("Invalid invitation code")
const [teacher] = 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, tid), eq(roles.name, "teacher")))
.limit(1)
if (!teacher) throw new Error("Teacher not found")
const [cls] = await db
.select({ id: classes.id, teacherId: classes.teacherId })
.from(classes)
.where(eq(classes.invitationCode, code))
.limit(1)
if (!cls) throw new Error("Invalid invitation code")
if (cls.teacherId === tid) return cls.id
const subjectValue = typeof subject === "string" ? subject.trim() : ""
const [existing] = await db
.select({ id: classSubjectTeachers.classId })
.from(classSubjectTeachers)
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.teacherId, tid)))
.limit(1)
if (existing && !subjectValue) return cls.id
if (subjectValue) {
const [subRow] = await db.select({ id: subjects.id }).from(subjects).where(eq(subjects.name, subjectValue)).limit(1)
if (!subRow) throw new Error("Subject not found")
const sid = subRow.id
const [mapping] = await db
.select({ teacherId: classSubjectTeachers.teacherId })
.from(classSubjectTeachers)
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid)))
.limit(1)
if (mapping?.teacherId && mapping.teacherId !== tid) throw new Error("Subject already assigned")
if (mapping?.teacherId === tid) return cls.id
if (!mapping) {
await db
.insert(classSubjectTeachers)
.values({ classId: cls.id, subjectId: sid, teacherId: null })
.onDuplicateKeyUpdate({ set: { teacherId: sql`${classSubjectTeachers.teacherId}` } })
}
const [existingSubject] = await db
.select({ id: classSubjectTeachers.classId })
.from(classSubjectTeachers)
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid), eq(classSubjectTeachers.teacherId, tid)))
.limit(1)
if (existingSubject) return cls.id
await db
.update(classSubjectTeachers)
.set({ teacherId: tid })
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid), isNull(classSubjectTeachers.teacherId)))
const [assigned] = await db
.select({ id: classSubjectTeachers.classId })
.from(classSubjectTeachers)
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid), eq(classSubjectTeachers.teacherId, tid)))
.limit(1)
if (!assigned) throw new Error("Subject already assigned")
} else {
const subjectRows = await db
.select({ id: classSubjectTeachers.subjectId, name: subjects.name })
.from(classSubjectTeachers)
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
.where(and(eq(classSubjectTeachers.classId, cls.id), isNull(classSubjectTeachers.teacherId)))
const preferred = DEFAULT_CLASS_SUBJECTS.find((s) => subjectRows.some((r) => r.name === s))
if (!preferred) throw new Error("Class already has assigned teachers")
const sid = subjectRows.find((r) => r.name === preferred)!.id
await db
.update(classSubjectTeachers)
.set({ teacherId: tid })
.where(
and(
eq(classSubjectTeachers.classId, cls.id),
eq(classSubjectTeachers.subjectId, sid),
isNull(classSubjectTeachers.teacherId)
)
)
const [assigned] = await db
.select({ id: classSubjectTeachers.classId })
.from(classSubjectTeachers)
.where(
and(
eq(classSubjectTeachers.classId, cls.id),
eq(classSubjectTeachers.subjectId, sid),
eq(classSubjectTeachers.teacherId, tid)
)
)
.limit(1)
if (!assigned) throw new Error("Class already has assigned teachers")
}
return cls.id
}
export async function updateTeacherClass(classId: string, data: UpdateTeacherClassInput): Promise<void> {
const teacherId = await getTeacherIdForMutations()
@@ -1468,7 +1679,9 @@ export async function updateAdminClass(
const [teacher] = await db
.select({ id: users.id })
.from(users)
.where(and(eq(users.id, nextTeacherId), eq(users.role, "teacher")))
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(and(eq(users.id, nextTeacherId), eq(roles.name, "teacher")))
.limit(1)
if (!teacher) throw new Error("Teacher not found")
@@ -1498,7 +1711,9 @@ export async function setClassSubjectTeachers(params: {
const rows = await db
.select({ id: users.id })
.from(users)
.where(and(eq(users.role, "teacher"), inArray(users.id, teacherIds)))
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(and(eq(roles.name, "teacher"), inArray(users.id, teacherIds)))
if (rows.length !== new Set(teacherIds).size) throw new Error("Teacher not found")
}
@@ -1508,15 +1723,24 @@ export async function setClassSubjectTeachers(params: {
teacherBySubject.set(a.subject, typeof a.teacherId === "string" && a.teacherId.trim().length > 0 ? a.teacherId.trim() : null)
}
// Map subject names to ids
const subjectRows = await db
.select({ id: subjects.id, name: subjects.name })
.from(subjects)
.where(inArray(subjects.name, DEFAULT_CLASS_SUBJECTS))
const idByName = new Map(subjectRows.map((r) => [r.name as ClassSubject, r.id]))
const values = DEFAULT_CLASS_SUBJECTS
.filter((name) => idByName.has(name))
.map((name) => ({
classId,
subjectId: idByName.get(name)!,
teacherId: teacherBySubject.get(name) ?? null,
}))
await db
.insert(classSubjectTeachers)
.values(
DEFAULT_CLASS_SUBJECTS.map((subject) => ({
classId,
subject,
teacherId: teacherBySubject.get(subject) ?? null,
}))
)
.values(values)
.onDuplicateKeyUpdate({ set: { teacherId: sql`VALUES(${classSubjectTeachers.teacherId})` } })
}
@@ -1564,13 +1788,19 @@ export async function enrollStudentByEmail(classId: string, email: string): Prom
if (!owned) throw new Error("Class not found")
const [student] = await db
.select({ id: users.id, role: users.role })
.select({ id: users.id })
.from(users)
.where(eq(users.email, normalized))
.limit(1)
if (!student) throw new Error("Student not found")
if (student.role !== "student") throw new Error("User is not a student")
const [studentRole] = await db
.select({ id: usersToRoles.userId })
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(and(eq(usersToRoles.userId, student.id), eq(roles.name, "student")))
.limit(1)
if (!studentRole) throw new Error("User is not a student")
await db
.insert(classEnrollments)
@@ -1823,8 +2053,26 @@ export const getStudentsSubjectScores = cache(
)
export const getClassStudentSubjectScoresV2 = cache(
async (classId: string): Promise<Map<string, Record<string, number | null>>> => {
// 1. Get student IDs in the class
async (params: { classId: string; teacherId?: string }): Promise<Map<string, Record<string, number | null>>> => {
const teacherId = params.teacherId ?? (await getSessionTeacherId())
if (!teacherId) return new Map()
const classId = params.classId.trim()
if (!classId) return new Map()
const accessibleIds = await getAccessibleClassIdsForTeacher(teacherId)
if (accessibleIds.length === 0 || !accessibleIds.includes(classId)) return new Map()
const [classRow] = await db
.select({ id: classes.id, teacherId: classes.teacherId })
.from(classes)
.where(eq(classes.id, classId))
.limit(1)
if (!classRow) return new Map()
const isHomeroomTeacher = classRow.teacherId === teacherId
const subjectIds = isHomeroomTeacher ? [] : await getTeacherSubjectIdsForClass(teacherId, classId)
if (!isHomeroomTeacher && subjectIds.length === 0) return new Map()
const enrollments = await db
.select({ studentId: classEnrollments.studentId })
.from(classEnrollments)
@@ -1833,7 +2081,24 @@ export const getClassStudentSubjectScoresV2 = cache(
eq(classEnrollments.status, "active")
))
const studentIds = enrollments.map(e => e.studentId)
return getStudentsSubjectScores(studentIds)
const studentIds = enrollments.map((e) => e.studentId)
const studentScores = await getStudentsSubjectScores(studentIds)
if (subjectIds.length === 0) return studentScores
// Map subjectIds to names for filtering
const subjectRows = await db
.select({ id: subjects.id, name: subjects.name })
.from(subjects)
.where(inArray(subjects.id, subjectIds))
const allowed = new Set(subjectRows.map((s) => s.name))
const filtered = new Map<string, Record<string, number | null>>()
for (const [studentId, scores] of studentScores.entries()) {
const nextScores: Record<string, number | null> = {}
for (const [subject, score] of Object.entries(scores)) {
if (allowed.has(subject)) nextScores[subject] = score
}
filtered.set(studentId, nextScores)
}
return filtered
}
)

View File

@@ -15,10 +15,8 @@ type Stat = {
}
export function StudentStatsGrid({
enrolledClassCount,
dueSoonCount,
overdueCount,
gradedCount,
ranking,
}: {
enrolledClassCount: number

View File

@@ -7,8 +7,6 @@ import { EmptyState } from "@/shared/components/ui/empty-state"
import type { TeacherClass } from "@/modules/classes/types"
export function TeacherClassesCard({ classes }: { classes: TeacherClass[] }) {
const totalStudents = classes.reduce((sum, c) => sum + (c.studentCount ?? 0), 0)
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">

View File

@@ -14,7 +14,6 @@ const toWeekday = (d: Date): 1 | 2 | 3 | 4 | 5 | 6 | 7 => {
}
export function TeacherDashboardView({ data }: { data: TeacherDashboardData }) {
const totalStudents = data.classes.reduce((sum, c) => sum + (c.studentCount ?? 0), 0)
const todayWeekday = toWeekday(new Date())
const classNameById = new Map(data.classes.map((c) => [c.id, c.name] as const))

View File

@@ -1,7 +1,7 @@
import "server-only"
import { cache } from "react"
import { count, desc, eq, gt } from "drizzle-orm"
import { count, desc, eq, gt, inArray } from "drizzle-orm"
import { db } from "@/shared/db"
import {
@@ -11,9 +11,11 @@ import {
homeworkAssignments,
homeworkSubmissions,
questions,
roles,
sessions,
textbooks,
users,
usersToRoles,
} from "@/shared/db/schema"
import type { AdminDashboardData } from "./types"
@@ -23,7 +25,7 @@ export const getAdminDashboardData = cache(async (): Promise<AdminDashboardData>
const [
activeSessionsRow,
userCountRow,
userRoleRows,
userRoleCountRows,
classCountRow,
textbookCountRow,
chapterCountRow,
@@ -37,7 +39,11 @@ export const getAdminDashboardData = cache(async (): Promise<AdminDashboardData>
] = await Promise.all([
db.select({ value: count() }).from(sessions).where(gt(sessions.expires, now)),
db.select({ value: count() }).from(users),
db.select({ role: users.role, value: count() }).from(users).groupBy(users.role),
db
.select({ role: roles.name, value: count() })
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.groupBy(roles.name),
db.select({ value: count() }).from(classes),
db.select({ value: count() }).from(textbooks),
db.select({ value: count() }).from(chapters),
@@ -52,7 +58,6 @@ export const getAdminDashboardData = cache(async (): Promise<AdminDashboardData>
id: users.id,
name: users.name,
email: users.email,
role: users.role,
createdAt: users.createdAt,
})
.from(users)
@@ -72,17 +77,55 @@ export const getAdminDashboardData = cache(async (): Promise<AdminDashboardData>
const homeworkSubmissionCount = Number(homeworkSubmissionCountRow[0]?.value ?? 0)
const homeworkSubmissionToGradeCount = Number(homeworkSubmissionToGradeCountRow[0]?.value ?? 0)
const userRoleCounts = userRoleRows
const userRoleCounts = userRoleCountRows
.map((r) => ({ role: r.role ?? "unknown", count: Number(r.value ?? 0) }))
.sort((a, b) => b.count - a.count)
const recentUsers = recentUserRows.map((u) => ({
id: u.id,
name: u.name,
email: u.email,
role: u.role,
createdAt: u.createdAt.toISOString(),
}))
const normalizeRole = (value: string) => {
const role = value.trim().toLowerCase()
if (role === "grade_head" || role === "teaching_head") return "teacher"
if (role === "admin" || role === "student" || role === "teacher" || role === "parent") return role
return ""
}
const recentUserIds = recentUserRows.map((u) => u.id)
const recentRoleRows = recentUserIds.length
? await db
.select({
userId: usersToRoles.userId,
roleName: roles.name,
})
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(inArray(usersToRoles.userId, recentUserIds))
: []
const rolesByUserId = new Map<string, string[]>()
for (const row of recentRoleRows) {
const list = rolesByUserId.get(row.userId) ?? []
list.push(row.roleName)
rolesByUserId.set(row.userId, list)
}
const resolvePrimaryRole = (roleNames: string[]) => {
const mapped = roleNames.map(normalizeRole).filter(Boolean)
if (mapped.includes("admin")) return "admin"
if (mapped.includes("teacher")) return "teacher"
if (mapped.includes("parent")) return "parent"
if (mapped.includes("student")) return "student"
return "student"
}
const recentUsers = recentUserRows.map((u) => {
const roleNames = rolesByUserId.get(u.id) ?? []
return {
id: u.id,
name: u.name,
email: u.email,
role: resolvePrimaryRole(roleNames),
createdAt: u.createdAt.toISOString(),
}
})
return {
activeSessionsCount,

View File

@@ -25,6 +25,20 @@ export function ExamPaperPreview({ title, subject, grade, durationMin, totalScor
// Helper to flatten questions for continuous numbering
let questionCounter = 0
const parseContent = (raw: unknown): QuestionContent => {
if (raw && typeof raw === "object") return raw as QuestionContent
if (typeof raw === "string") {
try {
const parsed = JSON.parse(raw) as unknown
if (parsed && typeof parsed === "object") return parsed as QuestionContent
return { text: raw }
} catch {
return { text: raw }
}
}
return {}
}
const renderNode = (node: ExamNode, depth: number = 0) => {
if (node.type === 'group') {
return (
@@ -45,7 +59,7 @@ export function ExamPaperPreview({ title, subject, grade, durationMin, totalScor
if (node.type === 'question' && node.question) {
questionCounter++
const q = node.question
const content = q.content as QuestionContent
const content = parseContent(q.content)
return (
<div key={node.id} className="mb-6 break-inside-avoid">

View File

@@ -28,13 +28,26 @@ export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMor
<div className="space-y-3 pb-4">
{questions.map((q) => {
const added = isAdded(q.id)
const content = q.content as { text?: string }
const parsedContent = (() => {
if (q.content && typeof q.content === "object") return q.content as { text?: string }
if (typeof q.content === "string") {
try {
const parsed = JSON.parse(q.content) as unknown
if (parsed && typeof parsed === "object") return parsed as { text?: string }
return { text: q.content }
} catch {
return { text: q.content }
}
}
return { text: "" }
})()
const typeLabel = typeof q.type === "string" ? q.type.replace("_", " ") : "unknown"
return (
<Card key={q.id} className="p-3 flex gap-3 hover:bg-muted/50 transition-colors">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-[10px] uppercase">
{q.type.replace("_", " ")}
{typeLabel}
</Badge>
<Badge variant="secondary" className="text-[10px]">
Lvl {q.difficulty}
@@ -46,7 +59,7 @@ export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMor
))}
</div>
<p className="text-sm line-clamp-2 text-muted-foreground">
{content.text || "No content preview"}
{parsedContent.text || "No content preview"}
</p>
</div>
<div className="flex items-center">

View File

@@ -118,7 +118,22 @@ function QuestionItem({ item, index, total, onRemove, onMove, onScoreChange }: {
onMove: (dir: 'up' | 'down') => void
onScoreChange: (score: number) => void
}) {
const content = item.question?.content as { text?: string }
const rawContent = item.question?.content
const parsedContent = (() => {
if (rawContent && typeof rawContent === "object") return rawContent as { text?: string; options?: Array<{ id?: string; text?: string }> }
if (typeof rawContent === "string") {
try {
const parsed = JSON.parse(rawContent) as unknown
if (parsed && typeof parsed === "object") return parsed as { text?: string; options?: Array<{ id?: string; text?: string }> }
return { text: rawContent }
} catch {
return { text: rawContent }
}
}
return { text: "" }
})()
const options = Array.isArray(parsedContent.options) ? parsedContent.options : []
return (
<div className="group flex flex-col gap-3 rounded-md border p-3 bg-card hover:border-primary/50 transition-colors">
<div className="flex items-start justify-between gap-3">
@@ -127,7 +142,7 @@ function QuestionItem({ item, index, total, onRemove, onMove, onScoreChange }: {
{index + 1}
</span>
<p className="text-sm line-clamp-2 pt-0.5">
{content?.text || "Question content"}
{parsedContent.text || "Question content"}
</p>
</div>
<Button
@@ -139,6 +154,16 @@ function QuestionItem({ item, index, total, onRemove, onMove, onScoreChange }: {
<Trash2 className="h-4 w-4" />
</Button>
</div>
{(item.question?.type === "single_choice" || item.question?.type === "multiple_choice") && options.length > 0 && (
<div className="grid grid-cols-1 gap-1 pl-8 text-xs text-muted-foreground">
{options.map((opt, idx) => (
<div key={opt.id ?? idx} className="flex gap-2">
<span className="font-medium">{opt.id ?? String.fromCharCode(65 + idx)}.</span>
<span>{opt.text ?? ""}</span>
</div>
))}
</div>
)}
<div className="flex items-center justify-between pl-8">
<div className="flex items-center gap-1">

View File

@@ -82,7 +82,22 @@ function SortableItem({
opacity: isDragging ? 0.5 : 1,
}
const content = item.question?.content as { text?: string }
const rawContent = item.question?.content
const parsedContent = (() => {
if (rawContent && typeof rawContent === "object") return rawContent as { text?: string; options?: Array<{ id?: string; text?: string }> }
if (typeof rawContent === "string") {
try {
const parsed = JSON.parse(rawContent) as unknown
if (parsed && typeof parsed === "object") return parsed as { text?: string; options?: Array<{ id?: string; text?: string }> }
return { text: rawContent }
} catch {
return { text: rawContent }
}
}
return { text: "" }
})()
const options = Array.isArray(parsedContent.options) ? parsedContent.options : []
return (
<div ref={setNodeRef} style={style} className={cn("group flex flex-col gap-3 rounded-md border p-3 bg-card hover:border-primary/50 transition-colors", isDragging && "ring-2 ring-primary")}>
@@ -92,7 +107,7 @@ function SortableItem({
<GripVertical className="h-4 w-4" />
</button>
<p className="text-sm line-clamp-2 pt-0.5 select-none">
{content?.text || "Question content"}
{parsedContent.text || "Question content"}
</p>
</div>
<Button
@@ -104,6 +119,16 @@ function SortableItem({
<Trash2 className="h-4 w-4" />
</Button>
</div>
{(item.question?.type === "single_choice" || item.question?.type === "multiple_choice") && options.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-1 pl-8 text-xs text-muted-foreground">
{options.map((opt, idx) => (
<div key={opt.id ?? idx} className="flex gap-2">
<span className="font-medium">{opt.id ?? String.fromCharCode(65 + idx)}.</span>
<span>{opt.text ?? ""}</span>
</div>
))}
</div>
)}
<div className="flex items-center justify-end pl-8">
<div className="flex items-center gap-2">

View File

@@ -79,7 +79,7 @@ export function ExamActions({ exam }: ExamActionsProps) {
toast.error("Failed to load exam preview")
setShowViewDialog(false)
}
} catch (e) {
} catch {
toast.error("Failed to load exam preview")
setShowViewDialog(false)
} finally {

View File

@@ -1,7 +1,6 @@
"use client"
import { useDeferredValue, useMemo, useState, useTransition, useEffect, useRef } from "react"
import { useFormStatus } from "react-dom"
import { useCallback, useDeferredValue, useMemo, useState, useTransition, useEffect, useRef } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Search, Eye } from "lucide-react"
@@ -34,15 +33,6 @@ type ExamAssemblyProps = {
questionOptions: Question[]
}
function SubmitButton({ label }: { label: string }) {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending} className="w-full">
{pending ? "Saving..." : label}
</Button>
)
}
export function ExamAssembly(props: ExamAssemblyProps) {
const router = useRouter()
const [search, setSearch] = useState("")
@@ -83,7 +73,7 @@ export function ExamAssembly(props: ExamAssemblyProps) {
return []
})
const fetchQuestions = (reset: boolean = false) => {
const fetchQuestions = useCallback((reset: boolean = false) => {
startBankTransition(async () => {
const nextPage = reset ? 1 : page + 1
try {
@@ -107,11 +97,11 @@ export function ExamAssembly(props: ExamAssemblyProps) {
setHasMore(result.data.length === 20)
setPage(nextPage)
}
} catch (error) {
} catch {
toast.error("Failed to load questions")
}
})
}
}, [deferredSearch, page, startBankTransition, typeFilter, difficultyFilter])
const isFirstRender = useRef(true)
@@ -123,7 +113,7 @@ export function ExamAssembly(props: ExamAssemblyProps) {
}
}
fetchQuestions(true)
}, [deferredSearch, typeFilter, difficultyFilter])
}, [deferredSearch, typeFilter, difficultyFilter, fetchQuestions])
// Recursively calculate total score
const assignedTotal = useMemo(() => {

View File

@@ -149,8 +149,8 @@ export const omitScheduledAtFromDescription = (description: string | null): stri
try {
const meta = JSON.parse(description)
if (typeof meta === "object" && meta !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { scheduledAt, ...rest } = meta as any
const rest = { ...(meta as Record<string, unknown>) }
delete rest.scheduledAt
return JSON.stringify(rest)
}
return description

View File

@@ -1,64 +1,64 @@
"use server"
import { revalidatePath } from "next/cache"
import { headers } from "next/headers"
import { createId } from "@paralleldrive/cuid2"
import { and, count, eq } from "drizzle-orm"
import { and, count, eq, inArray } from "drizzle-orm"
import { auth } from "@/auth"
import { db } from "@/shared/db"
import {
classes,
classEnrollments,
classSubjectTeachers,
exams,
homeworkAnswers,
homeworkAssignmentQuestions,
homeworkAssignmentTargets,
homeworkAssignments,
homeworkSubmissions,
roles,
users,
usersToRoles,
} from "@/shared/db/schema"
import type { ActionState } from "@/shared/types/action-state"
import { CreateHomeworkAssignmentSchema, GradeHomeworkSchema } from "./schema"
type CurrentUser = { id: string; role: "admin" | "teacher" | "student" }
type TeacherRole = "admin" | "teacher"
type StudentRole = "student"
async function getCurrentUser() {
const ref = (await headers()).get("referer") || ""
const roleHint: CurrentUser["role"] = ref.includes("/admin/")
? "admin"
: ref.includes("/student/")
? "student"
: ref.includes("/teacher/")
? "teacher"
: "teacher"
const byRole = await db.query.users.findFirst({
where: eq(users.role, roleHint),
orderBy: (u, { asc }) => [asc(u.createdAt)],
})
if (byRole) return { id: byRole.id, role: roleHint }
const anyUser = await db.query.users.findFirst({
orderBy: (u, { asc }) => [asc(u.createdAt)],
})
if (anyUser) return { id: anyUser.id, role: roleHint }
return { id: "user_teacher_math", role: roleHint }
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 user = await getCurrentUser()
if (!user || (user.role !== "teacher" && user.role !== "admin")) throw new Error("Unauthorized")
return user
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() {
const user = await getCurrentUser()
if (!user || user.role !== "student") throw new Error("Unauthorized")
return user
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[] => {
@@ -108,12 +108,12 @@ export async function createHomeworkAssignmentAction(
const input = parsed.data
const publish = input.publish ?? true
const [ownedClass] = await db
.select({ id: classes.id })
const [classRow] = await db
.select({ id: classes.id, teacherId: classes.teacherId })
.from(classes)
.where(user.role === "admin" ? eq(classes.id, input.classId) : and(eq(classes.id, input.classId), eq(classes.teacherId, user.id)))
.where(eq(classes.id, input.classId))
.limit(1)
if (!ownedClass) return { success: false, message: "Class not found" }
if (!classRow) return { success: false, message: "Class not found" }
const exam = await db.query.exams.findFirst({
where: eq(exams.id, input.sourceExamId),
@@ -126,23 +126,43 @@ export async function createHomeworkAssignmentAction(
if (!exam) return { success: false, message: "Exam not found" }
if (user.role !== "admin" && classRow.teacherId !== user.id) {
const assignedSubjectRows = await db
.select({ subjectId: classSubjectTeachers.subjectId })
.from(classSubjectTeachers)
.where(and(eq(classSubjectTeachers.classId, input.classId), eq(classSubjectTeachers.teacherId, user.id)))
if (assignedSubjectRows.length === 0) {
return { success: false, message: "Not assigned to this class" }
}
const assignedSubjectIds = new Set(assignedSubjectRows.map((r) => r.subjectId))
if (!exam.subjectId) {
return { success: false, message: "Exam subject not set" }
}
if (!assignedSubjectIds.has(exam.subjectId)) {
return { success: false, message: "Not assigned to this subject" }
}
}
const assignmentId = createId()
const availableAt = input.availableAt ? new Date(input.availableAt) : null
const dueAt = input.dueAt ? new Date(input.dueAt) : null
const lateDueAt = input.lateDueAt ? new Date(input.lateDueAt) : null
const classScope =
user.role === "admin"
? eq(classes.id, input.classId)
: classRow.teacherId === user.id
? eq(classes.teacherId, user.id)
: eq(classes.id, input.classId)
const classStudentIds = (
await db
.select({ studentId: classEnrollments.studentId })
.from(classEnrollments)
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
.where(
and(
eq(classEnrollments.classId, input.classId),
eq(classEnrollments.status, "active"),
user.role === "admin" ? eq(classes.id, input.classId) : eq(classes.teacherId, user.id)
)
and(eq(classEnrollments.classId, input.classId), eq(classEnrollments.status, "active"), classScope)
)
).map((r) => r.studentId)

View File

@@ -12,7 +12,6 @@ import { Checkbox } from "@/shared/components/ui/checkbox"
import { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Separator } from "@/shared/components/ui/separator"
import { Clock, CheckCircle2, Save, FileText } from "lucide-react"
import type { StudentHomeworkTakeData } from "../types"

View File

@@ -7,7 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/sha
import { Checkbox } from "@/shared/components/ui/checkbox"
import { Label } from "@/shared/components/ui/label"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { CheckCircle2, FileText, ChevronLeft } from "lucide-react"
import { FileText, ChevronLeft } from "lucide-react"
import Link from "next/link"
import type { StudentHomeworkTakeData } from "../types"
@@ -57,7 +57,6 @@ type HomeworkReviewViewProps = {
export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) {
const submissionStatus = initialData.submission?.status ?? "not_started"
const isGraded = submissionStatus === "graded"
const isSubmitted = submissionStatus === "submitted"
const answersByQuestionId = useMemo(() => {
const map = new Map<string, { answer: unknown }>()

View File

@@ -3,6 +3,7 @@ import "server-only"
import { cache } from "react"
import { and, count, desc, eq, inArray, isNull, lte, or, sql } from "drizzle-orm"
import { auth } from "@/auth"
import { db } from "@/shared/db"
import {
classEnrollments,
@@ -11,7 +12,9 @@ import {
homeworkAssignmentTargets,
homeworkAssignments,
homeworkSubmissions,
roles,
users,
usersToRoles,
} from "@/shared/db/schema"
import type {
@@ -550,17 +553,20 @@ export const getHomeworkSubmissionDetails = cache(async (submissionId: string):
})
export const getDemoStudentUser = cache(async (): Promise<{ id: string; name: string } | null> => {
const student = await db.query.users.findFirst({
where: eq(users.role, "student"),
orderBy: (u, { asc }) => [asc(u.createdAt)],
})
if (student) return { id: student.id, name: student.name || "Student" }
const session = await auth()
const userId = String(session?.user?.id ?? "").trim()
if (!userId) return null
const anyUser = await db.query.users.findFirst({
orderBy: (u, { asc }) => [asc(u.createdAt)],
})
if (!anyUser) return null
return { id: anyUser.id, name: anyUser.name || "User" }
const [student] = await db
.select({ id: users.id, name: users.name })
.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 (!student) return null
return { id: student.id, name: student.name || "Student" }
})
const toStudentProgressStatus = (v: string | null | undefined): StudentHomeworkProgressStatus => {
@@ -592,19 +598,23 @@ export const getStudentHomeworkAssignments = cache(async (studentId: string): Pr
const assignmentIds = assignments.map((a) => a.id)
const submissions = await db.query.homeworkSubmissions.findMany({
where: and(eq(homeworkSubmissions.studentId, studentId), inArray(homeworkSubmissions.assignmentId, assignmentIds)),
orderBy: [desc(homeworkSubmissions.createdAt)],
orderBy: [desc(homeworkSubmissions.updatedAt)],
})
const attemptsByAssignmentId = new Map<string, number>()
const latestByAssignmentId = new Map<string, (typeof submissions)[number]>()
const latestSubmittedByAssignmentId = new Map<string, (typeof submissions)[number]>()
for (const s of submissions) {
attemptsByAssignmentId.set(s.assignmentId, (attemptsByAssignmentId.get(s.assignmentId) ?? 0) + 1)
if (!latestByAssignmentId.has(s.assignmentId)) latestByAssignmentId.set(s.assignmentId, s)
if (s.status === "submitted" || s.status === "graded") {
if (!latestSubmittedByAssignmentId.has(s.assignmentId)) latestSubmittedByAssignmentId.set(s.assignmentId, s)
}
}
return assignments.map((a) => {
const latest = latestByAssignmentId.get(a.id) ?? null
const latest = latestSubmittedByAssignmentId.get(a.id) ?? latestByAssignmentId.get(a.id) ?? null
const attemptsUsed = attemptsByAssignmentId.get(a.id) ?? 0
const item: StudentHomeworkAssignmentListItem = {

View File

@@ -5,7 +5,6 @@ import {
LayoutDashboard,
Settings,
Users,
FileText,
MessageSquare,
Shield,
CreditCard,
@@ -156,11 +155,6 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
icon: Calendar,
href: "/student/schedule",
},
{
title: "Resources",
icon: FileText,
href: "/student/resources",
},
],
parent: [
{

View File

@@ -1,29 +1,51 @@
"use server";
import { db } from "@/shared/db";
import { questions, questionsToKnowledgePoints } from "@/shared/db/schema";
import { chapters, knowledgePoints, questions, questionsToKnowledgePoints, textbooks, roles, users, usersToRoles } 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, eq } from "drizzle-orm";
import { and, asc, eq, inArray } from "drizzle-orm";
import { z } from "zod";
import { getQuestions, type GetQuestionsParams } from "./data-access";
import type { KnowledgePointOption } from "./types";
import { auth } from "@/auth";
async function getCurrentUser() {
return {
id: "user_teacher_math",
role: "teacher",
};
}
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 user = await getCurrentUser();
if (!user || (user.role !== "teacher" && user.role !== "admin")) {
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 user;
return { id: row.id, role: row.role as "teacher" | "admin" };
}
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0]
@@ -244,3 +266,40 @@ export async function getQuestionsAction(params: GetQuestionsParams) {
await ensureTeacher();
return await getQuestions(params);
}
export async function getKnowledgePointOptionsAction(): Promise<KnowledgePointOption[]> {
await ensureTeacher();
const rows = await db
.select({
id: knowledgePoints.id,
name: knowledgePoints.name,
chapterId: chapters.id,
chapterTitle: chapters.title,
textbookId: textbooks.id,
textbookTitle: textbooks.title,
subject: textbooks.subject,
grade: textbooks.grade,
})
.from(knowledgePoints)
.leftJoin(chapters, eq(chapters.id, knowledgePoints.chapterId))
.leftJoin(textbooks, eq(textbooks.id, chapters.textbookId))
.orderBy(
asc(textbooks.title),
asc(chapters.order),
asc(chapters.title),
asc(knowledgePoints.order),
asc(knowledgePoints.name)
);
return rows.map((row) => ({
id: row.id,
name: row.name,
chapterId: row.chapterId ?? null,
chapterTitle: row.chapterTitle ?? null,
textbookId: row.textbookId ?? null,
textbookTitle: row.textbookTitle ?? null,
subject: row.subject ?? null,
grade: row.grade ?? null,
}));
}

View File

@@ -27,6 +27,7 @@ import {
FormMessage,
} from "@/shared/components/ui/form"
import { Input } from "@/shared/components/ui/input"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import {
Select,
SelectContent,
@@ -36,9 +37,9 @@ import {
} from "@/shared/components/ui/select"
import { Textarea } from "@/shared/components/ui/textarea"
import { BaseQuestionSchema } from "../schema"
import { createNestedQuestion, updateQuestionAction } from "../actions"
import { createNestedQuestion, getKnowledgePointOptionsAction, updateQuestionAction } from "../actions"
import { toast } from "sonner"
import { Question } from "../types"
import { KnowledgePointOption, Question } from "../types"
const QuestionFormSchema = BaseQuestionSchema.extend({
difficulty: z.number().min(1).max(5),
@@ -111,6 +112,10 @@ export function CreateQuestionDialog({
const router = useRouter()
const [isPending, setIsPending] = useState(false)
const isEdit = !!initialData
const [knowledgePointOptions, setKnowledgePointOptions] = useState<KnowledgePointOption[]>([])
const [knowledgePointQuery, setKnowledgePointQuery] = useState("")
const [selectedKnowledgePointIds, setSelectedKnowledgePointIds] = useState<string[]>([])
const [isLoadingKnowledgePoints, setIsLoadingKnowledgePoints] = useState(false)
const form = useForm<QuestionFormValues>({
resolver: zodResolver(QuestionFormSchema),
@@ -151,7 +156,60 @@ export function CreateQuestionDialog({
}
}, [initialData, form, open, defaultContent, defaultType])
useEffect(() => {
if (!open) return
setIsLoadingKnowledgePoints(true)
getKnowledgePointOptionsAction()
.then((rows) => {
setKnowledgePointOptions(rows)
})
.catch(() => {
toast.error("Failed to load knowledge points")
})
.finally(() => {
setIsLoadingKnowledgePoints(false)
})
}, [open])
useEffect(() => {
if (!open) return
if (initialData) {
const nextIds = initialData.knowledgePoints.map((kp) => kp.id)
setSelectedKnowledgePointIds((prev) => {
if (prev.length === nextIds.length && prev.every((id, idx) => id === nextIds[idx])) {
return prev
}
return nextIds
})
return
}
setSelectedKnowledgePointIds((prev) => {
if (
prev.length === defaultKnowledgePointIds.length &&
prev.every((id, idx) => id === defaultKnowledgePointIds[idx])
) {
return prev
}
return defaultKnowledgePointIds
})
}, [open, initialData, defaultKnowledgePointIds])
const questionType = form.watch("type")
const filteredKnowledgePoints = knowledgePointOptions.filter((kp) => {
const query = knowledgePointQuery.trim().toLowerCase()
if (!query) return true
const fullLabel = [
kp.textbookTitle,
kp.chapterTitle,
kp.name,
kp.subject,
kp.grade,
]
.filter(Boolean)
.join(" ")
.toLowerCase()
return fullLabel.includes(query)
})
const buildContent = (data: QuestionFormValues) => {
const text = data.content.trim()
@@ -194,7 +252,7 @@ export function CreateQuestionDialog({
type: data.type,
difficulty: data.difficulty,
content: buildContent(data),
knowledgePointIds: isEdit ? [] : defaultKnowledgePointIds,
knowledgePointIds: selectedKnowledgePointIds,
}
const fd = new FormData()
fd.set("json", JSON.stringify(payload))
@@ -306,6 +364,58 @@ export function CreateQuestionDialog({
)}
/>
<div className="space-y-3">
<div className="flex items-center justify-between">
<FormLabel>Knowledge Points</FormLabel>
<span className="text-xs text-muted-foreground">
{selectedKnowledgePointIds.length > 0 ? `${selectedKnowledgePointIds.length} selected` : "Optional"}
</span>
</div>
<Input
placeholder="Search knowledge points..."
value={knowledgePointQuery}
onChange={(e) => setKnowledgePointQuery(e.target.value)}
/>
<div className="rounded-md border">
<ScrollArea className="h-48">
{isLoadingKnowledgePoints ? (
<div className="p-3 text-sm text-muted-foreground">Loading...</div>
) : filteredKnowledgePoints.length === 0 ? (
<div className="p-3 text-sm text-muted-foreground">No knowledge points found.</div>
) : (
<div className="space-y-1 p-2">
{filteredKnowledgePoints.map((kp) => {
const labelParts = [
kp.textbookTitle,
kp.chapterTitle,
kp.name,
].filter(Boolean)
const label = labelParts.join(" · ")
return (
<label key={kp.id} className="flex items-center gap-2 rounded-md px-2 py-1 hover:bg-muted/50">
<Checkbox
checked={selectedKnowledgePointIds.includes(kp.id)}
onCheckedChange={(checked) => {
const isChecked = checked === true
setSelectedKnowledgePointIds((prev) => {
if (isChecked) {
if (prev.includes(kp.id)) return prev
return [...prev, kp.id]
}
return prev.filter((id) => id !== kp.id)
})
}}
/>
<span className="text-sm">{label}</span>
</label>
)
})}
</div>
)}
</ScrollArea>
</div>
</div>
{(questionType === "single_choice" || questionType === "multiple_choice") && (
<div className="space-y-4">
<div className="flex items-center justify-between">

View File

@@ -1,5 +1,6 @@
"use client"
import { useEffect, useState } from "react"
import { useQueryState, parseAsString } from "nuqs"
import { Search, X } from "lucide-react"
@@ -12,11 +13,25 @@ import {
SelectValue,
} from "@/shared/components/ui/select"
import { Button } from "@/shared/components/ui/button"
import { getKnowledgePointOptionsAction } from "../actions"
import type { KnowledgePointOption } from "../types"
export function QuestionFilters() {
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
const [type, setType] = useQueryState("type", parseAsString.withDefault("all"))
const [difficulty, setDifficulty] = useQueryState("difficulty", parseAsString.withDefault("all"))
const [knowledgePointId, setKnowledgePointId] = useQueryState("kp", parseAsString.withDefault("all"))
const [knowledgePointOptions, setKnowledgePointOptions] = useState<KnowledgePointOption[]>([])
useEffect(() => {
getKnowledgePointOptionsAction()
.then((rows) => {
setKnowledgePointOptions(rows)
})
.catch(() => {
setKnowledgePointOptions([])
})
}, [])
return (
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
@@ -56,14 +71,32 @@ export function QuestionFilters() {
<SelectItem value="5">Hard (5)</SelectItem>
</SelectContent>
</Select>
<Select value={knowledgePointId} onValueChange={(val) => setKnowledgePointId(val === "all" ? null : val)}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Knowledge Point" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Knowledge Points</SelectItem>
{knowledgePointOptions.map((kp) => {
const labelParts = [kp.textbookTitle, kp.chapterTitle, kp.name].filter(Boolean)
const label = labelParts.join(" · ")
return (
<SelectItem key={kp.id} value={kp.id}>
{label || kp.name}
</SelectItem>
)
})}
</SelectContent>
</Select>
{(search || type !== "all" || difficulty !== "all") && (
{(search || type !== "all" || difficulty !== "all" || knowledgePointId !== "all") && (
<Button
variant="ghost"
onClick={() => {
setSearch(null)
setType(null)
setDifficulty(null)
setKnowledgePointId(null)
}}
className="h-8 px-2 lg:px-3"
>

View File

@@ -21,3 +21,14 @@ export interface Question {
}[]
childrenCount?: number
}
export type KnowledgePointOption = {
id: string
name: string
chapterId: string | null
chapterTitle: string | null
textbookId: string | null
textbookTitle: string | null
subject: string | null
grade: string | null
}

View File

@@ -4,7 +4,7 @@ import { cache } from "react"
import { asc, eq, inArray, or } from "drizzle-orm"
import { db } from "@/shared/db"
import { academicYears, departments, grades, schools, users } from "@/shared/db/schema"
import { academicYears, departments, grades, roles, schools, users, usersToRoles } from "@/shared/db/schema"
import type { AcademicYearListItem, DepartmentListItem, GradeListItem, SchoolListItem, StaffOption } from "./types"
const toIso = (d: Date) => d.toISOString()
@@ -114,7 +114,10 @@ export const getStaffOptions = cache(async (): Promise<StaffOption[]> => {
const rows = await db
.select({ id: users.id, name: users.name, email: users.email })
.from(users)
.where(inArray(users.role, ["teacher", "admin"]))
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(inArray(roles.name, ["teacher", "admin"]))
.groupBy(users.id, users.name, users.email)
.orderBy(asc(users.name), asc(users.email))
return rows.map((r) => ({

View File

@@ -10,7 +10,6 @@ import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/shared/components/ui/card"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/shared/components/ui/form"
import { UserProfile } from "@/modules/users/data-access"

View File

@@ -224,30 +224,11 @@ export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapte
}
const activeParent = findParent(chapters, active.id as string)
const overParent = findParent(chapters, over.id as string)
// If parents don't match (and neither is root), we can't reorder easily in this simplified version
// But actually, we need to check if they are in the same list.
// If both are root items (activeParent is null), they are siblings.
const getSiblings = (parentId: string | null) => {
if (!parentId) return chapters
const parent = chapters.find(c => c.id === parentId) // This only finds root parents, we need recursive find
const findNode = (nodes: Chapter[], id: string): Chapter | null => {
for (const node of nodes) {
if (node.id === id) return node
if (node.children) {
const found = findNode(node.children, id)
if (found) return found
}
}
return null
}
return findNode(chapters, parentId)?.children || []
}
// Simplified logic: We trust dnd-kit's SortableContext to only allow valid drops if we restricted it?
// No, dnd-kit allows dropping anywhere by default unless restricted.
@@ -271,7 +252,6 @@ export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapte
// Check if over is in the same list
if (activeList.some(c => c.id === over.id)) {
const oldIndex = activeList.findIndex((item) => item.id === active.id)
const newIndex = activeList.findIndex((item) => item.id === over.id)
await reorderChaptersAction(active.id as string, newIndex, activeParentId, textbookId)

View File

@@ -24,29 +24,35 @@ interface TextbookCardProps {
}
const subjectColorMap: Record<string, string> = {
Mathematics: "from-blue-500/20 to-blue-600/20 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800",
Physics: "from-purple-500/20 to-purple-600/20 text-purple-700 dark:text-purple-300 border-purple-200 dark:border-purple-800",
Chemistry: "from-teal-500/20 to-teal-600/20 text-teal-700 dark:text-teal-300 border-teal-200 dark:border-teal-800",
English: "from-orange-500/20 to-orange-600/20 text-orange-700 dark:text-orange-300 border-orange-200 dark:border-orange-800",
History: "from-amber-500/20 to-amber-600/20 text-amber-700 dark:text-amber-300 border-amber-200 dark:border-amber-800",
Biology: "from-emerald-500/20 to-emerald-600/20 text-emerald-700 dark:text-emerald-300 border-emerald-200 dark:border-emerald-800",
Geography: "from-sky-500/20 to-sky-600/20 text-sky-700 dark:text-sky-300 border-sky-200 dark:border-sky-800",
Mathematics: "bg-blue-50 text-blue-700 border-blue-200/70 dark:bg-blue-950/50 dark:text-blue-200 dark:border-blue-900/60",
Physics: "bg-purple-50 text-purple-700 border-purple-200/70 dark:bg-purple-950/50 dark:text-purple-200 dark:border-purple-900/60",
Chemistry: "bg-teal-50 text-teal-700 border-teal-200/70 dark:bg-teal-950/50 dark:text-teal-200 dark:border-teal-900/60",
English: "bg-orange-50 text-orange-700 border-orange-200/70 dark:bg-orange-950/50 dark:text-orange-200 dark:border-orange-900/60",
History: "bg-amber-50 text-amber-700 border-amber-200/70 dark:bg-amber-950/50 dark:text-amber-200 dark:border-amber-900/60",
Biology: "bg-emerald-50 text-emerald-700 border-emerald-200/70 dark:bg-emerald-950/50 dark:text-emerald-200 dark:border-emerald-900/60",
Geography: "bg-sky-50 text-sky-700 border-sky-200/70 dark:bg-sky-950/50 dark:text-sky-200 dark:border-sky-900/60",
};
export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardProps) {
const base = hrefBase || "/teacher/textbooks";
const colorClass = subjectColorMap[textbook.subject] || "from-zinc-500/20 to-zinc-600/20 text-zinc-700 dark:text-zinc-300 border-zinc-200 dark:border-zinc-800";
const colorClass = subjectColorMap[textbook.subject] || "bg-zinc-50 text-zinc-700 border-zinc-200/70 dark:bg-zinc-950/50 dark:text-zinc-200 dark:border-zinc-800/70";
return (
<Card className="group flex flex-col h-full overflow-hidden transition-all duration-300 hover:shadow-md hover:border-primary/50">
<Card className="group flex flex-col h-full overflow-hidden border-border/60 transition-all duration-300 hover:shadow-md hover:border-primary/50">
<Link href={`${base}/${textbook.id}`} className="flex-1">
<div className={cn("relative h-32 w-full overflow-hidden bg-gradient-to-br p-6 transition-all group-hover:scale-105", colorClass)}>
<div className="absolute inset-0 bg-grid-black/[0.05] dark:bg-grid-white/[0.05]" />
<div className={cn("relative h-32 w-full overflow-hidden p-5", colorClass)}>
<div className="relative z-10 flex h-full flex-col justify-between">
<Badge variant="secondary" className="w-fit bg-background/50 backdrop-blur-sm border-transparent shadow-none">
<Badge variant="secondary" className="w-fit bg-background/80 border border-border/60 shadow-sm">
{textbook.subject}
</Badge>
<Book className="h-8 w-8 opacity-50" />
<div className="flex items-center gap-2">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-background text-foreground shadow-sm ring-1 ring-border/60">
<Book className="h-5 w-5" />
</div>
<div className="text-xs font-medium text-foreground/70">
{textbook.grade || "Grade N/A"}
</div>
</div>
</div>
</div>
@@ -74,9 +80,11 @@ export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardPr
</CardContent>
</Link>
<CardFooter className="p-4 pt-2 mt-auto border-t bg-muted/20 flex items-center justify-between">
<CardFooter className="p-4 pt-2 mt-auto border-t border-border/60 bg-muted/30 flex items-center justify-between">
<div className="flex items-center gap-2 text-xs text-muted-foreground tabular-nums">
<BookOpen className="h-3.5 w-3.5" />
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-background/80 ring-1 ring-border/60">
<BookOpen className="h-3.5 w-3.5" />
</div>
<span>{textbook._count?.chapters || 0} Chapters</span>
</div>

View File

@@ -1,7 +1,7 @@
"use client"
import { useQueryState, parseAsString } from "nuqs"
import { Search, Filter, X } from "lucide-react"
import { Search, X } from "lucide-react"
import { Input } from "@/shared/components/ui/input"
import { Button } from "@/shared/components/ui/button"

View File

@@ -5,13 +5,8 @@ import ReactMarkdown from "react-markdown"
import remarkBreaks from "remark-breaks"
import remarkGfm from "remark-gfm"
import { useQueryState, parseAsString } from "nuqs"
import { Tag, List, Plus, Edit2, Save, Trash2, Pencil, PlusCircle, ChevronDown, ChevronUp } from "lucide-react"
import { Tag, List, Plus, Edit2, Save, Trash2, Pencil, PlusCircle, Share2 } from "lucide-react"
import { toast } from "sonner"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/shared/components/ui/collapsible"
import type { Chapter, KnowledgePoint } from "../types"
import { createKnowledgePointAction, updateChapterContentAction, deleteKnowledgePointAction, updateKnowledgePointAction } from "../actions"
@@ -243,6 +238,96 @@ export function TextbookReader({ chapters, knowledgePoints = [], canEdit = false
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 ""
@@ -293,7 +378,7 @@ export function TextbookReader({ chapters, knowledgePoints = [], canEdit = false
<div className="lg:col-span-4 lg:border-r lg:pr-6 flex flex-col min-h-0">
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col h-full">
<div className="flex items-center justify-between mb-4 px-2 shrink-0">
<TabsList className="grid w-full grid-cols-2">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="chapters" className="gap-2">
<List className="h-4 w-4" />
@@ -305,6 +390,10 @@ export function TextbookReader({ chapters, knowledgePoints = [], canEdit = false
<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}>
<Share2 className="h-4 w-4" />
</TabsTrigger>
</TabsList>
</div>
@@ -399,6 +488,62 @@ export function TextbookReader({ chapters, knowledgePoints = [], canEdit = false
</ScrollArea>
)}
</TabsContent>
<TabsContent value="graph" 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="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>
)}
</TabsContent>
</Tabs>
</div>

View File

@@ -9,7 +9,6 @@ import { chapters, knowledgePoints, textbooks } from "@/shared/db/schema"
import type {
Chapter,
CreateChapterInput,
CreateKnowledgePointInput,
CreateTextbookInput,
KnowledgePoint,
Textbook,

View File

@@ -4,7 +4,7 @@ import { cache } from "react"
import { eq } from "drizzle-orm"
import { db } from "@/shared/db"
import { users } from "@/shared/db/schema"
import { roles, users, usersToRoles } from "@/shared/db/schema"
export type UserProfile = {
id: string
@@ -21,6 +21,25 @@ export type UserProfile = {
updatedAt: Date
}
const rolePriority = ["admin", "teacher", "parent", "student"] as const
const normalizeRoleName = (value: string) => {
const role = value.trim().toLowerCase()
if (role === "grade_head" || role === "teaching_head") return "teacher"
if (role === "admin" || role === "teacher" || role === "parent" || role === "student") return role
return ""
}
const resolvePrimaryRole = (roleNames: string[]) => {
const mapped = roleNames.map(normalizeRoleName).filter(Boolean)
if (mapped.length) {
for (const role of rolePriority) {
if (mapped.includes(role)) return role
}
}
return "student"
}
export const getUserProfile = cache(async (userId: string): Promise<UserProfile | null> => {
const user = await db.query.users.findFirst({
where: eq(users.id, userId),
@@ -28,12 +47,19 @@ export const getUserProfile = cache(async (userId: string): Promise<UserProfile
if (!user) return null
const roleRows = await db
.select({ name: roles.name })
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(eq(usersToRoles.userId, userId))
const role = resolvePrimaryRole(roleRows.map((r) => r.name))
return {
id: user.id,
name: user.name,
email: user.email,
image: user.image,
role: user.role,
role,
phone: user.phone,
address: user.address,
gender: user.gender,

View File

@@ -25,7 +25,7 @@ function isRecord(v: unknown): v is Record<string, unknown> {
export function OnboardingGate() {
const router = useRouter()
const { status, data: session } = useSession()
const { status, data: session, update } = useSession()
const [required, setRequired] = useState(false)
const [currentRole, setCurrentRole] = useState<Role>("student")
const [open, setOpen] = useState(false)
@@ -142,6 +142,7 @@ export function OnboardingGate() {
throw new Error(msg || "提交失败")
}
await update?.()
toast.success("配置完成")
setRequired(false)
setOpen(false)

View File

@@ -18,19 +18,12 @@ import {
ListOrdered,
Quote,
Undo,
Redo,
MoreHorizontal
Redo
} from "lucide-react"
import { cn } from "@/shared/lib/utils"
import { Button } from "@/shared/components/ui/button"
import { Separator } from "@/shared/components/ui/separator"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
// Since we don't have Toggle component yet, let's create a local one or use Button
// We will use Button for simplicity and to avoid dependency issues if Radix Toggle isn't installed

View File

@@ -25,10 +25,6 @@ export const users = mysqlTable("users", {
email: varchar("email", { length: 255 }).notNull().unique(),
emailVerified: timestamp("emailVerified", { mode: "date" }),
image: varchar("image", { length: 255 }),
// Custom Role Field for RBAC (Default Role)
role: varchar("role", { length: 50 }).default("student"),
// Credentials Auth (Optional)
password: varchar("password", { length: 255 }),
@@ -338,23 +334,27 @@ export const classes = mysqlTable("classes", {
}).onDelete("set null"),
}));
export const classSubjectEnum = mysqlEnum("subject", ["语文", "数学", "英语", "美术", "体育", "科学", "社会", "音乐"]);
export const classSubjectTeachers = mysqlTable("class_subject_teachers", {
classId: varchar("class_id", { length: 128 }).notNull(),
subject: classSubjectEnum.notNull(),
subjectId: varchar("subject_id", { length: 128 }).notNull(),
teacherId: varchar("teacher_id", { length: 128 }).references(() => users.id, { onDelete: "set null" }),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
pk: primaryKey({ columns: [table.classId, table.subject] }),
pk: primaryKey({ columns: [table.classId, table.subjectId] }),
classIdx: index("class_subject_teachers_class_idx").on(table.classId),
teacherIdx: index("class_subject_teachers_teacher_idx").on(table.teacherId),
subjectIdIdx: index("class_subject_teachers_subject_id_idx").on(table.subjectId),
classFk: foreignKey({
columns: [table.classId],
foreignColumns: [classes.id],
name: "cst_c_fk",
}).onDelete("cascade"),
subjectFk: foreignKey({
columns: [table.subjectId],
foreignColumns: [subjects.id],
name: "cst_s_fk",
}).onDelete("cascade"),
}));
export const classEnrollments = mysqlTable("class_enrollments", {