Compare commits

...

46 Commits

Author SHA1 Message Date
SpecialX
15fcf2bc78 BUG FIX && 权限验证
All checks were successful
CI / build-and-test (push) Successful in 18m11s
CI / deploy (push) Successful in 1m19s
2026-01-09 14:10:04 +08:00
SpecialX
15d9ea9cb8 fix: login suspense + migrate middleware to proxy 2026-01-08 11:35:15 +08:00
SpecialX
f513cf5399 Fix Bug
All checks were successful
CI / build-and-test (push) Successful in 22m3s
CI / deploy (push) Successful in 1m29s
Fix Not Suspense for Login Form
2026-01-08 11:29:23 +08:00
SpecialX
57807def37 完整性更新
Some checks failed
CI / build-and-test (push) Failing after 3m50s
CI / deploy (push) Has been skipped
现在已经实现了大部分基础功能
2026-01-08 11:14:03 +08:00
SpecialX
0da2eac0b4 强制buildSSR
All checks were successful
CI / build-and-test (push) Successful in 16m13s
CI / deploy (push) Successful in 1m47s
2025-12-31 14:01:12 +08:00
SpecialX
13e91e628d Merge exams grading into homework
Some checks failed
CI / build-and-test (push) Failing after 3m34s
CI / deploy (push) Has been skipped
Redirect /teacher/exams/grading* to /teacher/homework/submissions; remove exam grading UI/actions/data-access; add homework student workflow and update design docs.
2025-12-31 11:59:03 +08:00
SpecialX
f8e39f518d feat(teacher): 题库模块(QuestionBank)
工作内容

- 新增 /teacher/questions 页面,支持 Suspense/空状态/加载态

- 题库 CRUD Server Actions:创建/更新/递归删除子题,变更后 revalidatePath

- getQuestions 支持 q/type/difficulty/knowledgePointId 筛选与分页返回 meta

- UI:表格列/筛选器/创建编辑弹窗,content JSON 兼容组卷

- 更新中文设计文档:docs/design/004_question_bank_implementation.md
2025-12-30 19:04:22 +08:00
SpecialX
f7ff018490 feat: exam actions and data safety fixes 2025-12-30 17:48:22 +08:00
SpecialX
e7c902e8e1 Module Update
Some checks failed
CI / build-and-test (push) Failing after 1m31s
CI / deploy (push) Has been skipped
2025-12-30 14:42:30 +08:00
SpecialX
f1797265b2 chore: remove debug steps and loyment workflow
All checks were successful
CI / build-and-test (push) Successful in 7m42s
CI / deploy (push) Successful in 58s
2025-12-22 14:18:56 +08:00
SpecialX
be36a7131c chore: remove debug steps and loyment workflow
All checks were successful
CI / build-and-test (push) Successful in 7m53s
CI / deploy (push) Successful in 1m38s
2025-12-22 13:58:31 +08:00
SpecialX
b7ec909073 chore: remove debug steps and loyment workflow
Some checks failed
CI / deploy (push) Has been cancelled
CI / build-and-test (push) Has been cancelled
2025-12-22 13:52:34 +08:00
SpecialX
aaf4e498f8 chore: remove debug steps and loyment workflow
Some checks failed
CI / build-and-test (push) Successful in 8m12s
CI / deploy (push) Has been cancelled
2025-12-22 13:38:56 +08:00
SpecialX
36b2275f50 chore: remove debug steps and loyment workflow
All checks were successful
CI / build-and-test (push) Successful in 1m33s
2025-12-22 13:28:18 +08:00
SpecialX
b453bab8b8 chore: remove debug steps and loyment workflow
Some checks failed
CI / deploy (push) Has been cancelled
CI / build-and-test (push) Has been cancelled
2025-12-22 13:27:45 +08:00
SpecialX
fc3e9a4220 chore: remove debug steps and loyment workflow
All checks were successful
CI / build-and-test (push) Successful in 7m32s
CI / deploy (push) Successful in 1m1s
2025-12-19 18:13:48 +08:00
SpecialX
11ea7c880c chore: remove debug steps and loyment workflow
All checks were successful
CI / build-and-test (push) Successful in 7m4s
CI / deploy (push) Successful in 1m1s
2025-12-19 17:32:28 +08:00
SpecialX
b918744f6a chore: remove debug steps and loyment workflow 2025-12-19 17:25:57 +08:00
SpecialX
5e417d313b chore: remove debug steps and finalize deployment workflow 2025-12-19 17:22:21 +08:00
SpecialX
99a22910b2 chore: remove debug steps and finalize deployment workflow 2025-12-19 17:19:09 +08:00
SpecialX
5d8f474582 chore: debug build output structure
All checks were successful
CI / build-and-test (push) Successful in 7m42s
CI / deploy (push) Successful in 59s
2025-12-19 17:06:09 +08:00
SpecialX
576fe62cf6 fix: upload specific standalone artifacts to simplify structure
All checks were successful
CI / build-and-test (push) Successful in 7m19s
CI / deploy (push) Successful in 1m4s
2025-12-19 16:42:56 +08:00
SpecialX
1eff250619 fix: upload specific standalone artifacts to simplify structure
Some checks failed
CI / build-and-test (push) Successful in 8m8s
CI / deploy (push) Failing after 1m31s
2025-12-19 16:29:12 +08:00
SpecialX
6ed30de40c chore: add debug step to list artifact structure
Some checks failed
CI / build-and-test (push) Successful in 1m6s
CI / deploy (push) Failing after 23s
2025-12-19 16:23:30 +08:00
SpecialX
b4b21e2b16 chore: add debug step to list artifact structure
Some checks failed
CI / build-and-test (push) Failing after 1m4s
CI / deploy (push) Has been skipped
2025-12-19 16:15:53 +08:00
SpecialX
16a0c021b1 fix: remove invalid docker binary mount and reinstall cli
Some checks failed
CI / build-and-test (push) Successful in 2m39s
CI / deploy (push) Failing after 29s
2025-12-19 16:04:23 +08:00
SpecialX
59dc0bcf50 fix: remove invalid docker binary mount and reinstall cli
Some checks failed
CI / build-and-test (push) Successful in 1m2s
CI / deploy (push) Has been cancelled
2025-12-19 14:57:56 +08:00
SpecialX
277d671d9f fix: remove invalid docker binary mount and reinstall cli
Some checks failed
CI / build-and-test (push) Successful in 1m19s
CI / deploy (push) Failing after 3s
2025-12-19 14:55:47 +08:00
SpecialX
431924526f fix: mount docker socket and binary to node container
Some checks failed
CI / build-and-test (push) Successful in 1m9s
CI / deploy (push) Failing after 4s
2025-12-19 14:53:35 +08:00
SpecialX
178552659e fix: run deploy job on host to use host docker cli
Some checks failed
CI / build-and-test (push) Successful in 1m17s
CI / deploy (push) Failing after 2s
2025-12-19 14:49:14 +08:00
SpecialX
ba66d406dc fix: reinstall docker cli in deploy container
Some checks failed
CI / build-and-test (push) Successful in 1m31s
CI / deploy (push) Has been cancelled
2025-12-19 14:45:55 +08:00
SpecialX
5f8373b5ef fix: remove docker cli installation
Some checks failed
CI / build-and-test (push) Successful in 1m17s
CI / deploy (push) Failing after 12s
2025-12-19 14:35:09 +08:00
SpecialX
0fb25ea395 feat: setup standalone build and docker deployment
Some checks failed
CI / build-and-test (push) Successful in 1m9s
CI / deploy (push) Has been cancelled
2025-12-19 14:09:20 +08:00
SpecialX
4bd5c687bc feat: setup standalone build and docker deployment
Some checks failed
CI / build-and-test (push) Successful in 1m18s
CI / deploy (push) Failing after 3s
2025-12-18 17:16:39 +08:00
SpecialX
81a91fbfff v3
All checks were successful
CI / build-and-test (push) Successful in 6m57s
2025-12-17 17:16:25 +08:00
SpecialX
dbed215d5b ci: run job in node:20 container and install git; add proxy env
Some checks failed
CI / build-and-test (push) Failing after 1m28s
2025-12-17 17:07:06 +08:00
SpecialX
ca23d03634 node
Some checks failed
CI / build-and-test (push) Failing after 1m2s
2025-12-16 11:54:31 +08:00
SpecialX
909767b9ce ci: run job in node:20 container and install git; add proxy env
Some checks failed
CI / build-and-test (push) Has been cancelled
2025-12-15 18:48:41 +08:00
SpecialX
2cfd9ae72d ci: run job in node:20 container and install git; add proxy env
Some checks failed
CI / build-and-test (push) Failing after 1s
2025-12-15 18:46:39 +08:00
SpecialX
86fd51bfab ci: run job in node:20 container and install git; add proxy env
Some checks failed
CI / build-and-test (push) Failing after 25s
2025-12-15 18:35:34 +08:00
SpecialX
40e6a3a52a ci: run job in node:20 container and install git; add proxy env
Some checks failed
CI / build-and-test (push) Failing after 4s
2025-12-15 17:54:18 +08:00
SpecialX
068e90f5eb ci: run job in node:20 container and install git; add proxy env
Some checks failed
CI / build-and-test (push) Has been cancelled
2025-12-15 17:53:25 +08:00
SpecialX
7b03524c62 ci: add HTTP(S)_PROXY env and proxy setup
Some checks failed
CI / build-and-test (push) Failing after 10m21s
2025-12-15 16:59:16 +08:00
SpecialX
de25bc3b01 ci: add HTTP(S)_PROXY env and proxy setup
Some checks failed
CI / build-and-test (push) Has been cancelled
2025-12-15 15:12:28 +08:00
SpecialX
3cd01fa71e ci: add HTTP(S)_PROXY env and proxy setup
Some checks failed
CI / build-and-test (push) Has been cancelled
2025-12-12 19:26:43 +08:00
SpecialX
205ae6aec6 ci: add HTTP(S)_PROXY env and proxy setup 2025-12-12 19:25:23 +08:00
252 changed files with 50010 additions and 131 deletions

View File

@@ -8,18 +8,28 @@ on:
branches:
- main
jobs:
build-and-test:
runs-on: CICD
runs-on: CDCD
container: dockerreg.eazygame.cn/node:22-bookworm
env:
SKIP_ENV_VALIDATION: "1"
NEXT_TELEMETRY_DISABLED: "1"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
- name: Checkout
uses: actions/checkout@v3
# 1. 增加 Cache 策略,显著加快 npm ci 速度
- name: Cache npm dependencies
uses: actions/cache@v3
id: npm-cache
with:
node-version: '20'
cache: 'npm'
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install dependencies
run: npm ci
@@ -30,17 +40,92 @@ jobs:
- name: Typecheck
run: npm run typecheck
# 2. 增加 Next.js 构建缓存
- name: Cache Next.js build
uses: actions/cache@v3
with:
path: |
~/.npm
${{ github.workspace }}/.next/cache
# Generate a new cache whenever packages or source files change.
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
- name: 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
run: |
mkdir -p .next/standalone/public
mkdir -p .next/standalone/.next/static
cp -r public/* .next/standalone/public/
cp -r .next/static/* .next/standalone/.next/static/
cp Dockerfile .next/standalone/Dockerfile
# - name: 🔍 Debug - List Build Files
# run: |
# echo "======================="
# ls -la .next/standalone
- name: Upload production build artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: next-build
path: |
.next
package.json
package-lock.json
next.config.*
tsconfig.json
.npmrc
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
run: |
# 1. 使用 --no-cache 防止使用旧的构建层,确保部署的是最新代码
# 2. 使用 --pull 确保基础镜像是最新的
docker build --no-cache --pull -t nextjs-app .
# 3. 优雅停止:先尝试 stop如果失败则无需处理 (|| true)
docker stop nextjs-app || true
docker rm nextjs-app || true
# 4. 运行容器:
# --init: 解决 Node.js PID 1 僵尸进程问题
# --restart unless-stopped: 自动重启策略
docker run -d \
--init \
-p 8015:3000 \
--restart unless-stopped \
--name nextjs-app \
-e NODE_ENV=production \
-e DATABASE_URL=${{ secrets.DATABASE_URL }} \
-e NEXT_TELEMETRY_DISABLED=1 \
nextjs-app

332
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,332 @@
# Next_Edu Architecture RFC
**Status**: PROPOSED
**Date**: 2025-12-22
**Author**: Principal Software Architect
**Version**: 1.0.0
---
## 1. 核心原则 (Core Principles)
本架构设计遵循以下核心原则,旨在构建一个高性能、可扩展、易维护的企业级在线教育平台。
1. **Vertical Slice Architecture (垂直切片)**: 拒绝传统的按技术分层Layered Architecture采用按业务功能分层。代码应根据“它属于哪个功能”而不是“它是什么文件”来组织。
2. **Type Safety First (类型安全优先)**: 全链路 TypeScript (Strict Mode)。从数据库 Schema 到 API 再到 UI 组件,必须保持类型一致性。
3. **Server-First (服务端优先)**: 充分利用 Next.js 15 App Router 的 RSC (React Server Components) 能力,减少客户端 Bundle 体积。
4. **Performance by Default (默认高性能)**: 严禁引入重型动画库,动效优先使用 CSS Native 实现。Web Vitals 指标作为 CI 阻断标准。
5. **Strict Engineering (严格工程化)**: CI/CD 流程标准化,代码风格统一,自动化测试覆盖。
---
## 2. 技术栈全景图 (Technology Panorama)
### 核心框架
* **Framework**: Next.js 15 (App Router)
* **Language**: TypeScript 5.x (Strict Mode enabled)
* *Correction*: 鉴于 Next.js App Router 的特性,`.tsx` 仅用于包含 JSX 的组件文件。所有的业务逻辑、Hooks、API 路由、Lib 工具函数必须使用 `.ts` 后缀,以明确区分“渲染层”与“逻辑层”。
* **Runtime**: Node.js 20.x (LTS)
### 数据层
* **Database**: MySQL 8.0+
* **ORM**: Drizzle ORM (轻量、无运行时开销、类型安全)
* **Driver**: `mysql2` (配合连接池) 或 `serverless-mysql` (针对特定 Serverless 环境)
* **Validation**: Zod (Schema 定义与运行时验证)
### UI/UX 层
* **Styling**: Tailwind CSS v3.4+
* **Components**: Shadcn/UI (基于 Radix UI 的 Headless 组件拷贝)
* **Icons**: Lucide React
* **Animations**: CSS Transitions / Tailwind `animate-*` / `tailwindcss-animate`
* *Complex Interactions*: Framer Motion (仅限按需加载 `LazyMotion`)
### 身份验证与授权
* **Auth**: Auth.js v5 (NextAuth)
* *Decision Driver*: 相比 ClerkAuth.js 提供了完全的**数据所有权**和**无 Vendor Lock-in**。
* *Enterprise Needs*: 允许自定义 Session 结构(如注入 `Role` 字段)并直接对接现有 MySQL 数据库,满足复杂的企业级权限管理需求。
### 状态管理
* **Server State**: TanStack Query v5 (仅用于复杂客户端轮询/无限加载)
* **URL State (Primary)**: Nuqs (Type-safe search params state manager)
* *Principle*: 绝大多数状态筛选、分页、Tab应存在 URL 中,以支持分享和书签。
* **Global Client State (Secondary)**: Zustand
* *Usage*: 仅限极少数全局交互状态(如播放器悬浮窗、全局 Modal
* *Anti-pattern*: **严禁使用 Redux**。避免不必要的样板代码和 Bundle 体积。
### 基础设施 & DevOps
* **CI/CD**: GitHub Actions (Strictly v3)
* **Linting**: ESLint (Next.js config), Prettier
* **Package Manager**: pnpm (推荐) 或 npm
---
## 3. 项目目录结构规范 (Project Structure)
采用 **Feature-based / Vertical Slice** 架构。所有业务逻辑应封装在 `src/modules` 中。
文档存放位置:
* 架构设计文档: `docs/architecture/`
* API 规范文档: `docs/api/`
### 目录树 (Directory Tree)
```
Next_Edu/
├── .github/
│ └── workflows/
│ └── ci.yml # GitHub Actions (v3 strict)
├── docs/
│ └── architecture/ # 架构决策记录 (ADR)
├── drizzle/ # 数据库迁移文件 (Generated)
├── public/ # 静态资源
├── src/
│ ├── app/ # [路由层] 极薄,仅负责路由分发和布局
│ │ ├── (auth)/ # 路由组
│ │ ├── (dashboard)/
│ │ ├── api/ # Webhooks / External APIs
│ │ ├── layout.tsx
│ │ └── page.tsx
│ │
│ ├── modules/ # [核心业务层] 垂直切片
│ │ ├── courses/ # 课程模块
│ │ │ ├── components/ # 模块私有组件 (CourseCard, Player)
│ │ │ ├── actions.ts # Server Actions (业务逻辑入口)
│ │ │ ├── service.ts # 领域服务 (可选,复杂逻辑拆分)
│ │ │ ├── data-access.ts # 数据库查询 (DTOs)
│ │ │ └── types.ts # 模块私有类型
│ │ │
│ │ ├── users/ # 用户模块
│ │ ├── payments/ # 支付模块
│ │ └── community/ # 社区模块
│ │
│ ├── shared/ # [共享层] 仅存放真正通用的代码
│ │ ├── components/ # 通用 UI (Button, Dialog - Shadcn)
│ │ ├── lib/ # 通用工具 (utils, date formatting)
│ │ ├── db/ # Drizzle Client & Schema
│ │ │ ├── index.ts # DB 连接实例
│ │ │ └── schema.ts # 全局 Schema 定义 (或按模块拆分导出)
│ │ └── hooks/ # 通用 Hooks
│ │
│ ├── env.mjs # 环境变量类型检查
│ └── middleware.ts # 边缘中间件 (Auth check)
├── drizzle.config.ts # Drizzle 配置文件
├── next.config.mjs
└── package.json
```
---
## 4. 数据库层设计 (Database Strategy)
### 连接配置 (Connection Pooling)
在 Next.js 的 Serverless/Edge 环境中,直接连接 MySQL 可能导致连接数耗尽。我们采取以下策略:
1. **开发环境**: 使用 Global Singleton 模式防止 Hot Reload 导致连接泄露。
2. **生产环境**:
* 推荐使用支持 HTTP 连接或内置连接池的 Serverless MySQL 方案 (如 PlanetScale)。
* 若使用标准 MySQL必须配置连接池 (`connectionLimit`) 并合理设置空闲超时。
**代码示例 (`src/shared/db/index.ts`)**:
```typescript
import { drizzle } from "drizzle-orm/mysql2";
import mysql from "mysql2/promise";
import * as schema from "./schema";
// Global cache to prevent connection exhaustion in development
const globalForDb = globalThis as unknown as {
conn: mysql.Pool | undefined;
};
const poolConnection = globalForDb.conn ?? mysql.createPool({
uri: process.env.DATABASE_URL,
waitForConnections: true,
connectionLimit: 10, // 根据数据库规格调整
queueLimit: 0
});
if (process.env.NODE_ENV !== "production") globalForDb.conn = poolConnection;
export const db = drizzle(poolConnection, { schema, mode: "default" });
```
### Migration 策略
* 使用 `drizzle-kit` 进行迁移管理。
* 严禁在生产环境运行时自动执行 Migration。
* **流程**:
1. 修改 Schema (`schema.ts`).
2. 运行 `pnpm drizzle-kit generate` 生成 SQL 文件。
3. Review SQL 文件。
4. 在 CI/CD 部署前置步骤或手动运行 `pnpm drizzle-kit migrate`
### Server Components 中的数据查询
* **Colocation**: 查询逻辑应尽量靠近使用它的组件,或者封装在 `data-access.ts` 中。
* **Request Memoization**: 即使在一个请求中多次调用相同的查询函数Next.js 的 `cache` (或 React `cache`) 也会自动去重。
```typescript
// src/modules/courses/data-access.ts
import { cache } from 'react';
import { db } from '@/shared/db';
import { eq } from 'drizzle-orm';
import { courses } from '@/shared/db/schema';
// 使用 React cache 确保单次请求内的去重
export const getCourseById = cache(async (id: string) => {
return await db.query.courses.findFirst({
where: eq(courses.id, id),
});
});
```
---
## 5. UI/UX 动效规范 (Animation Guidelines)
### 核心策略
* **CSS Native First**: 90% 的交互通过 CSS `transition``animation` 实现。
* **Hardware Acceleration**: 确保动画属性触发 GPU 加速 (`transform`, `opacity`)。
* **Micro-interactions**: 关注 `:hover`, `:active`, `:focus-visible` 状态。
### 高性能通用组件示例 (Interactive Card)
这是一个符合规范的卡片组件,使用了 Tailwind 的 `group``transform` 属性实现丝滑的微交互,且没有 JS 运行时开销。
```tsx
// src/shared/components/ui/interactive-card.tsx
import { cn } from "@/shared/lib/utils";
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
export function InteractiveCard({ className, children, ...props }: CardProps) {
return (
<div
className={cn(
"group relative overflow-hidden rounded-xl border border-border bg-card text-card-foreground shadow-sm",
// 核心动效:
// 1. duration-300 ease-out: 丝滑的时间函数
// 2. hover:shadow-md: 悬浮提升感
// 3. hover:-translate-y-1: 物理反馈
"transition-all duration-300 ease-out hover:-translate-y-1 hover:shadow-md",
// 消除 Safari 上的闪烁
"transform-gpu backface-hidden",
className
)}
{...props}
>
{/* 光泽效果 (Shimmer Effect) - 仅 CSS */}
<div
className="absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-white/5 to-transparent transition-transform duration-700 group-hover:translate-x-full"
aria-hidden="true"
/>
<div className="relative p-6">
{children}
</div>
</div>
);
}
```
---
## 6. CI/CD 配置文件模板 (GitHub Actions)
**警告**: 必须严格遵守 `v3` 版本限制。严禁使用 `v4`
文件路径: `.github/workflows/ci.yml`
```yaml
name: CI/CD Pipeline
on:
push:
branches: [ "main", "develop" ]
pull_request:
branches: [ "main", "develop" ]
env:
NODE_VERSION: '20.x'
jobs:
quality-check:
name: Quality & Type Check
runs-on: ubuntu-latest
steps:
# 强制使用 v3
- name: Checkout Code
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm' # 或 'pnpm'
- name: Install Dependencies
run: npm ci
- name: Linting (ESLint)
run: npm run lint
- name: Type Checking (TSC)
# 确保没有 TS 错误
run: npx tsc --noEmit
test:
name: Unit Tests
needs: quality-check
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install Dependencies
run: npm ci
- name: Run Tests
run: npm run test
build-check:
name: Production Build Check
needs: test
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install Dependencies
run: npm ci
- name: Cache Next.js build
uses: actions/cache@v3
with:
path: |
~/.npm
${{ github.workspace }}/.next/cache
# Generate a new cache whenever packages or source files change.
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
- name: Build Application
run: npm run build
env:
# 构建时跳过 ESLint/TS 检查 (因为已经在 quality-check job 做过了,加速构建)
NEXT_TELEMETRY_DISABLED: 1
```

21
Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
FROM node:22-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_PRIVATE_STANDALONE true
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy standalone output
# The context is now the standalone folder itself
COPY --chown=nextjs:nodejs . .
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
CMD ["node", "server.js"]

View File

@@ -0,0 +1,112 @@
# 架构决策记录 (ADR): 数据库 Schema 设计方案 v1.0
**状态**: 已实施 (IMPLEMENTED)
**日期**: 2025-12-23
**作者**: 首席系统架构师
**背景**: Next_Edu 平台 - K12 智慧教育管理系统
---
## 1. 概述 (Overview)
本文档详细记录了 Next_Edu 平台的数据库 Schema 架构设计。本设计优先考虑 **可扩展性 (Scalability)**、**灵活性 (Flexibility)**(针对复杂的嵌套内容)以及 **严格的类型安全 (Strict Type Safety)**,并完全符合 PRD 中规定的领域驱动设计 (DDD) 原则。
## 2. 技术栈决策 (Technology Stack Decisions)
| 组件 | 选择 | 理由 |
| :--- | :--- | :--- |
| **数据库** | MySQL 8.0+ | 强大的关系型支持,完善的 JSON 能力,行业标准。 |
| **ORM** | Drizzle ORM | 轻量级,零运行时开销 (Zero-runtime overhead),业界一流的 TypeScript 类型推断。 |
| **ID 策略** | CUID2 | 分布式友好 (k-sortable),安全(防连续猜测攻击),比 UUID 更短。 |
| **认证方案** | Auth.js v5 | 标准化的 OAuth 流程,支持自定义数据库适配器。 |
---
## 3. 核心 Schema 领域模型 (Core Schema Domains)
物理文件位于 `src/shared/db/schema.ts`,逻辑上分为三大领域。
### 3.1 身份与访问管理 (IAM)
我们采用了 **Auth.js 标准表****自定义 RBAC** 相结合的混合模式。
* **标准表**: `users`, `accounts` (OAuth), `sessions`, `verificationTokens`
* **RBAC 扩展**:
* `roles`: 定义系统角色(例如:`grade_head` 年级主任, `teacher` 老师)。
* `users_to_roles`: 多对多关联表。
* **设计目标**: 解决“一人多职”问题(例如:一个老师同时也是年级主任),避免在 `users` 表中堆砌字段。
### 3.2 智能题库中心 (Intelligent Question Bank) - 核心
这是最复杂的领域,需要支持无限层级嵌套和富文本内容。
#### 实体定义:
1. **`questions` (题目表)**:
* `id`: CUID2。
* `content`: **JSON 类型**。存储结构化内容(如 SlateJS 节点),支持富文本、图片和公式混排。
* `parentId`: **自引用 (Self-Reference)**
* *若为 NULL*: 独立题目 或 “大题干” (Parent)。
* *若有值*: 子题目 (Child)例如一篇阅读理解下的第1小题
* `type`: 枚举 (`single_choice`, `text`, `composite` 等)。
2. **`knowledge_points` (知识点表)**:
* 通过 `parentId` 实现树状结构。
* 支持无限层级 (学科 -> 章 -> 节 -> 知识点)。
3. **`questions_to_knowledge_points`**:
* 多对多关联。一道题可考察多个知识点;一个知识点可关联数千道题。
### 3.3 教务教学流 (Academic Teaching Flow)
将物理世界的教学过程映射为数字实体。
* **`textbooks` & `chapters`**: 标准的教材大纲映射。`chapters` 同样支持通过 `parentId` 进行嵌套。
* **`exams`**: 考试/作业的聚合实体。
* **`exam_submissions`**: 代表一名学生的单次答题记录。
* **`submission_answers`**: 细粒度的答题详情,记录每道题的答案,支持自动评分 (`score`) 和人工反馈 (`feedback`)。
---
## 4. 关键设计模式 (Key Design Patterns)
### 4.1 无限嵌套 ("Composite" Pattern)
我们没有为“题干”和“题目”创建单独的表,而是在 `questions` 表上使用 **自引用 (Self-Referencing)** 模式。
* **优点**:
* 统一的查询接口 (`db.query.questions.findFirst({ with: { children: true } })`)。
* 递归逻辑可统一应用。
* 当内容结构变化时,迁移更简单。
* **缺点**:
* 需要处理递归查询逻辑(已通过 Drizzle Relations 解决)。
### 4.2 CUID2 优于 自增 ID
* **安全性**: 防止 ID 枚举攻击(猜测下一个用户 ID
* **分布式**: 支持在客户端或多服务器节点生成,无碰撞风险。
* **性能**: `k-sortable` 特性保证了比随机 UUID v4 更好的索引局部性。
### 4.3 JSON 存储内容
* 教育内容不仅仅是“文本”。它包含格式、LaTeX 公式和图片引用。
* 使用 `JSON` 存储允许前端 (Next.js) 直接渲染富组件,无需解析复杂的 HTML 字符串。
---
## 5. 安全与索引策略 (Security & Indexing Strategy)
### 索引 (Indexes)
* **外键**: 所有外键列 (`author_id`, `parent_id` 等) 均显式建立索引。
* **性能**:
* `parent_id_idx`: 对树形结构的遍历性能至关重要。
* `email_idx`: 登录查询的核心索引。
### 类型安全 (Type Safety)
* 严格的 TypeScript 定义直接从 `src/shared/db/schema.ts` 导出。
* Zod Schema (待生成) 将与这些 Drizzle 定义保持 1:1 对齐。
---
## 6. 目录结构 (Directory Structure)
```bash
src/shared/db/
├── index.ts # 单例数据库连接池
├── schema.ts # 物理表结构定义
└── relations.ts # 逻辑 Drizzle 关系定义
```

View File

@@ -0,0 +1,52 @@
# Database Schema Change Request: Exam Structure Support
## 1. Table: `exams`
### Change
**Add Column**: `structure`
### Details
- **Type**: `JSON`
- **Nullable**: `TRUE` (Default: `NULL`)
### Reason
To support hierarchical exam structures (e.g., Sections/Groups containing Questions). The existing flat `exam_questions` table only supports a simple list of questions and is insufficient for complex exam layouts (e.g., "Part A: Reading", "Part B: Writing").
### Before vs After
**Before**:
`exams` table only stores metadata (`title`, `description`, etc.). Question ordering relies solely on `exam_questions.order`.
**After**:
`exams` table includes `structure` column to store the full tree representation:
```json
[
{ "id": "uuid-1", "type": "group", "title": "Section A", "children": [...] },
{ "id": "uuid-2", "type": "question", "questionId": "q1", "score": 10 }
]
```
*Note: `exam_questions` table is retained for relational integrity and efficient querying of question usage, but the presentation order/structure is now driven by this new JSON column.*
---
## 2. Table: `questions_to_knowledge_points`
### Change
**Rename Foreign Key Constraints**
### Details
- Rename constraint for `question_id` to `q_kp_qid_fk`
- Rename constraint for `knowledge_point_id` to `q_kp_kpid_fk`
### Reason
The default generated foreign key names (e.g., `questions_to_knowledge_points_knowledge_point_id_knowledge_points_id_fk`) exceed the MySQL identifier length limit (64 characters), causing migration failures.
### Before vs After
**Before**:
- FK Name: `questions_to_knowledge_points_question_id_questions_id_fk` (Too long)
- FK Name: `questions_to_knowledge_points_knowledge_point_id_knowledge_points_id_fk` (Too long)
**After**:
- FK Name: `q_kp_qid_fk`
- FK Name: `q_kp_kpid_fk`

View File

@@ -0,0 +1,115 @@
# Architecture RFC: Role-Based Routing & Directory Structure
**Status**: PROPOSED
**Date**: 2025-12-23
**Context**: Next_Edu supports multiple roles (Admin, Teacher, Student, Parent) with distinct UI/UX requirements.
## 1. 核心问题 (The Problem)
目前项目结构中,页面与角色耦合不清。
* `/dashboard` 目前硬编码为教师视图。
* 不同角色的功能模块(如“课程”)可能有完全不同的视图和逻辑(教师管理课程 vs 学生学习课程)。
* 缺乏统一的路由规范指导开发人员“把页面放哪里”。
## 2. 解决方案:基于角色的路由策略 (Role-Based Routing Strategy)
我们采用 **"Hybrid Routing" (混合路由)** 策略:
1. **Explicit Dashboard Routing**: Dashboards are separated by role in the file structure (e.g., `/teacher/dashboard`).
2. **Explicit Role Scopes (显式角色域)**: 具体的业务功能页面放入 `/teacher`, `/student`, `/admin` 专属路径下。
### 2.1 目录结构 (Directory Structure)
```
src/app/(dashboard)/
├── layout.tsx # Shared App Shell (Sidebar, Header)
├── dashboard/
│ └── page.tsx # [Redirector] Redirects to role-specific dashboard
├── teacher/ # 教师专属路由域
│ ├── dashboard/ # Teacher Dashboard
│ │ └── page.tsx
│ ├── classes/
│ │ └── page.tsx
│ ├── exams/
│ │ └── page.tsx
│ └── textbooks/
│ └── page.tsx
├── student/ # 学生专属路由域
│ ├── dashboard/ # Student Dashboard
│ │ └── page.tsx
│ ├── learning/
│ │ └── page.tsx
│ └── schedule/
│ └── page.tsx
└── admin/ # 管理员专属路由域
├── dashboard/ # Admin Dashboard
│ └── page.tsx
├── users/
└── school/
```
### 2.2 Dashboard Routing Logic
`/dashboard` 页面现在作为一个 **Portal (入口)****Redirector (重定向器)**,而不是直接渲染内容。
**Example: `src/app/(dashboard)/dashboard/page.tsx`**
```tsx
import { redirect } from "next/navigation";
import { auth } from "@/auth";
export default async function DashboardPage() {
const session = await auth();
const role = session?.user?.role;
switch (role) {
case "teacher":
redirect("/teacher/dashboard");
case "student":
redirect("/student/dashboard");
case "admin":
redirect("/admin/dashboard");
default:
redirect("/login");
}
}
```
## 3. 模块化组织 (Module Organization)
为了避免 `src/app` 变得臃肿,具体的**业务组件**必须存放在 `src/modules` 中。
```
src/modules/
├── teacher/ # 教师业务领域
│ ├── components/ # 仅教师使用的组件 (e.g., GradebookTable)
│ ├── hooks/
│ └── actions.ts
├── student/ # 学生业务领域
│ ├── components/ # (e.g., CourseProgressCard)
│ └── ...
├── shared/ # 跨角色共享
│ ├── components/ # (e.g., CourseCard - if generic)
```
## 4. 路由与导航配置 (Navigation Config)
`src/modules/layout/config/navigation.ts` 已经配置好了基于角色的菜单。
* 当用户访问 `/dashboard` 时,根据角色看到不同的 Dashboard 组件。
* 点击侧边栏菜单(如 "Exams")时,跳转到显式路径 `/teacher/exams`
## 5. 优势 (Benefits)
1. **Security**: 可以在 Middleware 或 Layout 层级轻松对 `/admin/*` 路径实施权限控制。
2. **Clarity**: 开发者清楚知道“教师的试卷列表页”应该放在 `src/app/(dashboard)/teacher/exams/page.tsx`
3. **Decoupling**: 教师端和学生端的逻辑完全解耦,互不影响。
---
## 6. Action Items (执行计划)
1. **Refactor Dashboard**: 将 `src/app/(dashboard)/dashboard/page.tsx` 重构为 Dispatcher。
2. **Create Role Directories**: 在 `src/app/(dashboard)` 下创建 `teacher`, `student`, `admin` 目录。
3. **Move Components**: 确保 `src/modules` 结构清晰。

View File

@@ -0,0 +1,434 @@
# Frontend Engineering Standards (Next_Edu)
**Status**: ACTIVE
**Owner**: Frontend Team
**Scope**: Next.js App Router 前端工程规范(编码、目录、交互、样式、数据流、质量门禁)
**Applies To**: `src/app/*`, `src/modules/*`, `src/shared/*`, `docs/design/*`
---
## 0. 目标与非目标
### 0.1 目标
- 让新加入的前端工程师在 30 分钟内完成对齐并开始稳定迭代
- 保证 UI 一致性Design Token + Shadcn/UI 复用)
- 充分利用 App Router + RSCServer-First降低 bundle、提升性能
- 保证类型安全与可维护性Vertical Slice、数据访问边界清晰
- 形成可执行的质量门禁lint/typecheck/build 与评审清单)
### 0.2 非目标
- 不规定具体业务模块的需求细节(业务规则以 `docs/design/*` 与 PRD 为准)
- 不引入与当前仓库技术栈不一致的新框架/库(新增依赖需明确收益与替代方案)
---
## 1. 接手流程Onboarding Checklist
### 1.1 先读什么(按顺序)
- 设计系统与 UI 规范:[docs/design/design_system.md](file:///c:/Users/xiner/Desktop/CICD/docs/design/design_system.md)
- 角色路由与目录规范:[docs/architecture/002_role_based_routing.md](file:///c:/Users/xiner/Desktop/CICD/docs/architecture/002_role_based_routing.md)
- 项目架构总览:[ARCHITECTURE.md](file:///c:/Users/xiner/Desktop/CICD/ARCHITECTURE.md)
- 你将要改动的模块实现文档:`docs/design/00*_*.md`
### 1.2 开发前对齐(必须)
- 核对 Design Tokens 与暗色模式变量:
- Tailwind 语义色映射:[tailwind.config.ts](file:///c:/Users/xiner/Desktop/CICD/tailwind.config.ts)
- CSS 变量定义:[src/app/globals.css](file:///c:/Users/xiner/Desktop/CICD/src/app/globals.css)
- 盘点可复用 UI 组件:`src/shared/components/ui/*`
- 盘点通用工具(`cn` 等):[src/shared/lib/utils.ts](file:///c:/Users/xiner/Desktop/CICD/src/shared/lib/utils.ts)
### 1.3 环境变量与配置校验(必须)
- 统一使用 `@t3-oss/env-nextjs``env` 入口读取环境变量,禁止在业务代码中散落 `process.env.*`
- Schema 定义与校验入口:[src/env.mjs](file:///c:/Users/xiner/Desktop/CICD/src/env.mjs)
- 任何新增环境变量:
- 必须先在 `src/env.mjs` 增加 schema
- 必须在 docs 中更新部署/运行说明(就近更新对应模块文档或全局架构文档)
### 1.3 本地跑通(推荐顺序)
- 安装依赖:`npm install`
- 启动开发:`npm run dev`
- 质量检查:
- `npm run lint`
- `npm run typecheck`
- `npm run build`
---
## 2. 核心工程原则(必须遵守)
### 2.1 Vertical Slice按业务功能组织
- 业务必须放在 `src/modules/<feature>/*`
- `src/app/*` 是路由层,只负责:
- 布局组合layout
- 读取 `searchParams` / `params`
- 调用模块的数据访问函数(`data-access.ts`
- 组合模块组件渲染
- 通用能力放在 `src/shared/*`
- 通用 UI`src/shared/components/ui/*`
- 通用工具:`src/shared/lib/*`
- DB 与 schema`src/shared/db/*`
### 2.2 Server-First默认 Server Component
- 默认写 Server Component
- 只有在需要以下能力时,才把“最小子组件”标记为 Client Component
- `useState/useEffect/useMemo`(与交互/浏览器相关)
- DOM 事件(`onClick/onChange` 等)
- `useRouter/usePathname` 等客户端导航 hooks
- Radix/Portal 类组件需要客户端Dialog/Dropdown 等通常在 client 内组合使用)
### 2.3 不重复造轮子Shadcn/UI 优先)
- 禁止手写 Modal/Dropdown/Tooltip 等基础交互容器
- 优先组合 `src/shared/components/ui/*`Button/Card/Dialog/DropdownMenu/AlertDialog/Skeleton/EmptyState 等)
- 若现有基础组件无法满足需求:
1. 优先通过 Composition 在业务模块里封装“业务组件”
2. 仅在存在 bug 或需要全局一致性调整时,才考虑改动 `src/shared/components/ui/*`(并在 PR 中明确影响面)
### 2.4 Client Component 引用边界(强制)
- 禁止在 Client Component 中导入任何“服务端实现”代码(例如 DB 实例、data-access、server-only 模块)
- Client Component 允许导入:
- `src/shared/components/ui/*`(基础 UI
- `src/shared/lib/*`(纯前端工具函数)
- Server Actions`"use server"` 导出的 action 函数)
- 类型定义必须使用 `import type`(避免把服务端依赖带入 client bundle
- 所有 `data-access.ts` 必须包含 `import "server-only"`,并将其视为强制安全边界(不是可选优化)
---
## 3. 目录与路由规范
### 3.1 路由目录App Router
- 认证域:`src/app/(auth)/*`
- 控制台域(共享 App Shell`src/app/(dashboard)/*`
- 角色域:`src/app/(dashboard)/teacher|student|admin/*`
- `/dashboard` 作为入口页(重定向/分发到具体角色 dashboard参考 [002_role_based_routing.md](file:///c:/Users/xiner/Desktop/CICD/docs/architecture/002_role_based_routing.md)
### 3.2 页面文件职责
- `page.tsx`页面组装RSC不承载复杂交互
- `loading.tsx`路由级加载态Skeleton
- `error.tsx`:路由级错误边界(友好 UI
- `not-found.tsx`:路由级 404
### 3.3 错误处理与用户反馈Error Handling & Feedback
- 路由级错误404/500/未捕获异常):
- 交由 `not-found.tsx` / `error.tsx` 处理
- 禁止在 `error.tsx` 里弹 Toast 堆栈刷屏,错误页输出必须友好且可恢复(例如提供 retry / 返回入口)
- 业务操作反馈(表单提交/按钮操作/行级动作):
- 统一由 Client Component 在调用 Server Action 后触发 `sonner` toast
- 只在“成功”或“明确失败(业务/校验错误)”时触发 toast未知异常由 action 归一为失败 message
### 3.4 异步组件与 SuspenseStreaming
- 对于数据加载超过 300ms 的非核心 UI 区块(例如:仪表盘某张统计卡片/图表/第三方数据块):
- 必须用 `<Suspense fallback={<Skeleton />}>` 包裹,以避免全页阻塞
- 禁止在 `page.tsx` 顶层用多个串行 `await` 造成瀑布请求:
- 多个独立请求必须使用 `Promise.all`
- 或拆分为多个 async 子组件并行流式渲染(用 `Suspense` 分段展示)
### 3.3 动态渲染策略(避免 build 阶段查库)
当页面在渲染时会查询数据库或依赖 request-time 数据,且无法安全静态化时:
- 在页面入口显式声明:
- `export const dynamic = "force-dynamic"`
- 该策略已用于教师端班级与作业相关页面,见相应 design 文档(例如教师班级模块更新记录)
---
## 4. 模块内文件结构(强制)
每个业务模块使用统一结构(可按复杂度增减,但命名必须一致):
```
src/modules/<feature>/
├── components/ # 仅该模块使用的 UI 组件(可含 client 组件)
├── actions.ts # Server Actions写入/变更 + revalidatePath
├── data-access.ts # 数据查询与聚合server-only + cache
├── schema.ts # Zod schema若需要
└── types.ts # 类型定义(与 DB/DTO 对齐)
```
约束:
- `actions.ts` 必须包含 `"use server"`
- `data-access.ts` 必须包含 `import "server-only"`(防止误导入到 client bundle
- 复杂页面组件必须下沉到 `src/modules/<feature>/components/*`,路由层只做组装
---
## 5. Server / Client 边界与拆分策略
### 5.1 最小化 Client Component 的落地方式
- 页面保持 RSC
- 把需要交互的部分抽成独立 `components/*` 子组件并标记 `"use client"`
- Client 组件向上暴露“数据变化事件”,由 Server Action 完成写入并 `revalidatePath`
### 5.4 Hydration 一致性(必须)
- 所有 Client Component 的首屏渲染必须保证与 SSR 产出的 HTML 一致
- 禁止在 render 分支中使用:
- `typeof window !== "undefined"` 之类的 server/client 分支
- `Date.now()` / `Math.random()` 等不稳定输入
- 依赖用户 locale 的时间格式化(除非服务端与客户端完全一致并带 snapshot
- 对于 Radix 等组件生成的动态 aria/id 导致的属性差异:
- 优先通过组件封装确保首屏稳定
- 若确认差异不可避免且不影响交互,可在最小范围使用 `suppressHydrationWarning`
### 5.2 页面必须只做“拼装”,功能模块必须独立
- 任何功能模块都必须在 `src/modules/<feature>/components/*` 内独立实现
- `page.tsx` 只负责:
- 读取 `params/searchParams`
- 调用 `data-access.ts` 获取数据
- 以组合方式拼装模块组件(不在 page 内实现具体交互与复杂 UI
- 行数不是拆分依据,只是“路由层变厚”的信号;一旦出现成块的功能 UI应立即下沉到模块组件
### 5.3 什么时候允许在 Client 中做“局部工作台”
当交互复杂到“页面需要类似 SPA 的局部体验”,允许将工作台容器作为 Client
- 典型场景:三栏工作台、拖拽排序编辑器、复杂筛选器组合、富交互表格
- 但仍要求:
- 初始数据由 RSC 获取并传入 Client
- 写操作通过 Server Actions
- UI 状态尽量 URL 化(能分享/回溯)
---
## 6. 样式与 UI 一致性Design System 强制项)
### 6.1 Token 优先(语义化颜色/圆角)
- 颜色必须使用语义 token
- `bg-background`, `bg-card`, `bg-muted`, `text-foreground`, `text-muted-foreground`, `border-border`
- 禁止硬编码颜色值(`#fff`/`rgb()`)与随意引入灰度(如 `bg-gray-100`
- 圆角、边框、阴影遵循设计系统:
- 常规组件使用 `rounded-md` 等语义半径(由 `--radius` 映射)
### 6.2 className 规范
- 所有条件样式必须使用 `className={cn(...)}`
- `cn` 入口为 `@/shared/lib/utils`
### 6.3 禁止 Arbitrary Values默认
- 默认禁止 `w-[123px]` 等任意值
- 只有在设计系统或现有实现明确允许、并且无法用 token/栅格解决时,才可使用,并在 PR 描述说明原因
### 6.4 微交互与状态(必须有)
- 按钮 hover必须有 transition现有 Button 组件已内置)
- 列表项 hover使用 `hover:bg-muted/50` 等轻量反馈
- Loading必须使用 `Skeleton`(路由级 `loading.tsx` 或组件内 skeleton
- Empty必须使用 `EmptyState`
- Toast统一使用 `sonner`
---
## 7. 图标规范lucide-react
- 统一使用 `lucide-react`
- 图标尺寸统一:默认 `h-4 w-4`,需要强调时 `h-5 w-5`
- 颜色使用语义化:例如 `text-muted-foreground`
---
## 8. 数据流规范(查询、写入、状态)
### 8.1 查询data-access.ts
- 所有查询放在 `src/modules/<feature>/data-access.ts`
- 需要复用/去重的查询优先用 `cache` 包裹React cache
- 查询函数返回“UI 直接可消费的 DTO”避免页面层再做复杂映射
### 8.2 写入actions.ts
- 所有写操作必须通过 Server Actions
- 每个 action
- 校验输入Zod 或手写 guard
- 执行 DB 写入
- 必须 `revalidatePath`(以页面为单位)
### 8.3 Server Action 返回结构(统一反馈协议)
- 所有 Server Action 必须返回统一结构,用于前端统一处理 toast 与表单错误
- 统一使用类型:[src/shared/types/action-state.ts](file:///c:/Users/xiner/Desktop/CICD/src/shared/types/action-state.ts)
```ts
export type ActionState<T = void> = {
success: boolean
message?: string
errors?: Record<string, string[]>
data?: T
}
```
约束:
- `errors` 必须对齐 `zod``error.flatten().fieldErrors` 结构
- 禁止在各模块内重复定义自有的 ActionState 类型
### 8.4 Toast 触发时机(强制)
- Client Component 在调用 Server Action 后:
- `success: true`:触发 `toast.success(message)`(或使用模块内约定的成功文案)
- `success: false`
- 存在 `errors`:优先渲染表单字段错误;可选触发 `toast.error(message)`
- 不存在 `errors`:触发 `toast.error(message || "Action failed")`
- 对于路由级异常与边界错误,禁止用 toast 替代 `error.tsx`
### 8.5 URL Statenuqs 优先)
- 列表页筛选/分页/Tab/排序等“可分享状态”必须放 URL
- 使用 `nuqs` 做类型安全的 query state 管理
### 8.6 Data Access 权限边界Security / IDOR 防护)
- `data-access.ts` 不是纯 DTO 映射层,必须承担数据归属权校验
- 允许两种合规方式(二选一,但模块内必须统一):
- **方式 A强制传参**:所有 data-access 函数显式接收 `actor`userId/role并在查询条件中约束归属例如 teacherId
- **方式 B函数内获取**data-access 函数首行获取 session/user 并校验 role/归属,再执行查询
- 禁止把权限校验放在 page.tsx 或 client 组件中作为唯一屏障
---
## 9. 数据完整性与 Seed 规则(禁止 Mock
项目默认不使用 Mock 数据。
当某功能缺失实际数据,开发者必须把数据补齐到数据库与种子数据中,而不是在前端临时模拟。
执行规范:
- 若缺失的是“表结构/字段/关系”:
- 修改 `src/shared/db/schema.ts``src/shared/db/relations.ts`(按既有模式)
- 生成并提交 Drizzle migration`drizzle/*.sql`
- 若缺失的是“可演示的业务数据”:
- 更新 `scripts/seed.ts`,确保 `npm run db:seed` 可一键生成可用数据
- 文档同步(必须):
- 在 [schema-changelog.md](file:///c:/Users/xiner/Desktop/CICD/docs/db/schema-changelog.md) 记录本次新增/变更的数据表、字段、索引与外键
- 在对应模块的 `docs/design/00*_*.md` 中补充“新增了哪些数据/为什么需要/如何验证db:seed + 页面路径)”
### 9.1 Seed 分层(降低阻塞)
- Seed 分为两类:
- **Baseline Seed**:全项目必备的最小集合(核心用户/角色/基础字典数据等),保证任何页面都不因“数据空”而无法进入流程
- **Scenario Seed按模块**:面向具体模块的可演示数据包(例如:班级/题库/试卷/作业),用于复现与验证该模块交互
- 任何模块新增数据依赖,必须以 “Scenario Seed” 的形式落到 `scripts/seed.ts`,而不是把数据要求隐含在前端逻辑里
### 9.2 Seed 可复现与数据锚点(保证跨模块联动)
- Seed 必须可重复执行idempotent避免开发环境多次执行后产生脏数据与重复数据
- 对跨模块联动依赖的关键实体,必须提供可稳定引用的数据锚点:
- 固定标识(如固定 email/slug/title 组合)或可预测 ID按现有 seed 约定)
- 文档必须写明锚点是什么、依赖它的模块有哪些、如何验证
- 禁止在 UI 里依赖“随机生成数据顺序”来定位实体(例如 “取第一条记录作为 demo 用户” 这类逻辑应退化为明确锚点)
### 9.3 外部服务的例外(仅限 Adapter Mock
- 内部业务数据严格遵守“DB + Migration + Seed”不允许 Mock
- 仅当对接外部不可控服务(支付/短信/第三方 AI 流式等)且无法用本地 seed 复现时:
- 允许在 `src/shared/lib/mock-adapters/*` 建立 mock 适配器
- 必须先定义 Adapter 接口,再提供真实实现与 mock 实现(业务模块只能依赖接口,不可直接依赖某个具体实现)
- 该 mock 仅用于外部服务交互层,禁止承载内部业务数据
---
## 10. 表单规范react-hook-form + zod
- 表单统一使用 `react-hook-form` + `@hookform/resolvers` + `zod`
- 错误提示放在输入框下方:
- 字号 `text-xs`
- 颜色 `text-destructive`
- 破坏性操作必须二次确认(`AlertDialog`
- 提交中按钮禁用并展示 loading可使用 `useFormStatus` 或本地 state
---
## 11. 质量门禁与评审清单PR 必须过)
### 11.1 本地必须通过
- `npm run lint`
- `npm run typecheck`
- `npm run build`
### 11.2 代码评审清单Reviewer 逐项检查)
- 目录结构是否符合 Vertical Slice路由层是否保持“薄”
- 页面是否只做拼装(功能 UI 是否全部下沉到模块组件)
- Server/Client 边界是否最小化(是否把整页误标 client
- 是否复用 `src/shared/components/ui/*`,是否重复实现基础交互
- 是否使用语义化 token颜色/圆角/间距),是否引入硬编码颜色与大量 arbitrary values
- Loading/Empty/Error 是否齐全Skeleton/EmptyState/error.tsx
- 列表页筛选是否 URL 化nuqs是否支持刷新/分享
- 写操作是否通过 Server Action 且正确 `revalidatePath`
- 是否避免 Mock数据是否通过迁移 + seed 补齐,且 docs/db 与模块文档已同步)
- 是否引入不必要的依赖与重型客户端逻辑
### 11.3 Commit 规范Git History
- 推荐遵循 Conventional Commits
- `feat:` 新功能
- `fix:` 修复 bug
- `docs:` 文档更新
- `refactor:` 重构(无功能变化)
- `chore:` 工程杂项
- 约束:
- 单次提交必须聚焦一个意图,避免把大范围格式化与功能修改混在一起
- 涉及 DB 迁移与 seed 变更时commit message 必须包含模块/领域关键词,便于追溯
---
## 12. 文档同步规则Docs Sync
以下情况必须同步更新文档(就近放在 `docs/design/*``docs/architecture/*`
- 新增“全局交互模式”(例如:新的工作台/拖拽范式/跨模块复用交互)
- 新增“全局组件”或改变基础 UI 行为(影响 `src/shared/components/ui/*`
- 新增关键路由结构或权限/角色策略
### 12.1 业务组件可发现性(可选但推荐)
-`src/modules/<feature>/components` 内的复杂业务组件(例如:试卷编辑器、排课表、工作台):
- 推荐在对应的 `docs/design/00*_*.md` 增加“用法示例 + 关键 props + 截图”
- 若团队资源允许,可引入 Storybook 作为可视化组件目录(不作为硬性门禁)
---
## 13. Performance Essentials必须遵守
- 图片:
- 强制使用 `next/image` 替代 `<img>`SVG 或已明确无需优化的极小图标除外)
- 头像等外部域名资源必须配置并明确缓存策略
- 字体:
- 强制使用 `next/font` 管理字体加载
- 禁止在 CSS 中 `@import` 外部字体 URL避免 CLS 与阻塞渲染)
- 依赖:
- 禁止引入重型动画库作为默认方案;复杂动效需按需加载并解释收益
- 大体积 Client 组件必须拆分与动态加载,并通过 `Suspense` 提供 skeleton fallback
---
## 14. 参考实现(从现有代码学习的路径)
- 设计系统与 UI 组件清单:[docs/design/design_system.md](file:///c:/Users/xiner/Desktop/CICD/docs/design/design_system.md)
- Auth路由层 RSC + 表单 client 拆分模式:[docs/design/001_auth_ui_implementation.md](file:///c:/Users/xiner/Desktop/CICD/docs/design/001_auth_ui_implementation.md)
- 教师端班级模块URL state + client 交互组件 + server actions 的组合:[docs/design/002_teacher_dashboard_implementation.md](file:///c:/Users/xiner/Desktop/CICD/docs/design/002_teacher_dashboard_implementation.md)
- 教材工作台RSC 拉初始数据 + client 工作台容器接管交互:[docs/design/003_textbooks_module_implementation.md](file:///c:/Users/xiner/Desktop/CICD/docs/design/003_textbooks_module_implementation.md)
- 题库nuqs 驱动筛选 + TanStack Table + CRUD actions[docs/design/004_question_bank_implementation.md](file:///c:/Users/xiner/Desktop/CICD/docs/design/004_question_bank_implementation.md)
- 考试组卷:拖拽编辑器(@dnd-kit+ structure JSON 模型:[docs/design/005_exam_module_implementation.md](file:///c:/Users/xiner/Desktop/CICD/docs/design/005_exam_module_implementation.md)
- 作业:冻结 exam → assignment 的域模型 + 学生作答/教师批改闭环:[docs/design/006_homework_module_implementation.md](file:///c:/Users/xiner/Desktop/CICD/docs/design/006_homework_module_implementation.md)

202
docs/db/schema-changelog.md Normal file
View File

@@ -0,0 +1,202 @@
# Database Schema Changelog
## v1.1.0 - Exam Structure Support
**Date:** 2025-12-29
**Migration ID:** `0001_flawless_texas_twister`
**Author:** Principal Database Architect
### 1. Summary
This release introduces support for hierarchical exam structures (Sectioning/Grouping).
### 2. Changes
#### 2.1 Table: `exams`
* **Action**: `ADD COLUMN`
* **Field**: `structure` (JSON)
* **Reason**: To support nested exam layouts (e.g., "Part I: Listening", "Section A").
* *Architecture Note*: This JSON field is strictly for **Presentation Layer** ordering and grouping. The `exam_questions` table remains the **Source of Truth** for relational integrity and scoring logic.
* **Schema Definition**:
```typescript
type ExamStructure = Array<
| { type: 'group', title: string, children: ExamStructure }
| { type: 'question', questionId: string, score: number }
>
```
### 3. Migration Strategy
* **Up**: Run standard Drizzle migration.
* **Down**: Revert `structure` column. Note that FK names can be kept short as they are implementation details.
### 4. Impact Analysis
* **Performance**: Negligible. JSON parsing is done client-side or at application layer.
* **Data Integrity**: High. Existing data is unaffected. New `structure` field defaults to `NULL`.
## v1.2.0 - Homework Module Tables & FK Name Hardening
**Date:** 2025-12-31
**Migration ID:** `0002_equal_wolfpack`
**Author:** Principal Database Architect
### 1. Summary
This release introduces homework-related tables and hardens foreign key names to avoid exceeding MySQL identifier length limits (MySQL 64-char constraint names).
### 2. Changes
#### 2.1 Tables: Homework Domain
* **Action**: `CREATE TABLE`
* **Tables**:
* `homework_assignments`
* `homework_assignment_questions`
* `homework_assignment_targets`
* `homework_submissions`
* `homework_answers`
* **Reason**: Support assignment lifecycle, targeting, submissions, and per-question grading.
#### 2.2 Foreign Keys: Homework Domain (Name Hardening)
* **Action**: `ADD FOREIGN KEY` (with short constraint names)
* **Details**:
* `homework_assignments`: `hw_asg_exam_fk`, `hw_asg_creator_fk`
* `homework_assignment_questions`: `hw_aq_a_fk`, `hw_aq_q_fk`
* `homework_assignment_targets`: `hw_at_a_fk`, `hw_at_s_fk`
* `homework_submissions`: `hw_sub_a_fk`, `hw_sub_student_fk`
* `homework_answers`: `hw_ans_sub_fk`, `hw_ans_q_fk`
* **Reason**: Default generated FK names can exceed 64 characters in MySQL and fail during migration.
#### 2.3 Table: `questions_to_knowledge_points`
* **Action**: `RENAME FOREIGN KEY` (implemented as drop + add)
* **Details**:
* Old: `questions_to_knowledge_points_question_id_questions_id_fk` -> New: `q_kp_qid_fk`
* Old: `questions_to_knowledge_points_knowledge_point_id_knowledge_points_id_fk` -> New: `q_kp_kpid_fk`
* **Reason**: Previous names exceeded MySQL's 64-character identifier limit, causing potential migration failures in production environments.
### 3. Migration Strategy
* **Up**: Run standard Drizzle migration. The migration is resilient whether the legacy FK names exist or have already been renamed.
* **Down**: Not provided. Removing homework tables and FKs is destructive and should be handled explicitly per environment.
### 4. Impact Analysis
* **Performance**: Minimal. New indexes are scoped to common homework access patterns.
* **Data Integrity**: High. Foreign keys enforce referential integrity for homework workflow.
## v1.3.0 - Classes Domain (Teacher Class Management)
**Date:** 2025-12-31
**Migration ID:** `0003_petite_newton_destine`
**Author:** Principal Database Architect
### 1. Summary
This release introduces the core schema for teacher class management: classes, enrollments, and schedules.
### 2. Changes
#### 2.1 Tables: Classes Domain
* **Action**: `CREATE TABLE`
* **Tables**:
* `classes`
* `class_enrollments`
* `class_schedule`
* **Reason**: Support teacher-owned classes, student enrollment lists, and weekly schedules.
#### 2.2 Enum: Enrollment Status
* **Action**: `ADD ENUM`
* **Enum**: `class_enrollment_status` = (`active`, `inactive`)
* **Reason**: Provide a stable status field for filtering active enrollments.
#### 2.3 Foreign Keys & Indexes
* **Action**: `ADD FOREIGN KEY`, `CREATE INDEX`
* **Key Relationships**:
* `classes.teacher_id` -> `users.id` (cascade delete)
* `class_enrollments.class_id` -> `classes.id` (cascade delete)
* `class_enrollments.student_id` -> `users.id` (cascade delete)
* `class_schedule.class_id` -> `classes.id` (cascade delete)
* **Indexes**:
* `classes_teacher_idx`, `classes_grade_idx`
* `class_enrollments_class_idx`, `class_enrollments_student_idx`
* `class_schedule_class_idx`, `class_schedule_class_day_idx`
### 3. Migration Strategy
* **Up**: Run standard Drizzle migration. Ensure `DATABASE_URL` points to the intended schema (e.g., `next_edu`).
* **Down**: Not provided. Dropping these tables is destructive and should be handled explicitly per environment.
### 4. Impact Analysis
* **Performance**: Indexes align with common query patterns (teacher listing, enrollment filtering, per-class schedule).
* **Data Integrity**: High. Foreign keys enforce ownership and membership integrity across teacher/classes/students.
## v1.4.0 - Classes Domain Enhancements (School Name & Subject Teachers)
**Date:** 2026-01-07
**Migration ID:** `0005_add_class_school_subject_teachers`
**Author:** Frontend/Fullstack Engineering
### 1. Summary
This release extends the Classes domain to support school-level sorting and per-subject teacher assignment defaults.
### 2. Changes
#### 2.1 Table: `classes`
* **Action**: `ADD COLUMN`
* **Field**: `school_name` (varchar(255), nullable)
* **Reason**: Enable sorting/grouping by school name, then grade, then class name.
#### 2.2 Table: `class_subject_teachers`
* **Action**: `CREATE TABLE`
* **Primary Key**: (`class_id`, `subject`)
* **Columns**:
* `class_id` (varchar(128), FK -> `classes.id`, cascade delete)
* `subject` (enum: `语文/数学/英语/美术/体育/科学/社会/音乐`)
* `teacher_id` (varchar(128), FK -> `users.id`, set null on delete)
* `created_at`, `updated_at`
* **Reason**: Maintain a stable default “subject list” per class while allowing admin/teacher to assign the actual teacher per subject.
### 3. Migration Strategy
* **Up**: Run standard Drizzle migration.
* **Down**: Not provided. Dropping assignment history is destructive.
### 4. Impact Analysis
* **Performance**: Minimal. Table is small (8 rows per class) and indexed by class/teacher.
* **Data Integrity**: High. Composite PK prevents duplicates per class/subject; FKs enforce referential integrity.
## v1.4.1 - Classes Domain Enhancements (School/Grade Normalization)
**Date:** 2026-01-07
**Migration ID:** `0006_faithful_king_bedlam`
**Author:** Frontend/Fullstack Engineering
### 1. Summary
This release extends the `classes` table to support normalized school and grade references.
### 2. Changes
#### 2.1 Table: `classes`
* **Action**: `ADD COLUMN`
* **Fields**:
* `school_id` (varchar(128), nullable)
* `grade_id` (varchar(128), nullable)
* **Reason**: Enable filtering and sorting by canonical school/grade entities instead of relying on free-text fields.
### 3. Migration Strategy
* **Up**: Run standard Drizzle migration.
* **Down**: Not provided. Dropping columns is destructive.
### 4. Impact Analysis
* **Performance**: Minimal. Indexing and joins can be added as usage evolves.
* **Data Integrity**: Medium. Existing rows remain valid (nullable fields); application-level validation can enforce consistency.
## v1.5.0 - Classes Domain Feature (Invitation Code)
**Date:** 2026-01-08
**Migration ID:** `0007_add_class_invitation_code`
**Author:** Frontend/Fullstack Engineering
### 1. Summary
This release introduces a 6-digit invitation code on `classes` to support join-by-code enrollment.
### 2. Changes
#### 2.1 Table: `classes`
* **Action**: `ADD COLUMN` + `ADD UNIQUE CONSTRAINT`
* **Field**: `invitation_code` (varchar(6), nullable, unique)
* **Reason**: Allow students to enroll into a class using a short code, while ensuring uniqueness across all classes.
### 3. Migration Strategy
* **Up**: Run standard Drizzle migration.
* **Backfill**: Optional. Existing classes can keep `NULL` or be populated via application-level actions.
* **Down**: Not provided. Removing a unique constraint/column is destructive.
### 4. Impact Analysis
* **Performance**: Minimal. Uniqueness is enforced via an index.
* **Data Integrity**: High. Unique constraint prevents code collisions and simplifies server-side enrollment checks.

81
docs/db/seed-data.md Normal file
View File

@@ -0,0 +1,81 @@
# Database Seeding Strategy
**Status**: Implemented
**Script Location**: [`scripts/seed.ts`](../../scripts/seed.ts)
**Command**: `npm run db:seed`
---
## 1. Overview
The seed script is designed to populate the database with a **representative set of data** that covers all core business scenarios. It serves two purposes:
1. **Development**: Provides a consistent baseline for developers.
2. **Validation**: Verifies complex schema relationships (e.g., recursive trees, JSON structures).
## 2. Seed Data Topology
### 2.1 Identity & Access Management (IAM)
We strictly follow the RBAC model defined in `docs/architecture/001_database_schema_design.md`.
* **Roles**:
* `admin`: System Administrator.
* `teacher`: Academic Instructor.
* `student`: Learner.
* `grade_head`: Head of Grade Year (Demonstrates multi-role capability).
* **Users**:
* `admin@next-edu.com` (Role: Admin)
* `math@next-edu.com` (Role: Teacher + Grade Head)
* `alice@next-edu.com` (Role: Student)
### 2.2 Knowledge Graph
Generates a hierarchical structure to test recursive queries (`parentId`).
* **Math** (Level 0)
* └── **Algebra** (Level 1)
* └── **Linear Equations** (Level 2)
### 2.3 Question Bank
Includes rich content and nested structures.
1. **Simple Single Choice**: "What is 2 + 2?"
2. **Composite Question (Reading Comprehension)**:
* **Parent**: A reading passage.
* **Child 1**: Single Choice question about the passage.
* **Child 2**: Open-ended text question.
### 2.4 Exams
Demonstrates the new **JSON Structure** field (`exams.structure`).
* **Title**: "Algebra Mid-Term 2025"
* **Structure**:
```json
[
{
"type": "group",
"title": "Part 1: Basics",
"children": [{ "type": "question", "questionId": "...", "score": 10 }]
},
{
"type": "group",
"title": "Part 2: Reading",
"children": [{ "type": "question", "questionId": "...", "score": 20 }]
}
]
```
### 2.5 Classes / Enrollment / Schedule
Seeds the teacher class management domain.
* **Classes**: Creates at least one class owned by a teacher user.
* **Enrollments**: Links students to classes via `class_enrollments` (default status: `active`).
* **Schedule**: Populates `class_schedule` with weekday + start/end times for timetable validation.
## 3. How to Run
### Prerequisites
Ensure your `.env` file contains a valid `DATABASE_URL`.
### Execution
Run the following command in the project root:
```bash
npm run db:seed
```
### Reset Behavior
**WARNING**: The script currently performs a **TRUNCATE** on all core tables before seeding. This ensures a clean state but will **WIPE EXISTING DATA**.

View File

@@ -0,0 +1,76 @@
# Auth UI Implementation Details
**Date**: 2025-12-23
**Author**: Senior Frontend Engineer
**Module**: Auth (`src/modules/auth`)
---
## 1. 概述 (Overview)
本文档记录了登录 (`/login`) 和注册 (`/register`) 页面的前端实现细节。遵循 Vertical Slice Architecture 和 Pixel-Perfect UI 规范。
## 2. 架构设计 (Architecture)
### 2.1 目录结构
所有认证相关的业务逻辑和组件均封装在 `src/modules/auth` 下,保持了高内聚。
```
src/
├── app/
│ └── (auth)/ # 路由层 (Server Components)
│ ├── layout.tsx # 统一的 AuthLayout 容器
│ ├── login/page.tsx
│ └── register/page.tsx
├── modules/
│ └── auth/ # 业务模块
│ └── components/ # 模块私有组件
│ ├── auth-layout.tsx # 左右分屏布局
│ ├── login-form.tsx # 登录表单 (Client Component)
│ └── register-form.tsx # 注册表单 (Client Component)
```
### 2.2 渲染策略
* **Server Components**: 页面入口 (`page.tsx`) 和布局 (`layout.tsx`) 默认为服务端组件,负责元数据 (`Metadata`) 和静态结构渲染。
* **Client Components**: 表单组件 (`*-form.tsx`) 标记为 `'use client'`处理交互逻辑状态管理、表单提交、Loading 状态)。
## 3. UI/UX 细节
### 3.1 布局 (Layout)
采用 **Split Screen (分屏)** 设计:
* **左侧 (Desktop Only)**:
* 深色背景 (`bg-zinc-900`),强调品牌沉浸感。
* 包含 Logo (`Next_Edu`) 和用户证言 (`Blockquote`)。
* 使用 `hidden lg:flex` 实现响应式显隐。
* **右侧**:
* 居中对齐的表单容器。
* 移动端优先 (`w-full`),桌面端限制最大宽度 (`sm:w-[350px]`)。
### 3.2 交互 (Interactions)
* **Loading State**: 表单提交时按钮进入 `disabled` 状态并显示 `Loader2` 旋转动画。
* **Micro-animations**:
* 按钮 Hover 效果。
* 链接 Hover 下划线 (`hover:underline`).
* **Feedback**: 模拟了 3 秒的异步请求延迟,用于演示加载状态。
## 4. 错误处理 (Error Handling)
### 4.1 模块级错误边界
* **Scoped Error Boundary**: `src/app/(auth)/error.tsx` 仅处理 Auth 模块内的运行时错误。
* 显示友好的 "Authentication Error" 提示。
* 提供 "Try again" 按钮重置状态。
### 4.2 模块级 404
* **Scoped Not Found**: `src/app/(auth)/not-found.tsx` 处理 Auth 模块内的无效路径。
* 引导用户返回 `/login` 页面,防止用户迷失。
## 5. 组件复用
* 使用了 `src/shared/components/ui` 中的标准 Shadcn 组件:
* `Button`, `Input`, `Label` (新增).
* 图标库统一使用 `lucide-react`.
## 5. 后续计划 (Next Steps)
* [ ] 集成 `next-auth` (Auth.js) 进行实际的身份验证。
* [ ] 添加 Zod Schema 进行前端表单验证。
* [ ] 对接后端 API (`src/modules/auth/actions.ts`).

View File

@@ -0,0 +1,216 @@
# 教师仪表盘实现与 Hydration 修复记录
**日期**: 2025-12-23
**作者**: 资深前端工程师 (Senior Frontend Engineer)
**状态**: 已实现
## 1. 概述
本文档详细说明了教师仪表盘 (Teacher Dashboard) 的实现细节,该实现严格遵循 Next_Edu 设计系统 v1.3.0。文档还记录了开发过程中遇到的 Hydration 错误及其解决方案。
## 2. 组件架构
仪表盘采用垂直切片架构 (Vertical Slice Architecture),代码位于 `src/modules/dashboard`
### 2.1 文件结构
```
src/modules/dashboard/
└── components/
├── teacher-stats.tsx # 核心指标 (学生数, 课程数, 待批改作业)
├── teacher-schedule.tsx # 今日日程列表
├── recent-submissions.tsx # 最近的学生提交记录
└── teacher-quick-actions.tsx # 常用操作 (创建作业等)
```
### 2.2 设计系统集成
所有组件严格遵循 v1.3.0 规范:
- **排版 (Typography)**: 使用 `Geist Sans`,数据展示开启 `tabular-nums`
- **色彩 (Colors)**: 使用语义化 HSL 变量 (`muted`, `primary`, `destructive`)。
- **图标 (Icons)**: 使用 `lucide-react` (如 `Users`, `BookOpen`, `Inbox`)。
- **状态 (States)**:
- **Loading**: 使用自定义骨架屏 (Skeleton),拒绝全屏 Spinner。
- **Empty**: 使用 `EmptyState` 组件处理无数据场景。
## 3. 组件详情
### 3.1 TeacherStats (教师统计)
- **用途**: 展示教师当前状态的高层概览。
- **特性**:
- 在响应式网格中展示 4 个关键指标。
- 支持 `isLoading` 属性以渲染骨架屏。
- 使用 `Card` 组件作为容器。
### 3.2 TeacherSchedule (教师日程)
- **用途**: 展示今日课程安排。
- **特性**:
- 列出课程时间及地点。
- 使用 Badge 区分 "Lecture" (讲座) 和 "Workshop" (研讨会)。
- **空状态**: 当无日程时显示 "No Classes Today"。
### 3.3 RecentSubmissions (最近提交)
- **用途**: 追踪最新的学生活动。
- **特性**:
- 展示学生头像、姓名、作业名称及时间。
- "Late" (迟交) 状态指示器。
- **空状态**: 当列表为空时显示 "No New Submissions"。
### 3.4 EmptyState Component (空状态组件)
- **位置**: `src/shared/components/ui/empty-state.tsx`
- **规范**:
- 虚线边框容器。
- 居中图标 (Muted 背景)。
- 清晰的标题和描述。
- 可选的操作按钮插槽。
## 4. Hydration 错误修复
### 4.1 问题描述
开发过程中观察到 "Hydration failed" 错误,原因是 HTML 嵌套无效。具体来说,是 `p` 标签内包含了块级元素(或 React 在 hydration 检查期间视为块级的元素)。
### 4.2 根本原因分析
React 的 hydration 过程对 HTML 有效性要求极高。将 `div` 放入 `p` 标签中违反了 HTML5 标准,但浏览器通常会自动修正 DOM 结构,导致实际 DOM 与 React 基于虚拟 DOM 预期的结构不一致。
### 4.3 实施的修复
将所有仪表盘组件中存在风险的 `p` 标签替换为 `div` 标签,以确保嵌套结构的健壮性。
**示例 (RecentSubmissions):**
*修改前 (有风险):*
```tsx
<p className="text-sm font-medium leading-none">
{item.studentName}
</p>
```
*修改后 (安全):*
```tsx
<div className="text-sm font-medium leading-none">
{item.studentName}
</div>
```
**受影响的组件:**
1. `recent-submissions.tsx`
2. `teacher-stats.tsx`
3. `teacher-schedule.tsx`
## 5. 更新记录2026-01-04
- 教师仪表盘从 Mock Data 切换为真实数据查询:`/teacher/dashboard` 组合 `getTeacherClasses``getClassSchedule``getHomeworkSubmissions({ creatorId })` 渲染 KPI / 今日课表 / 最近提交。
- Quick Actions 落地为真实路由跳转(创建作业、查看列表等)。
- Schedule / Submissions 增加 “View All” 跳转到对应列表页(并携带筛选参数)。
---
## 6. 教师端班级管理模块(真实数据接入记录)
**日期**: 2025-12-31
**范围**: 教师端「我的班级 / 学生 / 课表」页面与 MySQL(Drizzle) 真数据对接
### 6.1 页面入口与路由
班级管理相关页面位于:
- `src/app/(dashboard)/teacher/classes/my/page.tsx`
- `src/app/(dashboard)/teacher/classes/students/page.tsx`
- `src/app/(dashboard)/teacher/classes/schedule/page.tsx`
为避免构建期/预渲染阶段访问数据库导致失败,以上页面显式启用动态渲染:
- `export const dynamic = "force-dynamic"`
### 6.2 模块结构Vertical Slice
班级模块采用垂直切片架构,代码位于 `src/modules/classes/`
```
src/modules/classes/
├── components/
│ ├── my-classes-grid.tsx
│ ├── students-filters.tsx
│ ├── students-table.tsx
│ ├── schedule-filters.tsx
│ └── schedule-view.tsx
├── data-access.ts
└── types.ts
```
其中 `data-access.ts` 负责班级、学生、课表三类查询的服务端数据读取,并作为页面层唯一的数据入口。
### 6.3 数据库表与迁移
新增班级领域表:
- `classes`
- `class_enrollments`
- `class_schedule`
对应 Drizzle Schema
- `src/shared/db/schema.ts`
- `src/shared/db/relations.ts`
对应迁移文件:
- `drizzle/0003_petite_newton_destine.sql`
外键关系(核心):
- `classes.teacher_id` -> `users.id`
- `class_enrollments.class_id` -> `classes.id`
- `class_enrollments.student_id` -> `users.id`
- `class_schedule.class_id` -> `classes.id`
索引(核心):
- `classes_teacher_idx`, `classes_grade_idx`
- `class_enrollments_class_idx`, `class_enrollments_student_idx`
- `class_schedule_class_idx`, `class_schedule_class_day_idx`
### 6.4 Seed 数据
Seed 脚本已覆盖班级相关数据,以便在开发环境快速验证页面渲染与关联关系:
- `scripts/seed.ts`
- 运行命令:`npm run db:seed`
### 6.5 开发过程中的问题与处理
- 端口占用EADDRINUSE开发服务器端口被占用时通过更换端口启动规避例如 `next dev -p <port>`)。
- Next dev 锁文件:出现 `.next/dev/lock` 无法获取锁时,需要确保只有一个 dev 实例在运行,并清理残留 lock。
- 头像资源 404移除 Header 中硬编码的本地头像资源引用,避免 `public/avatars/...` 不存在导致的 404 噪音(见 `src/modules/layout/components/site-header.tsx`)。
- 班级人数统计查询失败:`class_enrollments` 表实际列名为 `class_enrollment_status`,修复查询中引用的列名以恢复教师端班级列表渲染。
- Students 页面 key 冲突:学生列表跨班级汇总时,`<TableRow key={studentId}>` 会重复,改为使用 `classId:studentId` 作为 key。
- Build 预渲染失败(/login`LoginForm` 使用 `useSearchParams()` 获取回跳地址,需在 `/login` 页面用 `Suspense` 包裹以避免 CSR bailout 报错。
- 构建警告middlewareNext.js 16 将文件约定从 `middleware.ts` 改为 `proxy.ts`,已迁移以消除警告。
### 6.6 班级详情页(聚合视图 + Schedule Builder + Homework 统计)
**日期**: 2026-01-04
**入口**: `src/app/(dashboard)/teacher/classes/my/[id]/page.tsx`
聚合数据在单次 RSC 请求内并发获取:
- 学生:`getClassStudents({ classId })`
- 课表:`getClassSchedule({ classId })`
- 作业统计:`getClassHomeworkInsights({ classId, limit })`(包含 latest、历史列表、overallScores、以及每次作业的 scoreStatsavg/median
页面呈现:
- 顶部 KPI 卡片:学生数、课表条目数、作业数、整体 avg/median
- Latest homework目标人数、提交数、批改数、avg/median直达作业与提交列表
- Students / Schedule 预览:提供 View all 跳转到完整列表页
- Homework history 表格:支持通过 URL query `?hw=all|active|overdue` 过滤作业记录,并展示每条作业的 avg/median
课表编辑能力复用既有 Builder
- 组件:`src/modules/classes/components/schedule-view.tsx`(新增/编辑/删除课表项)
- 数据变更:`src/modules/classes/actions.ts`
### 6.7 班级邀请码6 位码)加入与管理
**日期**: 2026-01-08
**范围**: 为班级新增 6 位邀请码,支持学生通过输入邀请码加入班级;教师可查看与刷新邀请码
#### 6.7.1 数据结构
- 表:`classes`
- 字段:`invitation_code`varchar(6)unique可为空
- 迁移:`drizzle/0007_add_class_invitation_code.sql`
#### 6.7.2 教师端能力
- 在「我的班级」卡片中展示邀请码。
- 提供“刷新邀请码”操作:生成新的 6 位码并写入数据库(确保唯一性)。
#### 6.7.3 学生端能力
- 提供“通过邀请码加入班级”的入口,输入 6 位码后完成报名。
- 写库操作设计为幂等:重复提交同一个邀请码不会生成重复报名记录,已有记录会被更新为有效状态。
#### 6.7.4 Seed 支持
- `scripts/seed.ts` 为示例班级补充 `invitationCode`,便于在开发环境直接验证加入流程。

View File

@@ -0,0 +1,148 @@
# Textbooks Module Implementation Details
**Date**: 2025-12-23
**Updated**: 2025-12-31
**Author**: DevOps Architect
**Module**: Textbooks (`src/modules/textbooks`)
---
## 1. 概述 (Overview)
本文档记录了教材模块 (`Textbooks Module`) 的全栈实现细节。该模块负责教材、章节结构及知识点映射的数字化管理,采用了 **Vertical Slice Architecture****Immersive Workbench** 交互设计。
## 2. 架构设计 (Architecture)
### 2.1 目录结构
所有教材相关的业务逻辑、数据访问和组件均封装在 `src/modules/textbooks` 下,实现了高度的模块化和隔离。
```
src/
├── app/
│ └── (dashboard)/
│ └── teacher/
│ └── textbooks/ # 路由层 (Server Components)
│ ├── page.tsx # 列表页
│ ├── loading.tsx # 列表骨架屏
│ └── [id]/ # 详情页
│ ├── page.tsx
│ └── loading.tsx
│ └── student/
│ └── learning/
│ └── textbooks/ # 学生端只读阅读Server Components
│ ├── page.tsx # 列表页(复用筛选与卡片)
│ └── [id]/ # 详情页(阅读器)
│ └── page.tsx
├── modules/
│ └── textbooks/ # 业务模块
│ ├── actions.ts # Server Actions (增删改)
│ ├── data-access.ts # 数据访问层 (Mock/DB)
│ ├── types.ts # 类型定义 (Schema-aligned)
│ └── components/ # 模块私有组件
│ ├── textbook-content-layout.tsx # [核心] 三栏布局工作台
│ ├── textbook-reader.tsx # [新增] 学生端只读阅读器URL state
│ ├── chapter-sidebar-list.tsx # 递归章节树
│ ├── knowledge-point-panel.tsx # 知识点管理面板
│ ├── create-chapter-dialog.tsx # 章节创建弹窗
│ └── ... (其他交互组件)
```
### 2.2 渲染策略
* **Server Components**: 页面入口 (`page.tsx`) 负责初始数据获取 (Data Fetching),利用 `Promise.all` 并行拉取教材、章节和知识点数据,实现 "Render-as-you-fetch"。
* **Client Components**: 工作台布局 (`textbook-content-layout.tsx`) 标记为 `'use client'`,接管后续的所有交互逻辑(状态管理、局部更新、弹窗控制),提供类似 SPA 的流畅体验。
## 3. UI/UX 细节
### 3.1 布局 (Layout)
采用 **Immersive Workbench (沉浸式工作台)** 三栏设计:
* **左侧 (Navigation)**:
* 展示递归的章节树 (`Recursive Tree`)。
* 支持折叠/展开,清晰展示教材结构。
* 顶部提供 "+" 按钮快速创建新章节。
* **中间 (Content)**:
* **阅读模式**: 渲染 Markdown 格式的章节正文。
* **编辑模式**: 提供 `Textarea` 进行内容创作,支持实时保存。
* **右侧 (Context)**:
* **上下文感知**: 仅显示当前选中章节的关联知识点。
* 提供知识点的快速添加 (`Dialog`) 和删除操作。
### 3.2 交互 (Interactions)
* **Selection State**: 点击左侧章节,中间和右侧区域即时更新,无需页面跳转。
* **Optimistic UI**: 虽然使用 Server Actions但通过本地状态 (`useState`) 实现了操作的即时反馈(如保存正文后立即退出编辑模式)。
* **Feedback**: 使用 `sonner` (`toast`) 提供操作成功或失败的提示。
### 3.3 学生端阅读体验Read-Only Reader
* **两栏阅读**左侧章节树右侧正文渲染Markdown
* **URL State**:选中章节通过 `?chapterId=` 写入 URL支持刷新/分享后保持定位nuqs
* **只读边界**:学生端不暴露创建/删除/编辑/知识点管理入口,避免误用教师工作台能力。
## 4. 数据流与逻辑 (Data Flow)
### 4.1 Server Actions
所有数据变更操作均通过 `src/modules/textbooks/actions.ts` 定义的 Server Actions 处理:
* `createChapterAction`: 创建章节(支持嵌套)。
* `updateChapterContentAction`: 更新正文内容。
* `createKnowledgePointAction`: 创建知识点并自动关联当前章节。
* `deleteKnowledgePointAction`: 删除知识点并刷新详情页数据。
* `updateTextbookAction`: 更新教材元数据Title, Subject, Grade, Publisher
* `deleteTextbookAction`: 删除教材及其关联数据。
* `delete...Action`: 处理删除逻辑。
### 4.2 数据访问层 (Data Access)
* **DB Implementation**: 教材模块已接入真实数据库访问,`data-access.ts` 使用 `drizzle-orm` 直接查询并返回教材、章节、知识点数据。
* **章节树构建**: 章节采用父子关系存储,通过一次性拉取后在内存中构建嵌套树结构,避免 N+1 查询。
* **级联删除**: 删除章节时会同时删除其子章节以及关联的知识点,确保数据一致性。
* **Type Safety**: 定义严格的 TypeScript 类型(如 `Chapter`, `KnowledgePoint`, `UpdateTextbookInput`),保证数据契约与 UI 组件一致。
## 5. 组件复用
* 使用了 `src/shared/components/ui` 中的 Shadcn 组件:
* `Dialog`, `ScrollArea`, `Card`, `Button`, `Input`, `Textarea`, `Select`.
* `Collapsible` 用于实现递归章节树。
* `AlertDialog` 用于危险操作的二次确认(删除章节/删除知识点)。
* 图标库统一使用 `lucide-react`.
## 6. Settings 功能实现 (New)
* **入口**: 详情页右上角的 "Settings" 按钮。
* **组件**: `TextbookSettingsDialog`
* **功能**:
* **Edit**: 修改教材的基本信息。
* **Delete**: 提供红色删除按钮,二次确认后执行删除并跳转回列表页。
## 7. 关键更新记录 (Changelog)
### 7.1 数据与页面
* 教材模块从 Mock 切换为真实 DB新增教材/章节/知识点的数据访问与 Server Actions 刷新策略。
* 列表页支持过滤/搜索:通过 query 参数驱动,统一空状态反馈。
### 7.2 章节侧边栏与弹窗
* 修复子章节创建弹窗“闪现后消失”:改为受控 Dialog 状态管理。
* 修复移动端操作按钮不可见/被遮挡:调整布局与可见性策略,确保小屏可点。
* 删除章节使用确认弹窗并提供删除中状态。
### 7.3 Markdown 阅读体验
* 阅读模式使用 `react-markdown` 渲染章节内容,支持 GFM表格/任务列表等)。
* 启用 Typography`prose`)排版样式,使 `h1/h2/...` 在视觉上有明显层级差异。
* 修复阅读模式内容区无法滚动:为 flex 容器补齐 `min-h-0` 等必要约束。
### 7.4 知识点删除交互
* 删除知识点从浏览器 `confirm()` 升级为 `AlertDialog`
* 显示目标名称、危险样式按钮
* 删除中禁用交互并显示 loading 文案
* 删除成功后刷新页面数据
### 7.5 学生端 Textbooks 列表与阅读页New
* 新增学生端路由:
* `/student/learning/textbooks`教材列表页RSC复用筛选组件nuqs与卡片布局。
* `/student/learning/textbooks/[id]`教材阅读页RSC + client 阅读器容器),章节选择与阅读不跳页。
* 复用与适配:
* `TextbookCard` 增加可配置跳转基地址,避免学生端卡片误跳到教师端详情页。
* 新增 `TextbookReader`client用于只读阅读体验左侧章节树 + 右侧正文渲染,章节定位 URL 化(`chapterId`)。
* 质量门禁:
* 通过 `npm run lint / typecheck / build`
## 8. 后续计划 (Next Steps)
* [ ] **富文本编辑器**: 集成编辑器替换当前 Markdown Textarea提升编辑体验。
* [ ] **拖拽排序**: 实现章节树拖拽排序与持久化。
* [ ] **知识点能力增强**: 支持编辑、排序、分层(如需要)。

View File

@@ -0,0 +1,132 @@
# 题库模块实现
## 1. 概述
题库模块(`src/modules/questions`)是教师管理考试资源的核心组件,提供完整的 CRUD 能力,并支持搜索/筛选等常用管理能力。
**状态**:已实现
**日期**2025-12-23
**作者**:前端高级工程师
---
## 2. 架构与技术栈
### 2.1 垂直切片Vertical Slice架构
遵循项目的架构规范,所有与题库相关的逻辑都收敛在 `src/modules/questions` 下:
- `components/`UI 组件(表格、弹窗、筛选器)
- `actions.ts`Server Actions数据变更
- `data-access.ts`:数据库查询逻辑
- `schema.ts`Zod 校验 Schema
- `types.ts`TypeScript 类型定义
### 2.2 关键技术
- **数据表格**`@tanstack/react-table`(高性能表格渲染)
- **状态管理**`nuqs`(基于 URL Query 的筛选/搜索状态)
- **表单**`react-hook-form` + `zod` + `shadcn/ui` 表单组件
- **校验**Zod 提供严格的服务端/客户端校验
---
## 3. 组件设计
### 3.1 QuestionDataTable`question-data-table.tsx`
- **能力**:分页、排序、行选择
- **性能**:尽可能采用与 `React.memo` 兼容的写法(`useReactTable` 本身不做 memo
- **响应式**:移动端优先;复杂列支持横向滚动
### 3.2 QuestionColumns`question-columns.tsx`
用于增强单元格展示的自定义渲染:
- **题型 Badge**:不同题型的颜色/样式区分(单选、多选等)
- **难度展示**:难度标签 + 数值
- **行操作**:下拉菜单(编辑、删除、查看详情、复制 ID
### 3.3 创建/编辑弹窗(`create-question-dialog.tsx`
创建与编辑共用同一个弹窗组件:
- **动态字段**:根据题型显示/隐藏“选项”区域
- **选项编辑**:支持添加/删除选项(选择题)
- **交互反馈**:提交中 Loading 状态
### 3.4 筛选器(`question-filters.tsx`
- **URL 同步**:搜索、题型、难度等筛选条件与 URL 参数保持同步
- **无 Debounce当前**:搜索输入每次变更都会更新 URL
- **服务端筛选**:在服务端组件中通过 `getQuestions` 执行筛选查询
---
## 4. 实现细节
### 4.1 数据流
1. **读取**`page.tsx`Server Component根据 `searchParams` 拉取数据
2. **写入**:客户端组件调用 Server Actions -> `revalidatePath` -> UI 更新
3. **筛选**:用户操作 -> 更新 URL -> 服务端组件重新渲染 -> 返回新数据
### 4.2 类型安全
共享的 `Question` 类型用于保证前后端一致:
```typescript
export interface Question {
id: string
content: unknown
type: QuestionType
difficulty: number
// ... 关联字段
}
```
### 4.3 UI/UX 规范
- **空状态**:无数据时展示 `EmptyState`
- **加载态**:表格加载使用 Skeleton
- **反馈**`Sonner` toast 展示成功/失败提示
- **确认弹窗**:删除等破坏性操作使用 `AlertDialog`
---
## 5. 后续计划
- [x] 接入真实数据库(替换 Mock Data
- [ ] 为题目内容引入富文本编辑器Slate.js / Tiptap
- [ ] 增加“批量导入”能力
- [ ] 增加知识点“标签”管理能力
---
## 6. 实现更新2025-12-30
### 6.1 教师路由与加载态
- 实现 `/teacher/questions` 页面Suspense + 空状态)
- 新增路由级加载 UI`/teacher/questions/loading.tsx`
### 6.2 Content JSON 约定
为与考试组卷/预览组件保持一致,`questions.content` 采用最小 JSON 结构:
```typescript
type ChoiceOption = {
id: string
text: string
isCorrect?: boolean
}
type QuestionContent = {
text: string
options?: ChoiceOption[]
}
```
### 6.3 数据访问层(`getQuestions`
- 新增服务端筛选:`q`content LIKE`type``difficulty``knowledgePointId`
- 默认仅返回根题(`parentId IS NULL`),除非显式按 `ids` 查询
- 返回 `{ data, meta }`(包含分页统计),并为 UI 映射关联数据
### 6.4 Server ActionsCRUD
- `createNestedQuestion` 支持 FormData字段 `json`)与递归 `subQuestions`
- `updateQuestionAction` 更新题目与知识点关联
- `deleteQuestionAction` 递归删除子题
- 所有变更都会对 `/teacher/questions` 做缓存再验证
### 6.5 UI 集成
- `CreateQuestionDialog` 提交 `QuestionContent` JSON并支持选择题正确答案勾选
- `QuestionActions` 在编辑/删除后刷新列表
- 表格内容预览优先展示 `content.text`
### 6.6 校验
- `npm run lint`0 errors仓库其他位置仍存在 warnings
- `npm run typecheck`:通过

View File

@@ -0,0 +1,161 @@
# 考试模块实现设计文档
## 1. 概述
考试模块用于教师侧的“试卷制作与管理”,覆盖创建考试、组卷(支持嵌套分组)、发布/归档等流程。
**说明(合并调整)**与“作业Homework”模块合并后考试模块不再提供“阅卷/评分grading”与提交流转教师批改统一在 Homework 的 submissions 中完成。
## 2. 数据架构
### 2.1 核心实体
- **Exams**: 根实体,包含元数据(标题、时间安排)和结构信息。
- **ExamQuestions**: 关系链接,用于查询题目的使用情况(扁平化表示)。
- **ExamSubmissions**: (历史/保留)学生的考试尝试记录;当前 UI/路由不再使用。
- **SubmissionAnswers**: (历史/保留)链接到特定题目的单个答案;当前 UI/路由不再使用。
### 2.2 `structure` 字段
为了支持层级布局(如章节/分组),我们在 `exams` 表中引入了一个 JSON 列 `structure`。它作为“布局/呈现层”的单一事实来源Source of Truth用于渲染分组与排序`exam_questions` 仍然承担题目关联、外键完整性与索引查询职责。
**JSON Schema:**
```typescript
type ExamNode = {
id: string; // 节点的唯一 UUID
type: 'group' | 'question';
title?: string; // 'group' 类型必填
questionId?: string; // 'question' 类型必填
score?: number; // 在此考试上下文中的分值
children?: ExamNode[]; // 'group' 类型的递归子节点
}
```
### 2.3 `description` 元数据字段(当前实现)
当前版本将部分元数据(如 `subject/grade/difficulty/totalScore/durationMin/tags/scheduledAt`)以 JSON 字符串形式存入 `exams.description`,并在数据访问层解析后提供给列表页展示与筛选。
## 3. 组件架构
### 3.1 组卷(构建器)
位于 `/teacher/exams/[id]/build`
- **`ExamAssembly` (客户端组件)**
- 管理 `structure` 状态树。
- 处理“添加题目”、“添加章节”、“移除”和“重新排序”操作。
- 实时计算总分和进度。
- **`StructureEditor` (客户端组件)**
- 基于 `@dnd-kit` 构建。
- 提供嵌套的可排序Sortable界面。
- 支持在组内/组间拖拽题目(当前优化为 2 层深度)。
- **`QuestionBankList`**
- 可搜索/筛选的可用题目列表。
- “添加”操作将节点追加到结构树中。
### 3.2 阅卷界面(已下线)
原阅卷路由 `/teacher/exams/grading``/teacher/exams/grading/[submissionId]` 已移除业务能力并重定向到 Homework
- `/teacher/exams/grading*``/teacher/homework/submissions`
### 3.3 列表页All Exams
位于 `/teacher/exams/all`
- **Page (RSC)**: 负责解析 query`q/status/difficulty`)并调用数据访问层获取 exams。
- **`ExamFilters` (客户端组件)**: 使用 URL query 驱动筛选条件。
- **`ExamDataTable` (客户端组件)**: 基于 TanStack Table 渲染列表,并在 actions 列中渲染 `ExamActions`
## 4. 关键工作流
### 4.1 创建与构建考试
1. **创建**: 教师输入基本信息(标题、科目)。数据库创建记录(草稿状态)。
2. **构建**:
- 教师打开“构建”页面。
- 服务器从数据库 Hydrate注水`initialStructure`
- 教师从题库拖拽题目到结构树。
- 教师创建章节(分组)。
- **保存**: 同时提交 `questionsJson`(扁平化,用于索引)和 `structureJson`(树状,用于布局)到 `updateExamAction`
3. **发布**: 状态变更为 `published`
### 4.2 阅卷/批改流程(迁移到 Homework
教师批改统一在 Homework 模块完成:
- 提交列表:`/teacher/homework/submissions`
- 批改页:`/teacher/homework/submissions/[submissionId]`
### 4.3 考试管理All Exams Actions
位于 `/teacher/exams/all` 的表格行级菜单。
1. **Publish / Move to Draft / Archive**
- 客户端组件 `ExamActions` 触发 `updateExamAction`,传入 `examId` 与目标 `status`
- 服务器更新 `exams.status`,并对 `/teacher/exams/all` 执行缓存再验证。
2. **Duplicate**
- 客户端组件 `ExamActions` 触发 `duplicateExamAction`,传入 `examId`
- 服务器复制 `exams` 记录并复制关联的 `exam_questions`
- 新考试以 `draft` 状态创建,复制结构(`exams.structure`),并清空排期信息(`startTime/endTime`,以及 description 中的 `scheduledAt`)。
- 成功后跳转到新考试的构建页 `/teacher/exams/[id]/build`
3. **Delete**
- 客户端组件 `ExamActions` 触发 `deleteExamAction`,传入 `examId`
- 服务器删除 `exams` 记录;相关表(如 `exam_questions``exam_submissions``submission_answers`)通过外键级联删除。
- 成功后刷新列表。
4. **Edit / Build**
- 当前统一跳转到 `/teacher/exams/[id]/build`
## 5. 技术决策
### 5.1 混合存储策略
我们在存储考试题目时采用了 **混合方法**
- **关系型 (`exam_questions`)**: 用于“查找所有使用题目 X 的考试”查询和外键约束。
- **文档型 (`exams.structure`)**: 用于渲染嵌套 UI 和保留任意排序/分组。
*理由*: 这结合了 SQL 的完整性和 NoSQL 在 UI 布局上的灵活性。
### 5.2 拖拽功能
使用 `@dnd-kit` 代替旧库,因为:
- 更好的无障碍支持(键盘支持)。
- 模块化架构Sensors, Modifiers
- 面向未来(现代 React Hooks 模式)。
### 5.3 Server Actions
所有变更操作(保存草稿、发布、复制、删除)均使用 Next.js Server Actions以确保类型安全并自动重新验证缓存。
已落地的 Server Actions
- `createExamAction`
- `updateExamAction`
- `duplicateExamAction`
- `deleteExamAction`
## 6. 接口与数据影响
### 6.1 `updateExamAction`
- **入参FormData**: `examId`(必填),`status`可选draft/published/archived`questionsJson`(可选),`structureJson`(可选)
- **行为**:
- 若传入 `questionsJson`:先清空 `exam_questions` 再批量写入,`order` 由数组顺序决定;未传入则不触碰 `exam_questions`
- 若传入 `structureJson`:写入 `exams.structure`;未传入则不更新该字段
- 若传入 `status`:写入 `exams.status`
- **缓存**: `revalidatePath("/teacher/exams/all")`
### 6.2 `duplicateExamAction`
- **入参FormData**: `examId`(必填)
- **行为**:
- 复制一条 `exams`(新 id、新 title追加 “(Copy)”、`status` 强制为 `draft`
- `startTime/endTime` 置空;同时尝试从 `description` JSON 中移除 `scheduledAt`
- 复制 `exam_questions`(保留 questionId/score/order
- 复制 `exams.structure`
- **缓存**: `revalidatePath("/teacher/exams/all")`
### 6.3 `deleteExamAction`
- **入参FormData**: `examId`(必填)
- **行为**:
- 删除 `exams` 记录
- 依赖外键级联清理关联数据:`exam_questions``exam_submissions``submission_answers`
- **缓存**:
- `revalidatePath("/teacher/exams/all")`
### 6.4 数据访问层Data Access
位于 `src/modules/exams/data-access.ts`,对外提供与页面/组件解耦的查询函数。
- `getExams(params)`: 支持按 `q/status` 在数据库侧过滤;`difficulty` 因当前存储在 `description` JSON 中,采用内存过滤
- `getExamById(id)`: 查询 exam 及其 `exam_questions`,并返回 `structure` 以用于构建器 Hydrate
## 7. 变更记录(合并 Homework
**日期**2025-12-31
- 移除 Exams grading 入口与实现:删除阅卷 UI、server action、data-access 查询。
- Exams grading 路由改为重定向到 Homework submissions。

View File

@@ -0,0 +1,270 @@
# 作业模块实现设计文档Homework Module
**日期**: 2025-12-31
**模块**: Homework (`src/modules/homework`)
---
## 1. 概述
作业模块提供“由试卷派发作业”的完整生命周期:
- 教师从已存在的 Exam 派发 Homework Assignment冻结当时的结构与题目引用
- 指定作业目标学生Targets
- 学生开始一次作答Submission保存答案Answers并最终提交
- 教师在提交列表中查看并批改(按题给分/反馈,汇总总分)
核心目标是:在不破坏 Exam 本体数据的前提下,为作业提供可追溯、可批改、可统计的独立域模型。
**说明(合并调整)**:教师端“阅卷/批改”统一通过 Homework submissions 完成,`/teacher/exams/grading*` 相关路由已重定向到 `/teacher/homework/submissions`
---
## 2. 数据架构
### 2.1 核心实体
- `homework_assignments`: 作业实例(从 exam 派生)
- `homework_assignment_questions`: 作业与题目关系score/order
- `homework_assignment_targets`: 作业目标学生列表
- `homework_submissions`: 学生作业尝试attempt_no/status/时间/是否迟交)
- `homework_answers`: 每题答案answer_content/score/feedback
数据库变更记录见:[schema-changelog.md](file:///c:/Users/xiner/Desktop/CICD/docs/db/schema-changelog.md#L34-L77)
### 2.2 设计要点:冻结 Exam → Homework Assignment
- `homework_assignments.source_exam_id` 保存来源 Exam
- `homework_assignments.structure` 在 publish 时复制 `exams.structure`(冻结当时的呈现结构)
- 题目关联使用 `homework_assignment_questions`(仍引用 `questions` 表,作业侧记录分值与顺序)
---
## 3. 路由与页面
### 3.1 教师端
- `/teacher/homework/assignments`: 作业列表
实现:[assignments/page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/page.tsx)
- `/teacher/homework/assignments/create`: 从 Exam 派发作业
实现:[create/page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/create/page.tsx)
- `/teacher/homework/assignments/[id]`: 作业详情
实现:[[id]/page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/%5Bid%5D/page.tsx)
- `/teacher/homework/assignments/[id]/submissions`: 作业提交列表(按作业筛选)
实现:[[id]/submissions/page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/%5Bid%5D/submissions/page.tsx)
- `/teacher/homework/submissions`: 全部提交列表
实现:[submissions/page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/teacher/homework/submissions/page.tsx)
- `/teacher/homework/submissions/[submissionId]`: 批改页
实现:[[submissionId]/page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/teacher/homework/submissions/%5BsubmissionId%5D/page.tsx)
关联重定向:
- `/teacher/exams/grading``/teacher/homework/submissions`
实现:[grading/page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/teacher/exams/grading/page.tsx)
- `/teacher/exams/grading/[submissionId]``/teacher/homework/submissions`
实现:[grading/[submissionId]/page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/teacher/exams/grading/%5BsubmissionId%5D/page.tsx)
### 3.2 学生端
- `/student/learning/assignments`: 作业列表
实现:[page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/student/learning/assignments/page.tsx)
- `/student/learning/assignments/[assignmentId]`: 作答页(开始/保存/提交)
实现:[[assignmentId]/page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/student/learning/assignments/%5BassignmentId%5D/page.tsx)
---
## 4. 数据访问层Data Access
数据访问位于:[data-access.ts](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/data-access.ts)
### 4.1 教师侧查询
- `getHomeworkAssignments`:作业列表(可按 creatorId/ids
- `getHomeworkAssignmentById`:作业详情(含目标人数、提交数统计)
- `getHomeworkSubmissions`:提交列表(可按 assignmentId/classId/creatorId
- `getHomeworkSubmissionDetails`:提交详情(题目内容 + 学生答案 + 分值/顺序)
### 4.2 学生侧查询
- `getStudentHomeworkAssignments(studentId)`:只返回“已派发给该学生、已发布、且到达 availableAt”的作业
- `getStudentHomeworkTakeData(assignmentId, studentId)`进入作答页所需数据assignment + 当前/最近 submission + 题目列表 + 已保存答案)
### 4.3 开发模式用户选择Demo
为了在未接入真实 Auth 的情况下可演示学生端页面,提供:
- `getDemoStudentUser()`:优先选取最早创建的 student若无 student则退化到任意用户
---
## 5. Server Actions
实现位于:[actions.ts](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/actions.ts)
### 5.1 教师侧
- `createHomeworkAssignmentAction`:从 exam 创建 assignment可写入 targets可选择 publish默认 true
- `gradeHomeworkSubmissionAction`:按题写入 score/feedback并汇总写入 submission.score 与 status=graded
### 5.2 学生侧
- `startHomeworkSubmissionAction`:创建一次 submissionattemptNo + startedAt并校验
- assignment 已发布
- student 在 targets 中
- availableAt 已到
- 未超过 maxAttempts
- `saveHomeworkAnswerAction`:保存/更新某题答案upsert 到 homework_answers
- `submitHomeworkAction`:提交作业(校验 dueAt/lateDueAt/allowLate写入 submittedAt/isLate/status=submitted
---
## 6. UI 组件
### 6.1 教师批改视图
- [HomeworkGradingView](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/components/homework-grading-view.tsx)
- 左侧:学生答案只读展示
- 右侧:按题录入分数与反馈,并提交批改
### 6.2 学生作答视图
- [HomeworkTakeView](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/components/homework-take-view.tsx)
- Start开始一次作答
- Save按题保存
- Submit提交提交前会先保存当前题目答案
- 题型支持:`text` / `judgment` / `single_choice` / `multiple_choice`
题目 content 约定与题库一致:`{ text, options?: [{ id, text, isCorrect? }] }`(作答页仅消费 `id/text`)。
---
## 7. 类型定义
类型位于:[types.ts](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/types.ts)
- 教师侧:`HomeworkAssignmentListItem` / `HomeworkSubmissionDetails`
- 学生侧:`StudentHomeworkAssignmentListItem` / `StudentHomeworkTakeData`
---
## 8. 校验
- `npm run typecheck`: 通过
- `npm run lint`: 0 errors仓库其他位置存在 warnings与本模块新增功能无直接关联
---
## 9. 部署与环境变量CI/CD
### 9.1 本地开发
- 本地开发使用项目根目录的 `.env` 提供 `DATABASE_URL`
- `.env` 仅用于本机开发,不应写入真实生产库凭据
### 9.2 CI 构建与部署Gitea
工作流位于:[ci.yml](file:///c:/Users/xiner/Desktop/CICD/.gitea/workflows/ci.yml)
- 构建阶段(`npm run build`)不依赖数据库连接:作业相关页面在构建时不会静态预渲染执行查库
- 部署阶段通过 `docker run -e DATABASE_URL=...` 在运行时注入数据库连接串
- 需要在 Gitea 仓库 Secrets 配置 `DATABASE_URL`(生产环境 MySQL 连接串)
- CI 中关闭 Next.js telemetry设置 `NEXT_TELEMETRY_DISABLED=1`
### 9.3 Next.js 渲染策略(避免 build 阶段查库)
作业模块相关页面在渲染时会进行数据库查询,因此显式标记为动态渲染以避免构建期预渲染触发数据库连接:
- 教师端作业列表:[assignments/page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/page.tsx)
---
## 10. 实现更新2026-01-05
### 10.1 教师端作业详情页组件化(按 Vertical Slice 拆分)
`/teacher/homework/assignments/[id]` 页面调整为“只负责组装”,把可复用展示逻辑下沉到模块内组件:
- 页面组装:[page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/%5Bid%5D/page.tsx)
- 题目错误概览卡片overview[homework-assignment-question-error-overview-card.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/components/homework-assignment-question-error-overview-card.tsx)
- 题目错误明细卡片details[homework-assignment-question-error-details-card.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/components/homework-assignment-question-error-details-card.tsx)
- 试卷预览/错题工作台容器卡片:[homework-assignment-exam-content-card.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/components/homework-assignment-exam-content-card.tsx)
### 10.2 题目点击联动:试卷预览 ↔ 错题详情
在“试卷预览”中点击题目后,右侧联动展示该题的统计与错答列表(按学生逐条展示,不做合并):
- 工作台(选择题目、拼装左右面板):[homework-assignment-exam-error-explorer.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/components/homework-assignment-exam-error-explorer.tsx)
- 试卷预览面板(可选中题目):[homework-assignment-exam-preview-pane.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/components/homework-assignment-exam-preview-pane.tsx)
- 错题详情面板(错误人数/错误率/错答列表):[homework-assignment-question-error-detail-panel.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/components/homework-assignment-question-error-detail-panel.tsx)
### 10.3 统计数据增强:返回逐学生错答
为满足“错答列表逐条展示学生姓名 + 答案”的需求,作业统计查询返回每题的错答明细(包含学生信息):
- 数据访问:[getHomeworkAssignmentAnalytics](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/data-access.ts)
- 类型定义:[types.ts](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/types.ts)
### 10.4 加载优化Client Wrapper 动态分包
由于 `next/dynamic({ ssr: false })` 不能在 Server Component 内使用,工作台动态加载通过 Client wrapper 进行隔离:
- Client wrapper[homework-assignment-exam-error-explorer-lazy.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/components/homework-assignment-exam-error-explorer-lazy.tsx)
- 入口卡片Server Component渲染 wrapper[homework-assignment-exam-content-card.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/components/homework-assignment-exam-content-card.tsx)
### 10.5 校验
- `npm run lint`: 通过
- `npm run typecheck`: 通过
- `npm run build`: 通过
---
## 11. 学生成绩图表与排名2026-01-06
### 11.1 目标
在学生主页Dashboard展示
- 最近已批改作业的成绩趋势(百分比折线)
- 最近若干次已批改作业明细(标题、得分、时间)
- 班级排名(基于班级内作业总体得分百分比)
### 11.2 数据访问与计算口径
数据由 Homework 模块统一提供聚合查询,避免页面层拼 SQL
- 新增查询:[getStudentDashboardGrades](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/data-access.ts)
- `trend`:取该学生所有 `graded` 提交中“每个 assignment 最新一次”的集合,按时间升序取最近 10 个
- `recent`:对 `trend` 再按时间降序取最近 5 条,用于表格展示
- `maxScore`:通过 `homework_assignment_questions` 汇总每个 assignment 的总分SUM(score)
- `percentage``score / maxScore * 100`
- `ranking`
- 班级选择:取该学生最早创建的一条 active enrollment 作为当前班级
- 班级作业集合:班级内所有学生的 targets 合并得到 assignment 集合
- 计分口径:班级内“每个学生 × 每个 assignment”取最新一次 graded 提交,累加得分与满分,得到总体百分比
- 排名:按总体百分比降序排序(百分比相同按 studentId 作为稳定排序因子)
### 11.3 类型定义
为 Dashboard 聚合数据提供显式类型:
- [types.ts](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/types.ts)
- `StudentHomeworkScoreAnalytics`
- `StudentRanking`
- `StudentDashboardGradeProps`
### 11.4 页面与组件接入
- 学生主页页面负责“取数 + 计算基础计数 + 传参”:
- [student/dashboard/page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/student/dashboard/page.tsx)
- 取数:`getStudentDashboardGrades(student.id)`
- 传入:`<StudentDashboard grades={grades} />`
- 展示组件负责渲染卡片:
- [student-view.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/dashboard/components/student-view.tsx)
- 趋势图:使用内联 `svg polyline` 渲染折线,避免引入额外图表依赖
### 11.5 校验
- `npm run lint`: 通过
- `npm run typecheck`: 通过
- `npm run build`: 通过

View File

@@ -0,0 +1,164 @@
# 学校基础数据模块School实现文档与更新记录
**日期**: 2026-01-07
**作者**: Frontend Team
**状态**: 已实现
## 1. 范围
本文档覆盖管理端「School」域的基础数据维护页面Schools / Departments / Academic Year / Grades并记录相关实现约束与关键更新遵循 [003_frontend_engineering_standards.md](file:///c:/Users/xiner/Desktop/CICD/docs/architecture/003_frontend_engineering_standards.md) 的工程规范Vertical Slice、Server/Client 边界、质量门禁)。
## 2. 路由入口Admin
School 域路由位于 `src/app/(dashboard)/admin/school/*`,均显式声明 `export const dynamic = "force-dynamic"` 以避免构建期预渲染触发数据库访问。
- `/admin/school`:入口重定向到 Classes当前落点不在 `school` 模块内)
实现:[page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/admin/school/page.tsx)
- `/admin/school/schools`:学校维护(增删改)
实现:[page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/admin/school/schools/page.tsx)
- `/admin/school/departments`:部门维护(增删改)
实现:[page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/admin/school/departments/page.tsx)
- `/admin/school/academic-year`:学年维护(增删改 + 设为当前学年)
实现:[page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/admin/school/academic-year/page.tsx)
- `/admin/school/grades`:年级维护(增删改 + 指派年级组长/教研组长)
实现:[page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/admin/school/grades/page.tsx)
- `/admin/school/grades/insights`:年级维度作业统计(跨班级聚合)
实现:[page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/admin/school/grades/insights/page.tsx)
## 3. 模块结构Vertical Slice
School 模块位于 `src/modules/school`
```
src/modules/school/
├── components/
│ ├── schools-view.tsx
│ ├── departments-view.tsx
│ ├── academic-year-view.tsx
│ └── grades-view.tsx
├── actions.ts
├── data-access.ts
├── schema.ts
└── types.ts
```
边界约束:
- `data-access.ts` 包含 `import "server-only"`,仅用于服务端查询与 DTO 组装。
- `actions.ts` 包含 `"use server"`,写操作统一通过 Server Actions 并 `revalidatePath`
- `components/*` 为 Client 交互层表单、Dialog、筛选、行级操作调用 Server Actions 并用 `sonner` toast 反馈。
## 4. 数据访问data-access.ts
实现:[data-access.ts](file:///c:/Users/xiner/Desktop/CICD/src/modules/school/data-access.ts)
- `getSchools(): Promise<SchoolListItem[]>`
- `getDepartments(): Promise<DepartmentListItem[]>`
- `getAcademicYears(): Promise<AcademicYearListItem[]>`
- `getGrades(): Promise<GradeListItem[]>`
- join `schools` 获取 `school.name`
- 收集 `gradeHeadId/teachingHeadId` 并批量查询 `users` 以组装 `StaffOption`
- `getStaffOptions(): Promise<StaffOption[]>`
- 角色过滤 `teacher/admin`
- 排序 `name/email`,用于 Select 列表可用性
- `getGradesForStaff(staffId: string): Promise<GradeListItem[]>`
- 用于按负责人(年级组长/教研组长)反查关联年级
返回 DTO 类型定义位于:[types.ts](file:///c:/Users/xiner/Desktop/CICD/src/modules/school/types.ts)
## 5. 写操作actions.ts
实现:[actions.ts](file:///c:/Users/xiner/Desktop/CICD/src/modules/school/actions.ts)
通用约束:
- 输入校验:统一使用 [schema.ts](file:///c:/Users/xiner/Desktop/CICD/src/modules/school/schema.ts) 的 Zod schema
- 返回结构:统一使用 [ActionState](file:///c:/Users/xiner/Desktop/CICD/src/shared/types/action-state.ts)
- 刷新策略:对目标页面路径执行 `revalidatePath`
Departments
- `createDepartmentAction(formData)`
- `updateDepartmentAction(departmentId, formData)`
- `deleteDepartmentAction(departmentId)`
Academic Year
- `createAcademicYearAction(formData)`
- `updateAcademicYearAction(academicYearId, formData)`
- `deleteAcademicYearAction(academicYearId)`
-`isActive=true` 时,通过事务把其它学年置为非激活,保证唯一激活学年
Schools
- `createSchoolAction(formData)`
- `updateSchoolAction(schoolId, formData)`
- `deleteSchoolAction(schoolId)`
- 删除后会同时刷新 `/admin/school/schools``/admin/school/grades`
Grades
- `createGradeAction(formData)`
- `updateGradeAction(gradeId, formData)`
- `deleteGradeAction(gradeId)`
## 6. UI 组件components/*
Schools
- 实现:[schools-view.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/school/components/schools-view.tsx)
- 交互:列表 + Dialog 表单(新增/编辑)+ 删除确认AlertDialog
Departments
- 实现:[departments-view.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/school/components/departments-view.tsx)
- 交互:列表 + Dialog 表单(新增/编辑)+ 删除确认AlertDialog
Academic Year
- 实现:[academic-year-view.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/school/components/academic-year-view.tsx)
- 交互:列表 + Dialog 表单(新增/编辑)+ 删除确认AlertDialog+ 设为当前学年isActive
Grades
- 实现:[grades-view.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/school/components/grades-view.tsx)
- 交互:列表展示 + URL 驱动筛选(搜索/学校/负责人/排序)+ Dialog 表单(新增/编辑)+ 删除确认AlertDialog
- 负责人指派:
- 年级组长gradeHeadId
- 教研组长teachingHeadId
## 7. 关键交互与规则Grades
页面入口RSC 组装)在服务端并发拉取三类数据:
- 年级列表:`getGrades()`
- 学校选项:`getSchools()`
- 负责人候选:`getStaffOptions()`
实现:[page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/admin/school/grades/page.tsx)
### 7.1 URL State筛选/排序)
Grades 列表页的筛选状态 URL 化(`nuqs`
- `q`:关键字(匹配 grade/school
- `school`:学校过滤(`all` 或具体 schoolId
- `head`:负责人过滤(全部 / 两者缺失 / 缺年级组长 / 缺教研组长)
- `sort`:排序(默认/名称/更新时间等)
实现:[grades-view.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/school/components/grades-view.tsx)
### 7.2 表单校验
Grades 的新增/编辑表单在客户端做轻量校验:
- 必填:`schoolId``name`
- `order`:非负整数
- 去重:同一学校下年级名称不允许重复(忽略大小写 + 规范化空格)
说明:
- 服务端写入前仍会经过 `UpsertGradeSchema` 校验schema.ts避免仅依赖客户端校验。
### 7.3 负责人选择Radix Select
Radix Select 约束:`SelectItem``value` 不能为 `""`(空字符串),否则会触发运行时错误。
当前实现策略:
- UI 中 “未设置” 选项使用占位值 `__none__`
-`onValueChange` 中将 `__none__` 映射回 `""` 存入本地表单 state
- 提交时依旧传递空字符串,由 `UpsertGradeSchema` 将其归一为 `null`
实现:[grades-view.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/school/components/grades-view.tsx)
## 2. 更新记录2026-01-07
- 修复 Add Grades 弹窗报错:将 4 处 `<SelectItem value=\"\">` 替换为占位值 `__none__`,并在 `onValueChange` 中映射回 `\"\"`,保持“可清空选择/显示 placeholder”的行为不变。
- 修复新建年级按钮不可用:创建/编辑表单在状态变化时触发实时校验更新,避免校验状态滞后导致提交被禁用。
- 质量门禁:本地通过 `npm run lint``npm run typecheck`

View File

@@ -0,0 +1,258 @@
# Next_Edu Design System Specs
**Version**: 1.4.0 (Updated)
**Status**: ACTIVE
**Role**: Chief Creative Director
**Philosophy**: "Data as Art" - Clean, Minimalist, Information-Dense.
---
## 1. 核心理念 (Core Philosophy)
Next_Edu 旨在对抗教育系统常见的信息过载。我们的设计风格深受 **International Typographic Style (国际主义设计风格)** 影响。
* **Precision (精准)**: 每一个像素的留白都有其目的。
* **Clarity (清晰)**: 通过排版和对比度区分层级,而非装饰性的色块。
* **Efficiency (效率)**: 专为高密度数据操作优化,减少视觉噪音。
---
## 2. 视觉基础 (Visual Foundation)
### 2.1 色彩系统 (Color System)
我们放弃 Hex 直选,全面采用 **HSL 语义化变量** 以支持完美的多主题适配。
#### **Base (基调)**
* **Neutral**: Zinc (锌色). 冷峻、纯净,无偏色。
* **Brand**: Deep Indigo (深靛蓝).
* `Primary`: 专业、权威,避免幼稚的高饱和蓝。
* 语义: `hsl(var(--primary))`
#### **Functional (功能色)**
| 语义 | 色系 | 用途 |
| :--- | :--- | :--- |
| **Destructive** | Red | 删除、危险操作、系统错误 |
| **Warning** | Amber | 需注意的状态、非阻断性警告 |
| **Success** | Emerald | 操作成功、状态正常 |
| **Info** | Blue | 一般性提示、帮助信息 |
#### **Surface Hierarchy (层级)**
1. **Background**: 应用底层背景。
2. **Card**: 内容承载容器,轻微提升层级。
3. **Popover**: 悬浮层、下拉菜单,最高层级。
4. **Muted**: 用于次级信息或禁用状态背景。
### 2.2 排版 (Typography)
* **Font Family**: `Geist Sans` > `Inter` > `System UI`.
* *Requirement*: 必须开启 `tabular-nums` (等宽数字) 特性,确保表格数据对齐。
* **Scale**:
* **Base Body**: 14px (0.875rem) - 提升信息密度。
* **H1 (Page Title)**: 24px, Tracking -0.02em, Weight 600.
* **H2 (Section Title)**: 20px, Tracking -0.01em, Weight 600.
* **H3 (Card Title)**: 16px, Weight 600.
* **Tiny/Caption**: 12px, Text-Muted-Foreground.
### 2.3 质感 (Look & Feel)
* **Borders**: `1px solid var(--border)`. 界面骨架,以此分割区域而非背景色块。
* **Radius**:
* `sm` (4px): Badges, Checkboxes.
* `md` (8px): **Default**. Buttons, Inputs, Cards.
* `lg` (12px): Modals, Dialogs.
* **Shadows**:
* Default: None (Flat).
* Hover: `shadow-sm` (仅用于可交互元素).
* Dropdown/Popover: `shadow-md`.
* *Ban*: 禁止使用大面积弥散阴影。
---
## 3. 核心布局 (App Shell)
### 3.1 架构 (Architecture)
我们采用了 `SidebarProvider` + `AppSidebar` 的组合模式,确保了布局的灵活性和移动端的完美适配。
* **Provider**: `SidebarProvider` (src/modules/layout/components/sidebar-provider.tsx)
* 管理侧边栏状态 (`expanded`, `isMobile`).
* 负责在移动端渲染 Sheet (Drawer)。
* 负责在桌面端渲染 Sticky Sidebar。
* **Key Prop**: `sidebar` (显式传递侧边栏组件)。
### 3.2 布局结构
```
+-------------------------------------------------------+
| Sidebar | Header (Sticky) |
| |-------------------------------------------|
| (Collap- | Main Content |
| sible) | |
| | +-------------------------------------+ |
| | | Card | |
| | | +---------------------------------+ | |
| | | | Data Table | | |
| | | +---------------------------------+ | |
| | +-------------------------------------+ |
| | |
+-------------------------------------------------------+
```
### 3.3 详细规范
#### **Sidebar (侧边栏)**
* **Width**: Expanded `260px` | Collapsed `64px` | Mobile `Sheet (Drawer)`.
* **Behavior**:
* Desktop: 固定左侧,支持折叠。
* Mobile: 默认隐藏,点击汉堡菜单从左侧滑出。
* **Navigation Item**:
* Height: `36px` (Compact).
* State:
* `Inactive`: `text-muted-foreground hover:text-foreground`.
* `Active`: `bg-sidebar-accent text-sidebar-accent-foreground font-medium`.
#### **Header (顶栏)**
* **Height**: `64px` (h-16).
* **Layout**: `flex items-center justify-between px-6 border-b`.
* **Components**:
1. **Breadcrumb**: 显示当前路径,层级清晰。
2. **Global Search**: `Cmd+K` 触发,居中或靠右。
3. **User Nav**: 头像 + 下拉菜单。
#### **Main Content (内容区)**
* **Padding**: `p-6` (Desktop) / `p-4` (Mobile).
* **Max Width**: `max-w-[1600px]` (默认) 或 `w-full` (针对超宽报表)。
---
## 4. 导航与角色系统 (Navigation & Roles)
Next_Edu 支持多角色Multi-Tenant / Role-Based导航系统。
### 4.1 配置文件
导航结构已从 UI 组件中解耦,统一配置在:
`src/modules/layout/config/navigation.ts`
### 4.2 支持的角色 (Roles)
系统内置支持以下角色,每个角色拥有独立的侧边栏菜单结构:
* **Admin**: 系统管理员,拥有所有管理权限 (School Management, User Management, Finance)。
* **Teacher**: 教师,关注班级管理 (My Classes)、成绩录入 (Gradebook) 和日程。
* **Student**: 学生,关注课程学习 (My Learning) 和作业提交。
* **Parent**: 家长,关注子女动态和学费缴纳。
### 4.3 开发与调试
* **View As (Dev Mode)**: 在开发环境下,侧边栏顶部提供 "View As" 下拉菜单,允许开发者实时切换角色视角,预览不同角色的导航结构。
* **Implementation**: `AppSidebar` 组件通过读取 `NAV_CONFIG[currentRole]` 动态渲染菜单项。
---
## 5. 错误处理与边界情况 (Error Handling & Boundaries)
系统必须优雅地处理错误和边缘情况,避免白屏或无反馈。
### 5.1 全局错误边界 (Global Error Boundary)
* **Scope**: 捕获渲染期间的未处理异常。
* **UI**: 显示友好的错误页面(非技术堆栈信息),提供 "Try Again" 按钮重置状态。
* **Implementation**: 使用 React `ErrorBoundary` 或 Next.js `error.tsx`
### 5.2 404 Not Found
* **Design**: 必须保留 App Shell (Sidebar + Header),仅在 Main Content 区域显示 404 提示。
* **Content**: "Page not found" 文案 + 返回 Dashboard 的主操作按钮。
### 5.3 空状态 (Empty States)
当列表或表格无数据时,**严禁**只显示空白。
* **Component**: `EmptyState`
* **Composition**:
1. **Icon**: 线性风格图标 (muted foreground).
2. **Title**: 简短说明 (e.g., "No students found").
3. **Description**: 解释原因或下一步操作 (e.g., "Add a student to get started").
4. **Action**: (可选) "Create New" 按钮。
### 5.4 加载状态 (Loading States)
* **Initial Load**: 使用 `Skeleton` 骨架屏,模拟内容布局,避免 CLS (Content Layout Shift)。禁止使用全屏 Spinner。
* **Action Loading**: 按钮点击后进入 `disabled` + `spinner` 状态。
* **Table Loading**: 表格内容区域显示 3-5 行 Skeleton Rows。
### 5.5 表单验证 (Form Validation)
* **Style**: 错误信息显示在输入框下方,字号 `text-xs`,颜色 `text-destructive`
* **Input**: 边框变红 (`border-destructive`)。
---
## 6. 职责边界与协作 (Responsibility Boundaries)
**[IMPORTANT] 严禁越界修改 (Strict No-Modification Policy)**
为了维护大型项目的可维护性UI 工程师和开发人员必须遵守以下边界规则:
### 6.1 模块化原则 (Modularity)
* **Scope**: 开发者仅应对分配给自己的模块负责。例如,负责 "Dashboard" 的开发者**不应**修改 "Sidebar" 或 "Auth" 模块的代码。
* **Dependencies**: 如果你的模块依赖于其他模块的变更,**必须**先与该模块的负责人沟通,或在 PR 中明确标注。
### 6.2 共享组件 (Shared Components)
* **Immutable Core**: `src/shared/components/ui` 下的基础组件(如 Button, Card视为**核心库**。
* **Extension**: 如果基础组件不能满足需求优先考虑组合Composition或创建新的业务组件而不是修改核心组件的源码。
* **Modification Request**: 只有在发现严重 Bug 或需要全局样式调整时,才允许修改核心组件,且必须经过 Design Lead 审批。
### 6.3 样式一致性 (Consistency)
* **Global CSS**: `globals.css` 定义了系统的物理法则。严禁在局部组件中随意覆盖全局 CSS 变量。
* **Tailwind Config**: 禁止随意在组件中添加任意值Arbitrary Values, e.g., `w-[123px]`),必须使用 Design Token。
---
## 7. 组件设计规范 (Component Specs)
### 7.1 Card (卡片)
卡片是信息组织的基本单元。
* **Class**: `bg-card text-card-foreground border rounded-lg shadow-none`.
* **Header**: `p-6 pb-2`. Title (`font-semibold leading-none tracking-tight`).
* **Content**: `p-6 pt-0`.
### 7.2 Data Table (数据表格)
教务系统的核心组件。
* **Density**:
* `Default`: Row Height `48px` (h-12).
* `Compact`: Row Height `36px` (h-9).
* **Header**: `bg-muted/50 text-muted-foreground text-xs uppercase font-medium`.
* **Stripes**: 默认关闭。仅在列数 > 8 时开启 `even:bg-muted/50`
* **Actions**: 行操作按钮应默认隐形 (`opacity-0`)Hover 时显示 (`group-hover:opacity-100`),减少视觉干扰。
### 7.3 Feedback (反馈与通知)
* **Toast**: 使用 `Sonner` 组件。
* 位置: 默认右下角 (Bottom Right).
* 样式: 极简黑白风格 (跟随主题),支持撤销操作。
* 调用: `toast("Event has been created", { description: "Sunday, December 03, 2023 at 9:00 AM" })`.
* **Skeleton**: 加载状态必须使用 Skeleton 骨架屏,禁止使用全屏 Spinner。
* **Badge**: 状态指示器。
* `default`: 主要状态 (Primary).
* `secondary`: 次要状态 (Neutral).
* `destructive`: 错误/警告状态 (Error).
* `outline`: 描边风格 (Subtle).
---
## 8. 开发指南 (Developer Guide)
### 8.1 CSS Variables
所有颜色和圆角均通过 CSS 变量控制,定义在 `globals.css` 中。禁止在代码中 Hardcode 颜色值 (如 `#FFFFFF`, `rgb(0,0,0)` )。
### 8.2 Tailwind Utility 优先
优先使用 Tailwind Utility Classes。
*`text-sm text-muted-foreground`
*`.custom-text-class { font-size: 14px; color: #666; }`
### 8.3 Dark Mode
设计系统原生支持深色模式。只要正确使用语义化颜色变量(如 `bg-background`, `text-foreground`Dark Mode 将自动完美适配,无需额外编写 `dark:` 修饰符(除非为了特殊调整)。
### 8.4 组件库引用
所有 UI 组件位于 `src/shared/components/ui`
* `Button`: 基础按钮
* `Input`: 输入框
* `Select`: 下拉选择器 (New)
* `Sheet`: 侧边栏/抽屉
* `Sonner`: Toast 通知
* `Badge`: 徽章/标签
* `Skeleton`: 加载占位符
* `DropdownMenu`: 下拉菜单
* `Avatar`: 头像
* `Label`: 表单标签
* `EmptyState`: 空状态占位 (New)

View File

@@ -0,0 +1,180 @@
# Next_Edu 产品需求文档 (PRD) - K12 智慧教学管理系统
**版本**: 2.0.0 (K12 Enterprise Edition)
**状态**: 规划中
**最后更新**: 2025-12-22
**作者**: Senior EdTech Product Manager
**适用范围**: 全校级教学管理 (教-考-练-评)
---
## 1. 角色与权限矩阵 (Complex Role Matrix)
本系统采用基于 RBAC (Role-Based Access Control) 的多维权限设计,并结合 **行级安全 (Row-Level Security, RLS)** 策略,确保数据隔离与行政管理的精确匹配。
### 1.1 角色定义与核心职责
| 角色 | 核心职责 | 权限特征 (Scope) |
| :--- | :--- | :--- |
| **系统管理员 (Admin)** | 基础数据维护、账号管理、学期设置 | 全局系统配置,不可触碰教学业务数据内容(隐私保护)。 |
| **校长 (Principal)** | 全校教学概况监控、宏观统计报表 | **全校可见**。查看所有年级、学科的统计数据(平均分、作业完成率),无修改具体的题目/作业权限。 |
| **年级主任 (Grade Head)** | 本年级行政管理、班级均衡度分析 | **年级可见**。管理本年级所有行政班级;查看本年级跨学科对比;无权干涉其他年级。 |
| **教研组长 (Subject Head)** | 学科资源建设、命题质量把控 | **学科可见**。管理本学科公共题库、教案模板;查看全校该学科教学质量;无权查看其他学科详情。 |
| **班主任 (Class Teacher)** | 班级学生管理、家校通知、综合评价 | **行政班可见**。查看本班所有学生的跨学科成绩、考勤;发布班级公告。 |
| **任课老师 (Teacher)** | 备课、出卷、批改、个别辅导 | **教学班可见**。仅能操作自己所教班级的该学科作业/考试;私有题库管理。 |
| **学生 (Student)** | 完成作业、参加考试、查看错题本 | **个人可见**。仅能访问分配给自己的任务;查看个人成长档案。 |
### 1.2 关键权限辨析:年级主任 vs 教研组长
* **维度差异**:
* **年级主任 (横向管理)**: 关注的是 **"人" (People & Administration)**。例如:高一(3)班的整体纪律如何?高一年级整体是否在期中考试中达标?他们需要跨学科的数据视图(如:某学生是否偏科)。
* **教研组长 (纵向管理)**: 关注的是 **"内容" (Content & Pedagogy)**。例如:英语科目的“阅读理解”题型得分率全校是否偏低?公共题库的题目质量如何?他们需要跨年级但单学科的深度视图。
* **数据可见性 (RLS 策略)**:
* `GradeHead_View`: `WHERE class.grade_id = :current_user_grade_id`
* `SubjectHead_View`: `WHERE course.subject_id = :current_user_subject_id` (可能跨所有年级)
---
## 2. 核心功能模块深度拆解
### 2.1 智能题库中心 (Smart Question Bank)
这是系统的核心资产库,必须支持高复杂度的题目结构。
* **多层嵌套题目结构 (Nested Questions)**:
* **场景**: 英语完形填空、语文现代文阅读、理综大题。
* **逻辑**: 引入 **"题干 (Stem)"** 与 **"子题 (Sub-question)"** 的概念。
* **父题 (Parent)**: 承载公共题干(如一篇 500 字的文章、一张物理实验图表)。通常不直接设分,或者设总分。
* **子题 (Child)**: 依附于父题,是具体的答题点(选择、填空、简答)。每个子题有独立的分值、答案和解析。
* **交互**: 组卷时,拖动“父题”,所有“子题”必须作为一个原子整体跟随移动,不可拆分。
* **知识点图谱 (Knowledge Graph)**:
* **结构**: 树状结构 (Tree)。例如:`数学 -> 代数 -> 函数 -> 二次函数 -> 二次函数的图像`
* **关联**:
* **多对多 (Many-to-Many)**: 一道题可能考察多个知识点(综合题)。
* **权重**: (可选高级需求) 标记主要考点与次要考点。
### 2.2 课本与大纲映射 (Textbook & Curriculum)
* **课本数字化**:
* 系统预置主流教材版本 (如人教版、北师大版)。
* **核心映射**: `Textbook Chapter` (课本章节) <--> `Knowledge Point` (知识点)。
* **价值**: 老师备课时,只需选择“必修一 第一章”,系统自动推荐关联的“集合”相关题目,无需手动去海量题库搜索。
### 2.3 试卷/作业组装引擎 (Assembly Engine)
* **智能筛选**: 支持交集筛选(同时包含“力学”和“三角函数”的题目)。
* **AB 卷生成**: 针对防作弊场景支持题目乱序或选项乱序Shuffle
* **作业分层**: 支持“必做题”与“选做题”设置,满足分层教学需求。
### 2.4 消息通知中心 (Notification System)
分级分策略的消息分发:
* **强提醒 (High Priority)**: 系统公告、考试开始提醒。通过站内信 + 弹窗 + (集成)短信/微信模板消息。
* **业务流 (Medium Priority)**: 作业发布、成绩出炉。站内红点 + 列表推送。
* **弱提醒 (Low Priority)**: 错题本更新、周报生成。仅在进入相关模块时提示。
---
## 3. 数据实体关系推演 (Data Entity Relationships)
基于 MySQL 关系型数据库的设计方案。
### 3.1 核心实体模型 (ER Draft)
1. **SysUser**: `id`, `username`, `role`, `school_id`
2. **TeacherProfile**: `user_id`, `is_grade_head`, `is_subject_head`
3. **Class**: `id`, `grade_level` (e.g., 10), `class_name` (e.g., "3班"), `homeroom_teacher_id`
4. **Subject**: `id`, `name` (e.g., "Math")
5. **Course**: `id`, `class_id`, `subject_id`, `teacher_id` (核心教学关系表: 谁教哪个班的哪门课)
### 3.2 题库与知识点设计 (关键难点)
#### Table: `knowledge_points` (知识点树)
* `id`: UUID
* `subject_id`: FK
* `name`: VARCHAR
* `parent_id`: UUID (Self-reference, Root is NULL)
* `level`: INT (1, 2, 3...)
* `code`: VARCHAR (e.g., "M-ALG-01-02" 用于快速检索)
#### Table: `questions` (支持嵌套)
* `id`: UUID
* `content`: TEXT (HTML/Markdown, store images as URLs)
* `type`: ENUM ('SINGLE', 'MULTI', 'FILL', 'ESSAY', 'COMPOSITE')
* `parent_id`: UUID (Self-reference, **核心设计**)
* If `NULL`: 这是一道独立题目 OR 复合题的大题干。
* If `NOT NULL`: 这是一个子题目,属于 `parent_id` 对应的题干。
* `difficulty`: INT (1-5)
* `answer`: TEXT (JSON structure for structured answers)
* `analysis`: TEXT (解析)
* `created_by`: FK (Teacher)
* `scope`: ENUM ('PUBLIC', 'PRIVATE')
#### Table: `question_knowledge` (题目-知识点关联)
* `question_id`: FK
* `knowledge_point_id`: FK
* **Primary Key**: (`question_id`, `knowledge_point_id`)
### 3.3 课本映射设计
#### Table: `textbooks`
* `id`: UUID
* `name`: VARCHAR
* `grade_level`: INT
* `subject_id`: FK
#### Table: `textbook_chapters`
* `id`: UUID
* `textbook_id`: FK
* `name`: VARCHAR
* `parent_id`: UUID (Sections within Chapters)
* `content`: TEXT (Rich text content of the chapter/section) -- [ADDED for Content Viewing]
* `order`: INT
#### Table: `chapter_knowledge_mapping`
* `chapter_id`: FK
* `knowledge_point_id`: FK
* *解释*: 这张表是连接“教学进度”与“底层知识”的桥梁。
---
## 4. 关键业务流程 (User Flows)
### 4.1 智能组卷与发布流程 (Exam Creation Flow)
这是一个高频且复杂的路径,需要极高的流畅度。
1. **启动组卷**:
* 老师进入 [教学工作台] -> 点击 [新建试卷/作业]。
* 输入基本信息(名称、考试时长、总分)。
2. **设定范围 (锚定课本)**:
* 老师选择教材版本:`人教版高中数学必修一`
* 选择章节:勾选 `第一章 集合``第二章 函数概念`
* *系统动作*: 后台查询 `chapter_knowledge_mapping`,提取出这几章对应的所有 `knowledge_points`
3. **筛选题目**:
* 系统展示题目列表,默认过滤条件为上述提取的知识点。
* 老师增加筛选:`难度: 中等`, `题型: 选择题`
* **处理嵌套题**: 如果筛选结果包含一个“完形填空”的子题,系统在 UI 上必须**强制展示**其对应的父题干,并提示老师“需整体添加”。
4. **加入试题篮 (Cart)**:
* 老师点击“+”号。
* 试题篮动态更新:`当前题目数: 15, 预计总分: 85`
5. **试卷精修 (Refine)**:
* 进入“试卷预览”模式。
* 调整题目顺序 (Drag & drop)。
* 修改某道题的分值(覆盖默认分值)。
6. **发布设置**:
* 选择发布对象:`高一(3)班`, `高一(5)班` (基于 `Course` 表权限)。
* 设置时间:`开始时间`, `截止时间`
* 发布模式:`在线作答``线下答题卡` (若线下,系统生成 PDF 和答题卡样式)。
7. **完成**:
* 学生端收到 `Notifications` 推送。
* `Exams` 表生成记录,`ExamAllocations` 表为每个班级/学生生成状态记录。

View File

@@ -0,0 +1,90 @@
require("dotenv/config");
const fs = require("node:fs");
const crypto = require("node:crypto");
const path = require("node:path");
const mysql = require("mysql2/promise");
const JOURNAL = {
"0000_aberrant_cobalt_man": 1766460456274,
"0001_flawless_texas_twister": 1767004087964,
"0002_equal_wolfpack": 1767145757594,
};
function sha256Hex(input) {
return crypto.createHash("sha256").update(input).digest("hex");
}
async function main() {
const url = process.env.DATABASE_URL;
if (!url) {
throw new Error("DATABASE_URL is not set");
}
const conn = await mysql.createConnection(url);
await conn.query(
"CREATE TABLE IF NOT EXISTS `__drizzle_migrations` (id serial primary key, hash text not null, created_at bigint)"
);
const [existing] = await conn.query(
"SELECT id, hash, created_at FROM `__drizzle_migrations` ORDER BY created_at DESC LIMIT 1"
);
if (Array.isArray(existing) && existing.length > 0) {
console.log("✅ __drizzle_migrations already has entries. Skip baselining.");
await conn.end();
return;
}
const [[accountsRow]] = await conn.query(
"SELECT COUNT(*) AS cnt FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='accounts'"
);
const accountsExists = Number(accountsRow?.cnt ?? 0) > 0;
if (!accountsExists) {
console.log(" No existing tables detected (accounts missing). Skip baselining.");
await conn.end();
return;
}
const [[structureRow]] = await conn.query(
"SELECT COUNT(*) AS cnt FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='exams' AND column_name='structure'"
);
const examsStructureExists = Number(structureRow?.cnt ?? 0) > 0;
const [[homeworkRow]] = await conn.query(
"SELECT COUNT(*) AS cnt FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='homework_assignments'"
);
const homeworkExists = Number(homeworkRow?.cnt ?? 0) > 0;
const baselineTags = [];
baselineTags.push("0000_aberrant_cobalt_man");
if (examsStructureExists) baselineTags.push("0001_flawless_texas_twister");
if (homeworkExists) baselineTags.push("0002_equal_wolfpack");
const drizzleDir = path.resolve(__dirname, "..", "..", "drizzle");
for (const tag of baselineTags) {
const sqlPath = path.join(drizzleDir, `${tag}.sql`);
if (!fs.existsSync(sqlPath)) {
throw new Error(`Missing migration file: ${sqlPath}`);
}
const sqlText = fs.readFileSync(sqlPath).toString();
const hash = sha256Hex(sqlText);
const createdAt = JOURNAL[tag];
if (typeof createdAt !== "number") {
throw new Error(`Missing journal timestamp for: ${tag}`);
}
await conn.query(
"INSERT INTO `__drizzle_migrations` (`hash`, `created_at`) VALUES (?, ?)",
[hash, createdAt]
);
}
console.log(`✅ Baselined __drizzle_migrations: ${baselineTags.join(", ")}`);
await conn.end();
}
main().catch((err) => {
console.error("❌ Baseline failed:", err);
process.exit(1);
});

45
docs/scripts/reset-db.ts Normal file
View File

@@ -0,0 +1,45 @@
import "dotenv/config"
import { db } from "@/shared/db"
import { sql } from "drizzle-orm"
async function reset() {
console.log("🔥 Resetting database...")
// Disable foreign key checks
await db.execute(sql`SET FOREIGN_KEY_CHECKS = 0;`)
// Get all table names
const tables = await db.execute(sql`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = DATABASE();
`)
// Drop each table
const rows = (tables as unknown as [unknown])[0]
if (!Array.isArray(rows)) return
for (const row of rows) {
const record = row as Record<string, unknown>
const tableName =
typeof record.TABLE_NAME === "string"
? record.TABLE_NAME
: typeof record.table_name === "string"
? record.table_name
: null
if (!tableName) continue
console.log(`Dropping table: ${tableName}`)
await db.execute(sql.raw(`DROP TABLE IF EXISTS \`${tableName}\`;`))
}
// Re-enable foreign key checks
await db.execute(sql`SET FOREIGN_KEY_CHECKS = 1;`)
console.log("✅ Database reset complete.")
process.exit(0)
}
reset().catch((err) => {
console.error("❌ Reset failed:", err)
process.exit(1)
})

351
docs/scripts/seed-exams.ts Normal file
View File

@@ -0,0 +1,351 @@
import "dotenv/config"
import { db } from "@/shared/db"
import { users, exams, questions, knowledgePoints, examSubmissions, examQuestions, submissionAnswers } from "@/shared/db/schema"
import { createId } from "@paralleldrive/cuid2"
import { faker } from "@faker-js/faker"
import { eq } from "drizzle-orm"
/**
* Seed Script for Next_Edu
*
* Usage:
* 1. Ensure DATABASE_URL is set in .env
* 2. Run with tsx: npx tsx docs/scripts/seed-exams.ts
*/
const SUBJECTS = ["Mathematics", "Physics", "English", "Chemistry", "Biology"]
const GRADES = ["Grade 10", "Grade 11", "Grade 12"]
const DIFFICULTY = [1, 2, 3, 4, 5]
async function seed() {
console.log("🌱 Starting seed process...")
// 1. Create a Teacher User if not exists
const teacherEmail = "teacher@example.com"
let teacherId = "user_teacher_123"
const existingTeacher = await db.query.users.findFirst({
where: eq(users.email, teacherEmail)
})
if (!existingTeacher) {
console.log("Creating teacher user...")
await db.insert(users).values({
id: teacherId,
name: "Senior Teacher",
email: teacherEmail,
role: "teacher",
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Teacher",
})
} else {
teacherId = existingTeacher.id
console.log("Teacher user exists:", teacherId)
}
// 1b. Create Students
console.log("Creating students...")
const studentIds: string[] = []
for (let i = 0; i < 5; i++) {
const sId = createId()
studentIds.push(sId)
await db.insert(users).values({
id: sId,
name: faker.person.fullName(),
email: faker.internet.email(),
role: "student",
image: `https://api.dicebear.com/7.x/avataaars/svg?seed=${sId}`,
})
}
// 2. Create Knowledge Points
console.log("Creating knowledge points...")
const kpIds: string[] = []
for (const subject of SUBJECTS) {
for (let i = 0; i < 3; i++) {
const kpId = createId()
kpIds.push(kpId)
await db.insert(knowledgePoints).values({
id: kpId,
name: `${subject} - ${faker.science.unit()}`,
description: faker.lorem.sentence(),
level: 1,
})
}
}
// 3. Create Questions
console.log("Creating questions...")
const questionIds: string[] = []
for (let i = 0; i < 50; i++) {
const qId = createId()
questionIds.push(qId)
const type = faker.helpers.arrayElement([
"single_choice",
"multiple_choice",
"text",
"judgment",
] as const)
await db.insert(questions).values({
id: qId,
content: {
text: faker.lorem.paragraph(),
options: type.includes("choice") ? [
{ id: "A", text: faker.lorem.sentence(), isCorrect: true },
{ id: "B", text: faker.lorem.sentence(), isCorrect: false },
{ id: "C", text: faker.lorem.sentence(), isCorrect: false },
{ id: "D", text: faker.lorem.sentence(), isCorrect: false },
] : undefined
},
type,
difficulty: faker.helpers.arrayElement(DIFFICULTY),
authorId: teacherId,
})
}
// 4. Create Exams & Submissions
console.log("Creating exams and submissions...")
for (let i = 0; i < 15; i++) {
const examId = createId()
const subject = faker.helpers.arrayElement(SUBJECTS)
const grade = faker.helpers.arrayElement(GRADES)
const status = faker.helpers.arrayElement(["draft", "published", "archived"] as const)
const scheduledAt = faker.date.soon({ days: 30 })
const meta = {
subject,
grade,
difficulty: faker.helpers.arrayElement(DIFFICULTY),
totalScore: 100,
durationMin: faker.helpers.arrayElement([45, 60, 90, 120]),
questionCount: faker.number.int({ min: 10, max: 30 }),
tags: [faker.word.sample(), faker.word.sample()],
scheduledAt: scheduledAt.toISOString()
}
await db.insert(exams).values({
id: examId,
title: `${subject} ${faker.helpers.arrayElement(["Midterm", "Final", "Quiz", "Unit Test"])}`,
description: JSON.stringify(meta),
creatorId: teacherId,
startTime: scheduledAt,
status,
})
// Link some questions to this exam (random 5 questions)
const selectedQuestions = faker.helpers.arrayElements(questionIds, 5)
await db.insert(examQuestions).values(
selectedQuestions.map((qId, idx) => ({
examId,
questionId: qId,
score: 20, // 5 * 20 = 100
order: idx
}))
)
// Create submissions for published exams
if (status === "published") {
const submittingStudents = faker.helpers.arrayElements(studentIds, faker.number.int({ min: 1, max: 3 }))
for (const studentId of submittingStudents) {
const submissionId = createId()
const submissionStatus = faker.helpers.arrayElement(["submitted", "graded"])
await db.insert(examSubmissions).values({
id: submissionId,
examId,
studentId,
score: submissionStatus === "graded" ? faker.number.int({ min: 60, max: 100 }) : null,
status: submissionStatus,
submittedAt: faker.date.recent(),
})
// Generate answers for this submission
for (const qId of selectedQuestions) {
await db.insert(submissionAnswers).values({
id: createId(),
submissionId: submissionId,
questionId: qId,
answerContent: { answer: faker.lorem.sentence() }, // Mock answer
score: submissionStatus === "graded" ? faker.number.int({ min: 0, max: 20 }) : null,
feedback: submissionStatus === "graded" ? faker.lorem.sentence() : null,
})
}
}
}
}
// 5. Create a specific Primary School Chinese Exam (小学语文)
console.log("Creating Primary School Chinese Exam...")
const chineseExamId = createId()
const chineseQuestions = []
// 5a. Pinyin Questions
const pinyinQ1 = createId()
const pinyinQ2 = createId()
chineseQuestions.push({ id: pinyinQ1, score: 5 }, { id: pinyinQ2, score: 5 })
await db.insert(questions).values([
{
id: pinyinQ1,
content: { text: "看拼音写词语chūn tiān ( )" },
type: "text",
difficulty: 1,
authorId: teacherId,
},
{
id: pinyinQ2,
content: { text: "看拼音写词语huā duǒ ( )" },
type: "text",
difficulty: 1,
authorId: teacherId,
}
])
// 5b. Vocabulary Questions
const vocabQ1 = createId()
const vocabQ2 = createId()
chineseQuestions.push({ id: vocabQ1, score: 5 }, { id: vocabQ2, score: 5 })
await db.insert(questions).values([
{
id: vocabQ1,
content: {
text: "选词填空:今天天气真( )。",
options: [
{ id: "A", text: "美好", isCorrect: false },
{ id: "B", text: "晴朗", isCorrect: true },
{ id: "C", text: "快乐", isCorrect: false }
]
},
type: "single_choice",
difficulty: 2,
authorId: teacherId,
},
{
id: vocabQ2,
content: {
text: "下列词语中,书写正确的是( )。",
options: [
{ id: "A", text: "漂扬", isCorrect: false },
{ id: "B", text: "飘扬", isCorrect: true },
{ id: "C", text: "票扬", isCorrect: false }
]
},
type: "single_choice",
difficulty: 2,
authorId: teacherId,
}
])
// 5c. Reading Comprehension Questions
const readingQ1 = createId()
const readingQ2 = createId()
chineseQuestions.push({ id: readingQ1, score: 10 }, { id: readingQ2, score: 10 })
await db.insert(questions).values([
{
id: readingQ1,
content: {
text: "阅读短文《小兔子乖乖》,回答问题:\n\n小兔子乖乖把门儿开开...\n\n文中提到的动物是",
options: [
{ id: "A", text: "大灰狼", isCorrect: false },
{ id: "B", text: "小兔子", isCorrect: true },
{ id: "C", text: "小花猫", isCorrect: false }
]
},
type: "single_choice",
difficulty: 3,
authorId: teacherId,
},
{
id: readingQ2,
content: { text: "请用一句话形容小兔子。" },
type: "text",
difficulty: 3,
authorId: teacherId,
}
])
// 5d. Construct Exam Structure
const chineseExamStructure = [
{
id: createId(),
type: "group",
title: "第一部分:基础知识",
children: [
{
id: createId(),
type: "group",
title: "一、看拼音写词语",
children: [
{ id: createId(), type: "question", questionId: pinyinQ1, score: 5 },
{ id: createId(), type: "question", questionId: pinyinQ2, score: 5 }
]
},
{
id: createId(),
type: "group",
title: "二、词语积累",
children: [
{ id: createId(), type: "question", questionId: vocabQ1, score: 5 },
{ id: createId(), type: "question", questionId: vocabQ2, score: 5 }
]
}
]
},
{
id: createId(),
type: "group",
title: "第二部分:阅读理解",
children: [
{
id: createId(),
type: "group",
title: "三、短文阅读",
children: [
{ id: createId(), type: "question", questionId: readingQ1, score: 10 },
{ id: createId(), type: "question", questionId: readingQ2, score: 10 }
]
}
]
}
]
await db.insert(exams).values({
id: chineseExamId,
title: "小学语文三年级上册期末考试",
description: JSON.stringify({
subject: "Chinese",
grade: "Grade 3",
difficulty: 3,
totalScore: 40,
durationMin: 90,
questionCount: 6,
tags: ["期末", "语文", "三年级"]
}),
structure: chineseExamStructure,
creatorId: teacherId,
status: "published",
startTime: new Date(),
})
// Link questions to exam
await db.insert(examQuestions).values(
chineseQuestions.map((q, idx) => ({
examId: chineseExamId,
questionId: q.id,
score: q.score,
order: idx
}))
)
console.log("✅ Seed completed successfully!")
process.exit(0)
}
seed().catch((err) => {
console.error("❌ Seed failed:", err)
process.exit(1)
})

10
drizzle.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/shared/db/schema.ts",
out: "./drizzle",
dialect: "mysql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});

View File

@@ -0,0 +1,183 @@
CREATE TABLE `accounts` (
`userId` varchar(128) NOT NULL,
`type` varchar(255) NOT NULL,
`provider` varchar(255) NOT NULL,
`providerAccountId` varchar(255) NOT NULL,
`refresh_token` text,
`access_token` text,
`expires_at` int,
`token_type` varchar(255),
`scope` varchar(255),
`id_token` text,
`session_state` varchar(255),
CONSTRAINT `accounts_provider_providerAccountId_pk` PRIMARY KEY(`provider`,`providerAccountId`)
);
--> statement-breakpoint
CREATE TABLE `chapters` (
`id` varchar(128) NOT NULL,
`textbook_id` varchar(128) NOT NULL,
`title` varchar(255) NOT NULL,
`order` int DEFAULT 0,
`parent_id` varchar(128),
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `chapters_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `exam_questions` (
`exam_id` varchar(128) NOT NULL,
`question_id` varchar(128) NOT NULL,
`score` int DEFAULT 0,
`order` int DEFAULT 0,
CONSTRAINT `exam_questions_exam_id_question_id_pk` PRIMARY KEY(`exam_id`,`question_id`)
);
--> statement-breakpoint
CREATE TABLE `exam_submissions` (
`id` varchar(128) NOT NULL,
`exam_id` varchar(128) NOT NULL,
`student_id` varchar(128) NOT NULL,
`score` int,
`status` varchar(50) DEFAULT 'started',
`submitted_at` timestamp,
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `exam_submissions_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `exams` (
`id` varchar(128) NOT NULL,
`title` varchar(255) NOT NULL,
`description` text,
`creator_id` varchar(128) NOT NULL,
`start_time` timestamp,
`end_time` timestamp,
`status` varchar(50) DEFAULT 'draft',
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `exams_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `knowledge_points` (
`id` varchar(128) NOT NULL,
`name` varchar(255) NOT NULL,
`description` text,
`parent_id` varchar(128),
`level` int DEFAULT 0,
`order` int DEFAULT 0,
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `knowledge_points_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `questions` (
`id` varchar(128) NOT NULL,
`content` json NOT NULL,
`type` enum('single_choice','multiple_choice','text','judgment','composite') NOT NULL,
`difficulty` int DEFAULT 1,
`parent_id` varchar(128),
`author_id` varchar(128) NOT NULL,
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `questions_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `questions_to_knowledge_points` (
`question_id` varchar(128) NOT NULL,
`knowledge_point_id` varchar(128) NOT NULL,
CONSTRAINT `questions_to_knowledge_points_question_id_knowledge_point_id_pk` PRIMARY KEY(`question_id`,`knowledge_point_id`)
);
--> statement-breakpoint
CREATE TABLE `roles` (
`id` varchar(128) NOT NULL,
`name` varchar(50) NOT NULL,
`description` varchar(255),
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `roles_id` PRIMARY KEY(`id`),
CONSTRAINT `roles_name_unique` UNIQUE(`name`)
);
--> statement-breakpoint
CREATE TABLE `sessions` (
`sessionToken` varchar(255) NOT NULL,
`userId` varchar(128) NOT NULL,
`expires` timestamp NOT NULL,
CONSTRAINT `sessions_sessionToken` PRIMARY KEY(`sessionToken`)
);
--> statement-breakpoint
CREATE TABLE `submission_answers` (
`id` varchar(128) NOT NULL,
`submission_id` varchar(128) NOT NULL,
`question_id` varchar(128) NOT NULL,
`answer_content` json,
`score` int,
`feedback` text,
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `submission_answers_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `textbooks` (
`id` varchar(128) NOT NULL,
`title` varchar(255) NOT NULL,
`subject` varchar(100) NOT NULL,
`grade` varchar(50),
`publisher` varchar(100),
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `textbooks_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `users` (
`id` varchar(128) NOT NULL,
`name` varchar(255),
`email` varchar(255) NOT NULL,
`emailVerified` timestamp,
`image` varchar(255),
`role` varchar(50) DEFAULT 'student',
`password` varchar(255),
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `users_id` PRIMARY KEY(`id`),
CONSTRAINT `users_email_unique` UNIQUE(`email`)
);
--> statement-breakpoint
CREATE TABLE `users_to_roles` (
`user_id` varchar(128) NOT NULL,
`role_id` varchar(128) NOT NULL,
CONSTRAINT `users_to_roles_user_id_role_id_pk` PRIMARY KEY(`user_id`,`role_id`)
);
--> statement-breakpoint
CREATE TABLE `verificationTokens` (
`identifier` varchar(255) NOT NULL,
`token` varchar(255) NOT NULL,
`expires` timestamp NOT NULL,
CONSTRAINT `verificationTokens_identifier_token_pk` PRIMARY KEY(`identifier`,`token`)
);
--> statement-breakpoint
ALTER TABLE `accounts` ADD CONSTRAINT `accounts_userId_users_id_fk` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `chapters` ADD CONSTRAINT `chapters_textbook_id_textbooks_id_fk` FOREIGN KEY (`textbook_id`) REFERENCES `textbooks`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `exam_questions` ADD CONSTRAINT `exam_questions_exam_id_exams_id_fk` FOREIGN KEY (`exam_id`) REFERENCES `exams`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `exam_questions` ADD CONSTRAINT `exam_questions_question_id_questions_id_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `exam_submissions` ADD CONSTRAINT `exam_submissions_exam_id_exams_id_fk` FOREIGN KEY (`exam_id`) REFERENCES `exams`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `exam_submissions` ADD CONSTRAINT `exam_submissions_student_id_users_id_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `exams` ADD CONSTRAINT `exams_creator_id_users_id_fk` FOREIGN KEY (`creator_id`) REFERENCES `users`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `questions` ADD CONSTRAINT `questions_author_id_users_id_fk` FOREIGN KEY (`author_id`) REFERENCES `users`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `questions_to_knowledge_points` ADD CONSTRAINT `questions_to_knowledge_points_question_id_questions_id_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `questions_to_knowledge_points` ADD CONSTRAINT `questions_to_knowledge_points_knowledge_point_id_knowledge_points_id_fk` FOREIGN KEY (`knowledge_point_id`) REFERENCES `knowledge_points`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `sessions` ADD CONSTRAINT `sessions_userId_users_id_fk` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `submission_answers` ADD CONSTRAINT `submission_answers_submission_id_exam_submissions_id_fk` FOREIGN KEY (`submission_id`) REFERENCES `exam_submissions`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `submission_answers` ADD CONSTRAINT `submission_answers_question_id_questions_id_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `users_to_roles` ADD CONSTRAINT `users_to_roles_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `users_to_roles` ADD CONSTRAINT `users_to_roles_role_id_roles_id_fk` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `account_userId_idx` ON `accounts` (`userId`);--> statement-breakpoint
CREATE INDEX `textbook_idx` ON `chapters` (`textbook_id`);--> statement-breakpoint
CREATE INDEX `parent_id_idx` ON `chapters` (`parent_id`);--> statement-breakpoint
CREATE INDEX `exam_student_idx` ON `exam_submissions` (`exam_id`,`student_id`);--> statement-breakpoint
CREATE INDEX `parent_id_idx` ON `knowledge_points` (`parent_id`);--> statement-breakpoint
CREATE INDEX `parent_id_idx` ON `questions` (`parent_id`);--> statement-breakpoint
CREATE INDEX `author_id_idx` ON `questions` (`author_id`);--> statement-breakpoint
CREATE INDEX `kp_idx` ON `questions_to_knowledge_points` (`knowledge_point_id`);--> statement-breakpoint
CREATE INDEX `session_userId_idx` ON `sessions` (`userId`);--> statement-breakpoint
CREATE INDEX `submission_idx` ON `submission_answers` (`submission_id`);--> statement-breakpoint
CREATE INDEX `email_idx` ON `users` (`email`);--> statement-breakpoint
CREATE INDEX `user_id_idx` ON `users_to_roles` (`user_id`);

View File

@@ -0,0 +1 @@
ALTER TABLE `exams` ADD `structure` json;

View File

@@ -0,0 +1,274 @@
CREATE TABLE IF NOT EXISTS `homework_answers` (
`id` varchar(128) NOT NULL,
`submission_id` varchar(128) NOT NULL,
`question_id` varchar(128) NOT NULL,
`answer_content` json,
`score` int,
`feedback` text,
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `homework_answers_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS `homework_assignment_questions` (
`assignment_id` varchar(128) NOT NULL,
`question_id` varchar(128) NOT NULL,
`score` int DEFAULT 0,
`order` int DEFAULT 0,
CONSTRAINT `homework_assignment_questions_assignment_id_question_id_pk` PRIMARY KEY(`assignment_id`,`question_id`)
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS `homework_assignment_targets` (
`assignment_id` varchar(128) NOT NULL,
`student_id` varchar(128) NOT NULL,
`created_at` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `homework_assignment_targets_assignment_id_student_id_pk` PRIMARY KEY(`assignment_id`,`student_id`)
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS `homework_assignments` (
`id` varchar(128) NOT NULL,
`source_exam_id` varchar(128) NOT NULL,
`title` varchar(255) NOT NULL,
`description` text,
`structure` json,
`status` varchar(50) DEFAULT 'draft',
`creator_id` varchar(128) NOT NULL,
`available_at` timestamp,
`due_at` timestamp,
`allow_late` boolean NOT NULL DEFAULT false,
`late_due_at` timestamp,
`max_attempts` int NOT NULL DEFAULT 1,
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `homework_assignments_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS `homework_submissions` (
`id` varchar(128) NOT NULL,
`assignment_id` varchar(128) NOT NULL,
`student_id` varchar(128) NOT NULL,
`attempt_no` int NOT NULL DEFAULT 1,
`score` int,
`status` varchar(50) DEFAULT 'started',
`started_at` timestamp NOT NULL DEFAULT (now()),
`submitted_at` timestamp,
`is_late` boolean NOT NULL DEFAULT false,
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `homework_submissions_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
SET @__qkp_drop_qid := (
SELECT IF(
EXISTS(
SELECT 1
FROM information_schema.referential_constraints
WHERE constraint_schema = DATABASE()
AND constraint_name = 'questions_to_knowledge_points_question_id_questions_id_fk'
),
'ALTER TABLE `questions_to_knowledge_points` DROP FOREIGN KEY `questions_to_knowledge_points_question_id_questions_id_fk`;',
'SELECT 1;'
)
);--> statement-breakpoint
PREPARE __stmt FROM @__qkp_drop_qid;--> statement-breakpoint
EXECUTE __stmt;--> statement-breakpoint
DEALLOCATE PREPARE __stmt;--> statement-breakpoint
SET @__qkp_drop_kpid := (
SELECT IF(
EXISTS(
SELECT 1
FROM information_schema.referential_constraints
WHERE constraint_schema = DATABASE()
AND constraint_name = 'questions_to_knowledge_points_knowledge_point_id_knowledge_points_id_fk'
),
'ALTER TABLE `questions_to_knowledge_points` DROP FOREIGN KEY `questions_to_knowledge_points_knowledge_point_id_knowledge_points_id_fk`;',
'SELECT 1;'
)
);--> statement-breakpoint
PREPARE __stmt2 FROM @__qkp_drop_kpid;--> statement-breakpoint
EXECUTE __stmt2;--> statement-breakpoint
DEALLOCATE PREPARE __stmt2;--> statement-breakpoint
ALTER TABLE `homework_answers` ADD CONSTRAINT `hw_ans_sub_fk` FOREIGN KEY (`submission_id`) REFERENCES `homework_submissions`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `homework_answers` ADD CONSTRAINT `hw_ans_q_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `homework_assignment_questions` ADD CONSTRAINT `hw_aq_a_fk` FOREIGN KEY (`assignment_id`) REFERENCES `homework_assignments`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `homework_assignment_questions` ADD CONSTRAINT `hw_aq_q_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `homework_assignment_targets` ADD CONSTRAINT `hw_at_a_fk` FOREIGN KEY (`assignment_id`) REFERENCES `homework_assignments`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `homework_assignment_targets` ADD CONSTRAINT `hw_at_s_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `homework_assignments` ADD CONSTRAINT `hw_asg_exam_fk` FOREIGN KEY (`source_exam_id`) REFERENCES `exams`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `homework_assignments` ADD CONSTRAINT `hw_asg_creator_fk` FOREIGN KEY (`creator_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `homework_submissions` ADD CONSTRAINT `hw_sub_a_fk` FOREIGN KEY (`assignment_id`) REFERENCES `homework_assignments`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `homework_submissions` ADD CONSTRAINT `hw_sub_student_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
SET @__idx_hw_answer_submission := (
SELECT IF(
EXISTS(
SELECT 1
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'homework_answers'
AND index_name = 'hw_answer_submission_idx'
),
'SELECT 1;',
'CREATE INDEX `hw_answer_submission_idx` ON `homework_answers` (`submission_id`);'
)
);--> statement-breakpoint
PREPARE __stmt3 FROM @__idx_hw_answer_submission;--> statement-breakpoint
EXECUTE __stmt3;--> statement-breakpoint
DEALLOCATE PREPARE __stmt3;--> statement-breakpoint
SET @__idx_hw_answer_submission_question := (
SELECT IF(
EXISTS(
SELECT 1
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'homework_answers'
AND index_name = 'hw_answer_submission_question_idx'
),
'SELECT 1;',
'CREATE INDEX `hw_answer_submission_question_idx` ON `homework_answers` (`submission_id`,`question_id`);'
)
);--> statement-breakpoint
PREPARE __stmt4 FROM @__idx_hw_answer_submission_question;--> statement-breakpoint
EXECUTE __stmt4;--> statement-breakpoint
DEALLOCATE PREPARE __stmt4;--> statement-breakpoint
SET @__idx_hw_assignment_questions_assignment := (
SELECT IF(
EXISTS(
SELECT 1
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'homework_assignment_questions'
AND index_name = 'hw_assignment_questions_assignment_idx'
),
'SELECT 1;',
'CREATE INDEX `hw_assignment_questions_assignment_idx` ON `homework_assignment_questions` (`assignment_id`);'
)
);--> statement-breakpoint
PREPARE __stmt5 FROM @__idx_hw_assignment_questions_assignment;--> statement-breakpoint
EXECUTE __stmt5;--> statement-breakpoint
DEALLOCATE PREPARE __stmt5;--> statement-breakpoint
SET @__idx_hw_assignment_targets_assignment := (
SELECT IF(
EXISTS(
SELECT 1
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'homework_assignment_targets'
AND index_name = 'hw_assignment_targets_assignment_idx'
),
'SELECT 1;',
'CREATE INDEX `hw_assignment_targets_assignment_idx` ON `homework_assignment_targets` (`assignment_id`);'
)
);--> statement-breakpoint
PREPARE __stmt6 FROM @__idx_hw_assignment_targets_assignment;--> statement-breakpoint
EXECUTE __stmt6;--> statement-breakpoint
DEALLOCATE PREPARE __stmt6;--> statement-breakpoint
SET @__idx_hw_assignment_targets_student := (
SELECT IF(
EXISTS(
SELECT 1
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'homework_assignment_targets'
AND index_name = 'hw_assignment_targets_student_idx'
),
'SELECT 1;',
'CREATE INDEX `hw_assignment_targets_student_idx` ON `homework_assignment_targets` (`student_id`);'
)
);--> statement-breakpoint
PREPARE __stmt7 FROM @__idx_hw_assignment_targets_student;--> statement-breakpoint
EXECUTE __stmt7;--> statement-breakpoint
DEALLOCATE PREPARE __stmt7;--> statement-breakpoint
SET @__idx_hw_assignment_creator := (
SELECT IF(
EXISTS(
SELECT 1
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'homework_assignments'
AND index_name = 'hw_assignment_creator_idx'
),
'SELECT 1;',
'CREATE INDEX `hw_assignment_creator_idx` ON `homework_assignments` (`creator_id`);'
)
);--> statement-breakpoint
PREPARE __stmt8 FROM @__idx_hw_assignment_creator;--> statement-breakpoint
EXECUTE __stmt8;--> statement-breakpoint
DEALLOCATE PREPARE __stmt8;--> statement-breakpoint
SET @__idx_hw_assignment_source_exam := (
SELECT IF(
EXISTS(
SELECT 1
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'homework_assignments'
AND index_name = 'hw_assignment_source_exam_idx'
),
'SELECT 1;',
'CREATE INDEX `hw_assignment_source_exam_idx` ON `homework_assignments` (`source_exam_id`);'
)
);--> statement-breakpoint
PREPARE __stmt9 FROM @__idx_hw_assignment_source_exam;--> statement-breakpoint
EXECUTE __stmt9;--> statement-breakpoint
DEALLOCATE PREPARE __stmt9;--> statement-breakpoint
SET @__idx_hw_assignment_status := (
SELECT IF(
EXISTS(
SELECT 1
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'homework_assignments'
AND index_name = 'hw_assignment_status_idx'
),
'SELECT 1;',
'CREATE INDEX `hw_assignment_status_idx` ON `homework_assignments` (`status`);'
)
);--> statement-breakpoint
PREPARE __stmt10 FROM @__idx_hw_assignment_status;--> statement-breakpoint
EXECUTE __stmt10;--> statement-breakpoint
DEALLOCATE PREPARE __stmt10;--> statement-breakpoint
SET @__idx_hw_assignment_student := (
SELECT IF(
EXISTS(
SELECT 1
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'homework_submissions'
AND index_name = 'hw_assignment_student_idx'
),
'SELECT 1;',
'CREATE INDEX `hw_assignment_student_idx` ON `homework_submissions` (`assignment_id`,`student_id`);'
)
);--> statement-breakpoint
PREPARE __stmt11 FROM @__idx_hw_assignment_student;--> statement-breakpoint
EXECUTE __stmt11;--> statement-breakpoint
DEALLOCATE PREPARE __stmt11;--> statement-breakpoint
SET @__qkp_add_qid := (
SELECT IF(
EXISTS(
SELECT 1
FROM information_schema.referential_constraints
WHERE constraint_schema = DATABASE()
AND constraint_name = 'q_kp_qid_fk'
),
'SELECT 1;',
'ALTER TABLE `questions_to_knowledge_points` ADD CONSTRAINT `q_kp_qid_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE cascade ON UPDATE no action;'
)
);--> statement-breakpoint
PREPARE __stmt12 FROM @__qkp_add_qid;--> statement-breakpoint
EXECUTE __stmt12;--> statement-breakpoint
DEALLOCATE PREPARE __stmt12;--> statement-breakpoint
SET @__qkp_add_kpid := (
SELECT IF(
EXISTS(
SELECT 1
FROM information_schema.referential_constraints
WHERE constraint_schema = DATABASE()
AND constraint_name = 'q_kp_kpid_fk'
),
'SELECT 1;',
'ALTER TABLE `questions_to_knowledge_points` ADD CONSTRAINT `q_kp_kpid_fk` FOREIGN KEY (`knowledge_point_id`) REFERENCES `knowledge_points`(`id`) ON DELETE cascade ON UPDATE no action;'
)
);--> statement-breakpoint
PREPARE __stmt13 FROM @__qkp_add_kpid;--> statement-breakpoint
EXECUTE __stmt13;--> statement-breakpoint
DEALLOCATE PREPARE __stmt13;

View File

@@ -0,0 +1,43 @@
CREATE TABLE `class_enrollments` (
`class_id` varchar(128) NOT NULL,
`student_id` varchar(128) NOT NULL,
`class_enrollment_status` enum('active','inactive') NOT NULL DEFAULT 'active',
`created_at` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `class_enrollments_class_id_student_id_pk` PRIMARY KEY(`class_id`,`student_id`)
);
--> statement-breakpoint
CREATE TABLE `class_schedule` (
`id` varchar(128) NOT NULL,
`class_id` varchar(128) NOT NULL,
`weekday` int NOT NULL,
`start_time` varchar(5) NOT NULL,
`end_time` varchar(5) NOT NULL,
`course` varchar(255) NOT NULL,
`location` varchar(100),
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `class_schedule_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `classes` (
`id` varchar(128) NOT NULL,
`name` varchar(255) NOT NULL,
`grade` varchar(50) NOT NULL,
`homeroom` varchar(50),
`room` varchar(50),
`teacher_id` varchar(128) NOT NULL,
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `classes_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
ALTER TABLE `class_enrollments` ADD CONSTRAINT `ce_c_fk` FOREIGN KEY (`class_id`) REFERENCES `classes`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `class_enrollments` ADD CONSTRAINT `ce_s_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `class_schedule` ADD CONSTRAINT `cs_c_fk` FOREIGN KEY (`class_id`) REFERENCES `classes`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `classes` ADD CONSTRAINT `classes_teacher_id_users_id_fk` FOREIGN KEY (`teacher_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `class_enrollments_class_idx` ON `class_enrollments` (`class_id`);--> statement-breakpoint
CREATE INDEX `class_enrollments_student_idx` ON `class_enrollments` (`student_id`);--> statement-breakpoint
CREATE INDEX `class_schedule_class_idx` ON `class_schedule` (`class_id`);--> statement-breakpoint
CREATE INDEX `class_schedule_class_day_idx` ON `class_schedule` (`class_id`,`weekday`);--> statement-breakpoint
CREATE INDEX `classes_teacher_idx` ON `classes` (`teacher_id`);--> statement-breakpoint
CREATE INDEX `classes_grade_idx` ON `classes` (`grade`);

View File

@@ -0,0 +1,3 @@
ALTER TABLE `chapters` ADD `content` text;--> statement-breakpoint
ALTER TABLE `knowledge_points` ADD `chapter_id` varchar(128);--> statement-breakpoint
CREATE INDEX `kp_chapter_id_idx` ON `knowledge_points` (`chapter_id`);

View File

@@ -0,0 +1,52 @@
CREATE TABLE `academic_years` (
`id` varchar(128) NOT NULL,
`name` varchar(100) NOT NULL,
`start_date` timestamp NOT NULL,
`end_date` timestamp NOT NULL,
`is_active` boolean NOT NULL DEFAULT false,
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `academic_years_id` PRIMARY KEY(`id`),
CONSTRAINT `academic_years_name_unique` UNIQUE(`name`)
);
--> statement-breakpoint
CREATE TABLE `class_subject_teachers` (
`class_id` varchar(128) NOT NULL,
`subject` enum('语文','数学','英语','美术','体育','科学','社会','音乐') NOT NULL,
`teacher_id` varchar(128),
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `class_subject_teachers_class_id_subject_pk` PRIMARY KEY(`class_id`,`subject`)
);
--> statement-breakpoint
CREATE TABLE `classrooms` (
`id` varchar(128) NOT NULL,
`name` varchar(255) NOT NULL,
`building` varchar(100),
`floor` int,
`capacity` int,
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `classrooms_id` PRIMARY KEY(`id`),
CONSTRAINT `classrooms_name_unique` UNIQUE(`name`)
);
--> statement-breakpoint
CREATE TABLE `departments` (
`id` varchar(128) NOT NULL,
`name` varchar(255) NOT NULL,
`description` text,
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `departments_id` PRIMARY KEY(`id`),
CONSTRAINT `departments_name_unique` UNIQUE(`name`)
);
--> statement-breakpoint
ALTER TABLE `classes` ADD `school_name` varchar(255);--> statement-breakpoint
ALTER TABLE `class_subject_teachers` ADD CONSTRAINT `class_subject_teachers_teacher_id_users_id_fk` FOREIGN KEY (`teacher_id`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `class_subject_teachers` ADD CONSTRAINT `cst_c_fk` FOREIGN KEY (`class_id`) REFERENCES `classes`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `academic_years_name_idx` ON `academic_years` (`name`);--> statement-breakpoint
CREATE INDEX `academic_years_active_idx` ON `academic_years` (`is_active`);--> statement-breakpoint
CREATE INDEX `class_subject_teachers_class_idx` ON `class_subject_teachers` (`class_id`);--> statement-breakpoint
CREATE INDEX `class_subject_teachers_teacher_idx` ON `class_subject_teachers` (`teacher_id`);--> statement-breakpoint
CREATE INDEX `classrooms_name_idx` ON `classrooms` (`name`);--> statement-breakpoint
CREATE INDEX `departments_name_idx` ON `departments` (`name`);

View File

@@ -0,0 +1,38 @@
CREATE TABLE `grades` (
`id` varchar(128) NOT NULL,
`school_id` varchar(128) NOT NULL,
`name` varchar(100) NOT NULL,
`order` int NOT NULL DEFAULT 0,
`grade_head_id` varchar(128),
`teaching_head_id` varchar(128),
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `grades_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `schools` (
`id` varchar(128) NOT NULL,
`name` varchar(255) NOT NULL,
`code` varchar(50),
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `schools_id` PRIMARY KEY(`id`),
CONSTRAINT `schools_name_unique` UNIQUE(`name`),
CONSTRAINT `schools_code_unique` UNIQUE(`code`)
);
--> statement-breakpoint
ALTER TABLE `classes` ADD `school_id` varchar(128);--> statement-breakpoint
ALTER TABLE `classes` ADD `grade_id` varchar(128);--> statement-breakpoint
ALTER TABLE `grades` ADD CONSTRAINT `g_s_fk` FOREIGN KEY (`school_id`) REFERENCES `schools`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `grades` ADD CONSTRAINT `g_gh_fk` FOREIGN KEY (`grade_head_id`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `grades` ADD CONSTRAINT `g_th_fk` FOREIGN KEY (`teaching_head_id`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `grades_school_idx` ON `grades` (`school_id`);--> statement-breakpoint
CREATE INDEX `grades_school_name_uniq` ON `grades` (`school_id`,`name`);--> statement-breakpoint
CREATE INDEX `grades_grade_head_idx` ON `grades` (`grade_head_id`);--> statement-breakpoint
CREATE INDEX `grades_teaching_head_idx` ON `grades` (`teaching_head_id`);--> statement-breakpoint
CREATE INDEX `schools_name_idx` ON `schools` (`name`);--> statement-breakpoint
CREATE INDEX `schools_code_idx` ON `schools` (`code`);--> statement-breakpoint
ALTER TABLE `classes` ADD CONSTRAINT `c_s_fk` FOREIGN KEY (`school_id`) REFERENCES `schools`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `classes` ADD CONSTRAINT `c_g_fk` FOREIGN KEY (`grade_id`) REFERENCES `grades`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `classes_school_idx` ON `classes` (`school_id`);--> statement-breakpoint
CREATE INDEX `classes_grade_id_idx` ON `classes` (`grade_id`);

View File

@@ -0,0 +1,3 @@
ALTER TABLE `classes` ADD `invitation_code` varchar(6);
--> statement-breakpoint
ALTER TABLE `classes` ADD CONSTRAINT `classes_invitation_code_unique` UNIQUE(`invitation_code`);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,62 @@
{
"version": "7",
"dialect": "mysql",
"entries": [
{
"idx": 0,
"version": "5",
"when": 1766460456274,
"tag": "0000_aberrant_cobalt_man",
"breakpoints": true
},
{
"idx": 1,
"version": "5",
"when": 1767004087964,
"tag": "0001_flawless_texas_twister",
"breakpoints": true
},
{
"idx": 2,
"version": "5",
"when": 1767145757594,
"tag": "0002_equal_wolfpack",
"breakpoints": true
},
{
"idx": 3,
"version": "5",
"when": 1767166769676,
"tag": "0003_petite_newton_destine",
"breakpoints": true
},
{
"idx": 4,
"version": "5",
"when": 1767169003334,
"tag": "0004_add_chapter_content_and_kp_chapter",
"breakpoints": true
},
{
"idx": 5,
"version": "5",
"when": 1767751916045,
"tag": "0005_add_class_school_subject_teachers",
"breakpoints": true
},
{
"idx": 6,
"version": "5",
"when": 1767760693171,
"tag": "0006_faithful_king_bedlam",
"breakpoints": true
},
{
"idx": 7,
"version": "5",
"when": 1767782500000,
"tag": "0007_add_class_invitation_code",
"breakpoints": true
}
]
}

View File

@@ -5,6 +5,11 @@ import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
{
rules: {
"react-hooks/incompatible-library": "off",
},
},
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
@@ -12,6 +17,7 @@ const eslintConfig = defineConfig([
"out/**",
"build/**",
"next-env.d.ts",
"docs/scripts/**",
]),
]);

View File

@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: "standalone",
};
export default nextConfig;

5472
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,20 +7,66 @@
"build": "next build",
"start": "next start",
"lint": "eslint",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"db:seed": "npx tsx scripts/seed.ts",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2",
"@paralleldrive/cuid2": "^3.0.4",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@t3-oss/env-nextjs": "^0.13.10",
"@tanstack/react-query": "^5.90.12",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-orm": "^0.45.1",
"lucide-react": "^0.562.0",
"mysql2": "^3.16.0",
"next": "16.0.10",
"next-auth": "^5.0.0-beta.30",
"next-themes": "^0.4.6",
"nuqs": "^2.8.5",
"react": "19.2.1",
"react-dom": "19.2.1"
"react-dom": "19.2.1",
"react-hook-form": "^7.69.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"remark-breaks": "^4.0.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^4.2.1",
"zustand": "^5.0.9"
},
"devDependencies": {
"@faker-js/faker": "^10.1.0",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"dotenv": "^17.2.3",
"drizzle-kit": "^0.31.8",
"eslint": "^9",
"eslint-config-next": "16.0.10",
"prettier": "^3.7.4",
"prettier-plugin-tailwindcss": "^0.7.2",
"tailwindcss": "^4",
"typescript": "^5"
}

623
scripts/seed.ts Normal file
View File

@@ -0,0 +1,623 @@
import "dotenv/config";
import { db } from "../src/shared/db";
import {
users, roles, usersToRoles,
questions, knowledgePoints, questionsToKnowledgePoints,
exams, examQuestions,
homeworkAssignments,
homeworkAssignmentQuestions,
homeworkAssignmentTargets,
homeworkSubmissions,
homeworkAnswers,
textbooks, chapters,
schools,
grades,
classes, classEnrollments, classSchedule
} from "../src/shared/db/schema";
import { createId } from "@paralleldrive/cuid2";
import { faker } from "@faker-js/faker";
import { sql } from "drizzle-orm";
/**
* Enterprise-Grade Seed Script for Next_Edu
*
* Scenarios Covered:
* 1. IAM: RBAC with multiple roles (Teacher & Grade Head).
* 2. Knowledge Graph: Nested Knowledge Points (Math -> Algebra -> Linear Equations).
* 3. Question Bank: Rich Text Content & Nested Questions (Reading Comprehension).
* 4. Exams: JSON Structure for Sectioning.
*/
async function seed() {
console.log("🌱 Starting Database Seed...");
const start = performance.now();
// --- 0. Cleanup (Optional: Truncate tables for fresh start) ---
// Note: Order matters due to foreign keys if checks are enabled.
// Ideally, use: SET FOREIGN_KEY_CHECKS = 0;
try {
await db.execute(sql`SET FOREIGN_KEY_CHECKS = 0;`);
const tables = [
"class_schedule", "class_enrollments", "classes",
"homework_answers", "homework_submissions", "homework_assignment_targets", "homework_assignment_questions", "homework_assignments",
"submission_answers", "exam_submissions", "exam_questions", "exams",
"questions_to_knowledge_points", "questions", "knowledge_points",
"chapters", "textbooks",
"grades", "schools",
"users_to_roles", "roles", "users", "accounts", "sessions"
];
for (const table of tables) {
await db.execute(sql.raw(`TRUNCATE TABLE \`${table}\`;`));
}
await db.execute(sql`SET FOREIGN_KEY_CHECKS = 1;`);
console.log("🧹 Cleaned up existing data.");
} catch (e) {
console.warn("⚠️ Cleanup warning (might be fresh DB):", e);
}
// --- 1. IAM & Roles ---
console.log("👤 Seeding IAM...");
// Roles
const roleMap = {
admin: "role_admin",
teacher: "role_teacher",
student: "role_student",
grade_head: "role_grade_head",
teaching_head: "role_teaching_head"
};
await db.insert(roles).values([
{ id: roleMap.admin, name: "admin", description: "System Administrator" },
{ id: roleMap.teacher, name: "teacher", description: "Academic Instructor" },
{ id: roleMap.student, name: "student", description: "Learner" },
{ id: roleMap.grade_head, name: "grade_head", description: "Head of Grade Year" },
{ id: roleMap.teaching_head, name: "teaching_head", description: "Teaching Research Lead" }
]);
// Users
const usersData = [
{
id: "user_admin",
name: "Admin User",
email: "admin@next-edu.com",
role: "admin", // Legacy field
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Admin"
},
{
id: "user_teacher_math",
name: "Mr. Math",
email: "math@next-edu.com",
role: "teacher",
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Math"
},
{
id: "user_student_1",
name: "Alice Student",
email: "alice@next-edu.com",
role: "student",
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Alice"
}
];
await db.insert(users).values(usersData);
// Assign Roles (RBAC)
await db.insert(usersToRoles).values([
{ userId: "user_admin", roleId: roleMap.admin },
{ userId: "user_teacher_math", roleId: roleMap.teacher },
// Math teacher is also a Grade Head
{ userId: "user_teacher_math", roleId: roleMap.grade_head },
{ userId: "user_student_1", roleId: roleMap.student },
]);
const extraStudentIds: string[] = [];
for (let i = 0; i < 12; i++) {
const studentId = createId();
extraStudentIds.push(studentId);
await db.insert(users).values({
id: studentId,
name: faker.person.fullName(),
email: faker.internet.email().toLowerCase(),
role: "student",
image: `https://api.dicebear.com/7.x/avataaars/svg?seed=${studentId}`,
});
await db.insert(usersToRoles).values({ userId: studentId, roleId: roleMap.student });
}
const schoolId = "school_nextedu"
const grade10Id = "grade_10"
await db.insert(schools).values([
{ id: schoolId, name: "Next_Edu School", code: "NEXTEDU" },
{ id: "school_demo_2", name: "Demo School No.2", code: "DEMO2" },
])
await db.insert(grades).values([
{
id: grade10Id,
schoolId,
name: "Grade 10",
order: 10,
gradeHeadId: "user_teacher_math",
teachingHeadId: "user_teacher_math",
},
])
await db.insert(classes).values([
{
id: "class_10_3",
schoolName: "Next_Edu School",
schoolId,
name: "Grade 10 · Class 3",
grade: "Grade 10",
gradeId: grade10Id,
homeroom: "10-3",
room: "Room 304",
invitationCode: "100003",
teacherId: "user_teacher_math",
},
{
id: "class_10_7",
schoolName: "Next_Edu School",
schoolId,
name: "Grade 10 · Class 7",
grade: "Grade 10",
gradeId: grade10Id,
homeroom: "10-7",
room: "Room 201",
invitationCode: "100007",
teacherId: "user_teacher_math",
},
]);
await db.insert(classEnrollments).values([
{ classId: "class_10_3", studentId: "user_student_1", status: "active" },
...extraStudentIds.slice(0, 8).map((studentId) => ({ classId: "class_10_3", studentId, status: "active" as const })),
...extraStudentIds.slice(8, 12).map((studentId) => ({ classId: "class_10_7", studentId, status: "active" as const })),
]);
await db.insert(classSchedule).values([
{ id: "cs_001", classId: "class_10_3", weekday: 1, startTime: "09:00", endTime: "09:45", course: "Mathematics", location: "Room 304" },
{ id: "cs_002", classId: "class_10_3", weekday: 3, startTime: "14:00", endTime: "14:45", course: "Physics", location: "Lab A" },
{ id: "cs_003", classId: "class_10_7", weekday: 2, startTime: "11:00", endTime: "11:45", course: "Mathematics", location: "Room 201" },
]);
await db.insert(textbooks).values([
{
id: "tb_01",
title: "Advanced Mathematics Grade 10",
subject: "Mathematics",
grade: "Grade 10",
publisher: "Next Education Press",
},
])
await db.insert(chapters).values([
{
id: "ch_01",
textbookId: "tb_01",
title: "Chapter 1: Real Numbers",
order: 1,
parentId: null,
content: "# Chapter 1: Real Numbers\n\nIn this chapter, we will explore the properties of real numbers...",
},
{
id: "ch_01_01",
textbookId: "tb_01",
title: "1.1 Introduction to Real Numbers",
order: 1,
parentId: "ch_01",
content: "## 1.1 Introduction\n\nReal numbers include rational and irrational numbers.",
},
])
// --- 2. Knowledge Graph (Tree) ---
console.log("🧠 Seeding Knowledge Graph...");
const kpMathId = createId();
const kpAlgebraId = createId();
const kpLinearId = createId();
await db.insert(knowledgePoints).values([
{ id: kpMathId, name: "Mathematics", level: 0 },
{ id: kpAlgebraId, name: "Algebra", parentId: kpMathId, level: 1 },
{ id: kpLinearId, name: "Linear Equations", parentId: kpAlgebraId, level: 2 },
]);
await db.insert(knowledgePoints).values([
{
id: "kp_01",
name: "Real Numbers",
description: "Definition and properties of real numbers",
level: 1,
order: 1,
chapterId: "ch_01",
},
{
id: "kp_02",
name: "Rational Numbers",
description: "Numbers that can be expressed as a fraction",
level: 2,
order: 1,
chapterId: "ch_01_01",
},
])
// --- 3. Question Bank (Rich Content) ---
console.log("📚 Seeding Question Bank...");
const mathExamQuestions: Array<{
id: string;
type: "single_choice" | "text" | "judgment";
difficulty: number;
content: unknown;
score: number;
}> = [
{
id: createId(),
type: "single_choice",
difficulty: 1,
score: 4,
content: {
text: "1) What is 2 + 2?",
options: [
{ id: "A", text: "3", isCorrect: false },
{ id: "B", text: "4", isCorrect: true },
{ id: "C", text: "5", isCorrect: false },
{ id: "D", text: "6", isCorrect: false },
],
},
},
{
id: createId(),
type: "single_choice",
difficulty: 2,
score: 4,
content: {
text: "2) If f(x) = 2x + 1, then f(3) = ?",
options: [
{ id: "A", text: "5", isCorrect: false },
{ id: "B", text: "7", isCorrect: true },
{ id: "C", text: "8", isCorrect: false },
{ id: "D", text: "10", isCorrect: false },
],
},
},
{
id: createId(),
type: "single_choice",
difficulty: 2,
score: 4,
content: {
text: "3) Solve 3x - 5 = 7. What is x?",
options: [
{ id: "A", text: "3", isCorrect: false },
{ id: "B", text: "4", isCorrect: true },
{ id: "C", text: "5", isCorrect: false },
{ id: "D", text: "6", isCorrect: false },
],
},
},
{
id: createId(),
type: "single_choice",
difficulty: 3,
score: 4,
content: {
text: "4) Which is a factor of x^2 - 9?",
options: [
{ id: "A", text: "(x - 3)", isCorrect: true },
{ id: "B", text: "(x + 9)", isCorrect: false },
{ id: "C", text: "(x - 9)", isCorrect: false },
{ id: "D", text: "(x^2 + 9)", isCorrect: false },
],
},
},
{
id: createId(),
type: "single_choice",
difficulty: 2,
score: 4,
content: {
text: "5) If a^2 = 49 and a > 0, then a = ?",
options: [
{ id: "A", text: "-7", isCorrect: false },
{ id: "B", text: "0", isCorrect: false },
{ id: "C", text: "7", isCorrect: true },
{ id: "D", text: "49", isCorrect: false },
],
},
},
{
id: createId(),
type: "single_choice",
difficulty: 3,
score: 4,
content: {
text: "6) Simplify (x^2 y)(x y^3).",
options: [
{ id: "A", text: "x^2 y^3", isCorrect: false },
{ id: "B", text: "x^3 y^4", isCorrect: true },
{ id: "C", text: "x^3 y^3", isCorrect: false },
{ id: "D", text: "x^4 y^4", isCorrect: false },
],
},
},
{
id: createId(),
type: "single_choice",
difficulty: 2,
score: 4,
content: {
text: "7) The slope of the line y = -3x + 2 is:",
options: [
{ id: "A", text: "2", isCorrect: false },
{ id: "B", text: "-3", isCorrect: true },
{ id: "C", text: "3", isCorrect: false },
{ id: "D", text: "-2", isCorrect: false },
],
},
},
{
id: createId(),
type: "single_choice",
difficulty: 1,
score: 4,
content: {
text: "8) The probability of getting heads in one fair coin toss is:",
options: [
{ id: "A", text: "0", isCorrect: false },
{ id: "B", text: "1/4", isCorrect: false },
{ id: "C", text: "1/2", isCorrect: true },
{ id: "D", text: "1", isCorrect: false },
],
},
},
{
id: createId(),
type: "single_choice",
difficulty: 2,
score: 4,
content: {
text: "9) In an arithmetic sequence with a1 = 2 and d = 3, a5 = ?",
options: [
{ id: "A", text: "11", isCorrect: false },
{ id: "B", text: "12", isCorrect: false },
{ id: "C", text: "14", isCorrect: true },
{ id: "D", text: "17", isCorrect: false },
],
},
},
{
id: createId(),
type: "single_choice",
difficulty: 2,
score: 4,
content: {
text: "10) The solution set of x^2 = 0 is:",
options: [
{ id: "A", text: "{0}", isCorrect: true },
{ id: "B", text: "{1}", isCorrect: false },
{ id: "C", text: "{-1, 1}", isCorrect: false },
{ id: "D", text: "Empty set", isCorrect: false },
],
},
},
{ id: createId(), type: "text", difficulty: 1, score: 4, content: { text: "11) Fill in the blank: √81 = ____.", correctAnswer: "9" } },
{ id: createId(), type: "text", difficulty: 2, score: 4, content: { text: "12) Fill in the blank: (a - b)^2 = ____.", correctAnswer: ["a^2 - 2ab + b^2", "a² - 2ab + b²"] } },
{ id: createId(), type: "text", difficulty: 1, score: 4, content: { text: "13) Fill in the blank: 2^5 = ____.", correctAnswer: "32" } },
{ id: createId(), type: "text", difficulty: 2, score: 4, content: { text: "14) Fill in the blank: The area of a circle with radius r is ____.", correctAnswer: ["πr^2", "pi r^2", "πr²"] } },
{ id: createId(), type: "text", difficulty: 1, score: 4, content: { text: "15) Fill in the blank: If x = -2, then x^3 = ____.", correctAnswer: "-8" } },
{ id: createId(), type: "judgment", difficulty: 1, score: 2, content: { text: "16) If x > y, then x + 1 > y + 1.", correctAnswer: true } },
{ id: createId(), type: "judgment", difficulty: 1, score: 2, content: { text: "17) The graph of y = 2x is a parabola.", correctAnswer: false } },
{ id: createId(), type: "judgment", difficulty: 1, score: 2, content: { text: "18) The sum of interior angles of a triangle is 180°.", correctAnswer: true } },
{ id: createId(), type: "judgment", difficulty: 2, score: 2, content: { text: "19) (x + y)^2 = x^2 + y^2.", correctAnswer: false } },
{ id: createId(), type: "judgment", difficulty: 1, score: 2, content: { text: "20) 0 is a positive number.", correctAnswer: false } },
{ id: createId(), type: "text", difficulty: 3, score: 10, content: { text: "21) Solve the system: x + y = 5, x - y = 1." } },
{ id: createId(), type: "text", difficulty: 3, score: 10, content: { text: "22) Expand and simplify: (2x - 3)(x + 4)." } },
{ id: createId(), type: "text", difficulty: 3, score: 10, content: { text: "23) In a right triangle with legs 6 and 8, find the hypotenuse and the area." } },
];
await db.insert(questions).values(
mathExamQuestions.map((q) => ({
id: q.id,
type: q.type,
difficulty: q.difficulty,
content: q.content,
authorId: "user_teacher_math",
}))
);
await db.insert(questionsToKnowledgePoints).values({
questionId: mathExamQuestions[0].id,
knowledgePointId: kpLinearId
});
// --- 4. Exams (New Structure) ---
console.log("📝 Seeding Exams...");
const examId = createId();
const makeGroup = (title: string, children: unknown[]) => ({
id: createId(),
type: "group",
title,
children,
});
const makeQuestionNode = (questionId: string, score: number) => ({
id: createId(),
type: "question",
questionId,
score,
});
const choiceIds = mathExamQuestions.slice(0, 10).map((q) => q.id);
const fillIds = mathExamQuestions.slice(10, 15).map((q) => q.id);
const judgmentIds = mathExamQuestions.slice(15, 20).map((q) => q.id);
const shortAnswerIds = mathExamQuestions.slice(20, 23).map((q) => q.id);
const examStructure = [
makeGroup(
"第一部分单项选择题共10题每题4分共40分",
choiceIds.map((id) => makeQuestionNode(id, 4))
),
makeGroup(
"第二部分填空题共5题每题4分共20分",
fillIds.map((id) => makeQuestionNode(id, 4))
),
makeGroup(
"第三部分判断题共5题每题2分共10分",
judgmentIds.map((id) => makeQuestionNode(id, 2))
),
makeGroup(
"第四部分解答题共3题每题10分共30分",
shortAnswerIds.map((id) => makeQuestionNode(id, 10))
),
];
await db.insert(exams).values({
id: examId,
title: "Grade 10 Mathematics Final Exam (Seed)",
description: JSON.stringify({
subject: "Mathematics",
grade: "Grade 10",
difficulty: 3,
totalScore: 100,
durationMin: 120,
questionCount: 23,
tags: ["seed", "math", "grade10", "final"],
}),
creatorId: "user_teacher_math",
status: "published",
startTime: new Date(),
structure: examStructure as unknown
});
// Link questions physically (Source of Truth)
const orderedQuestionIds = [...choiceIds, ...fillIds, ...judgmentIds, ...shortAnswerIds];
const scoreById = new Map(mathExamQuestions.map((q) => [q.id, q.score] as const));
await db.insert(examQuestions).values(
orderedQuestionIds.map((questionId, order) => ({
examId,
questionId,
score: scoreById.get(questionId) ?? 0,
order,
}))
);
console.log("📌 Seeding Homework Assignments...");
const assignmentId = createId();
const now = new Date();
const dueAt = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
const lateDueAt = new Date(now.getTime() + 9 * 24 * 60 * 60 * 1000);
await db.insert(homeworkAssignments).values({
id: assignmentId,
sourceExamId: examId,
title: "Grade 10 Mathematics Final - Homework (Seed)",
description: "Auto-generated homework assignment from seeded math exam.",
structure: examStructure as unknown,
status: "published",
creatorId: "user_teacher_math",
availableAt: now,
dueAt,
allowLate: true,
lateDueAt,
maxAttempts: 2,
});
await db.insert(homeworkAssignmentQuestions).values(
orderedQuestionIds.map((questionId, order) => ({
assignmentId,
questionId,
score: scoreById.get(questionId) ?? 0,
order,
}))
);
const targetStudentIds = ["user_student_1", ...extraStudentIds.slice(0, 4)];
await db.insert(homeworkAssignmentTargets).values(
targetStudentIds.map((studentId) => ({
assignmentId,
studentId,
}))
);
const scoreForQuestion = (questionId: string) => scoreById.get(questionId) ?? 0;
const buildAnswer = (questionId: string, type: string) => {
if (type === "single_choice") return { answer: "B" };
if (type === "judgment") return { answer: true };
return { answer: "Seed answer" };
};
const questionTypeById = new Map(mathExamQuestions.map((q) => [q.id, q.type] as const));
const submissionIds: string[] = [];
for (let i = 0; i < 3; i++) {
const studentId = targetStudentIds[i];
const submissionId = createId();
submissionIds.push(submissionId);
const submittedAt = new Date(now.getTime() - (i + 1) * 24 * 60 * 60 * 1000);
const status = i === 0 ? "graded" : i === 1 ? "graded" : "submitted";
const perQuestionScores = orderedQuestionIds.map((qid, idx) => {
const max = scoreForQuestion(qid);
if (status !== "graded") return null;
if (max <= 0) return 0;
if (idx % 7 === 0) return Math.max(0, max - 1);
return max;
});
const totalScore =
status === "graded"
? perQuestionScores.reduce<number>((sum, s) => sum + Number(s ?? 0), 0)
: null;
await db.insert(homeworkSubmissions).values({
id: submissionId,
assignmentId,
studentId,
attemptNo: 1,
score: totalScore,
status,
startedAt: submittedAt,
submittedAt,
isLate: false,
createdAt: submittedAt,
updatedAt: submittedAt,
});
await db.insert(homeworkAnswers).values(
orderedQuestionIds.map((questionId, idx) => {
const questionType = questionTypeById.get(questionId) ?? "text";
const score = status === "graded" ? (perQuestionScores[idx] ?? 0) : null;
return {
id: createId(),
submissionId,
questionId,
answerContent: buildAnswer(questionId, questionType),
score,
feedback: status === "graded" ? (score === scoreForQuestion(questionId) ? "Good" : "Check calculation") : null,
createdAt: submittedAt,
updatedAt: submittedAt,
};
})
);
}
const end = performance.now();
console.log(`✅ Seed completed in ${(end - start).toFixed(2)}ms`);
process.exit(0);
}
seed().catch((err) => {
console.error("❌ Seed failed:", err);
process.exit(1);
});

23
src/app/(auth)/error.tsx Normal file
View File

@@ -0,0 +1,23 @@
"use client"
import { Button } from "@/shared/components/ui/button"
import { AlertCircle } from "lucide-react"
export default function AuthError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-4 p-4 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10">
<AlertCircle className="h-8 w-8 text-destructive" />
</div>
<div className="space-y-2">
<h2 className="text-xl font-bold tracking-tight">Authentication Error</h2>
<p className="text-sm text-muted-foreground">
There was a problem signing you in. Please try again.
</p>
</div>
<Button onClick={() => reset()} variant="default" size="sm">
Try again
</Button>
</div>
)
}

View File

@@ -0,0 +1,5 @@
import { AuthLayout } from "@/modules/auth/components/auth-layout"
export default function Layout({ children }: { children: React.ReactNode }) {
return <AuthLayout>{children}</AuthLayout>
}

View File

@@ -0,0 +1,16 @@
import { Metadata } from "next"
import { Suspense } from "react"
import { LoginForm } from "@/modules/auth/components/login-form"
export const metadata: Metadata = {
title: "Login - Next_Edu",
description: "Login to your account",
}
export default function LoginPage() {
return (
<Suspense fallback={null}>
<LoginForm />
</Suspense>
)
}

View File

@@ -0,0 +1,22 @@
import Link from "next/link"
import { Button } from "@/shared/components/ui/button"
import { FileQuestion } from "lucide-react"
export default function AuthNotFound() {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-4 p-4 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<FileQuestion className="h-8 w-8 text-muted-foreground" />
</div>
<div className="space-y-2">
<h2 className="text-xl font-bold tracking-tight">Page Not Found</h2>
<p className="text-sm text-muted-foreground">
The authentication page you are looking for does not exist.
</p>
</div>
<Button asChild variant="outline" size="sm">
<Link href="/login">Return to Login</Link>
</Button>
</div>
)
}

View File

@@ -0,0 +1,45 @@
import { Metadata } from "next"
import { createId } from "@paralleldrive/cuid2"
import { eq } from "drizzle-orm"
import { db } from "@/shared/db"
import { users } from "@/shared/db/schema"
import type { ActionState } from "@/shared/types/action-state"
import { RegisterForm } from "@/modules/auth/components/register-form"
export const metadata: Metadata = {
title: "Register - Next_Edu",
description: "Create an account",
}
export default function RegisterPage() {
async function registerAction(formData: FormData): Promise<ActionState> {
"use server"
const name = String(formData.get("name") ?? "").trim()
const email = String(formData.get("email") ?? "").trim().toLowerCase()
const password = String(formData.get("password") ?? "")
if (!email) return { success: false, message: "Email is required" }
if (!password) return { success: false, message: "Password is required" }
if (password.length < 6) return { success: false, message: "Password must be at least 6 characters" }
const existing = await db.query.users.findFirst({
where: eq(users.email, email),
columns: { id: true },
})
if (existing) return { success: false, message: "Email already registered" }
await db.insert(users).values({
id: createId(),
name: name.length ? name : null,
email,
password,
role: "student",
})
return { success: true, message: "Account created" }
}
return <RegisterForm registerAction={registerAction} />
}

View File

@@ -0,0 +1,9 @@
import { AdminDashboardView } from "@/modules/dashboard/components/admin-dashboard/admin-dashboard"
import { getAdminDashboardData } from "@/modules/dashboard/data-access"
export const dynamic = "force-dynamic"
export default async function AdminDashboardPage() {
const data = await getAdminDashboardData()
return <AdminDashboardView data={data} />
}

View File

@@ -0,0 +1,18 @@
import { AcademicYearClient } from "@/modules/school/components/academic-year-view"
import { getAcademicYears } from "@/modules/school/data-access"
export const dynamic = "force-dynamic"
export default async function AdminAcademicYearPage() {
const years = await getAcademicYears()
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Academic Year</h2>
<p className="text-muted-foreground">Manage academic year ranges and the active year.</p>
</div>
<AcademicYearClient years={years} />
</div>
)
}

View File

@@ -0,0 +1,19 @@
import { getAdminClasses, getTeacherOptions } from "@/modules/classes/data-access"
import { AdminClassesClient } from "@/modules/classes/components/admin-classes-view"
export const dynamic = "force-dynamic"
export default async function AdminSchoolClassesPage() {
const [classes, teachers] = await Promise.all([getAdminClasses(), getTeacherOptions()])
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Classes</h2>
<p className="text-muted-foreground">Manage classes and assign teachers.</p>
</div>
<AdminClassesClient classes={classes} teachers={teachers} />
</div>
)
}

View File

@@ -0,0 +1,17 @@
import { DepartmentsClient } from "@/modules/school/components/departments-view"
import { getDepartments } from "@/modules/school/data-access"
export const dynamic = "force-dynamic"
export default async function AdminDepartmentsPage() {
const departments = await getDepartments()
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Departments</h2>
<p className="text-muted-foreground">Manage school departments.</p>
</div>
<DepartmentsClient departments={departments} />
</div>
)
}

View File

@@ -0,0 +1,231 @@
import Link from "next/link"
import { getGrades } from "@/modules/school/data-access"
import { getGradeHomeworkInsights } from "@/modules/classes/data-access"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils"
import { BarChart3 } from "lucide-react"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
if (typeof v === "string") return v
if (Array.isArray(v)) return v[0]
return undefined
}
const fmt = (v: number | null, digits = 1) => (typeof v === "number" && Number.isFinite(v) ? v.toFixed(digits) : "-")
export default async function AdminGradeInsightsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams
const gradeId = getParam(params, "gradeId")
const grades = await getGrades()
const selected = gradeId && gradeId !== "all" ? gradeId : ""
const insights = selected ? await getGradeHomeworkInsights({ gradeId: selected, limit: 50 }) : null
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Grade Insights</h2>
<p className="text-muted-foreground">Homework statistics aggregated across all classes in a grade.</p>
</div>
<Button asChild variant="outline">
<Link href="/admin/school/grades">Manage grades</Link>
</Button>
</div>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Filters</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{grades.length}
</Badge>
</CardHeader>
<CardContent>
<form action="/admin/school/grades/insights" method="get" className="flex flex-col gap-3 md:flex-row md:items-center">
<label className="text-sm font-medium">Grade</label>
<select
name="gradeId"
defaultValue={selected || "all"}
className="h-10 w-full rounded-md border bg-background px-3 text-sm md:w-[360px]"
>
<option value="all">Select a grade</option>
{grades.map((g) => (
<option key={g.id} value={g.id}>
{g.school.name} / {g.name}
</option>
))}
</select>
<Button type="submit" className="md:ml-2">
Apply
</Button>
</form>
</CardContent>
</Card>
{!selected ? (
<EmptyState
icon={BarChart3}
title="Select a grade to view insights"
description="Pick a grade to see latest homework and historical score statistics."
className="h-[360px] bg-card"
/>
) : !insights ? (
<EmptyState
icon={BarChart3}
title="Grade not found"
description="This grade may not exist or has no accessible data."
className="h-[360px] bg-card"
/>
) : insights.assignments.length === 0 ? (
<EmptyState
icon={BarChart3}
title="No homework data for this grade"
description="No homework assignments were targeted to students in this grade yet."
className="h-[360px] bg-card"
/>
) : (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Classes</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{insights.classCount}</div>
<div className="text-xs text-muted-foreground">
{insights.grade.school.name} / {insights.grade.name}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Students</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{insights.studentCounts.total}</div>
<div className="text-xs text-muted-foreground">
Active {insights.studentCounts.active} Inactive {insights.studentCounts.inactive}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Overall Avg</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{fmt(insights.overallScores.avg)}</div>
<div className="text-xs text-muted-foreground">Across graded homework</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Latest Avg</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{fmt(insights.latest?.scoreStats.avg ?? null)}</div>
<div className="text-xs text-muted-foreground">{insights.latest ? insights.latest.title : "-"}</div>
</CardContent>
</Card>
</div>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Latest homework</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{insights.assignments.length}
</Badge>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead>Assignment</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Targeted</TableHead>
<TableHead className="text-right">Submitted</TableHead>
<TableHead className="text-right">Graded</TableHead>
<TableHead className="text-right">Avg</TableHead>
<TableHead className="text-right">Median</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{insights.assignments.map((a) => (
<TableRow key={a.assignmentId}>
<TableCell className="font-medium">{a.title}</TableCell>
<TableCell>
<Badge variant="secondary" className="capitalize">
{a.status}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">{formatDate(a.createdAt)}</TableCell>
<TableCell className="text-right tabular-nums">{a.targetCount}</TableCell>
<TableCell className="text-right tabular-nums">{a.submittedCount}</TableCell>
<TableCell className="text-right tabular-nums">{a.gradedCount}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(a.scoreStats.avg)}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(a.scoreStats.median)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Class ranking</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{insights.classes.length}
</Badge>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead>Class</TableHead>
<TableHead className="text-right">Students</TableHead>
<TableHead className="text-right">Latest Avg</TableHead>
<TableHead className="text-right">Prev Avg</TableHead>
<TableHead className="text-right">Δ</TableHead>
<TableHead className="text-right">Overall Avg</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{insights.classes.map((c) => (
<TableRow key={c.class.id}>
<TableCell className="font-medium">
{c.class.name}
{c.class.homeroom ? <span className="text-muted-foreground"> {c.class.homeroom}</span> : null}
</TableCell>
<TableCell className="text-right tabular-nums">{c.studentCounts.total}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(c.latestAvg)}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(c.prevAvg)}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(c.deltaAvg)}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(c.overallScores.avg)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,19 @@
import { GradesClient } from "@/modules/school/components/grades-view"
import { getGrades, getSchools, getStaffOptions } from "@/modules/school/data-access"
export const dynamic = "force-dynamic"
export default async function AdminGradesPage() {
const [grades, schools, staff] = await Promise.all([getGrades(), getSchools(), getStaffOptions()])
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Grades</h2>
<p className="text-muted-foreground">Manage grades and assign grade heads.</p>
</div>
<GradesClient grades={grades} schools={schools} staff={staff} />
</div>
)
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation"
export default function AdminSchoolPage() {
redirect("/admin/school/classes")
}

View File

@@ -0,0 +1,18 @@
import { SchoolsClient } from "@/modules/school/components/schools-view"
import { getSchools } from "@/modules/school/data-access"
export const dynamic = "force-dynamic"
export default async function AdminSchoolsPage() {
const schools = await getSchools()
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Schools</h2>
<p className="text-muted-foreground">Manage schools for multi-school setups.</p>
</div>
<SchoolsClient schools={schools} />
</div>
)
}

View File

@@ -0,0 +1,16 @@
import { redirect } from "next/navigation"
import { auth } from "@/auth"
export const dynamic = "force-dynamic"
export default async function DashboardPage() {
const session = await auth()
if (!session?.user) redirect("/login")
const role = String(session.user.role ?? "teacher")
if (role === "admin") redirect("/admin/dashboard")
if (role === "student") redirect("/student/dashboard")
if (role === "parent") redirect("/parent/dashboard")
redirect("/teacher/dashboard")
}

View File

@@ -0,0 +1,22 @@
"use client"
import { AlertCircle } from "lucide-react"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default function Error({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
return (
<div className="flex h-full flex-col items-center justify-center space-y-4">
<EmptyState
icon={AlertCircle}
title="Something went wrong!"
description="We apologize for the inconvenience. An unexpected error occurred."
action={{
label: "Try Again",
onClick: () => reset()
}}
className="border-none shadow-none h-auto"
/>
</div>
)
}

View File

@@ -0,0 +1,18 @@
import { AppSidebar } from "@/modules/layout/components/app-sidebar"
import { SidebarProvider } from "@/modules/layout/components/sidebar-provider"
import { SiteHeader } from "@/modules/layout/components/site-header"
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<SidebarProvider sidebar={<AppSidebar />}>
<SiteHeader />
<main className="flex-1 overflow-auto p-6">
{children}
</main>
</SidebarProvider>
)
}

View File

@@ -0,0 +1,23 @@
import Link from "next/link"
import { FileQuestion } from "lucide-react"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default function NotFound() {
return (
<div className="flex h-full flex-col items-center justify-center space-y-4">
<EmptyState
icon={FileQuestion}
title="Page Not Found"
description="The page you are looking for does not exist or has been moved."
className="border-none shadow-none h-auto"
/>
<Link
href="/dashboard"
className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-9 items-center justify-center rounded-md px-4 text-sm font-medium transition-colors"
>
Return to Dashboard
</Link>
</div>
)
}

View File

@@ -0,0 +1,8 @@
export default function ParentDashboardPage() {
return (
<div className="p-6">
<h1 className="text-2xl font-bold">Parent Dashboard</h1>
<p className="text-muted-foreground">Welcome, Parent!</p>
</div>
)
}

View File

@@ -0,0 +1,151 @@
import Link from "next/link"
import { redirect } from "next/navigation"
import { auth } from "@/auth"
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"
import { StudentGradesCard } from "@/modules/dashboard/components/student-dashboard/student-grades-card"
import { StudentRankingCard } from "@/modules/dashboard/components/student-dashboard/student-ranking-card"
import { StudentStatsGrid } from "@/modules/dashboard/components/student-dashboard/student-stats-grid"
import { StudentTodayScheduleCard } from "@/modules/dashboard/components/student-dashboard/student-today-schedule-card"
import { StudentUpcomingAssignmentsCard } from "@/modules/dashboard/components/student-dashboard/student-upcoming-assignments-card"
import { getStudentDashboardGrades, getStudentHomeworkAssignments } from "@/modules/homework/data-access"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
export const dynamic = "force-dynamic"
const toWeekday = (d: Date): 1 | 2 | 3 | 4 | 5 | 6 | 7 => {
const day = d.getDay()
return (day === 0 ? 7 : day) as 1 | 2 | 3 | 4 | 5 | 6 | 7
}
export default async function ProfilePage() {
const session = await auth()
if (!session?.user) redirect("/login")
const name = session.user.name ?? "User"
const email = session.user.email ?? "-"
const role = String(session.user.role ?? "teacher")
const userId = String(session.user.id ?? "").trim()
const studentData =
role === "student" && userId
? await (async () => {
const [classes, schedule, assignmentsAll, grades] = await Promise.all([
getStudentClasses(userId),
getStudentSchedule(userId),
getStudentHomeworkAssignments(userId),
getStudentDashboardGrades(userId),
])
const now = new Date()
const in7Days = new Date(now)
in7Days.setDate(in7Days.getDate() + 7)
const dueSoonCount = assignmentsAll.filter((a) => {
if (!a.dueAt) return false
const due = new Date(a.dueAt)
return due >= now && due <= in7Days && a.progressStatus !== "graded"
}).length
const overdueCount = assignmentsAll.filter((a) => {
if (!a.dueAt) return false
const due = new Date(a.dueAt)
return due < now && a.progressStatus !== "graded"
}).length
const gradedCount = assignmentsAll.filter((a) => a.progressStatus === "graded").length
const upcomingAssignments = [...assignmentsAll]
.sort((a, b) => {
const aTime = a.dueAt ? new Date(a.dueAt).getTime() : Number.POSITIVE_INFINITY
const bTime = b.dueAt ? new Date(b.dueAt).getTime() : Number.POSITIVE_INFINITY
if (aTime !== bTime) return aTime - bTime
return a.id.localeCompare(b.id)
})
.slice(0, 8)
const todayWeekday = toWeekday(now)
const todayScheduleItems = schedule
.filter((s) => s.weekday === todayWeekday)
.map((s) => ({
id: s.id,
classId: s.classId,
className: s.className,
course: s.course,
startTime: s.startTime,
endTime: s.endTime,
location: s.location ?? null,
}))
return {
enrolledClassCount: classes.length,
dueSoonCount,
overdueCount,
gradedCount,
todayScheduleItems,
upcomingAssignments,
grades,
}
})()
: null
return (
<div className="flex h-full flex-col gap-8 p-8">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div className="space-y-1">
<h1 className="text-3xl font-bold tracking-tight">Profile</h1>
<div className="text-sm text-muted-foreground">Your account information.</div>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline">
<Link href="/settings">Open settings</Link>
</Button>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Account</CardTitle>
<CardDescription>Signed-in user details from session.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm font-medium">{name}</div>
<Badge variant="secondary" className="capitalize">
{role}
</Badge>
</div>
<div className="text-sm text-muted-foreground">{email}</div>
</CardContent>
</Card>
{studentData ? (
<div className="space-y-6">
<div className="space-y-1">
<h2 className="text-xl font-semibold tracking-tight">Student</h2>
<div className="text-sm text-muted-foreground">Your learning overview.</div>
</div>
<StudentStatsGrid
enrolledClassCount={studentData.enrolledClassCount}
dueSoonCount={studentData.dueSoonCount}
overdueCount={studentData.overdueCount}
gradedCount={studentData.gradedCount}
/>
<div className="grid gap-4 md:grid-cols-2">
<StudentGradesCard grades={studentData.grades} />
<StudentRankingCard ranking={studentData.grades.ranking} />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
<StudentTodayScheduleCard items={studentData.todayScheduleItems} />
<StudentUpcomingAssignmentsCard upcomingAssignments={studentData.upcomingAssignments} />
</div>
</div>
) : null}
</div>
)
}

View File

@@ -0,0 +1,21 @@
import { redirect } from "next/navigation"
import { auth } from "@/auth"
import { AdminSettingsView } from "@/modules/settings/components/admin-settings-view"
import { StudentSettingsView } from "@/modules/settings/components/student-settings-view"
import { TeacherSettingsView } from "@/modules/settings/components/teacher-settings-view"
export const dynamic = "force-dynamic"
export default async function SettingsPage() {
const session = await auth()
if (!session?.user) redirect("/login")
const role = String(session.user.role ?? "teacher")
if (role === "admin") return <AdminSettingsView />
if (role === "student") return <StudentSettingsView user={session.user} />
if (role === "teacher") return <TeacherSettingsView user={session.user} />
redirect("/dashboard")
}

View File

@@ -0,0 +1,61 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="space-y-6">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div className="space-y-2">
<Skeleton className="h-9 w-48" />
<Skeleton className="h-4 w-56" />
</div>
<Skeleton className="h-10 w-40" />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-4 rounded-full" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
<Skeleton className="mt-2 h-3 w-28" />
</CardContent>
</Card>
))}
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
<Card className="lg:col-span-3">
<CardHeader>
<CardTitle className="text-sm">
<Skeleton className="h-4 w-40" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
<Card className="lg:col-span-4">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-sm">
<Skeleton className="h-4 w-44" />
</CardTitle>
<Skeleton className="h-9 w-24" />
</CardHeader>
<CardContent className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,88 @@
import { StudentDashboard } from "@/modules/dashboard/components/student-dashboard/student-dashboard-view"
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"
import { getDemoStudentUser, getStudentDashboardGrades, getStudentHomeworkAssignments } from "@/modules/homework/data-access"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Inbox } from "lucide-react"
export const dynamic = "force-dynamic"
const toWeekday = (d: Date): 1 | 2 | 3 | 4 | 5 | 6 | 7 => {
const day = d.getDay()
return (day === 0 ? 7 : day) as 1 | 2 | 3 | 4 | 5 | 6 | 7
}
export default async function StudentDashboardPage() {
const student = await getDemoStudentUser()
if (!student) {
return (
<div className="flex h-full flex-col items-center justify-center">
<EmptyState
title="No user found"
description="Create a student user to see dashboard."
icon={Inbox}
className="border-none shadow-none h-auto"
/>
</div>
)
}
const [classes, schedule, assignments, grades] = await Promise.all([
getStudentClasses(student.id),
getStudentSchedule(student.id),
getStudentHomeworkAssignments(student.id),
getStudentDashboardGrades(student.id),
])
const now = new Date()
const in7Days = new Date(now)
in7Days.setDate(in7Days.getDate() + 7)
const dueSoonCount = assignments.filter((a) => {
if (!a.dueAt) return false
const due = new Date(a.dueAt)
return due >= now && due <= in7Days && a.progressStatus !== "graded"
}).length
const overdueCount = assignments.filter((a) => {
if (!a.dueAt) return false
const due = new Date(a.dueAt)
return due < now && a.progressStatus !== "graded"
}).length
const gradedCount = assignments.filter((a) => a.progressStatus === "graded").length
const todayWeekday = toWeekday(now)
const todayScheduleItems = schedule
.filter((s) => s.weekday === todayWeekday)
.map((s) => ({
id: s.id,
classId: s.classId,
className: s.className,
course: s.course,
startTime: s.startTime,
endTime: s.endTime,
location: s.location ?? null,
}))
.sort((a, b) => a.startTime.localeCompare(b.startTime))
const upcomingAssignments = [...assignments]
.sort((a, b) => {
const aDue = a.dueAt ? new Date(a.dueAt).getTime() : Number.POSITIVE_INFINITY
const bDue = b.dueAt ? new Date(b.dueAt).getTime() : Number.POSITIVE_INFINITY
return aDue - bDue
})
.slice(0, 6)
return (
<StudentDashboard
studentName={student.name}
enrolledClassCount={classes.length}
dueSoonCount={dueSoonCount}
overdueCount={overdueCount}
gradedCount={gradedCount}
todayScheduleItems={todayScheduleItems}
upcomingAssignments={upcomingAssignments}
grades={grades}
/>
)
}

View File

@@ -0,0 +1,35 @@
import { notFound } from "next/navigation"
import { getDemoStudentUser, getStudentHomeworkTakeData } from "@/modules/homework/data-access"
import { HomeworkTakeView } from "@/modules/homework/components/homework-take-view"
import { formatDate } from "@/shared/lib/utils"
export const dynamic = "force-dynamic"
export default async function StudentAssignmentTakePage({
params,
}: {
params: Promise<{ assignmentId: string }>
}) {
const { assignmentId } = await params
const student = await getDemoStudentUser()
if (!student) return notFound()
const data = await getStudentHomeworkTakeData(assignmentId, student.id)
if (!data) return notFound()
return (
<div className="flex h-full flex-col space-y-4 p-6">
<div className="flex flex-col gap-1">
<h2 className="text-2xl font-bold tracking-tight">{data.assignment.title}</h2>
<div className="text-sm text-muted-foreground">
<span>Due: {data.assignment.dueAt ? formatDate(data.assignment.dueAt) : "-"}</span>
<span className="mx-2"></span>
<span>Max Attempts: {data.assignment.maxAttempts}</span>
</div>
</div>
<HomeworkTakeView assignmentId={data.assignment.id} initialData={data} />
</div>
)
}

View File

@@ -0,0 +1,106 @@
import Link from "next/link"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils"
import { getDemoStudentUser, getStudentHomeworkAssignments } from "@/modules/homework/data-access"
import { Inbox } from "lucide-react"
export const dynamic = "force-dynamic"
const getStatusVariant = (status: string): "default" | "secondary" | "outline" => {
if (status === "graded") return "default"
if (status === "submitted") return "secondary"
if (status === "in_progress") return "secondary"
return "outline"
}
const getStatusLabel = (status: string) => {
if (status === "graded") return "Graded"
if (status === "submitted") return "Submitted"
if (status === "in_progress") return "In progress"
return "Not started"
}
export default async function StudentAssignmentsPage() {
const student = await getDemoStudentUser()
if (!student) {
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">Assignments</h2>
<p className="text-muted-foreground">Your homework assignments.</p>
</div>
</div>
<EmptyState title="No user found" description="Create a student user to see assignments." icon={Inbox} />
</div>
)
}
const assignments = await getStudentHomeworkAssignments(student.id)
const hasAssignments = assignments.length > 0
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">Assignments</h2>
<p className="text-muted-foreground">Your homework assignments.</p>
</div>
<Button asChild variant="outline">
<Link href="/student/dashboard">Back</Link>
</Button>
</div>
{!hasAssignments ? (
<EmptyState title="No assignments" description="You have no assigned homework right now." icon={Inbox} />
) : (
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Status</TableHead>
<TableHead>Due</TableHead>
<TableHead>Attempts</TableHead>
<TableHead>Score</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assignments.map((a) => (
<TableRow key={a.id}>
<TableCell className="font-medium">
<Link href={`/student/learning/assignments/${a.id}`} className="hover:underline">
{a.title}
</Link>
</TableCell>
<TableCell>
<Badge variant={getStatusVariant(a.progressStatus)} className="capitalize">
{getStatusLabel(a.progressStatus)}
</Badge>
</TableCell>
<TableCell>{a.dueAt ? formatDate(a.dueAt) : "-"}</TableCell>
<TableCell className="tabular-nums text-muted-foreground">
{a.attemptsUsed}/{a.maxAttempts}
</TableCell>
<TableCell className="tabular-nums">{a.latestScore ?? "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,29 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-2">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-4 w-52" />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i}>
<CardHeader className="pb-2">
<Skeleton className="h-5 w-44" />
<Skeleton className="mt-2 h-4 w-56" />
</CardHeader>
<CardContent className="pt-2 flex items-center justify-between">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-9 w-24" />
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,40 @@
import { Inbox } from "lucide-react"
import { getStudentClasses } from "@/modules/classes/data-access"
import { getDemoStudentUser } from "@/modules/homework/data-access"
import { StudentCoursesView } from "@/modules/student/components/student-courses-view"
import { EmptyState } from "@/shared/components/ui/empty-state"
export const dynamic = "force-dynamic"
export default async function StudentCoursesPage() {
const student = await getDemoStudentUser()
if (!student) {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div>
<h2 className="text-2xl font-bold tracking-tight">Courses</h2>
<p className="text-muted-foreground">Your enrolled classes.</p>
</div>
<EmptyState
title="No user found"
description="Create a student user to see courses."
icon={Inbox}
/>
</div>
)
}
const classes = await getStudentClasses(student.id)
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div>
<h2 className="text-2xl font-bold tracking-tight">Courses</h2>
<p className="text-muted-foreground">Your enrolled classes.</p>
</div>
<StudentCoursesView classes={classes} />
</div>
)
}

View File

@@ -0,0 +1,78 @@
import Link from "next/link"
import { notFound } from "next/navigation"
import { ArrowLeft, BookOpen, Inbox } from "lucide-react"
import { getTextbookById, getChaptersByTextbookId } from "@/modules/textbooks/data-access"
import { TextbookReader } from "@/modules/textbooks/components/textbook-reader"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { getDemoStudentUser } from "@/modules/homework/data-access"
export const dynamic = "force-dynamic"
export default async function StudentTextbookDetailPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const student = await getDemoStudentUser()
if (!student) {
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">Textbook</h2>
<p className="text-muted-foreground">Read chapters and review content.</p>
</div>
</div>
<EmptyState title="No user found" description="Create a student user to read textbooks." icon={Inbox} />
</div>
)
}
const { id } = await params
const [textbook, chapters] = await Promise.all([getTextbookById(id), getChaptersByTextbookId(id)])
if (!textbook) notFound()
return (
<div className="flex h-[calc(100vh-4rem)] flex-col overflow-hidden">
<div className="flex items-center gap-4 border-b bg-background py-4 shrink-0 z-10">
<Button variant="ghost" size="icon" asChild>
<Link href="/student/learning/textbooks">
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Badge variant="outline">{textbook.subject}</Badge>
<span className="text-xs text-muted-foreground uppercase tracking-wider font-medium">
{textbook.grade ?? "-"}
</span>
</div>
<h1 className="text-xl font-bold tracking-tight line-clamp-1">{textbook.title}</h1>
</div>
</div>
<div className="flex-1 overflow-hidden pt-6">
{chapters.length === 0 ? (
<div className="px-8">
<EmptyState
icon={BookOpen}
title="No chapters"
description="This textbook has no chapters yet."
className="bg-card"
/>
</div>
) : (
<div className="h-[calc(100vh-140px)] px-8 min-h-0">
<TextbookReader chapters={chapters} />
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,80 @@
import { BookOpen, Inbox } from "lucide-react"
import { getTextbooks } from "@/modules/textbooks/data-access"
import { TextbookCard } from "@/modules/textbooks/components/textbook-card"
import { TextbookFilters } from "@/modules/textbooks/components/textbook-filters"
import { getDemoStudentUser } from "@/modules/homework/data-access"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Button } from "@/shared/components/ui/button"
import Link from "next/link"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
export default async function StudentTextbooksPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const [student, sp] = await Promise.all([getDemoStudentUser(), searchParams])
if (!student) {
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">Textbooks</h2>
<p className="text-muted-foreground">Browse your course textbooks.</p>
</div>
</div>
<EmptyState title="No user found" description="Create a student user to see textbooks." icon={Inbox} />
</div>
)
}
const q = getParam(sp, "q") || undefined
const subject = getParam(sp, "subject") || undefined
const grade = getParam(sp, "grade") || undefined
const textbooks = await getTextbooks(q, subject, grade)
const hasFilters = Boolean(q || (subject && subject !== "all") || (grade && grade !== "all"))
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">Textbooks</h2>
<p className="text-muted-foreground">Browse your course textbooks.</p>
</div>
<Button asChild variant="outline">
<Link href="/student/dashboard">Back</Link>
</Button>
</div>
<TextbookFilters />
{textbooks.length === 0 ? (
<EmptyState
icon={BookOpen}
title={hasFilters ? "No textbooks match your filters" : "No textbooks yet"}
description={hasFilters ? "Try clearing filters or adjusting keywords." : "No textbooks are available right now."}
action={hasFilters ? { label: "Clear filters", href: "/student/learning/textbooks" } : undefined}
className="bg-card"
/>
) : (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{textbooks.map((textbook) => (
<TextbookCard key={textbook.id} textbook={textbook} hrefBase="/student/learning/textbooks" />
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,32 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div className="space-y-2">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-4 w-52" />
</div>
<Skeleton className="h-10 w-60" />
</div>
<div className="grid gap-4 lg:grid-cols-2">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i}>
<CardHeader className="pb-3">
<Skeleton className="h-4 w-16" />
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 3 }).map((__, j) => (
<Skeleton key={j} className="h-16 w-full" />
))}
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,55 @@
import { Inbox } from "lucide-react"
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"
import { getDemoStudentUser } from "@/modules/homework/data-access"
import { StudentScheduleFilters } from "@/modules/student/components/student-schedule-filters"
import { StudentScheduleView } from "@/modules/student/components/student-schedule-view"
import { EmptyState } from "@/shared/components/ui/empty-state"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
export default async function StudentSchedulePage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const student = await getDemoStudentUser()
if (!student) {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div>
<h2 className="text-2xl font-bold tracking-tight">Schedule</h2>
<p className="text-muted-foreground">Your weekly timetable.</p>
</div>
<EmptyState title="No user found" description="Create a student user to see schedule." icon={Inbox} />
</div>
)
}
const [sp, classes, schedule] = await Promise.all([
searchParams,
getStudentClasses(student.id),
getStudentSchedule(student.id),
])
const classIdParam = sp.classId
const classId = typeof classIdParam === "string" ? classIdParam : Array.isArray(classIdParam) ? classIdParam[0] : "all"
const filteredItems =
classId && classId !== "all" ? schedule.filter((s) => s.classId === classId) : schedule
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div>
<h2 className="text-2xl font-bold tracking-tight">Schedule</h2>
<p className="text-muted-foreground">Your weekly timetable.</p>
</div>
<StudentScheduleFilters classes={classes} />
</div>
<StudentScheduleView items={filteredItems} />
</div>
)
}

View File

@@ -0,0 +1,259 @@
import Link from "next/link"
import { Suspense } from "react"
import { BarChart3 } from "lucide-react"
import { getClassHomeworkInsights, getTeacherClasses } from "@/modules/classes/data-access"
import { InsightsFilters } from "@/modules/classes/components/insights-filters"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Skeleton } from "@/shared/components/ui/skeleton"
import { Badge } from "@/shared/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
const formatNumber = (v: number | null, digits = 1) => {
if (typeof v !== "number" || Number.isNaN(v)) return "-"
return v.toFixed(digits)
}
function InsightsResultsFallback() {
return (
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, idx) => (
<div key={idx} className="rounded-lg border bg-card">
<div className="p-6">
<Skeleton className="h-5 w-28" />
<Skeleton className="mt-3 h-8 w-20" />
</div>
</div>
))}
</div>
<div className="rounded-md border bg-card">
<div className="p-4">
<Skeleton className="h-8 w-full" />
</div>
<div className="space-y-2 p-4 pt-0">
{Array.from({ length: 8 }).map((_, idx) => (
<Skeleton key={idx} className="h-10 w-full" />
))}
</div>
</div>
</div>
)
}
async function InsightsResults({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams
const classId = getParam(params, "classId")
if (!classId || classId === "all") {
return (
<EmptyState
icon={BarChart3}
title="Select a class to view insights"
description="Pick a class to see latest homework and historical score statistics."
className="h-[360px] bg-card"
/>
)
}
const insights = await getClassHomeworkInsights({ classId, limit: 50 })
if (!insights) {
return (
<EmptyState
icon={BarChart3}
title="Class not found"
description="This class may not exist or is not accessible."
className="h-[360px] bg-card"
/>
)
}
const hasAssignments = insights.assignments.length > 0
if (!hasAssignments) {
return (
<EmptyState
icon={BarChart3}
title="No homework data for this class"
description="No homework assignments were targeted to students in this class yet."
className="h-[360px] bg-card"
/>
)
}
const latest = insights.latest
return (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Students</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{insights.studentCounts.total}</div>
<div className="text-xs text-muted-foreground">
Active {insights.studentCounts.active} · Inactive {insights.studentCounts.inactive}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Assignments</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{insights.assignments.length}</div>
<div className="text-xs text-muted-foreground">Latest: {latest ? formatDate(latest.createdAt) : "-"}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Overall scores</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatNumber(insights.overallScores.avg, 1)}</div>
<div className="text-xs text-muted-foreground">
Median {formatNumber(insights.overallScores.median, 1)} · Count {insights.overallScores.count}
</div>
</CardContent>
</Card>
</div>
{latest && (
<Card>
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle className="text-base">Latest assignment</CardTitle>
<div className="mt-1 flex items-center gap-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">{latest.title}</span>
<Badge variant="outline" className="capitalize">
{latest.status}
</Badge>
<span>·</span>
<span>{formatDate(latest.createdAt)}</span>
{latest.dueAt ? (
<>
<span>·</span>
<span>Due {formatDate(latest.dueAt)}</span>
</>
) : null}
</div>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline" size="sm">
<Link href={`/teacher/homework/assignments/${latest.assignmentId}`}>Open</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={`/teacher/homework/assignments/${latest.assignmentId}/submissions`}>Submissions</Link>
</Button>
</div>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-5">
<div>
<div className="text-sm text-muted-foreground">Targeted</div>
<div className="text-lg font-semibold">{latest.targetCount}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Submitted</div>
<div className="text-lg font-semibold">{latest.submittedCount}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Graded</div>
<div className="text-lg font-semibold">{latest.gradedCount}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Average</div>
<div className="text-lg font-semibold">{formatNumber(latest.scoreStats.avg, 1)}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Median</div>
<div className="text-lg font-semibold">{formatNumber(latest.scoreStats.median, 1)}</div>
</div>
</CardContent>
</Card>
)}
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Assignment</TableHead>
<TableHead>Status</TableHead>
<TableHead>Due</TableHead>
<TableHead className="text-right">Targeted</TableHead>
<TableHead className="text-right">Submitted</TableHead>
<TableHead className="text-right">Graded</TableHead>
<TableHead className="text-right">Avg</TableHead>
<TableHead className="text-right">Median</TableHead>
<TableHead className="text-right">Min</TableHead>
<TableHead className="text-right">Max</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{insights.assignments.map((a) => (
<TableRow key={a.assignmentId}>
<TableCell className="font-medium">
<Link href={`/teacher/homework/assignments/${a.assignmentId}`} className="hover:underline">
{a.title}
</Link>
<div className="text-xs text-muted-foreground">Created {formatDate(a.createdAt)}</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{a.status}
</Badge>
</TableCell>
<TableCell>{a.dueAt ? formatDate(a.dueAt) : "-"}</TableCell>
<TableCell className="text-right">{a.targetCount}</TableCell>
<TableCell className="text-right">{a.submittedCount}</TableCell>
<TableCell className="text-right">{a.gradedCount}</TableCell>
<TableCell className="text-right">{formatNumber(a.scoreStats.avg, 1)}</TableCell>
<TableCell className="text-right">{formatNumber(a.scoreStats.median, 1)}</TableCell>
<TableCell className="text-right">{formatNumber(a.scoreStats.min, 0)}</TableCell>
<TableCell className="text-right">{formatNumber(a.scoreStats.max, 0)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)
}
export default async function ClassInsightsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const classes = await getTeacherClasses()
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
<div>
<h2 className="text-2xl font-bold tracking-tight">Class Insights</h2>
<p className="text-muted-foreground">Latest homework and historical score statistics for a class.</p>
</div>
</div>
<div className="space-y-4">
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
<InsightsFilters classes={classes} />
</Suspense>
<Suspense fallback={<InsightsResultsFallback />}>
<InsightsResults searchParams={searchParams} />
</Suspense>
</div>
</div>
)
}

View File

@@ -0,0 +1,315 @@
import Link from "next/link"
import { notFound } from "next/navigation"
import { getClassHomeworkInsights, getClassSchedule, getClassStudents } from "@/modules/classes/data-access"
import { ScheduleView } from "@/modules/classes/components/schedule-view"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
const formatNumber = (v: number | null, digits = 1) => {
if (typeof v !== "number" || Number.isNaN(v)) return "-"
return v.toFixed(digits)
}
export default async function ClassDetailPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>
searchParams: Promise<SearchParams>
}) {
const { id } = await params
const sp = await searchParams
const hw = getParam(sp, "hw")
const hwFilter = hw === "active" || hw === "overdue" ? hw : "all"
const [insights, students, schedule] = await Promise.all([
getClassHomeworkInsights({ classId: id, limit: 50 }),
getClassStudents({ classId: id }),
getClassSchedule({ classId: id }),
])
if (!insights) return notFound()
const latest = insights.latest
const filteredAssignments = insights.assignments.filter((a) => {
if (hwFilter === "all") return true
if (hwFilter === "overdue") return a.isOverdue
if (hwFilter === "active") return a.isActive
return true
})
const hasAssignments = filteredAssignments.length > 0
const scheduleBuilderClasses = [
{
id: insights.class.id,
name: insights.class.name,
grade: insights.class.grade,
homeroom: insights.class.homeroom ?? null,
room: insights.class.room ?? null,
studentCount: insights.studentCounts.total,
},
]
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Button asChild variant="outline" size="sm">
<Link href="/teacher/classes/my">Back</Link>
</Button>
<Badge variant="secondary">{insights.class.grade}</Badge>
<Badge variant="outline">{insights.studentCounts.total} students</Badge>
</div>
<h2 className="text-2xl font-bold tracking-tight">{insights.class.name}</h2>
<div className="text-sm text-muted-foreground">
{insights.class.room ? `Room: ${insights.class.room}` : "Room: Not set"}
{insights.class.homeroom ? ` · Homeroom: ${insights.class.homeroom}` : null}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button asChild variant="outline">
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(insights.class.id)}`}>Students</Link>
</Button>
<Button asChild variant="outline">
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(insights.class.id)}`}>Schedule</Link>
</Button>
<Button asChild variant="outline">
<Link href={`/teacher/classes/insights?classId=${encodeURIComponent(insights.class.id)}`}>Insights</Link>
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Students</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{insights.studentCounts.total}</div>
<div className="text-xs text-muted-foreground">
Active {insights.studentCounts.active} · Inactive {insights.studentCounts.inactive}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Schedule items</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{schedule.length}</div>
<div className="text-xs text-muted-foreground">Weekly timetable entries</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Assignments</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{insights.assignments.length}</div>
<div className="text-xs text-muted-foreground">{latest ? `Latest ${formatDate(latest.createdAt)}` : "No homework yet"}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Overall avg</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatNumber(insights.overallScores.avg, 1)}</div>
<div className="text-xs text-muted-foreground">
Median {formatNumber(insights.overallScores.median, 1)} · Count {insights.overallScores.count}
</div>
</CardContent>
</Card>
</div>
{latest ? (
<Card>
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle className="text-base">Latest homework</CardTitle>
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">{latest.title}</span>
<Badge variant="outline" className="capitalize">
{latest.status}
</Badge>
<span>·</span>
<span>{formatDate(latest.createdAt)}</span>
{latest.dueAt ? (
<>
<span>·</span>
<span>Due {formatDate(latest.dueAt)}</span>
</>
) : null}
</div>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline" size="sm">
<Link href={`/teacher/homework/assignments/${latest.assignmentId}`}>Open</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={`/teacher/homework/assignments/${latest.assignmentId}/submissions`}>Submissions</Link>
</Button>
</div>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-5">
<div>
<div className="text-sm text-muted-foreground">Targeted</div>
<div className="text-lg font-semibold">{latest.targetCount}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Submitted</div>
<div className="text-lg font-semibold">{latest.submittedCount}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Graded</div>
<div className="text-lg font-semibold">{latest.gradedCount}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Average</div>
<div className="text-lg font-semibold">{formatNumber(latest.scoreStats.avg, 1)}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Median</div>
<div className="text-lg font-semibold">{formatNumber(latest.scoreStats.median, 1)}</div>
</div>
</CardContent>
</Card>
) : null}
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">Students (preview)</CardTitle>
<Button asChild variant="outline" size="sm">
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(insights.class.id)}`}>View all</Link>
</Button>
</CardHeader>
<CardContent>
{students.length === 0 ? (
<div className="text-sm text-muted-foreground">No students enrolled.</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{students.slice(0, 8).map((s) => (
<TableRow key={s.id}>
<TableCell className="font-medium">{s.name}</TableCell>
<TableCell className="text-muted-foreground">{s.email}</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{s.status}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">Schedule</CardTitle>
<Button asChild variant="outline" size="sm">
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(insights.class.id)}`}>View all</Link>
</Button>
</CardHeader>
<CardContent>
<ScheduleView schedule={schedule} classes={scheduleBuilderClasses} />
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<CardTitle className="text-base">Homework history</CardTitle>
<div className="flex flex-wrap items-center gap-2">
<Button asChild size="sm" variant={hwFilter === "all" ? "secondary" : "outline"}>
<Link href={`/teacher/classes/my/${encodeURIComponent(insights.class.id)}`}>All</Link>
</Button>
<Button asChild size="sm" variant={hwFilter === "active" ? "secondary" : "outline"}>
<Link href={`/teacher/classes/my/${encodeURIComponent(insights.class.id)}?hw=active`}>Active</Link>
</Button>
<Button asChild size="sm" variant={hwFilter === "overdue" ? "secondary" : "outline"}>
<Link href={`/teacher/classes/my/${encodeURIComponent(insights.class.id)}?hw=overdue`}>Overdue</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={`/teacher/homework/assignments?classId=${encodeURIComponent(insights.class.id)}`}>Open list</Link>
</Button>
<Button asChild size="sm">
<Link href={`/teacher/homework/assignments/create?classId=${encodeURIComponent(insights.class.id)}`}>New homework</Link>
</Button>
</div>
</CardHeader>
<CardContent>
{!hasAssignments ? (
<div className="text-sm text-muted-foreground">No homework assignments yet.</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Assignment</TableHead>
<TableHead>Status</TableHead>
<TableHead>Due</TableHead>
<TableHead className="text-right">Targeted</TableHead>
<TableHead className="text-right">Submitted</TableHead>
<TableHead className="text-right">Graded</TableHead>
<TableHead className="text-right">Avg</TableHead>
<TableHead className="text-right">Median</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredAssignments.map((a) => (
<TableRow key={a.assignmentId}>
<TableCell className="font-medium">
<Link href={`/teacher/homework/assignments/${a.assignmentId}`} className="hover:underline">
{a.title}
</Link>
<div className="text-xs text-muted-foreground">Created {formatDate(a.createdAt)}</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{a.status}
</Badge>
</TableCell>
<TableCell>{a.dueAt ? formatDate(a.dueAt) : "-"}</TableCell>
<TableCell className="text-right">{a.targetCount}</TableCell>
<TableCell className="text-right">{a.submittedCount}</TableCell>
<TableCell className="text-right">{a.gradedCount}</TableCell>
<TableCell className="text-right">{formatNumber(a.scoreStats.avg, 1)}</TableCell>
<TableCell className="text-right">{formatNumber(a.scoreStats.median, 1)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,32 @@
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-2">
<Skeleton className="h-7 w-40" />
<Skeleton className="h-4 w-72" />
</div>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, idx) => (
<div key={idx} className="rounded-lg border bg-card p-6">
<div className="flex items-start justify-between gap-3">
<Skeleton className="h-5 w-[60%]" />
<Skeleton className="h-5 w-20" />
</div>
<Skeleton className="mt-3 h-4 w-32" />
<div className="mt-6 flex items-center justify-between">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-5 w-16" />
</div>
<div className="mt-4 grid grid-cols-2 gap-2">
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,41 @@
import { eq } from "drizzle-orm"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { MyClassesGrid } from "@/modules/classes/components/my-classes-grid"
import { auth } from "@/auth"
import { db } from "@/shared/db"
import { grades } from "@/shared/db/schema"
export const dynamic = "force-dynamic"
export default function MyClassesPage() {
return <MyClassesPageImpl />
}
async function MyClassesPageImpl() {
const classes = await getTeacherClasses()
const session = await auth()
const role = String(session?.user?.role ?? "")
const userId = String(session?.user?.id ?? "").trim()
const canCreateClass = await (async () => {
if (role === "admin") return true
if (!userId) return false
const [row] = await db.select({ id: grades.id }).from(grades).where(eq(grades.gradeHeadId, userId)).limit(1)
return Boolean(row)
})()
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
<div>
<h2 className="text-2xl font-bold tracking-tight">My Classes</h2>
<p className="text-muted-foreground">
Overview of your classes.
</p>
</div>
</div>
<MyClassesGrid classes={classes} canCreateClass={canCreateClass} />
</div>
)
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation"
export default function ClassesPage() {
redirect("/teacher/classes/my")
}

View File

@@ -0,0 +1,29 @@
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-2">
<Skeleton className="h-7 w-40" />
<Skeleton className="h-4 w-72" />
</div>
<Skeleton className="h-10 w-full" />
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, idx) => (
<div key={idx} className="rounded-lg border bg-card p-6">
<div className="flex items-center justify-between">
<Skeleton className="h-5 w-16" />
<Skeleton className="h-5 w-20" />
</div>
<div className="mt-6 space-y-3">
<Skeleton className="h-4 w-[70%]" />
<Skeleton className="h-4 w-[85%]" />
<Skeleton className="h-4 w-[60%]" />
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,90 @@
import { Suspense } from "react"
import { Calendar } from "lucide-react"
import { getClassSchedule, getTeacherClasses } from "@/modules/classes/data-access"
import { ScheduleFilters } from "@/modules/classes/components/schedule-filters"
import { ScheduleView } from "@/modules/classes/components/schedule-view"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Skeleton } from "@/shared/components/ui/skeleton"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
async function ScheduleResults({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams
const classId = getParam(params, "classId")
const classes = await getTeacherClasses()
const schedule = await getClassSchedule({
classId: classId && classId !== "all" ? classId : undefined,
})
const hasFilters = Boolean(classId && classId !== "all")
if (schedule.length === 0) {
return (
<EmptyState
icon={Calendar}
title={hasFilters ? "No schedule for this class" : "No schedule available"}
description={hasFilters ? "Try selecting another class." : "Your class schedule has not been set up yet."}
action={hasFilters ? { label: "Clear filters", href: "/teacher/classes/schedule" } : undefined}
className="h-[360px] bg-card"
/>
)
}
return <ScheduleView schedule={schedule} classes={classes} />
}
function ScheduleResultsFallback() {
return (
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, idx) => (
<div key={idx} className="rounded-lg border bg-card p-6">
<div className="flex items-center justify-between">
<Skeleton className="h-5 w-16" />
<Skeleton className="h-5 w-20" />
</div>
<div className="mt-6 space-y-3">
<Skeleton className="h-4 w-[70%]" />
<Skeleton className="h-4 w-[85%]" />
<Skeleton className="h-4 w-[60%]" />
</div>
</div>
))}
</div>
)
}
export default async function SchedulePage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const classes = await getTeacherClasses()
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
<div>
<h2 className="text-2xl font-bold tracking-tight">Schedule</h2>
<p className="text-muted-foreground">
View class schedule.
</p>
</div>
</div>
<div className="space-y-4">
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
<ScheduleFilters classes={classes} />
</Suspense>
<Suspense fallback={<ScheduleResultsFallback />}>
<ScheduleResults searchParams={searchParams} />
</Suspense>
</div>
</div>
)
}

View File

@@ -0,0 +1,21 @@
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-2">
<Skeleton className="h-7 w-40" />
<Skeleton className="h-4 w-72" />
</div>
<Skeleton className="h-10 w-full" />
<div className="rounded-md border bg-card">
<div className="space-y-2 p-4">
{Array.from({ length: 10 }).map((_, idx) => (
<Skeleton key={idx} className="h-10 w-full" />
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,91 @@
import { Suspense } from "react"
import { User } from "lucide-react"
import { getClassStudents, getTeacherClasses } from "@/modules/classes/data-access"
import { StudentsFilters } from "@/modules/classes/components/students-filters"
import { StudentsTable } from "@/modules/classes/components/students-table"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Skeleton } from "@/shared/components/ui/skeleton"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
async function StudentsResults({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams
const q = getParam(params, "q") || undefined
const classId = getParam(params, "classId")
const filteredStudents = await getClassStudents({
q,
classId: classId && classId !== "all" ? classId : undefined,
})
const hasFilters = Boolean(q || (classId && classId !== "all"))
if (filteredStudents.length === 0) {
return (
<EmptyState
icon={User}
title={hasFilters ? "No students match your filters" : "No students found"}
description={hasFilters ? "Try clearing filters or adjusting keywords." : "There are no students in your classes yet."}
action={hasFilters ? { label: "Clear filters", href: "/teacher/classes/students" } : undefined}
className="h-[360px] bg-card"
/>
)
}
return (
<div className="rounded-md border bg-card">
<StudentsTable students={filteredStudents} />
</div>
)
}
function StudentsResultsFallback() {
return (
<div className="rounded-md border bg-card">
<div className="p-4">
<Skeleton className="h-8 w-full" />
</div>
<div className="space-y-2 p-4 pt-0">
{Array.from({ length: 8 }).map((_, idx) => (
<Skeleton key={idx} className="h-10 w-full" />
))}
</div>
</div>
)
}
export default async function StudentsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const classes = await getTeacherClasses()
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
<div>
<h2 className="text-2xl font-bold tracking-tight">Students</h2>
<p className="text-muted-foreground">
Manage student list.
</p>
</div>
</div>
<div className="space-y-4">
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
<StudentsFilters classes={classes} />
</Suspense>
<Suspense fallback={<StudentsResultsFallback />}>
<StudentsResults searchParams={searchParams} />
</Suspense>
</div>
</div>
)
}

View File

@@ -0,0 +1,27 @@
import { TeacherDashboardView } from "@/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view"
import { getClassSchedule, getTeacherClasses, getTeacherIdForMutations } from "@/modules/classes/data-access";
import { getHomeworkAssignments, getHomeworkSubmissions } from "@/modules/homework/data-access";
export const dynamic = "force-dynamic";
export default async function TeacherDashboardPage() {
const teacherId = await getTeacherIdForMutations();
const [classes, schedule, assignments, submissions] = await Promise.all([
getTeacherClasses({ teacherId }),
getClassSchedule({ teacherId }),
getHomeworkAssignments({ creatorId: teacherId }),
getHomeworkSubmissions({ creatorId: teacherId }),
]);
return (
<TeacherDashboardView
data={{
classes,
schedule,
assignments,
submissions,
}}
/>
)
}

View File

@@ -0,0 +1,127 @@
import { notFound } from "next/navigation"
import { ExamAssembly } from "@/modules/exams/components/exam-assembly"
import { getExamById } from "@/modules/exams/data-access"
import { getQuestions } from "@/modules/questions/data-access"
import type { Question } from "@/modules/questions/types"
import type { ExamNode } from "@/modules/exams/components/assembly/selected-question-list"
import { createId } from "@paralleldrive/cuid2"
export default async function BuildExamPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const exam = await getExamById(id)
if (!exam) return notFound()
// Fetch all available questions (for selection pool)
// In a real app, this might be paginated or filtered by exam subject/grade
const { data: questionsData } = await getQuestions({ pageSize: 100 })
const initialSelected = (exam.questions || []).map(q => ({
id: q.id,
score: q.score || 0
}))
const selectedQuestionIds = initialSelected.map((s) => s.id)
const { data: selectedQuestionsData } = selectedQuestionIds.length
? await getQuestions({ ids: selectedQuestionIds, pageSize: Math.max(10, selectedQuestionIds.length) })
: { data: [] as typeof questionsData }
type RawQuestion = (typeof questionsData)[number]
const toQuestionOption = (q: RawQuestion): Question => ({
id: q.id,
content: q.content as Question["content"],
type: q.type as Question["type"],
difficulty: q.difficulty ?? 1,
createdAt: new Date(q.createdAt),
updatedAt: new Date(q.updatedAt),
author: q.author
? {
id: q.author.id,
name: q.author.name || "Unknown",
image: q.author.image || null,
}
: null,
knowledgePoints: q.knowledgePoints ?? [],
})
const questionOptionsById = new Map<string, Question>()
for (const q of questionsData) questionOptionsById.set(q.id, toQuestionOption(q))
for (const q of selectedQuestionsData) questionOptionsById.set(q.id, toQuestionOption(q))
const questionOptions = Array.from(questionOptionsById.values())
const normalizeStructure = (nodes: unknown): ExamNode[] => {
const seen = new Set<string>()
const isRecord = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null
const normalize = (raw: unknown[]): ExamNode[] => {
return raw
.map((n) => {
if (!isRecord(n)) return null
const type = n.type
if (type !== "group" && type !== "question") return null
let id = typeof n.id === "string" && n.id.length > 0 ? n.id : createId()
while (seen.has(id)) id = createId()
seen.add(id)
if (type === "group") {
return {
id,
type: "group",
title: typeof n.title === "string" ? n.title : undefined,
children: normalize(Array.isArray(n.children) ? n.children : []),
} satisfies ExamNode
}
if (typeof n.questionId !== "string" || n.questionId.length === 0) return null
return {
id,
type: "question",
questionId: n.questionId,
score: typeof n.score === "number" ? n.score : undefined,
} satisfies ExamNode
})
.filter(Boolean) as ExamNode[]
}
if (!Array.isArray(nodes)) return []
return normalize(nodes)
}
let initialStructure: ExamNode[] = normalizeStructure(exam.structure)
if (initialStructure.length === 0 && initialSelected.length > 0) {
initialStructure = initialSelected.map((s) => ({
id: createId(),
type: "question",
questionId: s.id,
score: s.score,
}))
}
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">Build Exam</h2>
<p className="text-muted-foreground">Add questions and adjust scores.</p>
</div>
</div>
<ExamAssembly
examId={exam.id}
title={exam.title}
subject={exam.subject}
grade={exam.grade}
difficulty={exam.difficulty}
totalScore={exam.totalScore}
durationMin={exam.durationMin}
initialSelected={initialSelected}
initialStructure={initialStructure}
questionOptions={questionOptions}
/>
</div>
)
}

View File

@@ -0,0 +1,25 @@
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-2">
<Skeleton className="h-7 w-40" />
<Skeleton className="h-4 w-72" />
</div>
<div className="space-y-3">
<Skeleton className="h-10 w-full" />
<div className="rounded-md border p-4">
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-[95%]" />
<Skeleton className="h-4 w-[90%]" />
<Skeleton className="h-4 w-[85%]" />
<Skeleton className="h-4 w-[80%]" />
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,152 @@
import { Suspense } from "react"
import Link from "next/link"
import { Button } from "@/shared/components/ui/button"
import { Badge } from "@/shared/components/ui/badge"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Skeleton } from "@/shared/components/ui/skeleton"
import { ExamDataTable } from "@/modules/exams/components/exam-data-table"
import { examColumns } from "@/modules/exams/components/exam-columns"
import { ExamFilters } from "@/modules/exams/components/exam-filters"
import { getExams } from "@/modules/exams/data-access"
import { FileText, PlusCircle } from "lucide-react"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
async function ExamsResults({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams
const q = getParam(params, "q")
const status = getParam(params, "status")
const difficulty = getParam(params, "difficulty")
const exams = await getExams({
q,
status,
difficulty,
})
const hasFilters = Boolean(q || (status && status !== "all") || (difficulty && difficulty !== "all"))
const counts = exams.reduce(
(acc, e) => {
acc.total += 1
if (e.status === "draft") acc.draft += 1
if (e.status === "published") acc.published += 1
if (e.status === "archived") acc.archived += 1
return acc
},
{ total: 0, draft: 0, published: 0, archived: 0 }
)
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 rounded-md border bg-card px-4 py-3 md:flex-row md:items-center md:justify-between">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">Showing</span>
<span className="text-sm font-medium">{counts.total}</span>
<span className="text-sm text-muted-foreground">exams</span>
<Badge variant="outline" className="ml-0 md:ml-2">
Draft {counts.draft}
</Badge>
<Badge variant="outline">Published {counts.published}</Badge>
<Badge variant="outline">Archived {counts.archived}</Badge>
</div>
<div className="flex items-center gap-2">
<Button asChild size="sm">
<Link href="/teacher/exams/create" className="inline-flex items-center gap-2">
<PlusCircle className="h-4 w-4" />
Create Exam
</Link>
</Button>
</div>
</div>
{exams.length === 0 ? (
<EmptyState
icon={FileText}
title={hasFilters ? "No exams match your filters" : "No exams yet"}
description={
hasFilters
? "Try clearing filters or adjusting keywords."
: "Create your first exam to start assigning and grading."
}
action={
hasFilters
? {
label: "Clear filters",
href: "/teacher/exams/all",
}
: {
label: "Create Exam",
href: "/teacher/exams/create",
}
}
className="h-[360px] bg-card"
/>
) : (
<ExamDataTable columns={examColumns} data={exams} />
)}
</div>
)
}
function ExamsResultsFallback() {
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 rounded-md border bg-card px-4 py-3 md:flex-row md:items-center md:justify-between">
<div className="flex flex-wrap items-center gap-2">
<Skeleton className="h-4 w-[160px]" />
<Skeleton className="h-5 w-[92px]" />
<Skeleton className="h-5 w-[112px]" />
<Skeleton className="h-5 w-[106px]" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-9 w-[120px]" />
<Skeleton className="h-9 w-[132px]" />
</div>
</div>
<div className="rounded-md border bg-card">
<div className="p-4">
<Skeleton className="h-8 w-full" />
</div>
<div className="space-y-2 p-4 pt-0">
{Array.from({ length: 6 }).map((_, idx) => (
<Skeleton key={idx} className="h-10 w-full" />
))}
</div>
</div>
</div>
)
}
export default async function AllExamsPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
<div>
<h2 className="text-2xl font-bold tracking-tight">All Exams</h2>
<p className="text-muted-foreground">View and manage all your exams.</p>
</div>
</div>
<div className="space-y-4">
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
<ExamFilters />
</Suspense>
<Suspense fallback={<ExamsResultsFallback />}>
<ExamsResults searchParams={searchParams} />
</Suspense>
</div>
</div>
)
}

View File

@@ -0,0 +1,17 @@
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-2">
<Skeleton className="h-7 w-40" />
<Skeleton className="h-4 w-72" />
</div>
<div className="space-y-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-[240px] w-full" />
</div>
</div>
)
}

View File

@@ -0,0 +1,15 @@
import { ExamForm } from "@/modules/exams/components/exam-form"
export default function CreateExamPage() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">Create Exam</h2>
<p className="text-muted-foreground">Design a new exam for your students.</p>
</div>
</div>
<ExamForm />
</div>
)
}

View File

@@ -0,0 +1,6 @@
import { redirect } from "next/navigation"
export default async function SubmissionGradingPage({ params }: { params: Promise<{ submissionId: string }> }) {
await params
redirect("/teacher/homework/submissions")
}

View File

@@ -0,0 +1,21 @@
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-2">
<Skeleton className="h-7 w-40" />
<Skeleton className="h-4 w-72" />
</div>
<div className="rounded-md border p-4">
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-[95%]" />
<Skeleton className="h-4 w-[90%]" />
<Skeleton className="h-4 w-[85%]" />
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation"
export default async function ExamGradingPage() {
redirect("/teacher/homework/submissions")
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation"
export default function ExamsPage() {
redirect("/teacher/exams/all")
}

View File

@@ -0,0 +1,244 @@
import { getTeacherIdForMutations } from "@/modules/classes/data-access"
import { getGradeHomeworkInsights } from "@/modules/classes/data-access"
import { getGradesForStaff } from "@/modules/school/data-access"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { BarChart3 } from "lucide-react"
import { formatDate } from "@/shared/lib/utils"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
if (typeof v === "string") return v
if (Array.isArray(v)) return v[0]
return undefined
}
const fmt = (v: number | null, digits = 1) => (typeof v === "number" && Number.isFinite(v) ? v.toFixed(digits) : "-")
export default async function TeacherGradeInsightsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams
const gradeId = getParam(params, "gradeId")
const teacherId = await getTeacherIdForMutations()
const grades = await getGradesForStaff(teacherId)
const allowedIds = new Set(grades.map((g) => g.id))
const selected = gradeId && gradeId !== "all" && allowedIds.has(gradeId) ? gradeId : ""
const insights = selected ? await getGradeHomeworkInsights({ gradeId: selected, limit: 50 }) : null
if (grades.length === 0) {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Grade Insights</h2>
<p className="text-muted-foreground">View grade-level homework statistics for grades you lead.</p>
</div>
<EmptyState
icon={BarChart3}
title="No grades assigned"
description="You are not assigned as a grade head or teaching head for any grade."
className="h-[360px] bg-card"
/>
</div>
)
}
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Grade Insights</h2>
<p className="text-muted-foreground">Homework statistics aggregated across all classes in a grade.</p>
</div>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Filters</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{grades.length}
</Badge>
</CardHeader>
<CardContent>
<form action="/teacher/grades/insights" method="get" className="flex flex-col gap-3 md:flex-row md:items-center">
<label className="text-sm font-medium">Grade</label>
<select
name="gradeId"
defaultValue={selected || "all"}
className="h-10 w-full rounded-md border bg-background px-3 text-sm md:w-[360px]"
>
<option value="all">Select a grade</option>
{grades.map((g) => (
<option key={g.id} value={g.id}>
{g.school.name} / {g.name}
</option>
))}
</select>
<Button type="submit" className="md:ml-2">
Apply
</Button>
</form>
</CardContent>
</Card>
{!selected ? (
<EmptyState
icon={BarChart3}
title="Select a grade to view insights"
description="Pick a grade to see latest homework and historical score statistics."
className="h-[360px] bg-card"
/>
) : !insights ? (
<EmptyState
icon={BarChart3}
title="Grade not found"
description="This grade may not exist or has no accessible data."
className="h-[360px] bg-card"
/>
) : insights.assignments.length === 0 ? (
<EmptyState
icon={BarChart3}
title="No homework data for this grade"
description="No homework assignments were targeted to students in this grade yet."
className="h-[360px] bg-card"
/>
) : (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Classes</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{insights.classCount}</div>
<div className="text-xs text-muted-foreground">
{insights.grade.school.name} / {insights.grade.name}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Students</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{insights.studentCounts.total}</div>
<div className="text-xs text-muted-foreground">
Active {insights.studentCounts.active} Inactive {insights.studentCounts.inactive}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Overall Avg</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{fmt(insights.overallScores.avg)}</div>
<div className="text-xs text-muted-foreground">Across graded homework</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Latest Avg</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{fmt(insights.latest?.scoreStats.avg ?? null)}</div>
<div className="text-xs text-muted-foreground">{insights.latest ? insights.latest.title : "-"}</div>
</CardContent>
</Card>
</div>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Homework timeline</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{insights.assignments.length}
</Badge>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead>Assignment</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Targeted</TableHead>
<TableHead className="text-right">Submitted</TableHead>
<TableHead className="text-right">Graded</TableHead>
<TableHead className="text-right">Avg</TableHead>
<TableHead className="text-right">Median</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{insights.assignments.map((a) => (
<TableRow key={a.assignmentId}>
<TableCell className="font-medium">{a.title}</TableCell>
<TableCell>
<Badge variant="secondary" className="capitalize">
{a.status}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">{formatDate(a.createdAt)}</TableCell>
<TableCell className="text-right tabular-nums">{a.targetCount}</TableCell>
<TableCell className="text-right tabular-nums">{a.submittedCount}</TableCell>
<TableCell className="text-right tabular-nums">{a.gradedCount}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(a.scoreStats.avg)}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(a.scoreStats.median)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Class ranking</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{insights.classes.length}
</Badge>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead>Class</TableHead>
<TableHead className="text-right">Students</TableHead>
<TableHead className="text-right">Latest Avg</TableHead>
<TableHead className="text-right">Prev Avg</TableHead>
<TableHead className="text-right">Δ</TableHead>
<TableHead className="text-right">Overall Avg</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{insights.classes.map((c) => (
<TableRow key={c.class.id}>
<TableCell className="font-medium">
{c.class.name}
{c.class.homeroom ? <span className="text-muted-foreground"> {c.class.homeroom}</span> : null}
</TableCell>
<TableCell className="text-right tabular-nums">{c.studentCounts.total}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(c.latestAvg)}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(c.prevAvg)}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(c.deltaAvg)}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(c.overallScores.avg)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,101 @@
import Link from "next/link"
import { notFound } from "next/navigation"
import { getHomeworkAssignmentAnalytics } from "@/modules/homework/data-access"
import { HomeworkAssignmentExamContentCard } from "@/modules/homework/components/homework-assignment-exam-content-card"
import { HomeworkAssignmentQuestionErrorDetailsCard } from "@/modules/homework/components/homework-assignment-question-error-details-card"
import { HomeworkAssignmentQuestionErrorOverviewCard } from "@/modules/homework/components/homework-assignment-question-error-overview-card"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { formatDate } from "@/shared/lib/utils"
export const dynamic = "force-dynamic"
export default async function HomeworkAssignmentDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const analytics = await getHomeworkAssignmentAnalytics(id)
if (!analytics) return notFound()
const { assignment, questions, gradedSampleCount } = analytics
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div>
<div className="flex items-center gap-3">
<h2 className="text-2xl font-bold tracking-tight">{assignment.title}</h2>
<Badge variant="outline" className="capitalize">
{assignment.status}
</Badge>
</div>
<p className="text-muted-foreground mt-1">{assignment.description || "—"}</p>
<div className="mt-2 text-sm text-muted-foreground">
<span>Source Exam: {assignment.sourceExamTitle}</span>
<span className="mx-2"></span>
<span>Created: {formatDate(assignment.createdAt)}</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline">
<Link href="/teacher/homework/assignments">Back</Link>
</Button>
<Button asChild>
<Link href={`/teacher/homework/assignments/${assignment.id}/submissions`}>View Submissions</Link>
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Targets</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{assignment.targetCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Submissions</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{assignment.submissionCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Due</CardTitle>
</CardHeader>
<CardContent>
<div className="text-sm">
<div className="font-medium">{assignment.dueAt ? formatDate(assignment.dueAt) : "—"}</div>
<div className="text-muted-foreground">
Late:{" "}
{assignment.allowLate
? assignment.lateDueAt
? formatDate(assignment.lateDueAt)
: "Allowed"
: "Not allowed"}
</div>
</div>
</CardContent>
</Card>
</div>
<div className="grid gap-6 md:grid-cols-2">
<HomeworkAssignmentQuestionErrorOverviewCard questions={questions} gradedSampleCount={gradedSampleCount} />
<HomeworkAssignmentQuestionErrorDetailsCard questions={questions} gradedSampleCount={gradedSampleCount} />
</div>
<HomeworkAssignmentExamContentCard
structure={assignment.structure}
questions={questions}
gradedSampleCount={gradedSampleCount}
/>
</div>
)
}

View File

@@ -0,0 +1,86 @@
import Link from "next/link"
import { notFound } from "next/navigation"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils"
import { getHomeworkAssignmentById, getHomeworkSubmissions } from "@/modules/homework/data-access"
export const dynamic = "force-dynamic"
export default async function HomeworkAssignmentSubmissionsPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const assignment = await getHomeworkAssignmentById(id)
if (!assignment) return notFound()
const submissions = await getHomeworkSubmissions({ assignmentId: id })
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div>
<h2 className="text-2xl font-bold tracking-tight">Submissions</h2>
<p className="text-muted-foreground">{assignment.title}</p>
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>Exam: {assignment.sourceExamTitle}</span>
<span></span>
<span>Targets: {assignment.targetCount}</span>
<span></span>
<span>Submitted: {assignment.submittedCount}</span>
<span></span>
<span>Graded: {assignment.gradedCount}</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline">
<Link href="/teacher/homework/submissions">Back</Link>
</Button>
<Button asChild variant="outline">
<Link href={`/teacher/homework/assignments/${id}`}>Open Assignment</Link>
</Button>
</div>
</div>
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Student</TableHead>
<TableHead>Status</TableHead>
<TableHead>Submitted</TableHead>
<TableHead>Score</TableHead>
<TableHead>Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{submissions.map((s) => (
<TableRow key={s.id}>
<TableCell className="font-medium">{s.studentName}</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{s.status}
</Badge>
{s.isLate ? <span className="ml-2 text-xs text-destructive">Late</span> : null}
</TableCell>
<TableCell className="text-muted-foreground">{s.submittedAt ? formatDate(s.submittedAt) : "-"}</TableCell>
<TableCell>{typeof s.score === "number" ? s.score : "-"}</TableCell>
<TableCell>
<Link href={`/teacher/homework/submissions/${s.id}`} className="text-sm underline-offset-4 hover:underline">
Grade
</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)
}

View File

@@ -0,0 +1,41 @@
import { HomeworkAssignmentForm } from "@/modules/homework/components/homework-assignment-form"
import { getExams } from "@/modules/exams/data-access"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { FileQuestion } from "lucide-react"
export const dynamic = "force-dynamic"
export default async function CreateHomeworkAssignmentPage() {
const [exams, classes] = await Promise.all([getExams({}), getTeacherClasses()])
const options = exams.map((e) => ({ id: e.id, title: e.title }))
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">Create Assignment</h2>
<p className="text-muted-foreground">Dispatch homework from an existing exam.</p>
</div>
</div>
{options.length === 0 ? (
<EmptyState
title="No exams available"
description="Create an exam first, then dispatch it as homework."
icon={FileQuestion}
action={{ label: "Create Exam", href: "/teacher/exams/create" }}
/>
) : classes.length === 0 ? (
<EmptyState
title="No classes available"
description="Create a class first, then publish homework to that class."
icon={FileQuestion}
action={{ label: "Go to Classes", href: "/teacher/classes/my" }}
/>
) : (
<HomeworkAssignmentForm exams={options} classes={classes} />
)}
</div>
)
}

View File

@@ -0,0 +1,117 @@
import Link from "next/link"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils"
import { getHomeworkAssignments } from "@/modules/homework/data-access"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { PenTool, PlusCircle } from "lucide-react"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
export default async function AssignmentsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const sp = await searchParams
const classId = getParam(sp, "classId") || undefined
const [assignments, classes] = await Promise.all([
getHomeworkAssignments({ classId: classId && classId !== "all" ? classId : undefined }),
classId && classId !== "all" ? getTeacherClasses() : Promise.resolve([]),
])
const hasAssignments = assignments.length > 0
const className = classId && classId !== "all" ? classes.find((c) => c.id === classId)?.name : undefined
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">Assignments</h2>
<p className="text-muted-foreground">
{classId && classId !== "all" ? `Filtered by class: ${className ?? classId}` : "Manage homework assignments."}
</p>
</div>
<div className="flex items-center gap-2">
{classId && classId !== "all" ? (
<Button asChild variant="outline">
<Link href="/teacher/homework/assignments">Clear filter</Link>
</Button>
) : null}
<Button asChild>
<Link
href={
classId && classId !== "all"
? `/teacher/homework/assignments/create?classId=${encodeURIComponent(classId)}`
: "/teacher/homework/assignments/create"
}
>
<PlusCircle className="mr-2 h-4 w-4" />
Create Assignment
</Link>
</Button>
</div>
</div>
{!hasAssignments ? (
<EmptyState
title="No assignments"
description={classId && classId !== "all" ? "No assignments for this class yet." : "You haven't created any assignments yet."}
icon={PenTool}
action={{
label: "Create Assignment",
href:
classId && classId !== "all"
? `/teacher/homework/assignments/create?classId=${encodeURIComponent(classId)}`
: "/teacher/homework/assignments/create",
}}
/>
) : (
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Status</TableHead>
<TableHead>Due</TableHead>
<TableHead>Source Exam</TableHead>
<TableHead>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assignments.map((a) => (
<TableRow key={a.id}>
<TableCell className="font-medium">
<Link href={`/teacher/homework/assignments/${a.id}`} className="hover:underline">
{a.title}
</Link>
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{a.status}
</Badge>
</TableCell>
<TableCell>{a.dueAt ? formatDate(a.dueAt) : "-"}</TableCell>
<TableCell className="text-muted-foreground">{a.sourceExamTitle}</TableCell>
<TableCell className="text-muted-foreground">{formatDate(a.createdAt)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation"
export default function HomeworkPage() {
redirect("/teacher/homework/assignments")
}

View File

@@ -0,0 +1,42 @@
import { notFound } from "next/navigation"
import { getHomeworkSubmissionDetails } from "@/modules/homework/data-access"
import { HomeworkGradingView } from "@/modules/homework/components/homework-grading-view"
import { formatDate } from "@/shared/lib/utils"
export const dynamic = "force-dynamic"
export default async function HomeworkSubmissionGradingPage({ params }: { params: Promise<{ submissionId: string }> }) {
const { submissionId } = await params
const submission = await getHomeworkSubmissionDetails(submissionId)
if (!submission) return notFound()
return (
<div className="flex h-full flex-col space-y-4 p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">{submission.assignmentTitle}</h2>
<div className="flex items-center gap-4 text-sm text-muted-foreground mt-1">
<span>
Student: <span className="font-medium text-foreground">{submission.studentName}</span>
</span>
<span></span>
<span>Submitted: {submission.submittedAt ? formatDate(submission.submittedAt) : "-"}</span>
<span></span>
<span className="capitalize">Status: {submission.status}</span>
</div>
</div>
</div>
<HomeworkGradingView
submissionId={submission.id}
studentName={submission.studentName}
assignmentTitle={submission.assignmentTitle}
submittedAt={submission.submittedAt}
status={submission.status}
totalScore={submission.totalScore}
answers={submission.answers}
/>
</div>
)
}

View File

@@ -0,0 +1,80 @@
import Link from "next/link"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Badge } from "@/shared/components/ui/badge"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils"
import { getHomeworkAssignmentReviewList } from "@/modules/homework/data-access"
import { Inbox } from "lucide-react"
import { getTeacherIdForMutations } from "@/modules/classes/data-access"
export const dynamic = "force-dynamic"
export default async function SubmissionsPage() {
const creatorId = await getTeacherIdForMutations()
const assignments = await getHomeworkAssignmentReviewList({ creatorId })
const hasAssignments = assignments.length > 0
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">Submissions</h2>
<p className="text-muted-foreground">
Review homework by assignment.
</p>
</div>
</div>
{!hasAssignments ? (
<EmptyState
title="No assignments"
description="There are no homework assignments to review yet."
icon={Inbox}
/>
) : (
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Assignment</TableHead>
<TableHead>Status</TableHead>
<TableHead>Due</TableHead>
<TableHead className="text-right">Targets</TableHead>
<TableHead className="text-right">Submitted</TableHead>
<TableHead className="text-right">Graded</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assignments.map((a) => (
<TableRow key={a.id}>
<TableCell className="font-medium">
<Link href={`/teacher/homework/assignments/${a.id}/submissions`} className="hover:underline">
{a.title}
</Link>
<div className="text-xs text-muted-foreground">{a.sourceExamTitle}</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{a.status}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">{a.dueAt ? formatDate(a.dueAt) : "-"}</TableCell>
<TableCell className="text-right">{a.targetCount}</TableCell>
<TableCell className="text-right">{a.submittedCount}</TableCell>
<TableCell className="text-right">{a.gradedCount}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
)
}

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