Compare commits

...

15 Commits

Author SHA1 Message Date
SpecialX
eb08c0ab68 sync-docs-and-fixes
All checks were successful
CI / build-deploy (push) Successful in 4m39s
2026-03-03 17:32:26 +08:00
SpecialX
538805bad0 docs 2026-03-02 12:43:38 +08:00
SpecialX
8f974c04e0 refactor-ci-standalone
All checks were successful
CI / build-deploy (push) Successful in 6m6s
2026-02-26 16:38:07 +08:00
SpecialX
16ebbbe924 remove deploy setup network
Some checks failed
CI / build-and-test (push) Successful in 13m12s
CI / deploy (push) Failing after 1m40s
2026-02-26 15:48:42 +08:00
SpecialX
175af10881 fix-deploy-no-proxy
Some checks failed
CI / build-and-test (push) Successful in 15m20s
CI / deploy (push) Failing after 3m46s
2026-02-25 18:27:53 +08:00
SpecialX
ef9b987653 fix-deploy-network
Some checks failed
CI / build-and-test (push) Successful in 15m29s
CI / deploy (push) Failing after 3m50s
2026-02-25 18:03:32 +08:00
SpecialX
dcc946f48c fix-lint
Some checks failed
CI / build-and-test (push) Has started running
CI / deploy (push) Has been cancelled
2026-02-25 15:27:14 +08:00
SpecialX
cc02ddf82e fix-ci-proxy
Some checks failed
CI / build-and-test (push) Failing after 1m53s
CI / deploy (push) Has been skipped
2026-02-25 15:17:49 +08:00
SpecialX
3986c5919c chore: inject proxy envs for ci
Some checks failed
CI / build-and-test (push) Failing after 1m55s
CI / deploy (push) Has been skipped
2026-02-25 13:42:41 +08:00
SpecialX
bd8a4d39a6 chore: configure npm proxy in ci
Some checks failed
CI / build-and-test (push) Failing after 8m36s
CI / deploy (push) Has been skipped
2026-02-25 13:34:34 +08:00
SpecialX
0f38e97b4e remove configure npm registry
Some checks failed
CI / build-and-test (push) Failing after 8m33s
CI / deploy (push) Has been skipped
2026-02-25 12:36:20 +08:00
SpecialX
9c83fcc7c1 chore-npm-ci-stable
Some checks failed
CI / build-and-test (push) Failing after 22m29s
CI / deploy (push) Has been skipped
2026-02-25 11:30:07 +08:00
SpecialX
ddd3227693 log ci fail
Some checks failed
CI / build-and-test (push) Failing after 1m58s
CI / deploy (push) Has been skipped
2026-02-25 11:02:06 +08:00
SpecialX
ca465622dc chore-ci-cache
Some checks failed
CI / build-and-test (push) Failing after 1m58s
CI / deploy (push) Has been skipped
2026-02-24 18:55:36 +08:00
SpecialX
2e9078f810 chore-ci-cache
Some checks failed
CI / build-and-test (push) Failing after 39s
CI / deploy (push) Has been skipped
2026-02-24 18:37:03 +08:00
76 changed files with 2308 additions and 480 deletions

View File

@@ -10,9 +10,10 @@ on:
jobs: jobs:
build-and-test: build-deploy:
runs-on: CDCD runs-on: CDCD
container: dockerreg.eazygame.cn/node:22-bookworm # 合并 Job统一使用带 Docker 的 Node 镜像
container: dockerreg.eazygame.cn/node-with-docker:22
env: env:
SKIP_ENV_VALIDATION: "1" SKIP_ENV_VALIDATION: "1"
NEXT_TELEMETRY_DISABLED: "1" NEXT_TELEMETRY_DISABLED: "1"
@@ -31,6 +32,30 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-node- ${{ runner.os }}-node-
# 【保留增强】配置代理,确保 npm install 能通
- name: Configure npm proxy
run: |
GATEWAY_IP=$(ip route show | grep default | awk '{print $3}')
echo "Detected Docker Gateway: $GATEWAY_IP"
if [ -z "$GATEWAY_IP" ]; then
echo "Warning: Could not detect gateway IP, falling back to 172.17.0.1"
GATEWAY_IP="172.17.0.1"
fi
PROXY_URL="http://$GATEWAY_IP:7890"
echo "Using Proxy: $PROXY_URL"
# 设置 npm 代理
npm config set proxy "$PROXY_URL"
npm config set https-proxy "$PROXY_URL"
# 设置环境变量供后续步骤使用
echo "http_proxy=$PROXY_URL" >> $GITHUB_ENV
echo "https_proxy=$PROXY_URL" >> $GITHUB_ENV
echo "HTTP_PROXY=$PROXY_URL" >> $GITHUB_ENV
echo "HTTPS_PROXY=$PROXY_URL" >> $GITHUB_ENV
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
@@ -54,25 +79,6 @@ jobs:
- name: Build - name: Build
run: npm run build run: npm run build
# - name: 🔍 Debug - List Build Files
# run: |
# echo "======================="
# echo "1. Root directory files:"
# ls -la
#
# echo "======================="
# echo "2. Checking .next directory:"
# if [ -d ".next" ]; then
# ls -la .next
# else
# echo "❌ Error: .next folder does not exist!"
# fi
# echo "======================="
# echo "3. Deep check of .next (excluding node_modules):"
# # 查找 .next 目录下 4 层深度的文件,但排除 node_modules 避免日志太长
# find .next -maxdepth 4 -not -path '*/node_modules*'
- name: Prepare standalone build - name: Prepare standalone build
run: | run: |
@@ -83,42 +89,22 @@ jobs:
cp -r .next/static/* .next/standalone/.next/static/ cp -r .next/static/* .next/standalone/.next/static/
cp Dockerfile .next/standalone/Dockerfile cp Dockerfile .next/standalone/Dockerfile
# - name: 🔍 Debug - List Build Files # 【核心变更】合并 Deploy 步骤,直接构建镜像,无需 artifact
# run: |
# echo "======================="
# ls -la .next/standalone
- name: Upload production build artifact
uses: actions/upload-artifact@v3
with:
name: next-build
path: .next/standalone
include-hidden-files: true
deploy:
needs: build-and-test
runs-on: CDCD
container:
image: dockerreg.eazygame.cn/node-with-docker:22
steps:
- name: Download artifacts
uses: actions/download-artifact@v3
with:
name: next-build
- name: Deploy to Docker - name: Deploy to Docker
run: | run: |
# 1. 使用 --no-cache 防止使用旧的构建层,确保部署的是最新代码 # 1. 进入 standalone 目录
# 2. 使用 --pull 确保基础镜像是最新的 cd .next/standalone
docker build --no-cache --pull -t nextjs-app .
# 3. 优雅停止:先尝试 stop如果失败则无需处理 (|| true) # 2. 构建镜像 (使用 standalone 目录下的 Dockerfile)
echo "Building Docker image from standalone..."
docker build --no-cache --pull -t nextjs-app .
# 3. 优雅停止
docker stop nextjs-app || true docker stop nextjs-app || true
docker rm nextjs-app || true docker rm nextjs-app || true
# 4. 运行容器 # 4. 运行容器
# --init: 解决 Node.js PID 1 僵尸进程问题 # 使用你后来补充的完整配置 (包含 network 和 NEXTAUTH)
# --restart unless-stopped: 自动重启策略
docker run -d \ docker run -d \
--init \ --init \
-p 8015:3000 \ -p 8015:3000 \
@@ -131,4 +117,5 @@ jobs:
-e NEXTAUTH_URL=${{ secrets.NEXTAUTH_URL }} \ -e NEXTAUTH_URL=${{ secrets.NEXTAUTH_URL }} \
-e NEXT_TELEMETRY_DISABLED=1 \ -e NEXT_TELEMETRY_DISABLED=1 \
nextjs-app nextjs-app
echo "Deploy complete!"

View File

@@ -113,3 +113,15 @@ src/modules/
1. **Refactor Dashboard**: 将 `src/app/(dashboard)/dashboard/page.tsx` 重构为 Dispatcher。 1. **Refactor Dashboard**: 将 `src/app/(dashboard)/dashboard/page.tsx` 重构为 Dispatcher。
2. **Create Role Directories**: 在 `src/app/(dashboard)` 下创建 `teacher`, `student`, `admin` 目录。 2. **Create Role Directories**: 在 `src/app/(dashboard)` 下创建 `teacher`, `student`, `admin` 目录。
3. **Move Components**: 确保 `src/modules` 结构清晰。 3. **Move Components**: 确保 `src/modules` 结构清晰。
---
## 7. RBAC 扩展2026-03-02
为满足班级维度的权限差异,在教师角色下新增学科粒度的访问控制:
- 班主任Class Head Teacher可查看班级内所有学科相关的数据与统计。
- 任课老师Subject Teacher仅可查看自己被分配的学科相关内容。
- 实现要点:
- 数据访问层通过“会话用户身份”与“学科分配表”联合过滤,防止越权。
- 页面与组件保持不变,由后端/数据访问层保证返回范围正确的聚合数据。

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` #### 2.2 Table: `class_subject_teachers`
* **Action**: `CREATE TABLE` * **Action**: `CREATE TABLE`
* **Primary Key**: (`class_id`, `subject`) * **Primary Key**: (`class_id`, `subject_id`)
* **Columns**: * **Columns**:
* `class_id` (varchar(128), FK -> `classes.id`, cascade delete) * `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) * `teacher_id` (varchar(128), FK -> `users.id`, set null on delete)
* `created_at`, `updated_at` * `created_at`, `updated_at`
* **Reason**: Maintain a stable default “subject list” per class while allowing admin/teacher to assign the actual teacher per subject. * **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` 模块功能冗余,已全量移除。 - **移除 Insights**: 经评估,`src/app/(dashboard)/teacher/classes/insights` 模块功能冗余,已全量移除。
- **保留核心数据**: 保留 `data-access.ts` 中的 `getClassHomeworkInsights` 函数,继续服务于班级详情页的统计卡片与图表。 - **保留核心数据**: 保留 `data-access.ts` 中的 `getClassHomeworkInsights` 函数,继续服务于班级详情页的统计卡片与图表。
- **导航更新**: 从 `NAV_CONFIG` 中移除 Insights 入口。 - **导航更新**: 从 `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

@@ -154,9 +154,10 @@ src/
**目标**: 提升教师端教材管理的视觉质感与操作体验,对齐 "International Typographic Style" 设计语言。 **目标**: 提升教师端教材管理的视觉质感与操作体验,对齐 "International Typographic Style" 设计语言。
### 9.1 卡片与列表 (Textbook Card & Filters) ### 9.1 卡片与列表 (Textbook Card & Filters)
* **Dynamic Covers**: 卡片封面采用动态渐变色,根据科目 (Subject) 自动映射不同色系(如数学蓝、物理紫、生物绿),提升识别度 * **Pure Color Covers**: 卡片封面采用简洁纯色,根据科目 (Subject) 自动映射不同色系(如数学蓝、物理紫、生物绿),弱化纹理与渐变,整体更清爽
* **Information Density**: 增加元数据展示Grade, Publisher, Chapter Count并优化排版层级。 * **Information Density**: 增加元数据展示Grade, Publisher, Chapter Count并优化排版层级。
* **Quick Actions**: 在卡片底部增加 "Edit Content" / "Delete" 快捷下拉菜单。 * **Quick Actions**: 在卡片底部增加 "Edit Content" / "Delete" 快捷下拉菜单。
* **Icon Polish**: 图标采用简洁方块底与统一尺寸,降低视觉噪音。
* **Filters**: 简化筛选栏设计,移除厚重的容器背景,使其更轻量融入页面。 * **Filters**: 简化筛选栏设计,移除厚重的容器背景,使其更轻量融入页面。
### 9.2 详情页工作台 (Detail Workbench) ### 9.2 详情页工作台 (Detail Workbench)

View File

@@ -130,3 +130,21 @@ type QuestionContent = {
### 6.6 校验 ### 6.6 校验
- `npm run lint`0 errors仓库其他位置仍存在 warnings - `npm run lint`0 errors仓库其他位置仍存在 warnings
- `npm run typecheck`:通过 - `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. 变更记录 ## 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 (当前) **日期**2026-01-12 (当前)
- **列表页优化 (`/teacher/exams/all`)**: - **列表页优化 (`/teacher/exams/all`)**:

View File

@@ -304,3 +304,18 @@
- 问题:`isCorrect` 字段可能为 `boolean | null`,直接用于 JSX 渲染导致 "Type 'unknown' is not assignable to type 'ReactNode'" 错误。 - 问题:`isCorrect` 字段可能为 `boolean | null`,直接用于 JSX 渲染导致 "Type 'unknown' is not assignable to type 'ReactNode'" 错误。
- 修复:增加显式布尔值检查 `opt.isCorrect === true`,确保 React 条件渲染接收到合法的 boolean 值。 - 修复:增加显式布尔值检查 `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 { createId } from "@paralleldrive/cuid2"
import { faker } from "@faker-js/faker" import { faker } from "@faker-js/faker"
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
import { hash } from "bcryptjs"
/** /**
* Seed Script for Next_Edu * Seed Script for Next_Edu
@@ -19,6 +20,7 @@ const DIFFICULTY = [1, 2, 3, 4, 5]
async function seed() { async function seed() {
console.log("🌱 Starting seed process...") console.log("🌱 Starting seed process...")
const passwordHash = await hash("123456", 10)
// 1. Create a Teacher User if not exists // 1. Create a Teacher User if not exists
const teacherEmail = "teacher@example.com" const teacherEmail = "teacher@example.com"
@@ -36,6 +38,7 @@ async function seed() {
email: teacherEmail, email: teacherEmail,
role: "teacher", role: "teacher",
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Teacher", image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Teacher",
password: passwordHash,
}) })
} else { } else {
teacherId = existingTeacher.id teacherId = existingTeacher.id
@@ -54,6 +57,7 @@ async function seed() {
email: faker.internet.email(), email: faker.internet.email(),
role: "student", role: "student",
image: `https://api.dicebear.com/7.x/avataaars/svg?seed=${sId}`, image: `https://api.dicebear.com/7.x/avataaars/svg?seed=${sId}`,
password: passwordHash,
}) })
} }

View File

@@ -1,5 +1,49 @@
# Work Log # 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. 班级详情访问修复(基于会话身份)
- 将数据获取中的教师 ID 来源改为会话用户,移除默认教师 ID 逻辑,修复“新加入班级无法查看班级详情”问题。
- 主要修改:
- [data-access.ts](file:///e:/Desktop/CICD/src/modules/classes/data-access.ts)
- [page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/my/%5Bid%5D/page.tsx)
### 2. 任课权限与班主任权限区分(学科可见范围)
- 班主任可查看班级下所有学科;任课老师仅可查看自己负责的学科。
- 新增并应用分配学科查询,过滤相应统计与列表。
- 主要修改:
- [data-access.ts](file:///e:/Desktop/CICD/src/modules/classes/data-access.ts)
### 3. 新注册学生默认班级问题修复
- 调整示例学生获取逻辑,避免为新注册学生展示“默认班级”。
- 主要修改:
- [data-access.ts](file:///e:/Desktop/CICD/src/modules/homework/data-access.ts)
### 4. 教材列表 UI 精简为纯色风格
- 将教材卡片头部背景从渐变与纹理改为简洁纯色;图标容器调整为简洁样式,视觉噪音更低。
- 主要修改:
- [textbook-card.tsx](file:///e:/Desktop/CICD/src/modules/textbooks/components/textbook-card.tsx)
### 5. 学生导航清理未实现入口
- 移除学生侧导航的“Resources”入口页面未实现避免死链
- 主要修改:
- [navigation.ts](file:///e:/Desktop/CICD/src/modules/layout/config/navigation.ts)
### 6. 验证
- 质量检查:`npm run lint``npm run typecheck` 均通过。
## 2026-02-24 ## 2026-02-24
### 1. Credentials 登录与密码安全修复 ### 1. Credentials 登录与密码安全修复

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ import Link from "next/link"
import { redirect } from "next/navigation" import { redirect } from "next/navigation"
import { auth } from "@/auth" 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 { StudentGradesCard } from "@/modules/dashboard/components/student-dashboard/student-grades-card"
import { StudentStatsGrid } from "@/modules/dashboard/components/student-dashboard/student-stats-grid" import { StudentStatsGrid } from "@/modules/dashboard/components/student-dashboard/student-stats-grid"
import { StudentTodayScheduleCard } from "@/modules/dashboard/components/student-dashboard/student-today-schedule-card" 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 role = userProfile.role || "student"
const isStudent = role === "student" const isStudent = role === "student"
const isTeacher = role === "teacher"
const studentData = const studentData =
isStudent isStudent
@@ -107,6 +108,14 @@ export default async function ProfilePage() {
})() })()
: null : null
const teacherData =
isTeacher
? await (async () => {
const [subjects, classes] = await Promise.all([getTeacherTeachingSubjects(), getTeacherClasses()])
return { subjects, classes }
})()
: null
return ( return (
<div className="flex h-full flex-col gap-8 p-8"> <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"> <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>
</div> </div>
) : null} ) : 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> </div>
) )
} }

View File

@@ -1,4 +1,3 @@
import Link from "next/link"
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
import { BookOpen, Inbox } from "lucide-react" 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 { TextbookFilters } from "@/modules/textbooks/components/textbook-filters"
import { getDemoStudentUser } from "@/modules/homework/data-access" import { getDemoStudentUser } from "@/modules/homework/data-access"
import { EmptyState } from "@/shared/components/ui/empty-state" 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" export const dynamic = "force-dynamic"
@@ -27,12 +25,6 @@ export default async function StudentTextbooksPage({
if (!student) { if (!student) {
return ( return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex"> <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} /> <EmptyState title="No user found" description="Create a student user to see textbooks." icon={Inbox} />
</div> </div>
) )
@@ -47,7 +39,7 @@ export default async function StudentTextbooksPage({
return ( return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex"> <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> <div>
<h2 className="text-2xl font-bold tracking-tight">Textbooks</h2> <h2 className="text-2xl font-bold tracking-tight">Textbooks</h2>
<p className="text-muted-foreground">Browse your course textbooks.</p> <p className="text-muted-foreground">Browse your course textbooks.</p>
@@ -55,7 +47,7 @@ export default async function StudentTextbooksPage({
<Button asChild variant="outline"> <Button asChild variant="outline">
<Link href="/student/dashboard">Back</Link> <Link href="/student/dashboard">Back</Link>
</Button> </Button>
</div> </div> */}
<TextbookFilters /> <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 { ClassTrendsWidget } from "@/modules/classes/components/class-detail/class-trends-widget"
import { ClassHeader } from "@/modules/classes/components/class-detail/class-header" import { ClassHeader } from "@/modules/classes/components/class-detail/class-header"
import { ClassOverviewStats } from "@/modules/classes/components/class-detail/class-overview-stats" 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 { ClassScheduleWidget } from "@/modules/classes/components/class-detail/class-schedule-widget"
import { ClassStudentsWidget } from "@/modules/classes/components/class-detail/class-students-widget" import { ClassStudentsWidget } from "@/modules/classes/components/class-detail/class-students-widget"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
export default async function ClassDetailPage({ export default async function ClassDetailPage({
params, params,
searchParams,
}: { }: {
params: Promise<{ id: string }> params: Promise<{ id: string }>
searchParams: Promise<SearchParams>
}) { }) {
const { id } = await params const { id } = await params
// Parallel data fetching // Parallel data fetching
const [insights, students, schedule] = await Promise.all([ 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 }), getClassStudents({ classId: id }),
getClassSchedule({ classId: id }), getClassSchedule({ classId: id }),
]) ])
@@ -32,7 +27,7 @@ export default async function ClassDetailPage({
if (!insights) return notFound() if (!insights) return notFound()
// Fetch subject scores // Fetch subject scores
const studentScores = await getClassStudentSubjectScoresV2(id) const studentScores = await getClassStudentSubjectScoresV2({ classId: id })
// Data mapping for widgets // Data mapping for widgets
const assignmentSummaries = insights.assignments.map(a => ({ 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"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Main Content Area (Left 2/3) */} {/* Main Content Area (Left 2/3) */}
<div className="space-y-6 lg:col-span-2"> <div className="space-y-6 lg:col-span-2">
<ClassTrendsWidget <ClassTrendsWidget assignments={assignmentSummaries} />
classId={insights.class.id}
assignments={assignmentSummaries}
/>
<ClassStudentsWidget <ClassStudentsWidget
classId={insights.class.id} classId={insights.class.id}
students={studentSummaries} students={studentSummaries}

View File

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

View File

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

View File

@@ -1,37 +1,9 @@
import { ExamForm } from "@/modules/exams/components/exam-form" import { ExamForm } from "@/modules/exams/components/exam-form"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/shared/components/ui/breadcrumb"
export default function CreateExamPage() { export default function CreateExamPage() {
return ( return (
<div className="flex flex-col space-y-8 p-8 max-w-[1200px] mx-auto">
<div className="space-y-4"> <div className="flex justify-center items-center min-h-[calc(100vh-160px)] p-8 max-w-[1200px] mx-auto">
<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>
<ExamForm /> <ExamForm />
</div> </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 { HomeworkAssignmentQuestionErrorOverviewCard } from "@/modules/homework/components/homework-assignment-question-error-overview-card"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { formatDate } from "@/shared/lib/utils" import { formatDate } from "@/shared/lib/utils"
import { ChevronLeft, Users, Calendar, BarChart3, CheckCircle2 } from "lucide-react" 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 { getHomeworkAssignments } from "@/modules/homework/data-access"
import { getTeacherClasses } from "@/modules/classes/data-access" import { getTeacherClasses } from "@/modules/classes/data-access"
import { PenTool, PlusCircle } from "lucide-react" import { PenTool, PlusCircle } from "lucide-react"
import { getTeacherIdForMutations } from "@/modules/classes/data-access"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
@@ -27,9 +28,10 @@ const getParam = (params: SearchParams, key: string) => {
export default async function AssignmentsPage({ searchParams }: { searchParams: Promise<SearchParams> }) { export default async function AssignmentsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const sp = await searchParams const sp = await searchParams
const classId = getParam(sp, "classId") || undefined const classId = getParam(sp, "classId") || undefined
const creatorId = await getTeacherIdForMutations()
const [assignments, classes] = await Promise.all([ 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([]), classId && classId !== "all" ? getTeacherClasses() : Promise.resolve([]),
]) ])
const hasAssignments = assignments.length > 0 const hasAssignments = assignments.length > 0

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import { eq } from "drizzle-orm"
import { auth } from "@/auth" import { auth } from "@/auth"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { users } from "@/shared/db/schema" import { roles, users, usersToRoles } from "@/shared/db/schema"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
@@ -14,12 +14,32 @@ export async function GET() {
return NextResponse.json({ required: false }) return NextResponse.json({ required: false })
} }
const row = await db.query.users.findFirst({ const [row, roleRows] = await Promise.all([
where: eq(users.id, userId), db.query.users.findFirst({
columns: { onboardedAt: true, role: true }, 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 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 NextAuth from "next-auth"
import Credentials from "next-auth/providers/credentials" import Credentials from "next-auth/providers/credentials"
const normalizeRole = (value: unknown) => { const normalizeRole = (value: unknown) => {
const role = String(value ?? "").trim().toLowerCase() 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 if (role === "admin" || role === "student" || role === "teacher" || role === "parent") return role
return "student" 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) => { const normalizeBcryptHash = (value: string) => {
if (value.startsWith("$2")) return value if (value.startsWith("$2")) return value
if (value.startsWith("$")) return `$2b${value}` if (value.startsWith("$")) return `$2b${value}`
@@ -30,7 +40,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
const password = String(credentials?.password ?? "") const password = String(credentials?.password ?? "")
if (!email || !password) return null 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("drizzle-orm"),
import("@/shared/db"), import("@/shared/db"),
import("@/shared/db/schema"), import("@/shared/db/schema"),
@@ -48,11 +58,19 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
const ok = await compare(password, normalizedPassword) const ok = await compare(password, normalizedPassword)
if (!ok) return null 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 { return {
id: user.id, id: user.id,
name: user.name ?? undefined, name: user.name ?? undefined,
email: user.email, 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() const userId = String(token.id ?? "").trim()
if (userId) { if (userId) {
const [{ eq }, { db }, { users }] = await Promise.all([ const [{ eq }, { db }, { roles, users, usersToRoles }] = await Promise.all([
import("drizzle-orm"), import("drizzle-orm"),
import("@/shared/db"), import("@/shared/db"),
import("@/shared/db/schema"), 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), 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) { if (fresh) {
token.role = normalizeRole(fresh.role ?? token.role) token.role = resolvePrimaryRole(roleRows.map((r) => r.name))
token.name = fresh.name ?? token.name token.name = fresh.name ?? token.name
} }
} }

View File

@@ -1,7 +1,7 @@
"use server"; "use server";
import { revalidatePath } from "next/cache" 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 { auth } from "@/auth"
import { db } from "@/shared/db" import { db } from "@/shared/db"
@@ -16,6 +16,7 @@ import {
deleteTeacherClass, deleteTeacherClass,
enrollStudentByEmail, enrollStudentByEmail,
enrollStudentByInvitationCode, enrollStudentByInvitationCode,
enrollTeacherByInvitationCode,
ensureClassInvitationCode, ensureClassInvitationCode,
regenerateClassInvitationCode, regenerateClassInvitationCode,
setClassSubjectTeachers, setClassSubjectTeachers,
@@ -371,8 +372,18 @@ export async function joinClassByInvitationCodeAction(
return { success: false, message: "Unauthorized" } 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 { 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") { if (role === "student") {
revalidatePath("/student/learning/courses") revalidatePath("/student/learning/courses")
revalidatePath("/student/schedule") revalidatePath("/student/schedule")

View File

@@ -1,6 +1,6 @@
import Link from "next/link" 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 { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" 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 ( return (
<div className="grid grid-cols-5 gap-1 text-center h-full grid-rows-[auto_1fr]"> <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"> <div key={day} className="text-[10px] font-medium text-muted-foreground uppercase py-0.5 border-b bg-muted/20 h-fit">
{day} {day}
</div> </div>

View File

@@ -6,7 +6,6 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avat
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { formatDate } from "@/shared/lib/utils"
interface StudentSummary { interface StudentSummary {
id: string id: string

View File

@@ -1,13 +1,12 @@
"use client" "use client"
import { useState, useMemo } from "react" import { useState } from "react"
import { Area, AreaChart, CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts" import { Area, AreaChart, CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
import { ChevronDown } from "lucide-react" import { ChevronDown } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/shared/components/ui/chart" import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/shared/components/ui/chart"
import { Tabs, TabsList, TabsTrigger } from "@/shared/components/ui/tabs" import { Tabs, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
import { cn } from "@/shared/lib/utils"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { import {
DropdownMenu, DropdownMenu,
@@ -31,7 +30,6 @@ interface AssignmentSummary {
} }
interface ClassTrendsWidgetProps { interface ClassTrendsWidgetProps {
classId: string
assignments: AssignmentSummary[] assignments: AssignmentSummary[]
compact?: boolean compact?: boolean
className?: string className?: string
@@ -77,7 +75,7 @@ export function ClassSubmissionTrendChart({
data, data,
className className
}: { }: {
data: any[] data: Record<string, string | number>[]
className?: string className?: string
}) { }) {
return ( return (
@@ -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 [chartTab, setChartTab] = useState<"submission" | "score">("submission")
const [selectedSubject, setSelectedSubject] = useState<string>("all") const [selectedSubject, setSelectedSubject] = useState<string>("all")
@@ -142,11 +140,11 @@ export function ClassTrendsWidget({ classId, assignments, compact, className }:
const lastAssignment = chartData[chartData.length - 1] const lastAssignment = chartData[chartData.length - 1]
let metricValue = "0%" let metricValue = "0%"
let metricLabel = "Latest" const metricLabel = "Latest"
if (lastAssignment) { if (lastAssignment) {
if (chartTab === "submission") { if (chartTab === "submission") {
metricValue = lastAssignment.target > 0 metricValue = lastAssignment.target > 0
? `${Math.round((lastAssignment.submitted / lastAssignment.target) * 100)}%` ? `${Math.round((lastAssignment.submitted / lastAssignment.target) * 100)}%`
: "0%" : "0%"
} else { } else {

View File

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

View File

@@ -2,11 +2,9 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation" 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 { 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 { cn } from "@/shared/lib/utils"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { import {
@@ -518,4 +516,4 @@ export function ScheduleView({
</AlertDialog> </AlertDialog>
</div> </div>
) )
} }

View File

@@ -1,9 +1,9 @@
"use client" "use client"
import { useEffect, useMemo, useState } from "react" import { useEffect, useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useQueryState, parseAsString } from "nuqs" 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 { toast } from "sonner"
import { Input } from "@/shared/components/ui/input" import { Input } from "@/shared/components/ui/input"
@@ -33,7 +33,6 @@ import {
SelectValue, SelectValue,
} from "@/shared/components/ui/select" } from "@/shared/components/ui/select"
import { Label } from "@/shared/components/ui/label" import { Label } from "@/shared/components/ui/label"
import { cn } from "@/shared/lib/utils"
import type { TeacherClass } from "../types" import type { TeacherClass } from "../types"
import { enrollStudentByEmailAction } from "../actions" 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 statusLabel = status === "all" ? "All Status" : (status === "active" ? "Active" : "Inactive")
const hasFilters = search || classId !== "all" || status !== "all"
return ( return (
<div className="flex items-center justify-between py-2"> <div className="flex items-center justify-between py-2">
<div className="flex items-center gap-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 { MoreHorizontal, UserCheck, UserX } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
import { Card, CardContent, CardFooter, CardHeader } from "@/shared/components/ui/card" import { Card, CardContent, CardFooter, CardHeader } from "@/shared/components/ui/card"
import { cn, formatDate } from "@/shared/lib/utils" import { cn } from "@/shared/lib/utils"
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,

View File

@@ -2,9 +2,10 @@ import "server-only";
import { randomInt } from "node:crypto" import { randomInt } from "node:crypto"
import { cache } from "react" 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 { createId } from "@paralleldrive/cuid2"
import { auth } from "@/auth"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { import {
classes, classes,
@@ -19,7 +20,9 @@ import {
schools, schools,
subjects, subjects,
exams, exams,
roles,
users, users,
usersToRoles,
} from "@/shared/db/schema" } from "@/shared/db/schema"
import { DEFAULT_CLASS_SUBJECTS } from "./types" import { DEFAULT_CLASS_SUBJECTS } from "./types"
import type { import type {
@@ -43,16 +46,22 @@ import type {
UpdateTeacherClassInput, UpdateTeacherClassInput,
} from "./types" } from "./types"
const getDefaultTeacherId = cache(async () => { const getSessionTeacherId = async (): Promise<string | null> => {
const [row] = await db const session = await auth()
const userId = String(session?.user?.id ?? "").trim()
if (!userId) return null
const [teacher] = await db
.select({ id: users.id }) .select({ id: users.id })
.from(users) .from(users)
.where(eq(users.role, "teacher")) .innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
.orderBy(asc(users.createdAt)) .innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(and(eq(users.id, userId), eq(roles.name, "teacher")))
.limit(1) .limit(1)
return teacher?.id ?? null
}
return row?.id // Strict subjectId-based mapping: no aliasing
})
const isDuplicateInvitationCodeError = (err: unknown) => { const isDuplicateInvitationCodeError = (err: unknown) => {
if (!err) return false if (!err) return false
@@ -80,11 +89,20 @@ const generateUniqueInvitationCode = async (): Promise<string> => {
} }
export const getTeacherIdForMutations = async (): Promise<string> => { export const getTeacherIdForMutations = async (): Promise<string> => {
const teacherId = await getDefaultTeacherId() const teacherId = await getSessionTeacherId()
if (!teacherId) throw new Error("No teacher available") if (!teacherId) throw new Error("Teacher not found")
return teacherId 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 normalizeSortText = (v: string | null | undefined) => (typeof v === "string" ? v.trim().toLowerCase() : "")
const parseFirstInt = (v: string) => { const parseFirstInt = (v: string) => {
@@ -118,23 +136,30 @@ const compareClassLike = (
return normalizeSortText(a.room).localeCompare(normalizeSortText(b.room)) 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[]> => { 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 [] if (!teacherId) return []
const rows = await (async () => { const rows = await (async () => {
try { try {
const ownedIds = await db const allIds = await getAccessibleClassIdsForTeacher(teacherId)
.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)]))
if (allIds.length === 0) return [] if (allIds.length === 0) return []
@@ -206,7 +231,9 @@ export const getTeacherOptions = cache(async (): Promise<TeacherOption[]> => {
const rows = await db const rows = await db
.select({ id: users.id, name: users.name, email: users.email }) .select({ id: users.id, name: users.name, email: users.email })
.from(users) .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)) .orderBy(asc(users.createdAt))
return rows.map((r) => ({ 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[]> => { export const getAdminClasses = cache(async (): Promise<AdminClassListItem[]> => {
const [rows, subjectRows] = await Promise.all([ const [rows, subjectRows] = await Promise.all([
(async () => { (async () => {
@@ -304,14 +348,15 @@ export const getAdminClasses = cache(async (): Promise<AdminClassListItem[]> =>
db db
.select({ .select({
classId: classSubjectTeachers.classId, classId: classSubjectTeachers.classId,
subject: classSubjectTeachers.subject, subject: subjects.name,
teacherId: users.id, teacherId: users.id,
teacherName: users.name, teacherName: users.name,
teacherEmail: users.email, teacherEmail: users.email,
}) })
.from(classSubjectTeachers) .from(classSubjectTeachers)
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
.leftJoin(users, eq(users.id, classSubjectTeachers.teacherId)) .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>>() const subjectsByClassId = new Map<string, Map<ClassSubject, TeacherOption | null>>()
@@ -425,16 +470,17 @@ export const getGradeManagedClasses = cache(async (userId: string): Promise<Admi
db db
.select({ .select({
classId: classSubjectTeachers.classId, classId: classSubjectTeachers.classId,
subject: classSubjectTeachers.subject, subject: subjects.name,
teacherId: users.id, teacherId: users.id,
teacherName: users.name, teacherName: users.name,
teacherEmail: users.email, teacherEmail: users.email,
}) })
.from(classSubjectTeachers) .from(classSubjectTeachers)
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
.innerJoin(classes, eq(classes.id, classSubjectTeachers.classId)) .innerJoin(classes, eq(classes.id, classSubjectTeachers.classId))
.leftJoin(users, eq(users.id, classSubjectTeachers.teacherId)) .leftJoin(users, eq(users.id, classSubjectTeachers.teacherId))
.where(inArray(classes.gradeId, gradeIds)) .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>>() 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( export const getClassStudents = cache(
async (params?: { classId?: string; q?: string; status?: string; teacherId?: string }): Promise<ClassStudent[]> => { 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 [] if (!teacherId) return []
const classId = params?.classId?.trim() const classId = params?.classId?.trim()
const q = params?.q?.trim().toLowerCase() const q = params?.q?.trim().toLowerCase()
const status = params?.status?.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) { if (classId) {
conditions.push(eq(classes.id, classId)) conditions.push(eq(classes.id, classId))
@@ -647,12 +696,15 @@ export const getClassStudents = cache(
export const getClassSchedule = cache( export const getClassSchedule = cache(
async (params?: { classId?: string; teacherId?: string }): Promise<ClassScheduleItem[]> => { async (params?: { classId?: string; teacherId?: string }): Promise<ClassScheduleItem[]> => {
const teacherId = params?.teacherId ?? (await getDefaultTeacherId()) const teacherId = params?.teacherId ?? (await getSessionTeacherId())
if (!teacherId) return [] if (!teacherId) return []
const classId = params?.classId?.trim() 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)) if (classId) conditions.push(eq(classSchedule.classId, classId))
const rows = await db const rows = await db
@@ -707,11 +759,13 @@ const toScoreStats = (scores: number[]): ScoreStats => {
export const getClassHomeworkInsights = cache( export const getClassHomeworkInsights = cache(
async (params: { classId: string; teacherId?: string; limit?: number }): Promise<ClassHomeworkInsights | null> => { 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 if (!teacherId) return null
const classId = params.classId.trim() const classId = params.classId.trim()
if (!classId) return null if (!classId) return null
const accessibleIds = await getAccessibleClassIdsForTeacher(teacherId)
if (accessibleIds.length === 0 || !accessibleIds.includes(classId)) return null
const [classRow] = await db const [classRow] = await db
.select({ .select({
@@ -721,12 +775,15 @@ export const getClassHomeworkInsights = cache(
homeroom: classes.homeroom, homeroom: classes.homeroom,
room: classes.room, room: classes.room,
invitationCode: classes.invitationCode, invitationCode: classes.invitationCode,
teacherId: classes.teacherId,
}) })
.from(classes) .from(classes)
.where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId))) .where(and(eq(classes.id, classId), inArray(classes.id, accessibleIds)))
.limit(1) .limit(1)
if (!classRow) return null if (!classRow) return null
const isHomeroomTeacher = classRow.teacherId === teacherId
const subjectIdFilter = isHomeroomTeacher ? [] : await getTeacherSubjectIdsForClass(teacherId, classId)
const enrollments = await db const enrollments = await db
.select({ .select({
@@ -735,12 +792,29 @@ export const getClassHomeworkInsights = cache(
}) })
.from(classEnrollments) .from(classEnrollments)
.innerJoin(classes, eq(classes.id, classEnrollments.classId)) .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 activeStudentIds = enrollments.filter((e) => e.status === "active").map((e) => e.studentId)
const inactiveStudentIds = 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) 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) { if (studentIds.length === 0) {
return { return {
class: { class: {
@@ -782,6 +856,10 @@ export const getClassHomeworkInsights = cache(
} }
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 50 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 const assignments = await db
.select({ .select({
id: homeworkAssignments.id, id: homeworkAssignments.id,
@@ -795,7 +873,7 @@ export const getClassHomeworkInsights = cache(
.from(homeworkAssignments) .from(homeworkAssignments)
.innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id)) .innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id))
.leftJoin(subjects, eq(exams.subjectId, subjects.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)) .orderBy(desc(homeworkAssignments.createdAt))
.limit(limit) .limit(limit)
@@ -1239,6 +1317,12 @@ export async function createTeacherClass(data: CreateTeacherClassInput): Promise
for (let attempt = 0; attempt < 20; attempt += 1) { for (let attempt = 0; attempt < 20; attempt += 1) {
const invitationCode = await generateUniqueInvitationCode() const invitationCode = await generateUniqueInvitationCode()
try { 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 db.transaction(async (tx) => {
await tx.insert(classes).values({ await tx.insert(classes).values({
id, id,
@@ -1253,13 +1337,14 @@ export async function createTeacherClass(data: CreateTeacherClassInput): Promise
teacherId, teacherId,
}) })
await tx.insert(classSubjectTeachers).values( const values = DEFAULT_CLASS_SUBJECTS
DEFAULT_CLASS_SUBJECTS.map((subject) => ({ .filter((name) => idByName.has(name))
.map((name) => ({
classId: id, classId: id,
subject, subjectId: idByName.get(name)!,
teacherId: null, teacherId: null,
})) }))
) await tx.insert(classSubjectTeachers).values(values)
}) })
return id return id
} catch (err) { } catch (err) {
@@ -1291,13 +1376,21 @@ export async function createAdminClass(data: CreateTeacherClassInput & { teacher
const [teacher] = await db const [teacher] = await db
.select({ id: users.id }) .select({ id: users.id })
.from(users) .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) .limit(1)
if (!teacher) throw new Error("Teacher not found") if (!teacher) throw new Error("Teacher not found")
for (let attempt = 0; attempt < 20; attempt += 1) { for (let attempt = 0; attempt < 20; attempt += 1) {
const invitationCode = await generateUniqueInvitationCode() const invitationCode = await generateUniqueInvitationCode()
try { 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 db.transaction(async (tx) => {
await tx.insert(classes).values({ await tx.insert(classes).values({
id, id,
@@ -1312,13 +1405,14 @@ export async function createAdminClass(data: CreateTeacherClassInput & { teacher
teacherId, teacherId,
}) })
await tx.insert(classSubjectTeachers).values( const values = DEFAULT_CLASS_SUBJECTS
DEFAULT_CLASS_SUBJECTS.map((subject) => ({ .filter((name) => idByName.has(name))
.map((name) => ({
classId: id, classId: id,
subject, subjectId: idByName.get(name)!,
teacherId: null, teacherId: null,
})) }))
) await tx.insert(classSubjectTeachers).values(values)
}) })
return id return id
} catch (err) { } catch (err) {
@@ -1410,6 +1504,123 @@ export async function enrollStudentByInvitationCode(studentId: string, invitatio
return cls.id 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> { export async function updateTeacherClass(classId: string, data: UpdateTeacherClassInput): Promise<void> {
const teacherId = await getTeacherIdForMutations() const teacherId = await getTeacherIdForMutations()
@@ -1468,7 +1679,9 @@ export async function updateAdminClass(
const [teacher] = await db const [teacher] = await db
.select({ id: users.id }) .select({ id: users.id })
.from(users) .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) .limit(1)
if (!teacher) throw new Error("Teacher not found") if (!teacher) throw new Error("Teacher not found")
@@ -1498,7 +1711,9 @@ export async function setClassSubjectTeachers(params: {
const rows = await db const rows = await db
.select({ id: users.id }) .select({ id: users.id })
.from(users) .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") 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) 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 await db
.insert(classSubjectTeachers) .insert(classSubjectTeachers)
.values( .values(values)
DEFAULT_CLASS_SUBJECTS.map((subject) => ({
classId,
subject,
teacherId: teacherBySubject.get(subject) ?? null,
}))
)
.onDuplicateKeyUpdate({ set: { teacherId: sql`VALUES(${classSubjectTeachers.teacherId})` } }) .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") if (!owned) throw new Error("Class not found")
const [student] = await db const [student] = await db
.select({ id: users.id, role: users.role }) .select({ id: users.id })
.from(users) .from(users)
.where(eq(users.email, normalized)) .where(eq(users.email, normalized))
.limit(1) .limit(1)
if (!student) throw new Error("Student not found") 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 await db
.insert(classEnrollments) .insert(classEnrollments)
@@ -1823,8 +2053,26 @@ export const getStudentsSubjectScores = cache(
) )
export const getClassStudentSubjectScoresV2 = cache( export const getClassStudentSubjectScoresV2 = cache(
async (classId: string): Promise<Map<string, Record<string, number | null>>> => { async (params: { classId: string; teacherId?: string }): Promise<Map<string, Record<string, number | null>>> => {
// 1. Get student IDs in the class 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 const enrollments = await db
.select({ studentId: classEnrollments.studentId }) .select({ studentId: classEnrollments.studentId })
.from(classEnrollments) .from(classEnrollments)
@@ -1833,7 +2081,24 @@ export const getClassStudentSubjectScoresV2 = cache(
eq(classEnrollments.status, "active") eq(classEnrollments.status, "active")
)) ))
const studentIds = enrollments.map(e => e.studentId) const studentIds = enrollments.map((e) => e.studentId)
return getStudentsSubjectScores(studentIds) 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({ export function StudentStatsGrid({
enrolledClassCount,
dueSoonCount, dueSoonCount,
overdueCount, overdueCount,
gradedCount,
ranking, ranking,
}: { }: {
enrolledClassCount: number enrolledClassCount: number

View File

@@ -7,8 +7,6 @@ import { EmptyState } from "@/shared/components/ui/empty-state"
import type { TeacherClass } from "@/modules/classes/types" import type { TeacherClass } from "@/modules/classes/types"
export function TeacherClassesCard({ classes }: { classes: TeacherClass[] }) { export function TeacherClassesCard({ classes }: { classes: TeacherClass[] }) {
const totalStudents = classes.reduce((sum, c) => sum + (c.studentCount ?? 0), 0)
return ( return (
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between"> <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 }) { export function TeacherDashboardView({ data }: { data: TeacherDashboardData }) {
const totalStudents = data.classes.reduce((sum, c) => sum + (c.studentCount ?? 0), 0)
const todayWeekday = toWeekday(new Date()) const todayWeekday = toWeekday(new Date())
const classNameById = new Map(data.classes.map((c) => [c.id, c.name] as const)) const classNameById = new Map(data.classes.map((c) => [c.id, c.name] as const))

View File

@@ -1,7 +1,7 @@
import "server-only" import "server-only"
import { cache } from "react" 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 { db } from "@/shared/db"
import { import {
@@ -11,9 +11,11 @@ import {
homeworkAssignments, homeworkAssignments,
homeworkSubmissions, homeworkSubmissions,
questions, questions,
roles,
sessions, sessions,
textbooks, textbooks,
users, users,
usersToRoles,
} from "@/shared/db/schema" } from "@/shared/db/schema"
import type { AdminDashboardData } from "./types" import type { AdminDashboardData } from "./types"
@@ -23,7 +25,7 @@ export const getAdminDashboardData = cache(async (): Promise<AdminDashboardData>
const [ const [
activeSessionsRow, activeSessionsRow,
userCountRow, userCountRow,
userRoleRows, userRoleCountRows,
classCountRow, classCountRow,
textbookCountRow, textbookCountRow,
chapterCountRow, chapterCountRow,
@@ -37,7 +39,11 @@ export const getAdminDashboardData = cache(async (): Promise<AdminDashboardData>
] = await Promise.all([ ] = await Promise.all([
db.select({ value: count() }).from(sessions).where(gt(sessions.expires, now)), db.select({ value: count() }).from(sessions).where(gt(sessions.expires, now)),
db.select({ value: count() }).from(users), 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(classes),
db.select({ value: count() }).from(textbooks), db.select({ value: count() }).from(textbooks),
db.select({ value: count() }).from(chapters), db.select({ value: count() }).from(chapters),
@@ -52,7 +58,6 @@ export const getAdminDashboardData = cache(async (): Promise<AdminDashboardData>
id: users.id, id: users.id,
name: users.name, name: users.name,
email: users.email, email: users.email,
role: users.role,
createdAt: users.createdAt, createdAt: users.createdAt,
}) })
.from(users) .from(users)
@@ -72,17 +77,55 @@ export const getAdminDashboardData = cache(async (): Promise<AdminDashboardData>
const homeworkSubmissionCount = Number(homeworkSubmissionCountRow[0]?.value ?? 0) const homeworkSubmissionCount = Number(homeworkSubmissionCountRow[0]?.value ?? 0)
const homeworkSubmissionToGradeCount = Number(homeworkSubmissionToGradeCountRow[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) })) .map((r) => ({ role: r.role ?? "unknown", count: Number(r.value ?? 0) }))
.sort((a, b) => b.count - a.count) .sort((a, b) => b.count - a.count)
const recentUsers = recentUserRows.map((u) => ({ const normalizeRole = (value: string) => {
id: u.id, const role = value.trim().toLowerCase()
name: u.name, if (role === "grade_head" || role === "teaching_head") return "teacher"
email: u.email, if (role === "admin" || role === "student" || role === "teacher" || role === "parent") return role
role: u.role, return ""
createdAt: u.createdAt.toISOString(), }
}))
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 { return {
activeSessionsCount, activeSessionsCount,

View File

@@ -25,6 +25,20 @@ export function ExamPaperPreview({ title, subject, grade, durationMin, totalScor
// Helper to flatten questions for continuous numbering // Helper to flatten questions for continuous numbering
let questionCounter = 0 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) => { const renderNode = (node: ExamNode, depth: number = 0) => {
if (node.type === 'group') { if (node.type === 'group') {
return ( return (
@@ -45,7 +59,7 @@ export function ExamPaperPreview({ title, subject, grade, durationMin, totalScor
if (node.type === 'question' && node.question) { if (node.type === 'question' && node.question) {
questionCounter++ questionCounter++
const q = node.question const q = node.question
const content = q.content as QuestionContent const content = parseContent(q.content)
return ( return (
<div key={node.id} className="mb-6 break-inside-avoid"> <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"> <div className="space-y-3 pb-4">
{questions.map((q) => { {questions.map((q) => {
const added = isAdded(q.id) 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 ( return (
<Card key={q.id} className="p-3 flex gap-3 hover:bg-muted/50 transition-colors"> <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-1 space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant="outline" className="text-[10px] uppercase"> <Badge variant="outline" className="text-[10px] uppercase">
{q.type.replace("_", " ")} {typeLabel}
</Badge> </Badge>
<Badge variant="secondary" className="text-[10px]"> <Badge variant="secondary" className="text-[10px]">
Lvl {q.difficulty} Lvl {q.difficulty}
@@ -46,7 +59,7 @@ export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMor
))} ))}
</div> </div>
<p className="text-sm line-clamp-2 text-muted-foreground"> <p className="text-sm line-clamp-2 text-muted-foreground">
{content.text || "No content preview"} {parsedContent.text || "No content preview"}
</p> </p>
</div> </div>
<div className="flex items-center"> <div className="flex items-center">

View File

@@ -118,7 +118,22 @@ function QuestionItem({ item, index, total, onRemove, onMove, onScoreChange }: {
onMove: (dir: 'up' | 'down') => void onMove: (dir: 'up' | 'down') => void
onScoreChange: (score: number) => 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 ( return (
<div className="group flex flex-col gap-3 rounded-md border p-3 bg-card hover:border-primary/50 transition-colors"> <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"> <div className="flex items-start justify-between gap-3">
@@ -127,7 +142,7 @@ function QuestionItem({ item, index, total, onRemove, onMove, onScoreChange }: {
{index + 1} {index + 1}
</span> </span>
<p className="text-sm line-clamp-2 pt-0.5"> <p className="text-sm line-clamp-2 pt-0.5">
{content?.text || "Question content"} {parsedContent.text || "Question content"}
</p> </p>
</div> </div>
<Button <Button
@@ -139,6 +154,16 @@ function QuestionItem({ item, index, total, onRemove, onMove, onScoreChange }: {
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </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 justify-between pl-8">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">

View File

@@ -82,7 +82,22 @@ function SortableItem({
opacity: isDragging ? 0.5 : 1, 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 ( 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")}> <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" /> <GripVertical className="h-4 w-4" />
</button> </button>
<p className="text-sm line-clamp-2 pt-0.5 select-none"> <p className="text-sm line-clamp-2 pt-0.5 select-none">
{content?.text || "Question content"} {parsedContent.text || "Question content"}
</p> </p>
</div> </div>
<Button <Button
@@ -104,6 +119,16 @@ function SortableItem({
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </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 justify-end pl-8">
<div className="flex items-center gap-2"> <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") toast.error("Failed to load exam preview")
setShowViewDialog(false) setShowViewDialog(false)
} }
} catch (e) { } catch {
toast.error("Failed to load exam preview") toast.error("Failed to load exam preview")
setShowViewDialog(false) setShowViewDialog(false)
} finally { } finally {

View File

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

View File

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

View File

@@ -1,64 +1,64 @@
"use server" "use server"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import { headers } from "next/headers"
import { createId } from "@paralleldrive/cuid2" 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 { db } from "@/shared/db"
import { import {
classes, classes,
classEnrollments, classEnrollments,
classSubjectTeachers,
exams, exams,
homeworkAnswers, homeworkAnswers,
homeworkAssignmentQuestions, homeworkAssignmentQuestions,
homeworkAssignmentTargets, homeworkAssignmentTargets,
homeworkAssignments, homeworkAssignments,
homeworkSubmissions, homeworkSubmissions,
roles,
users, users,
usersToRoles,
} from "@/shared/db/schema" } from "@/shared/db/schema"
import type { ActionState } from "@/shared/types/action-state" import type { ActionState } from "@/shared/types/action-state"
import { CreateHomeworkAssignmentSchema, GradeHomeworkSchema } from "./schema" import { CreateHomeworkAssignmentSchema, GradeHomeworkSchema } from "./schema"
type CurrentUser = { id: string; role: "admin" | "teacher" | "student" } type TeacherRole = "admin" | "teacher"
type StudentRole = "student"
async function getCurrentUser() { const getSessionUserId = async (): Promise<string | null> => {
const ref = (await headers()).get("referer") || "" const session = await auth()
const roleHint: CurrentUser["role"] = ref.includes("/admin/") const userId = String(session?.user?.id ?? "").trim()
? "admin" return userId.length > 0 ? userId : null
: 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 }
} }
async function ensureTeacher() { async function ensureTeacher(): Promise<{ id: string; role: TeacherRole }> {
const user = await getCurrentUser() const userId = await getSessionUserId()
if (!user || (user.role !== "teacher" && user.role !== "admin")) throw new Error("Unauthorized") if (!userId) throw new Error("Unauthorized")
return user 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() { async function ensureStudent(): Promise<{ id: string; role: StudentRole }> {
const user = await getCurrentUser() const userId = await getSessionUserId()
if (!user || user.role !== "student") throw new Error("Unauthorized") if (!userId) throw new Error("Unauthorized")
return user 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[] => { const parseStudentIds = (raw: string): string[] => {
@@ -108,12 +108,12 @@ export async function createHomeworkAssignmentAction(
const input = parsed.data const input = parsed.data
const publish = input.publish ?? true const publish = input.publish ?? true
const [ownedClass] = await db const [classRow] = await db
.select({ id: classes.id }) .select({ id: classes.id, teacherId: classes.teacherId })
.from(classes) .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) .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({ const exam = await db.query.exams.findFirst({
where: eq(exams.id, input.sourceExamId), where: eq(exams.id, input.sourceExamId),
@@ -126,23 +126,43 @@ export async function createHomeworkAssignmentAction(
if (!exam) return { success: false, message: "Exam not found" } 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 assignmentId = createId()
const availableAt = input.availableAt ? new Date(input.availableAt) : null const availableAt = input.availableAt ? new Date(input.availableAt) : null
const dueAt = input.dueAt ? new Date(input.dueAt) : null const dueAt = input.dueAt ? new Date(input.dueAt) : null
const lateDueAt = input.lateDueAt ? new Date(input.lateDueAt) : 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 = ( const classStudentIds = (
await db await db
.select({ studentId: classEnrollments.studentId }) .select({ studentId: classEnrollments.studentId })
.from(classEnrollments) .from(classEnrollments)
.innerJoin(classes, eq(classes.id, classEnrollments.classId)) .innerJoin(classes, eq(classes.id, classEnrollments.classId))
.where( .where(
and( and(eq(classEnrollments.classId, input.classId), eq(classEnrollments.status, "active"), classScope)
eq(classEnrollments.classId, input.classId),
eq(classEnrollments.status, "active"),
user.role === "admin" ? eq(classes.id, input.classId) : eq(classes.teacherId, user.id)
)
) )
).map((r) => r.studentId) ).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 { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea" import { Textarea } from "@/shared/components/ui/textarea"
import { ScrollArea } from "@/shared/components/ui/scroll-area" import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Separator } from "@/shared/components/ui/separator"
import { Clock, CheckCircle2, Save, FileText } from "lucide-react" import { Clock, CheckCircle2, Save, FileText } from "lucide-react"
import type { StudentHomeworkTakeData } from "../types" 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 { Checkbox } from "@/shared/components/ui/checkbox"
import { Label } from "@/shared/components/ui/label" import { Label } from "@/shared/components/ui/label"
import { ScrollArea } from "@/shared/components/ui/scroll-area" 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 Link from "next/link"
import type { StudentHomeworkTakeData } from "../types" import type { StudentHomeworkTakeData } from "../types"
@@ -57,7 +57,6 @@ type HomeworkReviewViewProps = {
export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) { export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) {
const submissionStatus = initialData.submission?.status ?? "not_started" const submissionStatus = initialData.submission?.status ?? "not_started"
const isGraded = submissionStatus === "graded" const isGraded = submissionStatus === "graded"
const isSubmitted = submissionStatus === "submitted"
const answersByQuestionId = useMemo(() => { const answersByQuestionId = useMemo(() => {
const map = new Map<string, { answer: unknown }>() const map = new Map<string, { answer: unknown }>()

View File

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

View File

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

View File

@@ -1,29 +1,51 @@
"use server"; "use server";
import { db } from "@/shared/db"; 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 { CreateQuestionSchema } from "./schema";
import type { CreateQuestionInput } from "./schema"; import type { CreateQuestionInput } from "./schema";
import { ActionState } from "@/shared/types/action-state"; import { ActionState } from "@/shared/types/action-state";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { createId } from "@paralleldrive/cuid2"; import { createId } from "@paralleldrive/cuid2";
import { and, eq } from "drizzle-orm"; import { and, asc, eq, inArray } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { getQuestions, type GetQuestionsParams } from "./data-access"; import { getQuestions, type GetQuestionsParams } from "./data-access";
import type { KnowledgePointOption } from "./types";
import { auth } from "@/auth";
async function getCurrentUser() { const getSessionUserId = async (): Promise<string | null> => {
return { const session = await auth();
id: "user_teacher_math", const userId = String(session?.user?.id ?? "").trim();
role: "teacher", return userId.length > 0 ? userId : null;
}; };
}
async function ensureTeacher() { async function ensureTeacher() {
const user = await getCurrentUser(); const userId = await getSessionUserId();
if (!user || (user.role !== "teacher" && user.role !== "admin")) { 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."); 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] type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0]
@@ -244,3 +266,40 @@ export async function getQuestionsAction(params: GetQuestionsParams) {
await ensureTeacher(); await ensureTeacher();
return await getQuestions(params); 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, FormMessage,
} from "@/shared/components/ui/form" } from "@/shared/components/ui/form"
import { Input } from "@/shared/components/ui/input" import { Input } from "@/shared/components/ui/input"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -36,9 +37,9 @@ import {
} from "@/shared/components/ui/select" } from "@/shared/components/ui/select"
import { Textarea } from "@/shared/components/ui/textarea" import { Textarea } from "@/shared/components/ui/textarea"
import { BaseQuestionSchema } from "../schema" import { BaseQuestionSchema } from "../schema"
import { createNestedQuestion, updateQuestionAction } from "../actions" import { createNestedQuestion, getKnowledgePointOptionsAction, updateQuestionAction } from "../actions"
import { toast } from "sonner" import { toast } from "sonner"
import { Question } from "../types" import { KnowledgePointOption, Question } from "../types"
const QuestionFormSchema = BaseQuestionSchema.extend({ const QuestionFormSchema = BaseQuestionSchema.extend({
difficulty: z.number().min(1).max(5), difficulty: z.number().min(1).max(5),
@@ -111,6 +112,10 @@ export function CreateQuestionDialog({
const router = useRouter() const router = useRouter()
const [isPending, setIsPending] = useState(false) const [isPending, setIsPending] = useState(false)
const isEdit = !!initialData 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>({ const form = useForm<QuestionFormValues>({
resolver: zodResolver(QuestionFormSchema), resolver: zodResolver(QuestionFormSchema),
@@ -151,7 +156,60 @@ export function CreateQuestionDialog({
} }
}, [initialData, form, open, defaultContent, defaultType]) }, [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 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 buildContent = (data: QuestionFormValues) => {
const text = data.content.trim() const text = data.content.trim()
@@ -194,7 +252,7 @@ export function CreateQuestionDialog({
type: data.type, type: data.type,
difficulty: data.difficulty, difficulty: data.difficulty,
content: buildContent(data), content: buildContent(data),
knowledgePointIds: isEdit ? [] : defaultKnowledgePointIds, knowledgePointIds: selectedKnowledgePointIds,
} }
const fd = new FormData() const fd = new FormData()
fd.set("json", JSON.stringify(payload)) 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") && ( {(questionType === "single_choice" || questionType === "multiple_choice") && (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">

View File

@@ -1,5 +1,6 @@
"use client" "use client"
import { useEffect, useState } from "react"
import { useQueryState, parseAsString } from "nuqs" import { useQueryState, parseAsString } from "nuqs"
import { Search, X } from "lucide-react" import { Search, X } from "lucide-react"
@@ -12,11 +13,25 @@ import {
SelectValue, SelectValue,
} from "@/shared/components/ui/select" } from "@/shared/components/ui/select"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { getKnowledgePointOptionsAction } from "../actions"
import type { KnowledgePointOption } from "../types"
export function QuestionFilters() { export function QuestionFilters() {
const [search, setSearch] = useQueryState("q", parseAsString.withDefault("")) const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
const [type, setType] = useQueryState("type", parseAsString.withDefault("all")) const [type, setType] = useQueryState("type", parseAsString.withDefault("all"))
const [difficulty, setDifficulty] = useQueryState("difficulty", 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 ( return (
<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">
@@ -56,14 +71,32 @@ export function QuestionFilters() {
<SelectItem value="5">Hard (5)</SelectItem> <SelectItem value="5">Hard (5)</SelectItem>
</SelectContent> </SelectContent>
</Select> </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 <Button
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
setSearch(null) setSearch(null)
setType(null) setType(null)
setDifficulty(null) setDifficulty(null)
setKnowledgePointId(null)
}} }}
className="h-8 px-2 lg:px-3" className="h-8 px-2 lg:px-3"
> >

View File

@@ -21,3 +21,14 @@ export interface Question {
}[] }[]
childrenCount?: number 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 { asc, eq, inArray, or } from "drizzle-orm"
import { db } from "@/shared/db" 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" import type { AcademicYearListItem, DepartmentListItem, GradeListItem, SchoolListItem, StaffOption } from "./types"
const toIso = (d: Date) => d.toISOString() const toIso = (d: Date) => d.toISOString()
@@ -114,7 +114,10 @@ export const getStaffOptions = cache(async (): Promise<StaffOption[]> => {
const rows = await db const rows = await db
.select({ id: users.id, name: users.name, email: users.email }) .select({ id: users.id, name: users.name, email: users.email })
.from(users) .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)) .orderBy(asc(users.name), asc(users.email))
return rows.map((r) => ({ return rows.map((r) => ({

View File

@@ -10,7 +10,6 @@ import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/shared/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/shared/components/ui/card"
import { Input } from "@/shared/components/ui/input" 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/shared/components/ui/form" import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/shared/components/ui/form"
import { UserProfile } from "@/modules/users/data-access" 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 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 // 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. // But actually, we need to check if they are in the same list.
// If both are root items (activeParent is null), they are siblings. // 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? // 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. // 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 // Check if over is in the same list
if (activeList.some(c => c.id === over.id)) { 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) const newIndex = activeList.findIndex((item) => item.id === over.id)
await reorderChaptersAction(active.id as string, newIndex, activeParentId, textbookId) await reorderChaptersAction(active.id as string, newIndex, activeParentId, textbookId)

View File

@@ -24,29 +24,35 @@ interface TextbookCardProps {
} }
const subjectColorMap: Record<string, string> = { 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", 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: "from-purple-500/20 to-purple-600/20 text-purple-700 dark:text-purple-300 border-purple-200 dark:border-purple-800", 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: "from-teal-500/20 to-teal-600/20 text-teal-700 dark:text-teal-300 border-teal-200 dark:border-teal-800", 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: "from-orange-500/20 to-orange-600/20 text-orange-700 dark:text-orange-300 border-orange-200 dark:border-orange-800", 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: "from-amber-500/20 to-amber-600/20 text-amber-700 dark:text-amber-300 border-amber-200 dark:border-amber-800", 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: "from-emerald-500/20 to-emerald-600/20 text-emerald-700 dark:text-emerald-300 border-emerald-200 dark:border-emerald-800", 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: "from-sky-500/20 to-sky-600/20 text-sky-700 dark:text-sky-300 border-sky-200 dark:border-sky-800", 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) { export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardProps) {
const base = hrefBase || "/teacher/textbooks"; 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 ( 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"> <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={cn("relative h-32 w-full overflow-hidden p-5", colorClass)}>
<div className="absolute inset-0 bg-grid-black/[0.05] dark:bg-grid-white/[0.05]" />
<div className="relative z-10 flex h-full flex-col justify-between"> <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} {textbook.subject}
</Badge> </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>
</div> </div>
@@ -74,9 +80,11 @@ export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardPr
</CardContent> </CardContent>
</Link> </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"> <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> <span>{textbook._count?.chapters || 0} Chapters</span>
</div> </div>

View File

@@ -1,7 +1,7 @@
"use client" "use client"
import { useQueryState, parseAsString } from "nuqs" 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 { Input } from "@/shared/components/ui/input"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"

View File

@@ -5,13 +5,8 @@ import ReactMarkdown from "react-markdown"
import remarkBreaks from "remark-breaks" import remarkBreaks from "remark-breaks"
import remarkGfm from "remark-gfm" import remarkGfm from "remark-gfm"
import { useQueryState, parseAsString } from "nuqs" 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 { toast } from "sonner"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/shared/components/ui/collapsible"
import type { Chapter, KnowledgePoint } from "../types" import type { Chapter, KnowledgePoint } from "../types"
import { createKnowledgePointAction, updateChapterContentAction, deleteKnowledgePointAction, updateKnowledgePointAction } from "../actions" 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) return knowledgePoints.filter(kp => kp.chapterId === selectedId)
}, [knowledgePoints, 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 // Pre-process content to mark knowledge points
const processedContent = useMemo(() => { const processedContent = useMemo(() => {
if (!selected?.content) return "" 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"> <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"> <Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col h-full">
<div className="flex items-center justify-between mb-4 px-2 shrink-0"> <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"> <TabsTrigger value="chapters" className="gap-2">
<List className="h-4 w-4" /> <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> <Badge variant="secondary" className="ml-1 px-1 py-0 h-5 text-[10px]">{currentChapterKPs.length}</Badge>
)} )}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="graph" className="gap-2" disabled={!selectedId}>
<Share2 className="h-4 w-4" />
</TabsTrigger>
</TabsList> </TabsList>
</div> </div>
@@ -399,6 +488,62 @@ export function TextbookReader({ chapters, knowledgePoints = [], canEdit = false
</ScrollArea> </ScrollArea>
)} )}
</TabsContent> </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> </Tabs>
</div> </div>

View File

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

View File

@@ -4,7 +4,7 @@ import { cache } from "react"
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { users } from "@/shared/db/schema" import { roles, users, usersToRoles } from "@/shared/db/schema"
export type UserProfile = { export type UserProfile = {
id: string id: string
@@ -21,6 +21,25 @@ export type UserProfile = {
updatedAt: Date 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> => { export const getUserProfile = cache(async (userId: string): Promise<UserProfile | null> => {
const user = await db.query.users.findFirst({ const user = await db.query.users.findFirst({
where: eq(users.id, userId), where: eq(users.id, userId),
@@ -28,12 +47,19 @@ export const getUserProfile = cache(async (userId: string): Promise<UserProfile
if (!user) return null 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 { return {
id: user.id, id: user.id,
name: user.name, name: user.name,
email: user.email, email: user.email,
image: user.image, image: user.image,
role: user.role, role,
phone: user.phone, phone: user.phone,
address: user.address, address: user.address,
gender: user.gender, gender: user.gender,

View File

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

View File

@@ -18,19 +18,12 @@ import {
ListOrdered, ListOrdered,
Quote, Quote,
Undo, Undo,
Redo, Redo
MoreHorizontal
} from "lucide-react" } from "lucide-react"
import { cn } from "@/shared/lib/utils" import { cn } from "@/shared/lib/utils"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Separator } from "@/shared/components/ui/separator" 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 // 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 // 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(), email: varchar("email", { length: 255 }).notNull().unique(),
emailVerified: timestamp("emailVerified", { mode: "date" }), emailVerified: timestamp("emailVerified", { mode: "date" }),
image: varchar("image", { length: 255 }), image: varchar("image", { length: 255 }),
// Custom Role Field for RBAC (Default Role)
role: varchar("role", { length: 50 }).default("student"),
// Credentials Auth (Optional) // Credentials Auth (Optional)
password: varchar("password", { length: 255 }), password: varchar("password", { length: 255 }),
@@ -338,23 +334,27 @@ export const classes = mysqlTable("classes", {
}).onDelete("set null"), }).onDelete("set null"),
})); }));
export const classSubjectEnum = mysqlEnum("subject", ["语文", "数学", "英语", "美术", "体育", "科学", "社会", "音乐"]);
export const classSubjectTeachers = mysqlTable("class_subject_teachers", { export const classSubjectTeachers = mysqlTable("class_subject_teachers", {
classId: varchar("class_id", { length: 128 }).notNull(), 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" }), teacherId: varchar("teacher_id", { length: 128 }).references(() => users.id, { onDelete: "set null" }),
createdAt: timestamp("created_at").defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
}, (table) => ({ }, (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), classIdx: index("class_subject_teachers_class_idx").on(table.classId),
teacherIdx: index("class_subject_teachers_teacher_idx").on(table.teacherId), teacherIdx: index("class_subject_teachers_teacher_idx").on(table.teacherId),
subjectIdIdx: index("class_subject_teachers_subject_id_idx").on(table.subjectId),
classFk: foreignKey({ classFk: foreignKey({
columns: [table.classId], columns: [table.classId],
foreignColumns: [classes.id], foreignColumns: [classes.id],
name: "cst_c_fk", name: "cst_c_fk",
}).onDelete("cascade"), }).onDelete("cascade"),
subjectFk: foreignKey({
columns: [table.subjectId],
foreignColumns: [subjects.id],
name: "cst_s_fk",
}).onDelete("cascade"),
})); }));
export const classEnrollments = mysqlTable("class_enrollments", { export const classEnrollments = mysqlTable("class_enrollments", {