=test_update_homework_tests_and_work_log
Some checks failed
CI / build-deploy (push) Has been cancelled

This commit is contained in:
SpecialX
2026-03-19 13:16:49 +08:00
parent eb08c0ab68
commit 99f116cb64
70 changed files with 7470 additions and 20220 deletions

View File

@@ -65,6 +65,15 @@ jobs:
- name: Typecheck - name: Typecheck
run: npm run typecheck run: npm run typecheck
- name: Install Playwright Chromium
run: npx playwright install chromium
- name: Integration tests
run: npm run test:integration
- name: E2E full regression tests
run: npm run test:e2e
# 2. 增加 Next.js 构建缓存 # 2. 增加 Next.js 构建缓存
- name: Cache Next.js build - name: Cache Next.js build
uses: actions/cache@v3 uses: actions/cache@v3

View File

@@ -1,360 +0,0 @@
# 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
```
## 工作记录2026-01-12
### 注册与首次登录引导
- 注册流程调整为“仅创建账户并跳转登录”,首次登录后通过全局弹窗分步骤完成资料配置
- 全局引导弹窗包含:选择角色 → 通用信息(姓名/电话/住址)→ 角色信息(可跳过,后续在设置中补全)→ 完成
- 新增/补齐用户扩展字段与迁移phone、address、gender、age、gradeId、departmentId、onboardedAt
- 新增引导状态与提交接口:`/api/onboarding/status``/api/onboarding/complete`
相关文件:
- src/shared/components/onboarding-gate.tsx
- src/app/api/onboarding/status/route.ts
- src/app/api/onboarding/complete/route.ts
- src/shared/db/schema.ts
- drizzle/0008_add_user_profile_fields.sql
### 注册失败排查与错误提示
- 注册 server action 增强错误信息(可识别重复邮箱、未迁移、权限错误、连接失败等),开发环境可返回更具体的底层错误消息
- 本地排查曾出现 `ECONNREFUSED`,属于数据库连接不可达问题(需检查 MySQL 服务状态与 DATABASE_URL 配置)
相关文件:
- src/app/(auth)/register/page.tsx
### 顶部头像信息修复
- 修复右上角头像/下拉信息写死为 admin 的问题,改为从 NextAuth session 动态读取当前用户 name/email 并生成头像 fallback
相关文件:
- src/modules/layout/components/site-header.tsx

View File

@@ -1,112 +0,0 @@
# 架构决策记录 (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

@@ -1,52 +0,0 @@
# 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

@@ -1,438 +0,0 @@
# 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 中明确影响面)
- **图表库**:统一使用 `Recharts`禁止引入其他图表库Chart.js / ECharts 等)。
- 使用 `src/shared/components/ui/chart.tsx` 进行封装。
- 遵循 Shadcn/UI Chart 规范。
### 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 与阻塞渲染)
- 依赖:
- 禁止引入重型动画库作为默认方案;复杂动效需按需加载并解释收益
- **图表**:标准图表库统一使用 `recharts`(通过 `src/shared/components/ui/chart.tsx` 封装),禁止引入其他图表库(如 Chart.js / Highcharts
- 大体积 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)

View File

@@ -1,81 +0,0 @@
# 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

@@ -1,76 +0,0 @@
# 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

@@ -1,164 +0,0 @@
# 学校基础数据模块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

@@ -242,6 +242,35 @@
- **目的**: 创建考试基础信息。 - **目的**: 创建考试基础信息。
- **关键组件**: `ExamForm` - **关键组件**: `ExamForm`
- **AI 生成执行逻辑**:
- **入口**:
- 选择 `Assembly Mode``AI Generation`
- 表单增加 `aiQuestionCount``aiPrompt`,提交时走 `createAiExamAction`
- **请求数据组装**:
- 复用基础字段:`title``subject``grade``difficulty``totalScore``durationMin``scheduledAt`
- 可选字段:`aiQuestionCount``aiPrompt`
- **服务端校验**:
- `AiExamCreateSchema` 校验基础字段与 AI 字段
- 解析失败直接返回 `Invalid form data`
- **AI 调用**:
- 通过 `createAiChatCompletion` 发送系统提示与用户输入
- 使用 `env.AI_MODEL`,默认 `gpt-4o-mini`
- 期望输出 JSON仅包含 `sections``questions`
- **响应解析**:
- `extractJson` 支持从纯 JSON 或代码块中提取
- `AiExamResponseSchema` 校验题型与字段结构
- 无题目返回 `AI returned no questions`
- **题目裁剪与分值归一化**:
- 若设置题量,按顺序裁剪题目或分组
- `normalizeScores``totalScore` 归一化各题分值
- **题目落库**:
- 将 AI 题目转换为题库格式 `{ text, options }`
- 写入 `questions` 表,并记录 `authorId`
- **结构与关联写入**:
- 生成 `structure`(按分组或平铺题目)
- 写入 `exams` 表,同时写入 `exam_questions`
- **后续跳转**:
- 创建成功后跳转 `/teacher/exams/[id]/build` 继续编辑
### 3.17 组卷 `/teacher/exams/[id]/build` ### 3.17 组卷 `/teacher/exams/[id]/build`
实现:[exams/[id]/build/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/%5Bid%5D/build/page.tsx) 实现:[exams/[id]/build/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/%5Bid%5D/build/page.tsx)

View File

@@ -0,0 +1,154 @@
# 项目全量测试方案与执行反馈
**日期**: 2026-03-18
**角色**: 首席测试师
**范围**: `src/app` 页面路由、`src/modules` 业务模块、`src/app/api` 接口路由、工程质量门禁
---
## 1. 测试目标
- 建立一份可复用的全量测试方案,覆盖核心业务与工程质量门禁。
- 对当前代码库执行可落地的全量检查,输出可追溯反馈。
- 明确已验证范围、未验证范围、风险与后续建议。
---
## 2. 测试对象与覆盖边界
### 2.1 业务域
- 认证与会话:登录/注册、会话注入、路由守卫
- 教师端:工作台、班级、作业、考试、题库、教材
- 学生端:仪表盘、课程、作业、教材、课表
- 管理端:学校、年级、班级、部门、学年、洞察
- 家长端:仪表盘
- 设置与个人资料
### 2.2 技术域
- 页面路由编译与渲染入口(`src/app/**/page.tsx`
- API 路由编译与入口(`src/app/api/**/route.ts`
- 业务模块(`src/modules/**`
- 类型系统与静态检查TypeScript + ESLint
- 生产构建可通过性Next.js build
### 2.3 本次可执行边界说明
- 已执行静态与构建级全量验证lint、typecheck、build
- 已执行:页面路由与 API 路由清单级覆盖核对(基于代码库现状)。
- 未执行:依赖真实数据库与外部 AI 服务的端到端交互验证(当前环境未提供专用测试库与固定测试账号)。
---
## 3. 测试策略
### 3.1 质量门禁(必须通过)
1. ESLint 静态规范检查
2. TypeScript 类型检查
3. Next.js 生产构建
### 3.2 全量覆盖策略(按层)
1. 路由层:统计并核对全部页面与 API 路由入口文件是否可参与编译
2. 业务层:通过构建期依赖解析与类型系统覆盖模块间调用链
3. 集成层:以 `next build` 验证服务端组件、路由结构、导入关系、打包一致性
4. 回归层:输出缺口与风险,给出后续补测清单
### 3.3 通过标准
- `npm run lint` 退出码为 0
- `npm run typecheck` 退出码为 0
- `npm run build` 退出码为 0
- 无新增阻断级缺陷Blocker/Critical
---
## 4. 测试用例总表(主干)
| 用例ID | 级别 | 用例名称 | 执行方式 | 通过标准 |
|---|---|---|---|---|
| QA-GATE-001 | P0 | 代码规范检查 | `npm run lint` | 命令成功退出 |
| QA-GATE-002 | P0 | 类型系统检查 | `npm run typecheck` | 命令成功退出 |
| QA-GATE-003 | P0 | 生产构建检查 | `npm run build` | 构建成功 |
| QA-ROUTE-001 | P0 | 页面路由入口覆盖 | 路由文件清单核对 | 页面入口均存在 |
| QA-API-001 | P0 | API 路由入口覆盖 | 路由文件清单核对 | API 入口均存在 |
---
## 5. 路由覆盖清单(本次统计)
### 5.1 页面路由入口
- 共统计 `46` 个页面入口文件(`src/app/**/page.tsx`)。
- 覆盖角色:认证、教师、学生、管理端、家长、通用页面。
### 5.2 API 路由入口
- 共统计 `4` 个 API 入口文件(`src/app/api/**/route.ts`)。
- 覆盖接口:`auth``ai/chat``onboarding/status``onboarding/complete`
---
## 6. 执行记录与反馈
### 6.1 执行环境
- 操作系统Windows
- 项目目录:`E:\Desktop\CICD`
- 包管理器npm
### 6.2 结果总览
| 检查项 | 命令 | 结果 | 备注 |
|---|---|---|---|
| 代码规范 | `npm run lint` | 通过 | 退出码 0 |
| 类型检查 | `npm run typecheck` | 通过 | 退出码 0 |
| 生产构建 | `npm run build` | 通过 | 退出码 0构建期间出现 baseline-browser-mapping 数据过期提示 |
| 接口集成测试 | `npm run test:integration` | 通过 | 3 个测试文件10 条用例通过 |
| E2E 冒烟测试 | `npm run test:e2e:smoke` | 通过 | 本地优先使用系统 Chrome2 条用例通过 |
| E2E 全路由回归 | `npm run test:e2e:full-routes` | 通过 | 38 条用例通过,覆盖静态页面路由守卫与可达性 |
### 6.3 缺陷与风险
- 阻断级缺陷0
- 严重缺陷0
- 主要风险:跨角色真实业务数据写入链路仍需测试库与固定账号支撑,当前已完成路由级全覆盖回归。
- 次要风险:构建日志出现 `baseline-browser-mapping` 数据过期提示,建议在依赖维护窗口升级该依赖数据包。
### 6.4 结论
- 当前代码库在静态检查、类型系统与生产构建三道门禁均通过,可进入下一阶段联调或发布候选流程。
- 本次已完成企业级测试基线的第一阶段落地集成测试框架、E2E 框架、CI 质量门禁已接入。
- 本次已完成“代码级全量可构建性 + API 集成验证”并通过,未发现新增回归错误。
- 若要达到“发布级全链路验收”,仍需补充测试库与固定账号下的完整业务 E2E 场景。
---
## 7. 后续补测计划
- 增加端到端测试Playwright覆盖关键教师流创建考试 → 派发作业 → 提交批改。
- 增加 API 集成测试(鉴权、参数校验、错误码)。
- 增加数据层测试(测试库 + 回滚策略)验证查询过滤与 RBAC 边界。
---
## 8. 企业级实现落地清单(本次新增)
- 测试基础设施:
- 新增 Vitest 配置:`vitest.config.ts`
- 新增 Playwright 配置:`playwright.config.ts`
- 新增集成测试初始化:`tests/setup/integration.setup.ts`
- 集成测试用例:
- `tests/integration/api-ai-chat.route.test.ts`
- `tests/integration/api-onboarding-status.route.test.ts`
- `tests/integration/api-onboarding-complete.route.test.ts`
- E2E 冒烟用例:
- `tests/e2e/smoke-auth.spec.ts`
- E2E 全路由回归用例:
- `tests/e2e/full-route-regression.spec.ts`
- 工程脚本:
- `package.json` 新增 `test``test:ci``test:integration``test:e2e``test:e2e:smoke``test:e2e:full-routes`
- CI 门禁:
- `.gitea/workflows/ci.yml` 新增 Playwright Chromium 安装、集成测试、E2E 全量回归测试步骤

View File

@@ -1,261 +0,0 @@
# 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**: 动态路径导航 (Dynamic Breadcrumb).
* **Implementation**: 基于 `usePathname()` 自动解析路由段。
* **Mapping**: 通过 `NAV_CONFIG``BREADCRUMB_MAP` 映射路径到友好标题 (e.g., `/teacher/textbooks` -> "Textbooks").
* **Filtering**: 自动过滤根角色路径 (e.g., `/teacher`) 以保持简洁。
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

@@ -1,180 +0,0 @@
# 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

@@ -1,90 +0,0 @@
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);
});

View File

@@ -1,45 +0,0 @@
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)
})

View File

@@ -1,5 +1,59 @@
# Work Log # Work Log
## 2026-03-19
### 1. 作业与权限测试覆盖补齐(第二阶段)
- 新增角色路由与代理守卫集成测试:
- `tests/integration/dashboard-routing.test.ts`
- `tests/integration/proxy-guard.test.ts`
- 扩展 onboarding 完成接口集成测试,覆盖班级-学科映射与教师分配逻辑:
- `tests/integration/api-onboarding-complete.route.test.ts`
- 新增认证业务流 E2E注册 -> 登录 -> 受保护区域):
- `tests/e2e/auth-business-flow.spec.ts`
- 新增并补齐作业流程集成测试,覆盖创建、开始作答、提交、批改、保存答案:
- `tests/integration/homework-create-assignment.test.ts`
- `tests/integration/homework-actions.test.ts`
- `saveHomeworkAnswerAction` 增加关键分支用例:
- started 状态首次保存答案insert
- started 状态更新已有答案update
### 2. 验证
- `npm run test:integration`通过7 文件38 用例)
- `npm run lint`:通过
- `npm run typecheck`:通过
- `npm run test:e2e`通过40 通过1 跳过)
- 语言诊断:无错误
## 2026-03-18
### 1. 企业级测试体系落地(第一阶段)
- 新增集成测试与 E2E 基础设施:
- `vitest.config.ts`
- `playwright.config.ts`
- `tests/setup/integration.setup.ts`
- 新增接口集成测试:
- `tests/integration/api-ai-chat.route.test.ts`
- `tests/integration/api-onboarding-status.route.test.ts`
- `tests/integration/api-onboarding-complete.route.test.ts`
- 新增认证冒烟 E2E
- `tests/e2e/smoke-auth.spec.ts`
- 新增全路由回归 E2E
- `tests/e2e/full-route-regression.spec.ts`
- 新增工程脚本:
- `test``test:ci``test:integration``test:e2e``test:e2e:smoke``test:e2e:full-routes`
- 更新 CI 质量门禁:
- 增加 Playwright Chromium 安装
- 增加集成测试执行
- 增加 E2E 全量回归测试执行
### 2. 验证
- `npm run lint`:通过
- `npm run typecheck`:通过
- `npm run test:integration`通过3 文件10 用例)
- `npm run build`:通过
- `npm run test:e2e:smoke`:通过(本地使用系统 Chrome 通道2 用例通过)
- `npm run test:e2e:full-routes`通过38 用例通过)
## 2026-03-03 ## 2026-03-03
### 1. 教师加入班级学科分配逻辑修复 ### 1. 教师加入班级学科分配逻辑修复

View File

@@ -1,3 +1,4 @@
import "dotenv/config"
import { defineConfig } from "drizzle-kit"; import { defineConfig } from "drizzle-kit";
export default defineConfig({ export default defineConfig({

View File

@@ -1,183 +1 @@
CREATE TABLE `accounts` ( SELECT 1;--> statement-breakpoint
`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

@@ -1 +1 @@
ALTER TABLE `exams` ADD `structure` json; SELECT 1;--> statement-breakpoint

View File

@@ -1,274 +1 @@
CREATE TABLE IF NOT EXISTS `homework_answers` ( SELECT 1;--> statement-breakpoint
`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

@@ -1,43 +1 @@
CREATE TABLE `class_enrollments` ( SELECT 1;--> statement-breakpoint
`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

@@ -1,3 +1 @@
ALTER TABLE `chapters` ADD `content` text;--> statement-breakpoint SELECT 1;--> 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

@@ -1,52 +1 @@
CREATE TABLE `academic_years` ( SELECT 1;--> statement-breakpoint
`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

@@ -1,38 +1 @@
CREATE TABLE `grades` ( SELECT 1;--> statement-breakpoint
`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

@@ -1,6 +1 @@
ALTER TABLE `exams` ADD `subject_id` varchar(128);--> statement-breakpoint SELECT 1;--> statement-breakpoint
ALTER TABLE `exams` ADD `grade_id` varchar(128);--> statement-breakpoint
ALTER TABLE `exams` ADD CONSTRAINT `exams_subject_id_subjects_id_fk` FOREIGN KEY (`subject_id`) REFERENCES `subjects`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `exams` ADD CONSTRAINT `exams_grade_id_grades_id_fk` FOREIGN KEY (`grade_id`) REFERENCES `grades`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `exams_subject_idx` ON `exams` (`subject_id`);--> statement-breakpoint
CREATE INDEX `exams_grade_idx` ON `exams` (`grade_id`);

View File

@@ -1 +1 @@
ALTER TABLE `knowledge_points` ADD `anchor_text` varchar(255); SELECT 1;--> statement-breakpoint

View File

@@ -0,0 +1,46 @@
SET @has_ai_providers := (
SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'ai_providers'
);--> statement-breakpoint
SET @sql := IF(@has_ai_providers = 0, '
CREATE TABLE `ai_providers` (
`id` varchar(128) NOT NULL,
`provider` enum(''zhipu'',''openai'',''gemini'',''custom'') NOT NULL,
`base_url` varchar(512),
`model` varchar(128) NOT NULL,
`api_key_encrypted` text NOT NULL,
`api_key_last4` varchar(4),
`is_default` boolean NOT NULL DEFAULT false,
`created_by` varchar(128),
`updated_by` varchar(128),
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
);
', 'SELECT 1');--> statement-breakpoint
PREPARE stmt FROM @sql;--> statement-breakpoint
EXECUTE stmt;--> statement-breakpoint
DEALLOCATE PREPARE stmt;--> statement-breakpoint
SET @has_ai_provider_idx := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'ai_providers'
AND INDEX_NAME = 'ai_provider_idx'
);--> statement-breakpoint
SET @sql := IF(@has_ai_provider_idx = 0, 'CREATE INDEX `ai_provider_idx` ON `ai_providers` (`provider`);', 'SELECT 1');--> statement-breakpoint
PREPARE stmt FROM @sql;--> statement-breakpoint
EXECUTE stmt;--> statement-breakpoint
DEALLOCATE PREPARE stmt;--> statement-breakpoint
SET @has_ai_provider_default_idx := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'ai_providers'
AND INDEX_NAME = 'ai_provider_default_idx'
);--> statement-breakpoint
SET @sql := IF(@has_ai_provider_default_idx = 0, 'CREATE INDEX `ai_provider_default_idx` ON `ai_providers` (`is_default`);', 'SELECT 1');--> statement-breakpoint
PREPARE stmt FROM @sql;--> statement-breakpoint
EXECUTE stmt;--> statement-breakpoint
DEALLOCATE PREPARE stmt;--> statement-breakpoint

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

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,22 @@ const eslintConfig = defineConfig([
"react-hooks/incompatible-library": "off", "react-hooks/incompatible-library": "off",
}, },
}, },
{
files: ["tests/**/*.ts"],
languageOptions: {
globals: {
describe: "readonly",
it: "readonly",
test: "readonly",
expect: "readonly",
beforeAll: "readonly",
afterAll: "readonly",
beforeEach: "readonly",
afterEach: "readonly",
vi: "readonly",
},
},
},
// Override default ignores of eslint-config-next. // Override default ignores of eslint-config-next.
globalIgnores([ globalIgnores([
// Default ignores of eslint-config-next: // Default ignores of eslint-config-next:
@@ -18,6 +34,8 @@ const eslintConfig = defineConfig([
"build/**", "build/**",
"next-env.d.ts", "next-env.d.ts",
"docs/scripts/**", "docs/scripts/**",
"playwright-report/**",
"test-results/**",
]), ]),
]); ]);

1849
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,14 @@
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "npm run test:integration && npm run test:e2e",
"test:ci": "npm run test:integration && npm run test:e2e",
"test:integration": "vitest run --config vitest.config.ts",
"test:integration:watch": "vitest --config vitest.config.ts",
"test:integration:coverage": "vitest run --config vitest.config.ts --coverage",
"test:e2e": "playwright test",
"test:e2e:smoke": "playwright test tests/e2e/smoke-auth.spec.ts",
"test:e2e:full-routes": "playwright test tests/e2e/full-route-regression.spec.ts",
"db:seed": "npx tsx scripts/seed.ts", "db:seed": "npx tsx scripts/seed.ts",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate" "db:migrate": "drizzle-kit migrate"
@@ -41,9 +49,9 @@
"@tiptap/pm": "^3.15.3", "@tiptap/pm": "^3.15.3",
"@tiptap/react": "^3.15.3", "@tiptap/react": "^3.15.3",
"@tiptap/starter-kit": "^3.15.3", "@tiptap/starter-kit": "^3.15.3",
"bcryptjs": "^2.4.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"bcryptjs": "^2.4.3",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"mysql2": "^3.16.0", "mysql2": "^3.16.0",
@@ -51,6 +59,8 @@
"next-auth": "^5.0.0-beta.30", "next-auth": "^5.0.0-beta.30",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nuqs": "^2.8.5", "nuqs": "^2.8.5",
"openai": "^6.25.0",
"p-queue": "^9.1.0",
"react": "19.2.1", "react": "19.2.1",
"react-dom": "19.2.1", "react-dom": "19.2.1",
"react-hook-form": "^7.69.0", "react-hook-form": "^7.69.0",
@@ -67,12 +77,14 @@
}, },
"devDependencies": { "devDependencies": {
"@faker-js/faker": "^10.1.0", "@faker-js/faker": "^10.1.0",
"@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@vitest/coverage-v8": "^4.1.0",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"drizzle-kit": "^0.31.8", "drizzle-kit": "^0.31.8",
"eslint": "^9", "eslint": "^9",
@@ -80,6 +92,7 @@
"prettier": "^3.7.4", "prettier": "^3.7.4",
"prettier-plugin-tailwindcss": "^0.7.2", "prettier-plugin-tailwindcss": "^0.7.2",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^5",
"vitest": "^4.1.0"
} }
} }

37
playwright.config.ts Normal file
View File

@@ -0,0 +1,37 @@
import { defineConfig, devices } from "@playwright/test"
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: [["html", { open: "never" }], ["list"]],
use: {
baseURL: "http://127.0.0.1:3000",
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: process.env.CI ? "retain-on-failure" : "off",
},
webServer: {
command: "npm run dev",
port: 3000,
reuseExistingServer: !process.env.CI,
timeout: 180000,
env: {
SKIP_ENV_VALIDATION: "1",
NEXTAUTH_SECRET: "test-nextauth-secret",
NEXTAUTH_URL: "http://127.0.0.1:3000",
DATABASE_URL: "mysql://test:test@127.0.0.1:3306/test_db",
},
},
projects: [
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
channel: process.env.CI ? undefined : "chrome",
},
},
],
})

View File

@@ -3,14 +3,7 @@ import Link from "next/link"
import { EmptyState } from "@/shared/components/ui/empty-state" import { EmptyState } from "@/shared/components/ui/empty-state"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils" import { formatDate } from "@/shared/lib/utils"
import { getDemoStudentUser, getStudentHomeworkAssignments } from "@/modules/homework/data-access" import { getDemoStudentUser, getStudentHomeworkAssignments } from "@/modules/homework/data-access"
import { Inbox } from "lucide-react" import { Inbox } from "lucide-react"
@@ -43,18 +36,14 @@ const getActionVariant = (status: string): "default" | "secondary" | "outline" =
return "default" return "default"
} }
const isAnswered = (status: string) => status === "submitted" || status === "graded"
export default async function StudentAssignmentsPage() { export default async function StudentAssignmentsPage() {
const student = await getDemoStudentUser() const student = await getDemoStudentUser()
if (!student) { if (!student) {
return ( return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex"> <div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">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} /> <EmptyState title="No user found" description="Create a student user to see assignments." icon={Inbox} />
</div> </div>
) )
@@ -62,63 +51,115 @@ export default async function StudentAssignmentsPage() {
const assignments = await getStudentHomeworkAssignments(student.id) const assignments = await getStudentHomeworkAssignments(student.id)
const hasAssignments = assignments.length > 0 const hasAssignments = assignments.length > 0
const assignmentsBySubject = assignments.reduce((acc, assignment) => {
const subject = assignment.subjectName?.trim() || "Other"
const existing = acc.get(subject)
if (existing) {
existing.push(assignment)
} else {
acc.set(subject, [assignment])
}
return acc
}, new Map<string, typeof assignments>())
const subjectEntries = Array.from(assignmentsBySubject.entries()).sort((a, b) => a[0].localeCompare(b[0]))
return ( return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex"> <div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">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 ? ( {!hasAssignments ? (
<EmptyState title="No assignments" description="You have no assigned homework right now." icon={Inbox} /> <EmptyState title="No assignments" description="You have no assigned homework right now." icon={Inbox} />
) : ( ) : (
<div className="rounded-md border bg-card"> <div className="space-y-6">
<Table> {subjectEntries.map(([subject, items]) => {
<TableHeader> const answeredItems = items.filter((a) => isAnswered(a.progressStatus))
<TableRow> const unansweredItems = items.filter((a) => !isAnswered(a.progressStatus))
<TableHead>Title</TableHead>
<TableHead>Status</TableHead> return (
<TableHead>Due</TableHead> <div key={subject} className="space-y-3">
<TableHead>Attempts</TableHead> <div className="text-sm font-semibold text-muted-foreground">{subject}</div>
<TableHead>Score</TableHead> {unansweredItems.length > 0 && (
<TableHead className="text-right">Action</TableHead> <div className="space-y-3">
</TableRow> <div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></div>
</TableHeader> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<TableBody> {unansweredItems.map((a) => (
{assignments.map((a) => ( <Card key={a.id} className="flex h-full flex-col overflow-hidden transition-all hover:shadow-md">
<TableRow key={a.id}> <CardHeader className="gap-2 pb-3">
<TableCell className="font-medium"> <div className="flex items-start justify-between gap-3">
<CardTitle className="text-base">
<Link href={`/student/learning/assignments/${a.id}`} className="hover:underline"> <Link href={`/student/learning/assignments/${a.id}`} className="hover:underline">
{a.title} {a.title}
</Link> </Link>
</TableCell> </CardTitle>
<TableCell>
<Badge variant={getStatusVariant(a.progressStatus)} className="capitalize"> <Badge variant={getStatusVariant(a.progressStatus)} className="capitalize">
{getStatusLabel(a.progressStatus)} {getStatusLabel(a.progressStatus)}
</Badge> </Badge>
</TableCell> </div>
<TableCell>{a.dueAt ? formatDate(a.dueAt) : "-"}</TableCell> <div className="text-xs text-muted-foreground">
<TableCell className="tabular-nums text-muted-foreground"> <span>Due {a.dueAt ? formatDate(a.dueAt) : "-"}</span>
{a.attemptsUsed}/{a.maxAttempts} <span className="px-2"></span>
</TableCell> <span>
<TableCell className="tabular-nums">{a.latestScore ?? "-"}</TableCell> Attempts {a.attemptsUsed}/{a.maxAttempts}
<TableCell className="text-right"> </span>
</div>
</CardHeader>
<CardContent className="mt-auto flex items-center justify-between">
<div className="text-sm">
<div className="text-muted-foreground">Score</div>
<div className="font-medium tabular-nums">{a.latestScore ?? "-"}</div>
</div>
<Button asChild size="sm" variant={getActionVariant(a.progressStatus)}> <Button asChild size="sm" variant={getActionVariant(a.progressStatus)}>
<Link href={`/student/learning/assignments/${a.id}`}> <Link href={`/student/learning/assignments/${a.id}`}>
{getActionLabel(a.progressStatus)} {getActionLabel(a.progressStatus)}
</Link> </Link>
</Button> </Button>
</TableCell> </CardContent>
</TableRow> </Card>
))} ))}
</TableBody> </div>
</Table> </div>
)}
{answeredItems.length > 0 && (
<div className="space-y-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{answeredItems.map((a) => (
<Card key={a.id} className="flex h-full flex-col overflow-hidden transition-all hover:shadow-md">
<CardHeader className="gap-2 pb-3">
<div className="flex items-start justify-between gap-3">
<CardTitle className="text-base">
<Link href={`/student/learning/assignments/${a.id}`} className="hover:underline">
{a.title}
</Link>
</CardTitle>
<Badge variant={getStatusVariant(a.progressStatus)} className="capitalize">
{getStatusLabel(a.progressStatus)}
</Badge>
</div>
<div className="text-xs text-muted-foreground">
<span>Due {a.dueAt ? formatDate(a.dueAt) : "-"}</span>
<span className="px-2"></span>
<span>
Attempts {a.attemptsUsed}/{a.maxAttempts}
</span>
</div>
</CardHeader>
<CardContent className="mt-auto flex items-center justify-between">
<div className="text-sm">
<div className="text-muted-foreground">Score</div>
<div className="font-medium tabular-nums">{a.latestScore ?? "-"}</div>
</div>
<Button asChild size="sm" variant={getActionVariant(a.progressStatus)}>
<Link href={`/student/learning/assignments/${a.id}`}>
{getActionLabel(a.progressStatus)}
</Link>
</Button>
</CardContent>
</Card>
))}
</div>
</div>
)}
</div>
)})}
</div> </div>
)} )}
</div> </div>

View File

@@ -3,7 +3,7 @@ import { ExamForm } from "@/modules/exams/components/exam-form"
export default function CreateExamPage() { export default function CreateExamPage() {
return ( return (
<div className="flex justify-center items-center min-h-[calc(100vh-160px)] p-8 max-w-[1200px] mx-auto"> <div className="flex w-full justify-center items-center min-h-[calc(100vh-160px)] p-8 max-w-[1200px] mx-auto">
<ExamForm /> <ExamForm />
</div> </div>
) )

View File

@@ -0,0 +1,28 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { createAiChatCompletion, getAiErrorMessage, parseAiChatPayload } from "@/shared/lib/ai"
export const dynamic = "force-dynamic"
const getStatusFromError = (message: string) => {
if (message === "Invalid payload" || message === "Messages are required") return 400
if (message === "AI API key missing") return 500
if (message === "Empty response") return 502
return 502
}
export async function POST(req: Request) {
const session = await auth()
const userId = String(session?.user?.id ?? "").trim()
if (!userId) return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 401 })
try {
const body = await req.json().catch(() => null)
const input = parseAiChatPayload(body)
const result = await createAiChatCompletion(input)
return NextResponse.json({ success: true, content: result.content, usage: result.usage })
} catch (e) {
const message = getAiErrorMessage(e)
return NextResponse.json({ success: false, message }, { status: getStatusFromError(message) })
}
}

View File

@@ -172,7 +172,7 @@
@apply border-border; @apply border-border;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground h-screen overflow-hidden;
font-feature-settings: "rlig" 1, "calt" 1; font-feature-settings: "rlig" 1, "calt" 1;
} }
} }

View File

@@ -20,6 +20,7 @@ export default function RootLayout({
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<body <body
className={`antialiased`} className={`antialiased`}
suppressHydrationWarning
> >
<ThemeProvider <ThemeProvider
attribute="class" attribute="class"

View File

@@ -7,6 +7,9 @@ export const env = createEnv({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"), NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
NEXTAUTH_SECRET: z.string().min(1).optional(), NEXTAUTH_SECRET: z.string().min(1).optional(),
NEXTAUTH_URL: z.string().url().optional(), NEXTAUTH_URL: z.string().url().optional(),
AI_API_KEY: z.string().min(1).optional(),
AI_BASE_URL: z.string().url().optional(),
AI_MODEL: z.string().min(1).optional(),
}, },
client: { client: {
NEXT_PUBLIC_APP_URL: z.string().url().optional(), NEXT_PUBLIC_APP_URL: z.string().url().optional(),
@@ -17,6 +20,9 @@ export const env = createEnv({
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
NEXTAUTH_URL: process.env.NEXTAUTH_URL, NEXTAUTH_URL: process.env.NEXTAUTH_URL,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
AI_API_KEY: process.env.AI_API_KEY,
AI_BASE_URL: process.env.AI_BASE_URL,
AI_MODEL: process.env.AI_MODEL,
}, },
skipValidation: !!process.env.SKIP_ENV_VALIDATION, skipValidation: !!process.env.SKIP_ENV_VALIDATION,
emptyStringAsUndefined: true, emptyStringAsUndefined: true,

View File

@@ -5,9 +5,24 @@ import { ActionState } from "@/shared/types/action-state"
import { z } from "zod" import { z } from "zod"
import { createId } from "@paralleldrive/cuid2" import { createId } from "@paralleldrive/cuid2"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { exams, examQuestions, subjects, grades } from "@/shared/db/schema" import { exams, examQuestions } from "@/shared/db/schema"
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
import { omitScheduledAtFromDescription } from "./data-access" import { buildExamDescription, omitScheduledAtFromDescription, persistAiGeneratedExamDraft, persistExamDraft, resolveSubjectGradeNames } from "./data-access"
import {
AiGeneratedStructureSchema,
AiInsertQuestionSchema,
AiQuestionSchema,
generateAiCreateDraftFromSource,
generateAiPreviewData,
regenerateAiQuestionByInstruction,
} from "./ai-pipeline"
import type {
AiGeneratedQuestion,
AiGeneratedStructureNode,
AiPreviewData,
AiRewriteQuestionData,
} from "./ai-pipeline"
export type { AiPreviewData, AiRewriteQuestionData } from "./ai-pipeline"
const ExamCreateSchema = z.object({ const ExamCreateSchema = z.object({
title: z.string().min(1), title: z.string().min(1),
@@ -27,6 +42,213 @@ const ExamCreateSchema = z.object({
.optional(), .optional(),
}) })
const getStringValue = (formData: FormData, key: string) => {
const value = formData.get(key)
return typeof value === "string" ? value : undefined
}
const failState = <T>(message: string, errors?: Record<string, string[]>): ActionState<T> => ({
success: false,
message,
errors,
})
const successState = <T>(data: T, message?: string): ActionState<T> => ({
success: true,
message,
data,
})
const invalidFormState = <T>(
error: z.ZodError,
options?: { fallbackMessage?: string; useFirstMessage?: boolean }
): ActionState<T> => {
const errors = error.flatten().fieldErrors
const fallbackMessage = options?.fallbackMessage ?? "Invalid form data"
const useFirstMessage = options?.useFirstMessage ?? true
const messages = Object.values(errors).flatMap((items) => items ?? [])
const firstMessage = messages.find((msg): msg is string => typeof msg === "string" && msg.length > 0)
return failState<T>(useFirstMessage ? (firstMessage ?? fallbackMessage) : fallbackMessage, errors)
}
const prepareExamCreateContext = async (input: {
subject: string
grade: string
difficulty: number
totalScore: number
durationMin: number
scheduledAt?: string | null
}) => {
const examId = createId()
const scheduled = input.scheduledAt || undefined
const resolvedNames = await resolveSubjectGradeNames({
subjectId: input.subject,
gradeId: input.grade,
})
const subjectName = resolvedNames.subjectName ?? input.subject
const gradeName = resolvedNames.gradeName ?? input.grade
const buildDescription = (options?: { questionCount?: number }) => buildExamDescription({
subject: subjectName,
grade: gradeName,
difficulty: input.difficulty,
totalScore: input.totalScore,
durationMin: input.durationMin,
scheduledAt: scheduled,
questionCount: options?.questionCount,
})
return { examId, scheduled, subjectName, gradeName, buildDescription }
}
const loadAiDraftQuestionsAndStructure = async (input: {
rawAiQuestions: string | null
rawStructure: string | null
title: string
subject: string
grade: string
difficulty: number
totalScore: number
durationMin: number
aiSourceText?: string
aiQuestionCount?: number
aiProviderId?: string
}): Promise<
| { ok: true; generated: AiGeneratedQuestion[]; structure: AiGeneratedStructureNode[] }
| { ok: false; message: string }
> => {
if (input.rawAiQuestions) {
let parsedQuestions: unknown = null
try {
parsedQuestions = JSON.parse(input.rawAiQuestions)
} catch {
return { ok: false, message: "Invalid AI preview payload" }
}
const validated = z.array(AiInsertQuestionSchema).safeParse(parsedQuestions)
if (!validated.success || validated.data.length === 0) {
return { ok: false, message: "Invalid AI preview payload" }
}
const generated = validated.data.map((q) => ({
id: q.id,
type: q.type,
difficulty: q.difficulty,
content: q.content,
score: q.score,
}))
let structure: AiGeneratedStructureNode[] = []
if (input.rawStructure) {
try {
const parsedStructure = JSON.parse(input.rawStructure)
const validatedStructure = AiGeneratedStructureSchema.safeParse(parsedStructure)
if (validatedStructure.success) {
structure = validatedStructure.data
} else {
return { ok: false, message: "Invalid preview structure" }
}
} catch {
return { ok: false, message: "Invalid preview structure" }
}
}
if (structure.length === 0) {
structure = generated.map((q) => ({
id: createId(),
type: "question",
questionId: q.id,
score: q.score,
}))
}
return { ok: true, generated, structure }
}
const sourceText = input.aiSourceText?.trim()
if (!sourceText) {
return { ok: false, message: "Please analyze and preview before creating" }
}
const aiDraft = await generateAiCreateDraftFromSource({
title: input.title,
subject: input.subject,
grade: input.grade,
difficulty: input.difficulty,
totalScore: input.totalScore,
durationMin: input.durationMin,
questionCount: input.aiQuestionCount,
sourceText,
aiProviderId: input.aiProviderId,
})
if (!aiDraft.ok) {
return { ok: false, message: aiDraft.message }
}
return { ok: true, generated: aiDraft.generated, structure: aiDraft.structure }
}
const prepareAiPreviewRequest = async (input: {
title?: string
subject?: string
grade?: string
difficulty?: number
totalScore?: number
durationMin?: number
aiSourceText: string
aiQuestionCount?: number
aiProviderId?: string
}) => {
const resolvedNames = await resolveSubjectGradeNames({
subjectId: input.subject,
gradeId: input.grade,
})
const title = input.title && input.title.trim().length > 0 ? input.title : "AI Exam"
const subjectName = input.subject ? resolvedNames.subjectName ?? input.subject : undefined
const gradeName = input.grade ? resolvedNames.gradeName ?? input.grade : undefined
return {
title,
subject: subjectName,
grade: gradeName,
difficulty: input.difficulty ?? 3,
totalScore: input.totalScore ?? 100,
durationMin: input.durationMin ?? 90,
questionCount: input.aiQuestionCount,
sourceText: input.aiSourceText,
aiProviderId: input.aiProviderId,
}
}
const parseRegenerateAiQuestionInput = (
formData: FormData
):
| {
ok: true
instruction: string
aiProviderId?: string
sourceText?: string
originalQuestion: z.infer<typeof AiQuestionSchema>
}
| { ok: false; state: ActionState<AiRewriteQuestionData> } => {
const instruction = getStringValue(formData, "instruction")?.trim()
const aiProviderId = getStringValue(formData, "aiProviderId")?.trim()
const sourceText = getStringValue(formData, "sourceText")?.trim()
const questionJson = getStringValue(formData, "questionJson")
if (!instruction) {
return { ok: false, state: failState<AiRewriteQuestionData>("Please enter rewrite instruction") }
}
if (!questionJson) {
return { ok: false, state: failState<AiRewriteQuestionData>("No selected question data") }
}
try {
const parsedQuestion = JSON.parse(questionJson) as unknown
const validatedQuestion = AiQuestionSchema.safeParse(parsedQuestion)
if (!validatedQuestion.success) {
return { ok: false, state: failState<AiRewriteQuestionData>("Selected question format invalid") }
}
return {
ok: true,
instruction,
aiProviderId,
sourceText,
originalQuestion: validatedQuestion.data,
}
} catch {
return { ok: false, state: failState<AiRewriteQuestionData>("Selected question format invalid") }
}
}
export async function createExamAction( export async function createExamAction(
prevState: ActionState<string> | null, prevState: ActionState<string> | null,
formData: FormData formData: FormData
@@ -34,72 +256,235 @@ export async function createExamAction(
const rawQuestions = formData.get("questionsJson") as string | null const rawQuestions = formData.get("questionsJson") as string | null
const parsed = ExamCreateSchema.safeParse({ const parsed = ExamCreateSchema.safeParse({
title: formData.get("title"), title: getStringValue(formData, "title"),
subject: formData.get("subject"), subject: getStringValue(formData, "subject"),
grade: formData.get("grade"), grade: getStringValue(formData, "grade"),
difficulty: formData.get("difficulty"), difficulty: getStringValue(formData, "difficulty"),
totalScore: formData.get("totalScore"), totalScore: getStringValue(formData, "totalScore"),
durationMin: formData.get("durationMin"), durationMin: getStringValue(formData, "durationMin"),
scheduledAt: formData.get("scheduledAt"), scheduledAt: getStringValue(formData, "scheduledAt") ?? null,
questions: rawQuestions ? JSON.parse(rawQuestions) : [], questions: rawQuestions ? JSON.parse(rawQuestions) : [],
}) })
if (!parsed.success) { if (!parsed.success) {
return { return invalidFormState<string>(parsed.error, { useFirstMessage: false })
success: false,
message: "Invalid form data",
errors: parsed.error.flatten().fieldErrors,
}
} }
const input = parsed.data const input = parsed.data
const context = await prepareExamCreateContext({
const examId = createId() subject: input.subject,
const scheduled = input.scheduledAt || undefined grade: input.grade,
// Retrieve names for JSON description (to maintain compatibility)
const subjectRecord = await db.query.subjects.findFirst({
where: eq(subjects.id, input.subject),
})
const gradeRecord = await db.query.grades.findFirst({
where: eq(grades.id, input.grade),
})
const meta = {
subject: subjectRecord?.name ?? input.subject,
grade: gradeRecord?.name ?? input.grade,
difficulty: input.difficulty, difficulty: input.difficulty,
totalScore: input.totalScore, totalScore: input.totalScore,
durationMin: input.durationMin, durationMin: input.durationMin,
scheduledAt: scheduled ?? undefined, scheduledAt: input.scheduledAt,
} })
const description = context.buildDescription()
try { try {
const user = await getCurrentUser() const user = await getCurrentUser()
await db.insert(exams).values({ await persistExamDraft({
id: examId, examId: context.examId,
title: input.title, title: input.title,
description: JSON.stringify(meta),
creatorId: user?.id ?? "user_teacher_math", creatorId: user?.id ?? "user_teacher_math",
subjectId: input.subject, subjectId: input.subject,
gradeId: input.grade, gradeId: input.grade,
startTime: scheduled ? new Date(scheduled) : null, scheduledAt: context.scheduled,
status: "draft", description,
}) })
} catch (error) { } catch (error) {
console.error("Failed to create exam:", error) console.error("Failed to create exam:", error)
return { return failState<string>("Database error: Failed to create exam")
success: false,
message: "Database error: Failed to create exam",
}
} }
revalidatePath("/teacher/exams/all") revalidatePath("/teacher/exams/all")
return { return successState(context.examId, "Exam created successfully.")
success: true, }
message: "Exam created successfully.",
data: examId, const AiExamCreateSchema = ExamCreateSchema.extend({
aiSourceText: z.string().optional(),
aiQuestionCount: z.coerce.number().int().min(1).max(200).optional(),
aiProviderId: z.string().min(1).optional(),
})
const AiExamPreviewSchema = z.object({
title: z.string().optional(),
subject: z.string().optional(),
grade: z.string().optional(),
difficulty: z.coerce.number().int().min(1).max(5).optional(),
totalScore: z.coerce.number().int().min(1).optional(),
durationMin: z.coerce.number().int().min(1).optional(),
aiSourceText: z.string().min(1),
aiQuestionCount: z.coerce.number().int().min(1).max(200).optional(),
aiProviderId: z.string().min(1).optional(),
})
export async function createAiExamAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
const rawQuestions = formData.get("questionsJson") as string | null
const rawAiQuestions = formData.get("aiQuestionsJson") as string | null
const rawStructure = formData.get("structureJson") as string | null
const aiSourceTextRaw = formData.get("aiSourceText")
const aiQuestionCountRaw = formData.get("aiQuestionCount")
const aiProviderIdRaw = formData.get("aiProviderId")
const parsed = AiExamCreateSchema.safeParse({
title: getStringValue(formData, "title"),
subject: getStringValue(formData, "subject"),
grade: getStringValue(formData, "grade"),
difficulty: getStringValue(formData, "difficulty"),
totalScore: getStringValue(formData, "totalScore"),
durationMin: getStringValue(formData, "durationMin"),
scheduledAt: getStringValue(formData, "scheduledAt") ?? null,
questions: rawQuestions ? JSON.parse(rawQuestions) : [],
aiSourceText: typeof aiSourceTextRaw === "string" ? aiSourceTextRaw.trim() : undefined,
aiQuestionCount: typeof aiQuestionCountRaw === "string" && aiQuestionCountRaw.trim().length > 0
? aiQuestionCountRaw
: undefined,
aiProviderId: typeof aiProviderIdRaw === "string" && aiProviderIdRaw.trim().length > 0
? aiProviderIdRaw
: undefined,
})
if (!parsed.success) {
return invalidFormState<string>(parsed.error)
}
const input = parsed.data
if (!rawAiQuestions && !input.aiSourceText) {
return failState<string>("Please analyze and preview before creating")
}
const context = await prepareExamCreateContext({
subject: input.subject,
grade: input.grade,
difficulty: input.difficulty,
totalScore: input.totalScore,
durationMin: input.durationMin,
scheduledAt: input.scheduledAt,
})
const user = await getCurrentUser()
const aiDraftResult = await loadAiDraftQuestionsAndStructure({
rawAiQuestions,
rawStructure,
title: input.title,
subject: context.subjectName,
grade: context.gradeName,
difficulty: input.difficulty,
totalScore: input.totalScore,
durationMin: input.durationMin,
aiSourceText: input.aiSourceText,
aiQuestionCount: input.aiQuestionCount,
aiProviderId: input.aiProviderId,
})
if (!aiDraftResult.ok) {
return failState<string>(aiDraftResult.message)
}
const { generated, structure } = aiDraftResult
const questionCount = generated.length
const description = context.buildDescription({ questionCount })
try {
await persistAiGeneratedExamDraft({
examId: context.examId,
title: input.title,
creatorId: user?.id ?? "user_teacher_math",
subjectId: input.subject,
gradeId: input.grade,
scheduledAt: context.scheduled,
description,
structure,
generated,
})
} catch (error) {
console.error("Failed to create exam:", error)
return failState<string>("Database error: Failed to create exam")
}
revalidatePath("/teacher/exams/all")
return successState(context.examId, "Exam created successfully.")
}
export async function previewAiExamAction(
prevState: ActionState<AiPreviewData> | null,
formData: FormData
): Promise<ActionState<AiPreviewData>> {
const aiSourceTextRaw = formData.get("aiSourceText")
const aiQuestionCountRaw = formData.get("aiQuestionCount")
const aiProviderIdRaw = formData.get("aiProviderId")
const sourceText = typeof aiSourceTextRaw === "string" ? aiSourceTextRaw.trim() : ""
if (!sourceText) {
return failState<AiPreviewData>("Please paste the full exam text first", {
aiSourceText: ["Please paste the full exam text first"],
})
}
const parsed = AiExamPreviewSchema.safeParse({
title: getStringValue(formData, "title"),
subject: getStringValue(formData, "subject"),
grade: getStringValue(formData, "grade"),
difficulty: getStringValue(formData, "difficulty"),
totalScore: getStringValue(formData, "totalScore"),
durationMin: getStringValue(formData, "durationMin"),
aiSourceText: sourceText,
aiQuestionCount: typeof aiQuestionCountRaw === "string" && aiQuestionCountRaw.trim().length > 0
? aiQuestionCountRaw
: undefined,
aiProviderId: typeof aiProviderIdRaw === "string" && aiProviderIdRaw.trim().length > 0
? aiProviderIdRaw
: undefined,
})
if (!parsed.success) {
return invalidFormState<AiPreviewData>(parsed.error)
}
const input = parsed.data
const previewRequest = await prepareAiPreviewRequest(input)
const aiDraft = await generateAiPreviewData(previewRequest)
if (!aiDraft.ok) {
return failState<AiPreviewData>(aiDraft.message)
}
return successState({ ...aiDraft.data, rawOutput: aiDraft.rawOutput })
}
export async function regenerateAiQuestionAction(
prevState: ActionState<AiRewriteQuestionData> | null,
formData: FormData
): Promise<ActionState<AiRewriteQuestionData>> {
const parsedInput = parseRegenerateAiQuestionInput(formData)
if (!parsedInput.ok) {
return parsedInput.state
}
const { instruction, aiProviderId, sourceText, originalQuestion } = parsedInput
const originalDifficulty = originalQuestion.difficulty ?? 3
const originalScore = originalQuestion.score ?? 0
try {
const result = await regenerateAiQuestionByInstruction({
instruction,
originalQuestion,
sourceText,
aiProviderId,
})
if (!result.ok) {
return failState<AiRewriteQuestionData>(result.message)
}
return successState({
type: result.data.type,
difficulty: result.data.difficulty ?? originalDifficulty,
score: result.data.score ?? originalScore,
content: result.data.content,
})
} catch {
return failState<AiRewriteQuestionData>("AI question format invalid")
} }
} }
@@ -134,11 +519,10 @@ export async function updateExamAction(
}) })
if (!parsed.success) { if (!parsed.success) {
return { return invalidFormState<string>(parsed.error, {
success: false, fallbackMessage: "Invalid update data",
message: "Invalid update data", useFirstMessage: false,
errors: parsed.error.flatten().fieldErrors, })
}
} }
const { examId, questions, structure, status } = parsed.data const { examId, questions, structure, status } = parsed.data
@@ -168,19 +552,12 @@ export async function updateExamAction(
} }
} catch { } catch {
return { return failState<string>("Database error: Failed to update exam")
success: false,
message: "Database error: Failed to update exam",
}
} }
revalidatePath("/teacher/exams/all") revalidatePath("/teacher/exams/all")
return { return successState(examId, "Exam updated")
success: true,
message: "Exam updated",
data: examId,
}
} }
const ExamDeleteSchema = z.object({ const ExamDeleteSchema = z.object({
@@ -196,11 +573,10 @@ export async function deleteExamAction(
}) })
if (!parsed.success) { if (!parsed.success) {
return { return invalidFormState<string>(parsed.error, {
success: false, fallbackMessage: "Invalid delete data",
message: "Invalid delete data", useFirstMessage: false,
errors: parsed.error.flatten().fieldErrors, })
}
} }
const { examId } = parsed.data const { examId } = parsed.data
@@ -208,19 +584,12 @@ export async function deleteExamAction(
try { try {
await db.delete(exams).where(eq(exams.id, examId)) await db.delete(exams).where(eq(exams.id, examId))
} catch { } catch {
return { return failState<string>("Database error: Failed to delete exam")
success: false,
message: "Database error: Failed to delete exam",
}
} }
revalidatePath("/teacher/exams/all") revalidatePath("/teacher/exams/all")
return { return successState(examId, "Exam deleted")
success: true,
message: "Exam deleted",
data: examId,
}
} }
const ExamDuplicateSchema = z.object({ const ExamDuplicateSchema = z.object({
@@ -236,11 +605,10 @@ export async function duplicateExamAction(
}) })
if (!parsed.success) { if (!parsed.success) {
return { return invalidFormState<string>(parsed.error, {
success: false, fallbackMessage: "Invalid duplicate data",
message: "Invalid duplicate data", useFirstMessage: false,
errors: parsed.error.flatten().fieldErrors, })
}
} }
const { examId } = parsed.data const { examId } = parsed.data
@@ -255,10 +623,7 @@ export async function duplicateExamAction(
}) })
if (!source) { if (!source) {
return { return failState<string>("Exam not found")
success: false,
message: "Exam not found",
}
} }
const newExamId = createId() const newExamId = createId()
@@ -289,22 +654,17 @@ export async function duplicateExamAction(
} }
}) })
} catch { } catch {
return { return failState<string>("Database error: Failed to duplicate exam")
success: false,
message: "Database error: Failed to duplicate exam",
}
} }
revalidatePath("/teacher/exams/all") revalidatePath("/teacher/exams/all")
return { return successState(newExamId, "Exam duplicated")
success: true,
message: "Exam duplicated",
data: newExamId,
}
} }
export async function getExamPreviewAction(examId: string) { export async function getExamPreviewAction(
examId: string
): Promise<ActionState<{ structure: unknown; questions: Array<{ id: string }> }>> {
try { try {
const exam = await db.query.exams.findFirst({ const exam = await db.query.exams.findFirst({
where: eq(exams.id, examId), where: eq(exams.id, examId),
@@ -319,22 +679,16 @@ export async function getExamPreviewAction(examId: string) {
}) })
if (!exam) { if (!exam) {
return { success: false, message: "Exam not found" } return failState<{ structure: unknown; questions: Array<{ id: string }> }>("Exam not found")
} }
const questions = exam.questions.map((eq) => eq.question)
// Extract questions from the relation return successState({
const questions = exam.questions.map(eq => eq.question)
return {
success: true,
data: {
structure: exam.structure, structure: exam.structure,
questions: questions questions,
} })
}
} catch (error) { } catch (error) {
console.error(error) console.error(error)
return { success: false, message: "Failed to load exam preview" } return failState<{ structure: unknown; questions: Array<{ id: string }> }>("Failed to load exam preview")
} }
} }
@@ -344,16 +698,10 @@ export async function getSubjectsAction(): Promise<ActionState<{ id: string; nam
orderBy: (subjects, { asc }) => [asc(subjects.order), asc(subjects.name)], orderBy: (subjects, { asc }) => [asc(subjects.order), asc(subjects.name)],
}) })
return { return successState(allSubjects.map((s) => ({ id: s.id, name: s.name })))
success: true,
data: allSubjects.map((s) => ({ id: s.id, name: s.name })),
}
} catch (error) { } catch (error) {
console.error("Failed to fetch subjects:", error) console.error("Failed to fetch subjects:", error)
return { return failState<{ id: string; name: string }[]>("Failed to load subjects")
success: false,
message: "Failed to load subjects",
}
} }
} }
@@ -363,16 +711,10 @@ export async function getGradesAction(): Promise<ActionState<{ id: string; name:
orderBy: (grades, { asc }) => [asc(grades.order), asc(grades.name)], orderBy: (grades, { asc }) => [asc(grades.order), asc(grades.name)],
}) })
return { return successState(allGrades.map((g) => ({ id: g.id, name: g.name })))
success: true,
data: allGrades.map((g) => ({ id: g.id, name: g.name })),
}
} catch (error) { } catch (error) {
console.error("Failed to fetch grades:", error) console.error("Failed to fetch grades:", error)
return { return failState<{ id: string; name: string }[]>("Failed to load grades")
success: false,
message: "Failed to load grades",
}
} }
} }

View File

@@ -0,0 +1,912 @@
import { createId } from "@paralleldrive/cuid2"
import { z } from "zod"
import { createAiChatCompletion, getAiErrorMessage } from "@/shared/lib/ai"
import { env } from "@/env.mjs"
const AiSubQuestionSchema = z.object({
id: z.string().min(1).optional(),
text: z.string().min(1),
answer: z.string().min(1).optional(),
score: z.coerce.number().int().min(0).optional(),
})
const AiQuestionContentSchema = z.object({
text: z.string().min(1),
options: z
.array(
z.object({
id: z.string().min(1).optional(),
text: z.string().min(1),
isCorrect: z.boolean().optional(),
})
)
.optional(),
subQuestions: z.array(AiSubQuestionSchema).optional(),
})
export const AiQuestionSchema = z.object({
type: z.enum(["single_choice", "multiple_choice", "text", "judgment"]),
difficulty: z.coerce.number().int().min(1).max(5).optional(),
score: z.coerce.number().int().min(0).optional(),
content: AiQuestionContentSchema,
})
export const AiInsertQuestionSchema = z.object({
id: z.string().min(1),
type: z.enum(["single_choice", "multiple_choice", "text", "judgment"]),
difficulty: z.coerce.number().int().min(1).max(5),
score: z.coerce.number().int().min(0),
content: AiQuestionContentSchema.extend({
options: z
.array(
z.object({
id: z.string().min(1),
text: z.string().min(1),
isCorrect: z.boolean().optional(),
})
)
.optional(),
subQuestions: z.array(
AiSubQuestionSchema.extend({
id: z.string().min(1),
})
).optional(),
}),
})
const AiSectionSchema = z.object({
title: z.string().min(1),
questions: z.array(AiQuestionSchema).min(1),
})
const AiExamResponseSchema = z.object({
title: z.string().optional(),
questions: z.array(AiQuestionSchema).optional(),
sections: z.array(AiSectionSchema).optional(),
})
const sanitizeJsonCandidate = (value: string) => value
.replace(/\[\s*\.\.\.\s*\]/g, "[]")
.replace(/\{\s*\.\.\.\s*\}/g, "{}")
.trim()
const tryParseJson = (value: string): unknown | null => {
const sanitized = sanitizeJsonCandidate(value)
if (!sanitized) return null
try {
return JSON.parse(sanitized)
} catch {
return null
}
}
const extractBalancedJsonSegment = (value: string): string | null => {
const startBrace = value.indexOf("{")
const startBracket = value.indexOf("[")
const start =
startBrace === -1
? startBracket
: startBracket === -1
? startBrace
: Math.min(startBrace, startBracket)
if (start === -1) return null
const opening = value[start]
const closing = opening === "{" ? "}" : "]"
let depth = 0
let inString = false
let escaped = false
for (let i = start; i < value.length; i += 1) {
const char = value[i]
if (inString) {
if (escaped) {
escaped = false
} else if (char === "\\") {
escaped = true
} else if (char === "\"") {
inString = false
}
continue
}
if (char === "\"") {
inString = true
continue
}
if (char === opening) {
depth += 1
continue
}
if (char === closing) {
depth -= 1
if (depth === 0) {
return value.slice(start, i + 1)
}
}
}
return null
}
const extractJson = (raw: string): unknown => {
const trimmed = raw.trim()
const candidates: string[] = []
const fencedMatches = [...trimmed.matchAll(/```(?:json)?\s*([\s\S]*?)```/ig)]
if (fencedMatches.length > 0) {
candidates.push(...fencedMatches.map((match) => (match[1] ?? "").trim()))
}
candidates.push(trimmed)
for (const candidate of candidates) {
const direct = tryParseJson(candidate)
if (direct !== null) return direct
const segment = extractBalancedJsonSegment(candidate)
if (!segment) continue
const parsed = tryParseJson(segment)
if (parsed !== null) return parsed
}
throw new Error("Invalid AI response")
}
const AI_JSON_REPAIR_PROMPT = [
"You are a JSON repair engine.",
"Fix the provided invalid JSON into valid JSON only.",
"Keep the original structure and values as much as possible.",
"Do not use placeholders such as ... or [...].",
"Return JSON only without markdown.",
].join("\n")
const repairJson = async (raw: string, providerId?: string) => {
const aiResult = await createAiChatCompletion({
model: String(env.AI_MODEL ?? "gpt-4o-mini"),
providerId,
messages: [
{ role: "system" as const, content: AI_JSON_REPAIR_PROMPT },
{ role: "user" as const, content: raw },
],
temperature: 0,
maxTokens: 4000,
})
return extractJson(aiResult.content)
}
const parseAiResponse = async (raw: string, providerId?: string) => {
try {
return extractJson(raw)
} catch {
return repairJson(raw, providerId)
}
}
const normalizeScores = (scores: number[], totalScore: number) => {
if (scores.length === 0) return []
const sum = scores.reduce((acc, s) => acc + s, 0)
if (sum <= 0) {
const base = Math.floor(totalScore / scores.length)
const remainder = totalScore - base * scores.length
return scores.map((_, idx) => base + (idx < remainder ? 1 : 0))
}
const scaled = scores.map((s) => Math.max(0, Math.round((s / sum) * totalScore)))
let diff = totalScore - scaled.reduce((acc, s) => acc + s, 0)
let i = 0
while (diff !== 0 && i < scaled.length * 2) {
const idx = i % scaled.length
if (diff > 0) {
scaled[idx] += 1
diff -= 1
} else if (scaled[idx] > 0) {
scaled[idx] -= 1
diff += 1
}
i += 1
}
return scaled
}
const AI_EXAM_SYSTEM_PROMPT = [
"You are an exam parsing engine.",
"Parse the provided exam text and output JSON only.",
"Allowed question types: single_choice, multiple_choice, judgment, text.",
"Preserve the original order and sectioning if present.",
"Escape double quotes inside string values.",
"Output schema:",
"{",
' "sections": [',
' { "title": "Section Title", "questions": [',
' { "type": "single_choice", "difficulty": 1, "score": 5, "content": { "text": "...", "options": [ { "id": "A", "text": "...", "isCorrect": true } ] } }',
" ] }",
" ]",
"}",
"For grouped blanks or one prompt with multiple small questions, keep one parent question and place each child item into content.subQuestions.",
'content.subQuestions item schema: { "id": "1", "text": "lǎn duò ", "answer": "懒惰", "score": 1 }',
"If you do not need sections, return { \"questions\": [] } or include real question items.",
"Never output placeholders like ..., [...], or {...}.",
"Return JSON only without markdown.",
].join("\n")
const AI_REWRITE_QUESTION_SYSTEM_PROMPT = [
"You are a question rewriting engine.",
"Rewrite exactly one question based on teacher instruction.",
"Return JSON only without markdown.",
"Allowed question types: single_choice, multiple_choice, judgment, text.",
"Output schema:",
"{",
' "type": "single_choice | multiple_choice | judgment | text",',
' "difficulty": 1,',
' "score": 5,',
' "content": { "text": "...", "options": [ { "id": "A", "text": "...", "isCorrect": true } ], "subQuestions": [ { "id": "1", "text": "...", "answer": "...", "score": 1 } ] }',
"}",
"For judgment/text, options can be omitted. Keep subQuestions when original question has multiple child items.",
"Never output placeholders like ..., [...], or {...}.",
].join("\n")
const AiStructureQuestionSchema = z.object({
text: z.string().min(1),
score: z.coerce.number().int().min(0).optional(),
})
const AiStructureSectionSchema = z.object({
title: z.string().min(1),
questions: z.array(AiStructureQuestionSchema).min(1),
})
const AiStructureResponseSchema = z.object({
title: z.string().optional(),
sections: z.array(AiStructureSectionSchema).optional(),
questions: z.array(AiStructureQuestionSchema).optional(),
})
const AiSourceValidationSchema = z.object({
valid: z.boolean(),
reason: z.string().optional(),
})
const AI_EXAM_STRUCTURE_SYSTEM_PROMPT = [
"You are an exam splitter engine.",
"Split the provided exam text into ordered question units quickly.",
"Do not deeply analyze choices or answers in this step.",
"Keep original sectioning and question order.",
"If one stem contains multiple numbered sub-items, keep them in one question unit and include all sub-items in the same text.",
"Do not split one parent question into several child-only units.",
"Output JSON only.",
"Output schema:",
"{",
' "title": "Optional title",',
' "sections": [',
' { "title": "Section Title", "questions": [',
' { "text": "Original full question text", "score": 5 }',
" ] }",
" ]",
"}",
"If no sections, return:",
'{ "questions": [ { "text": "Original full question text", "score": 5 } ] }',
"Never output placeholders like ..., [...], or {...}.",
].join("\n")
const AI_EXAM_SOURCE_VALIDATION_SYSTEM_PROMPT = [
"You are an exam text validator.",
"Judge whether the input text is readable and likely a normal exam/question text.",
"Reject garbled text, random symbols, severely disordered fragments, or meaningless content.",
"Do not require strict section formatting. Focus only on readability and whether it resembles exam questions.",
"Return JSON only without markdown.",
"Output schema:",
'{ "valid": true, "reason": "short reason" }',
].join("\n")
const AI_QUESTION_DETAIL_SYSTEM_PROMPT = [
"You are an exam question detail parser.",
"Given one split question text, output one structured question JSON only.",
"Allowed question types: single_choice, multiple_choice, judgment, text.",
"For one stem with multiple child sub-items, keep one parent content.text and place child items in content.subQuestions.",
"Use exact key name content.subQuestions (camelCase).",
"Output schema:",
"{",
' "type": "single_choice | multiple_choice | judgment | text",',
' "difficulty": 1,',
' "score": 5,',
' "content": { "text": "...", "options": [ { "id": "A", "text": "...", "isCorrect": true } ], "subQuestions": [ { "id": "1", "text": "...", "answer": "...", "score": 1 } ] }',
"}",
"For judgment/text, options can be omitted.",
"Never output placeholders like ..., [...], or {...}.",
].join("\n")
const buildAiMessages = (input: {
title?: string
subject?: string
grade?: string
difficulty?: number
totalScore?: number
durationMin?: number
questionCount?: number
sourceText: string
}) => {
const userLines = [
input.title ? `Title: ${input.title}` : "",
input.subject ? `Subject: ${input.subject}` : "",
input.grade ? `Grade: ${input.grade}` : "",
typeof input.difficulty === "number" ? `Difficulty: ${input.difficulty}` : "",
typeof input.totalScore === "number" ? `Total Score: ${input.totalScore}` : "",
typeof input.durationMin === "number" ? `Duration (min): ${input.durationMin}` : "",
input.questionCount ? `Question Count: ${input.questionCount}` : "",
`Source Exam Text:\n${input.sourceText}`,
]
const userContent = userLines.filter((l) => l.length > 0).join("\n")
return [
{ role: "system" as const, content: AI_EXAM_SYSTEM_PROMPT },
{ role: "user" as const, content: userContent },
]
}
type AiDraftResult =
| { ok: true; data: z.infer<typeof AiExamResponseSchema>; rawOutput: string }
| { ok: false; message: string }
type AiStructureDraftResult =
| { ok: true; data: z.infer<typeof AiStructureResponseSchema>; rawOutput: string }
| { ok: false; message: string }
const requestAiExamDraft = async (input: {
title?: string
subject?: string
grade?: string
difficulty?: number
totalScore?: number
durationMin?: number
questionCount?: number
sourceText: string
aiProviderId?: string
}): Promise<AiDraftResult> => {
try {
const aiResult = await createAiChatCompletion({
model: String(env.AI_MODEL ?? "gpt-4o-mini"),
providerId: input.aiProviderId,
messages: buildAiMessages(input),
temperature: 0.7,
maxTokens: 4000,
})
const rawOutput = aiResult.content
const data = await parseAiResponse(rawOutput, input.aiProviderId)
const validated = AiExamResponseSchema.safeParse(data)
if (!validated.success) {
return { ok: false, message: "AI response format invalid" }
}
return { ok: true, data: validated.data, rawOutput }
} catch (error) {
return { ok: false, message: getAiErrorMessage(error) }
}
}
const requestAiExamStructureDraft = async (input: {
title?: string
subject?: string
grade?: string
difficulty?: number
totalScore?: number
durationMin?: number
questionCount?: number
sourceText: string
aiProviderId?: string
}): Promise<AiStructureDraftResult> => {
try {
const aiResult = await createAiChatCompletion({
model: String(env.AI_MODEL ?? "gpt-4o-mini"),
providerId: input.aiProviderId,
messages: [
{ role: "system" as const, content: AI_EXAM_STRUCTURE_SYSTEM_PROMPT },
{ role: "user" as const, content: buildAiMessages(input)[1].content },
],
temperature: 0.2,
maxTokens: 4000,
})
const rawOutput = aiResult.content
const data = await parseAiResponse(rawOutput, input.aiProviderId)
const validated = AiStructureResponseSchema.safeParse(data)
if (!validated.success) {
return { ok: false, message: "AI response format invalid" }
}
return { ok: true, data: validated.data, rawOutput }
} catch (error) {
return { ok: false, message: getAiErrorMessage(error) }
}
}
type SplitQuestionItem = {
sectionIndex: number | null
sectionTitle?: string
text: string
score?: number
}
const validateExamSourceText = async (input: { sourceText: string; aiProviderId?: string }) => {
const text = input.sourceText.trim()
if (!text) {
return { ok: false as const, message: "请先粘贴试卷文本" }
}
const userContent = [
"请判断下面文本是否是可读、正常的题目/试卷内容(不是乱码、随机字符或混乱文本)。",
`文本内容:\n${text}`,
].join("\n\n")
try {
const aiResult = await createAiChatCompletion({
model: String(env.AI_MODEL ?? "gpt-4o-mini"),
providerId: input.aiProviderId,
messages: [
{ role: "system" as const, content: AI_EXAM_SOURCE_VALIDATION_SYSTEM_PROMPT },
{ role: "user" as const, content: userContent },
],
temperature: 0,
maxTokens: 300,
})
const parsed = await parseAiResponse(aiResult.content, input.aiProviderId)
const validated = AiSourceValidationSchema.safeParse(parsed)
if (!validated.success) {
return { ok: false as const, message: "试卷文本校验失败,请重试" }
}
if (!validated.data.valid) {
return {
ok: false as const,
message: validated.data.reason?.trim() || "识别为乱码或混乱文本,请粘贴清晰完整的题目内容",
}
}
return { ok: true as const }
} catch (error) {
return { ok: false as const, message: getAiErrorMessage(error) }
}
}
const splitStructureItems = (draft: z.infer<typeof AiStructureResponseSchema>) => {
const hasSections = Array.isArray(draft.sections) && draft.sections.length > 0
if (!hasSections) {
return (draft.questions ?? []).map((q) => ({
sectionIndex: null,
sectionTitle: undefined,
text: q.text,
score: q.score,
} satisfies SplitQuestionItem))
}
const rows: SplitQuestionItem[] = []
draft.sections!.forEach((section, sectionIndex) => {
section.questions.forEach((q) => {
rows.push({
sectionIndex,
sectionTitle: section.title,
text: q.text,
score: q.score,
})
})
})
return rows
}
const mapWithConcurrency = async <T, R>(
items: T[],
concurrency: number,
worker: (item: T, index: number) => Promise<R>
) => {
const results = new Array<R>(items.length)
let cursor = 0
const runWorker = async () => {
while (cursor < items.length) {
const index = cursor
cursor += 1
results[index] = await worker(items[index], index)
}
}
const workers = Array.from({ length: Math.max(1, Math.min(concurrency, items.length)) }, () => runWorker())
await Promise.all(workers)
return results
}
const parseQuestionDetail = async (input: {
item: SplitQuestionItem
subject?: string
grade?: string
difficulty: number
aiProviderId?: string
}) => {
const normalizeQuestionCandidate = (value: unknown): unknown => {
if (!value || typeof value !== "object") return value
const record = value as Record<string, unknown>
const contentRaw = record.content
if (!contentRaw || typeof contentRaw !== "object") return value
const content = contentRaw as Record<string, unknown>
const normalizedSubQuestions = Array.isArray(content.subQuestions)
? content.subQuestions
: Array.isArray(content.subquestions)
? content.subquestions
: Array.isArray(content.sub_questions)
? content.sub_questions
: undefined
if (!normalizedSubQuestions) return value
return {
...record,
content: {
...content,
subQuestions: normalizedSubQuestions,
},
}
}
const userContent = [
input.subject ? `Subject: ${input.subject}` : "",
input.grade ? `Grade: ${input.grade}` : "",
`Question Text:\n${input.item.text}`,
].filter((line) => line.length > 0).join("\n\n")
try {
const aiResult = await createAiChatCompletion({
model: String(env.AI_MODEL ?? "gpt-4o-mini"),
providerId: input.aiProviderId,
messages: [
{ role: "system" as const, content: AI_QUESTION_DETAIL_SYSTEM_PROMPT },
{ role: "user" as const, content: userContent },
],
temperature: 0.4,
maxTokens: 1200,
})
const parsed = await parseAiResponse(aiResult.content, input.aiProviderId)
const candidate = parsed && typeof parsed === "object" && "question" in parsed
? (parsed as { question: unknown }).question
: parsed
const validated = AiQuestionSchema.safeParse(normalizeQuestionCandidate(candidate))
if (validated.success) {
const q = validated.data
return {
type: q.type,
difficulty: q.difficulty ?? input.difficulty,
score: q.score ?? input.item.score ?? 0,
content: q.content,
} satisfies z.infer<typeof AiQuestionSchema>
}
} catch {
}
return {
type: "text",
difficulty: input.difficulty,
score: input.item.score ?? 0,
content: { text: input.item.text },
} satisfies z.infer<typeof AiQuestionSchema>
}
const buildQuestionContent = (q: z.infer<typeof AiQuestionSchema>) => {
const base = { text: q.content.text }
const subQuestions = Array.isArray(q.content.subQuestions)
? q.content.subQuestions.map((item, index) => ({
id: item.id ?? String(index + 1),
text: item.text,
answer: item.answer,
score: item.score,
}))
: []
if (q.type === "single_choice" || q.type === "multiple_choice") {
const options = (q.content.options ?? []).map((opt, idx) => ({
id: opt.id ?? String.fromCharCode(65 + idx),
text: opt.text,
isCorrect: opt.isCorrect ?? false,
}))
if (options.length > 0 && subQuestions.length > 0) return { ...base, options, subQuestions }
if (options.length > 0) return { ...base, options }
if (subQuestions.length > 0) return { ...base, subQuestions }
return base
}
if (subQuestions.length > 0) return { ...base, subQuestions }
return base
}
type AiPreviewQuestion = {
id: string
type: z.infer<typeof AiQuestionSchema>["type"]
difficulty: number
score: number
content: ReturnType<typeof buildQuestionContent>
}
export type AiPreviewData = {
title: string
rawOutput?: string
sections?: Array<{
id: string
title: string
questions: AiPreviewQuestion[]
}>
questions?: AiPreviewQuestion[]
}
export type AiRewriteQuestionData = {
type: z.infer<typeof AiQuestionSchema>["type"]
difficulty: number
score: number
content: ReturnType<typeof buildQuestionContent>
}
export type AiGeneratedQuestion = {
id: string
type: z.infer<typeof AiQuestionSchema>["type"]
difficulty: number
score: number
content: ReturnType<typeof buildQuestionContent>
}
export type AiGeneratedStructureNode = {
id: string
type: "group" | "question"
title?: string
questionId?: string
score?: number
children?: AiGeneratedStructureNode[]
}
export const AiGeneratedStructureNodeSchema: z.ZodType<AiGeneratedStructureNode> = z.lazy(() => z.object({
id: z.string().min(1),
type: z.enum(["group", "question"]),
title: z.string().optional(),
questionId: z.string().optional(),
score: z.coerce.number().int().min(0).optional(),
children: z.array(AiGeneratedStructureNodeSchema).optional(),
}))
export const AiGeneratedStructureSchema = z.array(AiGeneratedStructureNodeSchema)
const buildPreviewPayload = (
aiParsed: z.infer<typeof AiExamResponseSchema>,
input: {
title: string
difficulty: number
totalScore: number
questionCount?: number
}
): AiPreviewData => {
const hasSections = Array.isArray(aiParsed.sections) && aiParsed.sections.length > 0
const baseQuestions = hasSections ? aiParsed.sections!.flatMap((s) => s.questions) : aiParsed.questions ?? []
const limit = input.questionCount
let sections = aiParsed.sections
let flatQuestions = baseQuestions
if (typeof limit === "number" && limit > 0) {
if (hasSections) {
let remaining = limit
sections = aiParsed.sections!.map((s) => {
if (remaining <= 0) return { ...s, questions: [] }
const sliced = s.questions.slice(0, remaining)
remaining -= sliced.length
return { ...s, questions: sliced }
}).filter((s) => s.questions.length > 0)
flatQuestions = sections.flatMap((s) => s.questions)
} else {
flatQuestions = baseQuestions.slice(0, limit)
}
}
const scores = normalizeScores(
flatQuestions.map((q) => q.score ?? 0),
input.totalScore
)
let scoreIndex = 0
const toPreviewQuestion = (q: z.infer<typeof AiQuestionSchema>): AiPreviewQuestion => ({
id: createId(),
type: q.type,
difficulty: q.difficulty ?? input.difficulty,
score: scores[scoreIndex++] ?? 0,
content: buildQuestionContent(q),
})
if (hasSections && sections && sections.length > 0) {
return {
title: aiParsed.title ?? input.title,
sections: sections.map((section) => ({
id: createId(),
title: section.title,
questions: section.questions.map((q) => toPreviewQuestion(q)),
})),
}
}
return {
title: aiParsed.title ?? input.title,
questions: flatQuestions.map((q) => toPreviewQuestion(q)),
}
}
const previewToDraft = (preview: AiPreviewData) => {
const generated: AiGeneratedQuestion[] = []
const structure: AiGeneratedStructureNode[] = []
if (Array.isArray(preview.sections) && preview.sections.length > 0) {
for (const section of preview.sections) {
const children: AiGeneratedStructureNode[] = []
for (const question of section.questions) {
generated.push({
id: question.id,
type: question.type,
difficulty: question.difficulty,
score: question.score,
content: question.content,
})
children.push({
id: createId(),
type: "question",
questionId: question.id,
score: question.score,
})
}
structure.push({
id: section.id || createId(),
type: "group",
title: section.title,
children,
})
}
return { generated, structure }
}
for (const question of preview.questions ?? []) {
generated.push({
id: question.id,
type: question.type,
difficulty: question.difficulty,
score: question.score,
content: question.content,
})
structure.push({
id: createId(),
type: "question",
questionId: question.id,
score: question.score,
})
}
return { generated, structure }
}
export async function generateAiPreviewData(input: {
title: string
subject?: string
grade?: string
difficulty: number
totalScore: number
durationMin: number
questionCount?: number
sourceText: string
aiProviderId?: string
}) {
const sourceValidation = await validateExamSourceText({
sourceText: input.sourceText,
aiProviderId: input.aiProviderId,
})
if (!sourceValidation.ok) {
return { ok: false as const, message: sourceValidation.message }
}
const structureDraft = await requestAiExamStructureDraft(input)
if (!structureDraft.ok) return structureDraft
const splitItems = splitStructureItems(structureDraft.data)
const limitedItems = typeof input.questionCount === "number" && input.questionCount > 0
? splitItems.slice(0, input.questionCount)
: splitItems
if (limitedItems.length === 0) {
return { ok: false as const, message: "AI returned no questions" }
}
const detailedQuestions = await mapWithConcurrency(limitedItems, 6, (item) => parseQuestionDetail({
item,
subject: input.subject,
grade: input.grade,
difficulty: input.difficulty,
aiProviderId: input.aiProviderId,
}))
const hasSectionStructure = limitedItems.some((item) => item.sectionIndex !== null)
const aiParsed: z.infer<typeof AiExamResponseSchema> = hasSectionStructure
? {
title: structureDraft.data.title ?? input.title,
sections: (() => {
const sectionMap = new Map<number, { title: string; questions: z.infer<typeof AiQuestionSchema>[] }>()
limitedItems.forEach((item, index) => {
if (item.sectionIndex === null) return
const existed = sectionMap.get(item.sectionIndex)
const question = detailedQuestions[index]
if (existed) {
existed.questions.push(question)
return
}
sectionMap.set(item.sectionIndex, {
title: item.sectionTitle || `Section ${item.sectionIndex + 1}`,
questions: [question],
})
})
return Array.from(sectionMap.entries())
.sort((a, b) => a[0] - b[0])
.map(([, section]) => section)
})(),
questions: undefined,
}
: {
title: structureDraft.data.title ?? input.title,
questions: detailedQuestions,
sections: undefined,
}
const payload = buildPreviewPayload(aiParsed, input)
return {
ok: true as const,
data: payload,
rawOutput: structureDraft.rawOutput,
}
}
export async function generateAiCreateDraftFromSource(input: {
title: string
subject?: string
grade?: string
difficulty: number
totalScore: number
durationMin: number
questionCount?: number
sourceText: string
aiProviderId?: string
}) {
const preview = await generateAiPreviewData(input)
if (!preview.ok) {
return preview
}
const draft = previewToDraft(preview.data)
return {
ok: true as const,
generated: draft.generated,
structure: draft.structure,
rawOutput: preview.rawOutput,
}
}
export async function regenerateAiQuestionByInstruction(input: {
instruction: string
originalQuestion: z.infer<typeof AiQuestionSchema>
sourceText?: string
aiProviderId?: string
}) {
const originalDifficulty = input.originalQuestion.difficulty ?? 3
const originalScore = input.originalQuestion.score ?? 0
const contextLines = [
`Instruction:\n${input.instruction}`,
`Original Question JSON:\n${JSON.stringify(input.originalQuestion, null, 2)}`,
input.sourceText ? `Source Exam Text:\n${input.sourceText}` : "",
]
const userContent = contextLines.filter((line) => line.length > 0).join("\n\n")
try {
const aiResult = await createAiChatCompletion({
model: String(env.AI_MODEL ?? "gpt-4o-mini"),
providerId: input.aiProviderId && input.aiProviderId.length > 0 ? input.aiProviderId : undefined,
messages: [
{ role: "system" as const, content: AI_REWRITE_QUESTION_SYSTEM_PROMPT },
{ role: "user" as const, content: userContent },
],
temperature: 0.7,
maxTokens: 2000,
})
const parsed = await parseAiResponse(aiResult.content, input.aiProviderId)
const candidate = parsed && typeof parsed === "object" && "question" in parsed
? (parsed as { question: unknown }).question
: parsed
const validated = AiQuestionSchema.safeParse(candidate)
if (!validated.success) {
return { ok: false as const, message: "AI question format invalid" }
}
const question = validated.data
return {
ok: true as const,
data: {
type: question.type,
difficulty: question.difficulty ?? originalDifficulty,
score: question.score ?? originalScore,
content: buildQuestionContent(question),
} satisfies AiRewriteQuestionData,
}
} catch (error) {
return { ok: false as const, message: getAiErrorMessage(error) }
}
}
export async function generateAiExamDraft(input: {
title?: string
subject?: string
grade?: string
difficulty?: number
totalScore?: number
durationMin?: number
questionCount?: number
sourceText: string
aiProviderId?: string
}) {
return requestAiExamDraft(input)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,10 @@
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { exams } from "@/shared/db/schema" import { exams, examQuestions, questions, subjects, grades } from "@/shared/db/schema"
import { eq, desc, like, and, or } from "drizzle-orm" import { eq, desc, like, and, or } from "drizzle-orm"
import { cache } from "react" import { cache } from "react"
import type { Exam, ExamDifficulty, ExamStatus } from "./types" import type { Exam, ExamDifficulty, ExamStatus } from "./types"
import type { AiGeneratedQuestion, AiGeneratedStructureNode } from "./ai-pipeline"
export type GetExamsParams = { export type GetExamsParams = {
q?: string q?: string
@@ -158,3 +159,139 @@ export const omitScheduledAtFromDescription = (description: string | null): stri
return description || "{}" return description || "{}"
} }
} }
export const resolveSubjectGradeNames = async (input: {
subjectId?: string
gradeId?: string
}) => {
const [subjectRecord, gradeRecord] = await Promise.all([
input.subjectId
? db.query.subjects.findFirst({
where: eq(subjects.id, input.subjectId),
})
: Promise.resolve(null),
input.gradeId
? db.query.grades.findFirst({
where: eq(grades.id, input.gradeId),
})
: Promise.resolve(null),
])
return {
subjectName: subjectRecord?.name,
gradeName: gradeRecord?.name,
}
}
export const buildExamDescription = (input: {
subject: string
grade: string
difficulty: number
totalScore: number
durationMin: number
scheduledAt?: string
questionCount?: number
}) => JSON.stringify({
subject: input.subject,
grade: input.grade,
difficulty: input.difficulty,
totalScore: input.totalScore,
durationMin: input.durationMin,
scheduledAt: input.scheduledAt,
questionCount: input.questionCount,
})
export const persistExamDraft = async (input: {
examId: string
title: string
creatorId: string
subjectId: string
gradeId: string
scheduledAt?: string
description: string
}) => {
await db.insert(exams).values({
id: input.examId,
title: input.title,
description: input.description,
creatorId: input.creatorId,
subjectId: input.subjectId,
gradeId: input.gradeId,
startTime: input.scheduledAt ? new Date(input.scheduledAt) : null,
status: "draft",
})
}
const buildOrderedQuestionsFromStructure = (
structure: AiGeneratedStructureNode[],
generated: AiGeneratedQuestion[]
) => {
const questionById = new Map(generated.map((q) => [q.id, q] as const))
const orderedQuestions: Array<{ id: string; score: number }> = []
const collectOrder = (nodes: AiGeneratedStructureNode[]) => {
for (const node of nodes) {
if (node.type === "question" && typeof node.questionId === "string" && node.questionId) {
const score = typeof node.score === "number" ? node.score : questionById.get(node.questionId)?.score ?? 0
orderedQuestions.push({ id: node.questionId, score })
continue
}
if (node.type === "group" && Array.isArray(node.children) && node.children.length > 0) {
collectOrder(node.children)
}
}
}
collectOrder(structure)
if (orderedQuestions.length === 0) {
return generated.map((q) => ({ id: q.id, score: q.score ?? 0 }))
}
return orderedQuestions
}
export const persistAiGeneratedExamDraft = async (input: {
examId: string
title: string
creatorId: string
subjectId: string
gradeId: string
scheduledAt?: string
description: string
structure: AiGeneratedStructureNode[]
generated: AiGeneratedQuestion[]
}) => {
const orderedQuestions = buildOrderedQuestionsFromStructure(input.structure, input.generated)
await db.transaction(async (tx) => {
await tx.insert(exams).values({
id: input.examId,
title: input.title,
description: input.description,
creatorId: input.creatorId,
subjectId: input.subjectId,
gradeId: input.gradeId,
startTime: input.scheduledAt ? new Date(input.scheduledAt) : null,
status: "draft",
structure: input.structure,
})
if (input.generated.length > 0) {
await tx.insert(questions).values(
input.generated.map((q) => ({
id: q.id,
content: q.content,
type: q.type,
difficulty: q.difficulty,
authorId: input.creatorId,
}))
)
}
if (orderedQuestions.length > 0) {
await tx.insert(examQuestions).values(
orderedQuestions.map((q, idx) => ({
examId: input.examId,
questionId: q.id,
score: q.score ?? 0,
order: idx,
}))
)
}
})
}

View File

@@ -7,12 +7,14 @@ import { auth } from "@/auth"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { import {
classEnrollments, classEnrollments,
exams,
homeworkAnswers, homeworkAnswers,
homeworkAssignmentQuestions, homeworkAssignmentQuestions,
homeworkAssignmentTargets, homeworkAssignmentTargets,
homeworkAssignments, homeworkAssignments,
homeworkSubmissions, homeworkSubmissions,
roles, roles,
subjects,
users, users,
usersToRoles, usersToRoles,
} from "@/shared/db/schema" } from "@/shared/db/schema"
@@ -584,14 +586,27 @@ export const getStudentHomeworkAssignments = cache(async (studentId: string): Pr
.from(homeworkAssignmentTargets) .from(homeworkAssignmentTargets)
.where(eq(homeworkAssignmentTargets.studentId, studentId)) .where(eq(homeworkAssignmentTargets.studentId, studentId))
const assignments = await db.query.homeworkAssignments.findMany({ const assignments = await db
where: and( .select({
id: homeworkAssignments.id,
title: homeworkAssignments.title,
dueAt: homeworkAssignments.dueAt,
availableAt: homeworkAssignments.availableAt,
maxAttempts: homeworkAssignments.maxAttempts,
createdAt: homeworkAssignments.createdAt,
subjectName: subjects.name,
})
.from(homeworkAssignments)
.innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id))
.leftJoin(subjects, eq(exams.subjectId, subjects.id))
.where(
and(
eq(homeworkAssignments.status, "published"), eq(homeworkAssignments.status, "published"),
inArray(homeworkAssignments.id, targetAssignmentIds), inArray(homeworkAssignments.id, targetAssignmentIds),
or(isNull(homeworkAssignments.availableAt), lte(homeworkAssignments.availableAt, now)) or(isNull(homeworkAssignments.availableAt), lte(homeworkAssignments.availableAt, now))
), )
orderBy: [desc(homeworkAssignments.dueAt), desc(homeworkAssignments.createdAt)], )
}) .orderBy(desc(homeworkAssignments.dueAt), desc(homeworkAssignments.createdAt))
if (assignments.length === 0) return [] if (assignments.length === 0) return []
@@ -620,6 +635,7 @@ export const getStudentHomeworkAssignments = cache(async (studentId: string): Pr
const item: StudentHomeworkAssignmentListItem = { const item: StudentHomeworkAssignmentListItem = {
id: a.id, id: a.id,
title: a.title, title: a.title,
subjectName: a.subjectName ?? null,
dueAt: a.dueAt ? a.dueAt.toISOString() : null, dueAt: a.dueAt ? a.dueAt.toISOString() : null,
availableAt: a.availableAt ? a.availableAt.toISOString() : null, availableAt: a.availableAt ? a.availableAt.toISOString() : null,
maxAttempts: a.maxAttempts, maxAttempts: a.maxAttempts,

View File

@@ -82,6 +82,7 @@ export type StudentHomeworkProgressStatus = "not_started" | "in_progress" | "sub
export interface StudentHomeworkAssignmentListItem { export interface StudentHomeworkAssignmentListItem {
id: string id: string
title: string title: string
subjectName?: string | null
dueAt: string | null dueAt: string | null
availableAt: string | null availableAt: string | null
maxAttempts: number maxAttempts: number

View File

@@ -64,7 +64,7 @@ export function SidebarProvider({ children, sidebar }: SidebarProviderProps) {
<SidebarContext.Provider <SidebarContext.Provider
value={{ expanded, setExpanded, isMobile, toggleSidebar }} value={{ expanded, setExpanded, isMobile, toggleSidebar }}
> >
<div className="flex min-h-screen flex-col md:flex-row bg-background"> <div className="flex h-screen overflow-hidden w-full flex-col md:flex-row bg-background">
{/* Mobile Trigger & Sheet */} {/* Mobile Trigger & Sheet */}
{isMobile && ( {isMobile && (
<Sheet open={openMobile} onOpenChange={setOpenMobile}> <Sheet open={openMobile} onOpenChange={setOpenMobile}>

View File

@@ -0,0 +1,204 @@
"use server"
import { z } from "zod"
import { revalidatePath } from "next/cache"
import { createId } from "@paralleldrive/cuid2"
import { count, desc, eq } from "drizzle-orm"
import { auth } from "@/auth"
import { db } from "@/shared/db"
import { aiProviders } from "@/shared/db/schema"
import type { ActionState } from "@/shared/types/action-state"
import { encryptAiApiKey, getAiErrorMessage, testAiProviderById, testAiProviderConfig } from "@/shared/lib/ai"
const ProviderSchema = z.enum(["zhipu", "openai", "gemini", "custom"])
const AiProviderFormSchema = z.object({
id: z.string().optional(),
provider: ProviderSchema,
baseUrl: z.string().url().optional().or(z.literal("")),
model: z.string().min(1),
apiKey: z.string().min(1).optional(),
isDefault: z.boolean().optional(),
})
const AiProviderTestSchema = AiProviderFormSchema.extend({
apiKey: z.string().optional(),
}).superRefine((data, ctx) => {
if (!data.apiKey?.trim() && !data.id?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["apiKey"],
message: "API key is required",
})
}
})
export type AiProviderSummary = {
id: string
provider: z.infer<typeof ProviderSchema>
baseUrl: string | null
model: string
apiKeyLast4: string | null
isDefault: boolean
updatedAt: Date
}
const ensureUser = async () => {
const session = await auth()
const userId = String(session?.user?.id ?? "").trim()
if (!userId) throw new Error("Unauthorized")
return { id: userId }
}
const normalizeBaseUrl = (value: string | undefined) => {
const raw = String(value ?? "").trim()
if (!raw.length) return null
const trimmed = raw.replace(/\/+$/, "")
return trimmed
.replace(/\/v1\/chat\/completions$/i, "")
.replace(/\/chat\/completions$/i, "")
}
export async function getAiProviderSummaries(): Promise<AiProviderSummary[]> {
await ensureUser()
const rows = await db
.select({
id: aiProviders.id,
provider: aiProviders.provider,
baseUrl: aiProviders.baseUrl,
model: aiProviders.model,
apiKeyLast4: aiProviders.apiKeyLast4,
isDefault: aiProviders.isDefault,
updatedAt: aiProviders.updatedAt,
})
.from(aiProviders)
.orderBy(desc(aiProviders.updatedAt))
return rows
}
export async function upsertAiProviderAction(
data: z.infer<typeof AiProviderFormSchema>
): Promise<ActionState<string>> {
try {
const user = await ensureUser()
const parsed = AiProviderFormSchema.safeParse(data)
if (!parsed.success) {
return { success: false, message: "Invalid form data" }
}
const payload = parsed.data
const baseUrl = normalizeBaseUrl(payload.baseUrl)
if (payload.provider !== "openai" && !baseUrl) {
return { success: false, message: "Base URL is required for this provider" }
}
const [defaultRow] = await db
.select({ value: count() })
.from(aiProviders)
.where(eq(aiProviders.isDefault, true))
const defaultCount = Number(defaultRow?.value ?? 0)
const hasDefault = defaultCount > 0
if (payload.id) {
const id = payload.id
const [existing] = await db
.select({
id: aiProviders.id,
apiKeyEncrypted: aiProviders.apiKeyEncrypted,
apiKeyLast4: aiProviders.apiKeyLast4,
isDefault: aiProviders.isDefault,
})
.from(aiProviders)
.where(eq(aiProviders.id, id))
.limit(1)
if (!existing) return { success: false, message: "AI provider not found" }
const nextKey = payload.apiKey?.trim()
const encrypted = nextKey ? encryptAiApiKey(nextKey) : existing.apiKeyEncrypted
const last4 = nextKey ? nextKey.slice(-4) : existing.apiKeyLast4
const nextIsDefault =
payload.isDefault === false && existing.isDefault && defaultCount <= 1 ? true : payload.isDefault ?? existing.isDefault
await db.transaction(async (tx) => {
if (payload.isDefault) {
await tx.update(aiProviders).set({ isDefault: false }).where(eq(aiProviders.isDefault, true))
}
await tx
.update(aiProviders)
.set({
provider: payload.provider,
baseUrl,
model: payload.model,
apiKeyEncrypted: encrypted,
apiKeyLast4: last4,
isDefault: nextIsDefault,
updatedBy: user.id,
})
.where(eq(aiProviders.id, id))
})
revalidatePath("/settings")
return { success: true, message: "AI provider updated", data: id }
}
if (!payload.apiKey) {
return { success: false, message: "API key is required" }
}
const id = createId()
const encrypted = encryptAiApiKey(payload.apiKey.trim())
const last4 = payload.apiKey.trim().slice(-4)
const makeDefault = payload.isDefault ?? !hasDefault
await db.transaction(async (tx) => {
if (makeDefault) {
await tx.update(aiProviders).set({ isDefault: false }).where(eq(aiProviders.isDefault, true))
}
await tx.insert(aiProviders).values({
id,
provider: payload.provider,
baseUrl,
model: payload.model,
apiKeyEncrypted: encrypted,
apiKeyLast4: last4,
isDefault: makeDefault,
createdBy: user.id,
updatedBy: user.id,
})
})
revalidatePath("/settings")
return { success: true, message: "AI provider created", data: id }
} catch {
return { success: false, message: "Failed to save AI provider" }
}
}
export async function testAiProviderAction(
data: z.infer<typeof AiProviderTestSchema>
): Promise<ActionState<null>> {
try {
await ensureUser()
const parsed = AiProviderTestSchema.safeParse(data)
if (!parsed.success) {
return { success: false, message: "Invalid form data" }
}
const payload = parsed.data
const baseUrl = normalizeBaseUrl(payload.baseUrl)
if (payload.provider !== "openai" && !baseUrl) {
return { success: false, message: "Base URL is required for this provider" }
}
const model = payload.model.trim()
const apiKey = payload.apiKey?.trim()
if (apiKey) {
await testAiProviderConfig({ apiKey, baseUrl: baseUrl ?? undefined, model })
} else if (payload.id) {
await testAiProviderById(payload.id, { baseUrl: baseUrl ?? undefined, model })
}
return { success: true, message: "AI connection ok", data: null }
} catch (error) {
return { success: false, message: getAiErrorMessage(error) }
}
}

View File

@@ -0,0 +1,405 @@
"use client"
import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react"
import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { Loader2, Save, Sparkles } from "lucide-react"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Checkbox } from "@/shared/components/ui/checkbox"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/shared/components/ui/form"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { getAiProviderSummaries, testAiProviderAction, upsertAiProviderAction, type AiProviderSummary } from "@/modules/settings/actions"
const ProviderSchema = z.enum(["zhipu", "openai", "gemini", "custom"])
const AiProviderFormSchema = z.object({
id: z.string().optional(),
provider: ProviderSchema,
baseUrl: z.string().optional(),
model: z.string().min(1, "Model is required"),
apiKey: z.string().optional(),
isDefault: z.boolean().optional(),
})
type AiProviderFormValues = z.infer<typeof AiProviderFormSchema>
const providerLabels: Record<z.infer<typeof ProviderSchema>, string> = {
zhipu: "智谱",
openai: "OpenAI",
gemini: "Gemini",
custom: "Custom",
}
const NEW_PROVIDER_VALUE = "__new__"
export function AiProviderSettingsCard({
onProvidersChanged,
initialMode = "first",
}: {
onProvidersChanged?: (rows: AiProviderSummary[]) => void
initialMode?: "new" | "first"
}) {
const [isPending, startTransition] = useTransition()
const [providers, setProviders] = useState<AiProviderSummary[]>([])
const [selectedId, setSelectedId] = useState<string>("")
const [testStatus, setTestStatus] = useState<"idle" | "testing" | "passed" | "failed">("idle")
const [lastTestedSignature, setLastTestedSignature] = useState<string>("")
const loadedRef = useRef(false)
const form = useForm<AiProviderFormValues>({
resolver: zodResolver(AiProviderFormSchema),
defaultValues: {
id: "",
provider: "openai",
baseUrl: "",
model: "",
apiKey: "",
isDefault: false,
},
})
const selectedProvider = useMemo(
() => providers.find((item) => item.id === selectedId) ?? null,
[providers, selectedId]
)
const buildSignature = useCallback((values: AiProviderFormValues) => {
return JSON.stringify({
provider: values.provider,
baseUrl: values.baseUrl?.trim() || "",
model: values.model.trim(),
apiKey: values.apiKey?.trim() || "",
})
}, [])
const resetToNew = useCallback(() => {
setSelectedId("")
setTestStatus("idle")
setLastTestedSignature("")
form.reset({
id: "",
provider: "openai",
baseUrl: "",
model: "",
apiKey: "",
isDefault: false,
})
}, [form])
useEffect(() => {
if (loadedRef.current) return
loadedRef.current = true
startTransition(async () => {
try {
const rows = await getAiProviderSummaries()
setProviders(rows)
onProvidersChanged?.(rows)
if (initialMode === "new") {
resetToNew()
return
}
if (rows.length > 0 && !selectedId) {
const next = rows[0]
setSelectedId(next.id)
form.reset({
id: next.id,
provider: next.provider,
baseUrl: next.baseUrl ?? "",
model: next.model,
apiKey: "",
isDefault: next.isDefault,
})
}
} catch {
toast.error("Failed to load AI providers")
}
})
}, [form, selectedId, onProvidersChanged, initialMode, resetToNew])
const handleSelectChange = (value: string) => {
if (value === NEW_PROVIDER_VALUE) {
resetToNew()
return
}
setSelectedId(value)
setTestStatus("idle")
setLastTestedSignature("")
const next = providers.find((item) => item.id === value)
if (!next) return
form.reset({
id: next.id,
provider: next.provider,
baseUrl: next.baseUrl ?? "",
model: next.model,
apiKey: "",
isDefault: next.isDefault,
})
}
useEffect(() => {
const subscription = form.watch(() => {
if (!lastTestedSignature) return
const currentSignature = buildSignature(form.getValues())
if (currentSignature !== lastTestedSignature) {
setTestStatus("idle")
}
})
return () => subscription.unsubscribe()
}, [form, buildSignature, lastTestedSignature])
const handleTest = () => {
const values = form.getValues()
const apiKey = values.apiKey?.trim()
if (!apiKey && !values.id?.trim()) {
toast.error("Please enter API key to test")
return
}
setTestStatus("testing")
startTransition(async () => {
const payload = {
id: values.id?.trim() || undefined,
provider: values.provider,
baseUrl: values.baseUrl?.trim() || undefined,
model: values.model.trim(),
apiKey: apiKey || undefined,
isDefault: values.isDefault ?? false,
}
const result = await testAiProviderAction(payload)
if (result.success) {
setTestStatus("passed")
setLastTestedSignature(buildSignature(values))
toast.success(result.message ?? "Test passed")
} else {
setTestStatus("failed")
toast.error(result.message ?? "Test failed")
}
})
}
const onSubmit = (values: AiProviderFormValues) => {
const signature = buildSignature(values)
if (testStatus !== "passed" || signature !== lastTestedSignature) {
toast.error("Please test the configuration before saving")
return
}
startTransition(async () => {
const payload = {
id: values.id?.trim() || undefined,
provider: values.provider,
baseUrl: values.baseUrl?.trim() || undefined,
model: values.model.trim(),
apiKey: values.apiKey?.trim() || undefined,
isDefault: values.isDefault ?? false,
}
const result = await upsertAiProviderAction(payload)
if (result.success) {
toast.success(result.message ?? "Saved")
setTestStatus("idle")
setLastTestedSignature("")
const rows = await getAiProviderSummaries()
setProviders(rows)
onProvidersChanged?.(rows)
const nextId = result.data ?? payload.id ?? ""
setSelectedId(nextId)
const next = rows.find((item) => item.id === nextId)
if (next) {
form.reset({
id: next.id,
provider: next.provider,
baseUrl: next.baseUrl ?? "",
model: next.model,
apiKey: "",
isDefault: next.isDefault,
})
}
} else {
toast.error(result.message ?? "Failed to save")
}
})
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-purple-500" />
AI Providers
</CardTitle>
<CardDescription>Manage AI vendors and default model configuration.</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<FormLabel>Existing Providers</FormLabel>
<Select value={selectedId || NEW_PROVIDER_VALUE} onValueChange={handleSelectChange}>
<SelectTrigger>
<SelectValue placeholder="Create new or select existing" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NEW_PROVIDER_VALUE}>Create new</SelectItem>
{providers.map((item) => (
<SelectItem key={item.id} value={item.id}>
{providerLabels[item.provider]} · {item.model}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<FormLabel>Key Status</FormLabel>
<div className="rounded-md border px-3 py-2 text-sm text-muted-foreground">
{selectedProvider?.apiKeyLast4
? `Stored • ****${selectedProvider.apiKeyLast4}`
: "No key stored"}
</div>
</div>
</div>
<Form {...form}>
<div className="grid gap-6">
<div className="grid gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="id"
render={({ field }) => (
<FormItem>
<FormLabel>ID</FormLabel>
<FormControl>
<Input {...field} value={field.value ?? ""} disabled />
</FormControl>
<FormDescription>Auto-generated for each provider.</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="provider"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select provider" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="zhipu"></SelectItem>
<SelectItem value="openai">OpenAI</SelectItem>
<SelectItem value="gemini">Gemini</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="baseUrl"
render={({ field }) => (
<FormItem>
<FormLabel>API URL</FormLabel>
<FormControl>
<Input {...field} placeholder="https://open.bigmodel.cn/api/paas/v4" />
</FormControl>
<FormDescription> /chat/completions</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="model"
render={({ field }) => (
<FormItem>
<FormLabel>Model</FormLabel>
<FormControl>
<Input {...field} placeholder="gpt-4o-mini" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="apiKey"
render={({ field }) => (
<FormItem className="sm:col-span-2">
<FormLabel>API Key</FormLabel>
<FormControl>
<Input type="password" {...field} placeholder="Paste new key to replace" />
</FormControl>
<FormDescription> Key</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="isDefault"
render={({ field }) => (
<FormItem className="flex items-center gap-2">
<FormControl>
<Checkbox checked={!!field.value} onCheckedChange={(value) => field.onChange(value === true)} />
</FormControl>
<FormLabel></FormLabel>
</FormItem>
)}
/>
<CardFooter className="flex justify-between border-t px-0 pt-4">
<Button type="button" variant="outline" onClick={handleTest} disabled={isPending || testStatus === "testing"}>
{testStatus === "testing" ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Testing...
</>
) : (
<>
<Sparkles className="mr-2 h-4 w-4" />
Test
</>
)}
</Button>
<Button type="button" onClick={form.handleSubmit(onSubmit)} disabled={isPending}>
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Save Changes
</>
)}
</Button>
</CardFooter>
</div>
</Form>
</CardContent>
</Card>
)
}

View File

@@ -41,10 +41,13 @@ export default auth((req: NextAuthRequest) => {
if (pathname.startsWith("/parent/") && role !== "parent") { if (pathname.startsWith("/parent/") && role !== "parent") {
return NextResponse.redirect(new URL(roleHome(role), req.url)) return NextResponse.redirect(new URL(roleHome(role), req.url))
} }
if (pathname.startsWith("/management/") && role !== "admin" && role !== "teacher") {
return NextResponse.redirect(new URL(roleHome(role), req.url))
}
return NextResponse.next() return NextResponse.next()
}) })
export const config = { export const config = {
matcher: ["/dashboard", "/admin/:path*", "/teacher/:path*", "/student/:path*", "/parent/:path*", "/settings/:path*", "/profile"], matcher: ["/dashboard", "/admin/:path*", "/teacher/:path*", "/student/:path*", "/parent/:path*", "/management/:path*", "/settings/:path*", "/profile"],
} }

View File

@@ -4,6 +4,10 @@ import * as React from "react"
import { SessionProvider } from "next-auth/react" import { SessionProvider } from "next-auth/react"
export function AuthSessionProvider({ children }: { children: React.ReactNode }) { export function AuthSessionProvider({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider> return (
<SessionProvider refetchOnWindowFocus={false} refetchInterval={0}>
{children}
</SessionProvider>
)
} }

View File

@@ -54,7 +54,7 @@ export function OnboardingGate() {
const role = String(json.role ?? "student") as Role const role = String(json.role ?? "student") as Role
setRequired(required) setRequired(required)
setCurrentRole(role) setCurrentRole(role)
setRole(role === "admin" ? "admin" : "student") setRole(role === "admin" ? "admin" : role)
setName(String(session?.user?.name ?? "").trim()) setName(String(session?.user?.name ?? "").trim())
if (required) { if (required) {
setOpen(true) setOpen(true)

View File

@@ -597,6 +597,23 @@ export const homeworkAnswers = mysqlTable("homework_answers", {
}), }),
})); }));
export const aiProviders = mysqlTable("ai_providers", {
id: id("id").primaryKey(),
provider: mysqlEnum("provider", ["zhipu", "openai", "gemini", "custom"]).notNull(),
baseUrl: varchar("base_url", { length: 512 }),
model: varchar("model", { length: 128 }).notNull(),
apiKeyEncrypted: text("api_key_encrypted").notNull(),
apiKeyLast4: varchar("api_key_last4", { length: 4 }),
isDefault: boolean("is_default").default(false).notNull(),
createdBy: varchar("created_by", { length: 128 }),
updatedBy: varchar("updated_by", { length: 128 }),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
providerIdx: index("ai_provider_idx").on(table.provider),
defaultIdx: index("ai_provider_default_idx").on(table.isDefault),
}));
// Re-export old courses table if needed or deprecate it. // Re-export old courses table if needed or deprecate it.
// Assuming we are replacing the old simple schema with this robust one. // Assuming we are replacing the old simple schema with this robust one.
// But if there were existing tables, we might keep them or comment them out. // But if there were existing tables, we might keep them or comment them out.

247
src/shared/lib/ai.ts Normal file
View File

@@ -0,0 +1,247 @@
import "server-only"
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto"
import OpenAI from "openai"
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions"
import { env } from "@/env.mjs"
import { db } from "@/shared/db"
import { aiProviders } from "@/shared/db/schema"
import { desc, eq } from "drizzle-orm"
type ChatRole = "system" | "user" | "assistant"
type ChatMessage = {
role: ChatRole
content: string
}
type AiChatRequest = {
messages: ChatCompletionMessageParam[]
model: string
temperature: number
maxTokens?: number
thinking?: Record<string, unknown>
providerId?: string
}
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
const isChatMessage = (v: unknown): v is ChatMessage => {
if (!isRecord(v)) return false
const role = String(v.role ?? "")
if (role !== "system" && role !== "user" && role !== "assistant") return false
const content = String(v.content ?? "")
return content.trim().length > 0
}
const extractText = (value: unknown): string => {
if (typeof value === "string") return value.trim()
if (Array.isArray(value)) {
const joined = value.map((item) => extractText(item)).filter(Boolean).join("\n")
return joined.trim()
}
if (isRecord(value)) {
const candidates = ["text", "content", "output_text", "reasoning", "reasoning_content", "thinking"]
for (const key of candidates) {
const text = extractText(value[key])
if (text) return text
}
}
return ""
}
const extractMessageContent = (message: unknown): string => {
if (!isRecord(message)) return ""
const direct = extractText(message.content)
if (direct) return direct
const candidates = ["reasoning", "reasoning_content", "thinking", "output", "text"]
for (const key of candidates) {
const text = extractText(message[key])
if (text) return text
}
for (const value of Object.values(message)) {
const text = extractText(value)
if (text) return text
}
return ""
}
export const parseAiChatPayload = (body: unknown): AiChatRequest => {
if (!isRecord(body)) throw new Error("Invalid payload")
const rawMessages = Array.isArray(body.messages) ? body.messages : []
const messages = rawMessages
.filter(isChatMessage)
.map((m) => ({ role: m.role, content: m.content })) as ChatCompletionMessageParam[]
if (messages.length === 0) throw new Error("Messages are required")
const model = String(body.model ?? env.AI_MODEL ?? "gpt-4o-mini").trim()
const temperatureRaw = Number(body.temperature ?? 0.2)
const temperature = Number.isFinite(temperatureRaw) ? Math.min(Math.max(temperatureRaw, 0), 2) : 0.2
const maxTokensRaw = Number(body.max_tokens ?? body.maxTokens ?? 0)
const maxTokens = Number.isFinite(maxTokensRaw) && maxTokensRaw > 0 ? Math.floor(maxTokensRaw) : undefined
const thinking = isRecord(body.thinking) ? body.thinking : undefined
const providerId = typeof body.providerId === "string" ? body.providerId.trim() : undefined
return {
messages,
model,
temperature,
maxTokens,
thinking,
providerId: providerId && providerId.length > 0 ? providerId : undefined,
}
}
const getEncryptionKey = () => {
const secret = String(env.NEXTAUTH_SECRET ?? "").trim()
if (!secret) throw new Error("AI encryption secret missing")
return createHash("sha256").update(secret).digest()
}
export const encryptAiApiKey = (value: string) => {
const iv = randomBytes(12)
const key = getEncryptionKey()
const cipher = createCipheriv("aes-256-gcm", key, iv)
const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()])
const tag = cipher.getAuthTag()
return Buffer.concat([iv, tag, encrypted]).toString("base64")
}
export const decryptAiApiKey = (value: string) => {
const raw = Buffer.from(value, "base64")
if (raw.length < 28) throw new Error("Invalid API key payload")
const iv = raw.subarray(0, 12)
const tag = raw.subarray(12, 28)
const encrypted = raw.subarray(28)
const key = getEncryptionKey()
const decipher = createDecipheriv("aes-256-gcm", key, iv)
decipher.setAuthTag(tag)
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()])
return decrypted.toString("utf8")
}
const getAiProviderConfig = async (providerId?: string) => {
if (providerId) {
const [selected] = await db
.select({
apiKeyEncrypted: aiProviders.apiKeyEncrypted,
baseUrl: aiProviders.baseUrl,
model: aiProviders.model,
})
.from(aiProviders)
.where(eq(aiProviders.id, providerId))
.limit(1)
if (!selected) throw new Error("AI provider not configured")
return {
apiKey: decryptAiApiKey(selected.apiKeyEncrypted),
baseUrl: selected.baseUrl ?? undefined,
model: selected.model,
}
}
const [active] = await db
.select({
apiKeyEncrypted: aiProviders.apiKeyEncrypted,
baseUrl: aiProviders.baseUrl,
model: aiProviders.model,
})
.from(aiProviders)
.where(eq(aiProviders.isDefault, true))
.orderBy(desc(aiProviders.updatedAt))
.limit(1)
if (active) {
return {
apiKey: decryptAiApiKey(active.apiKeyEncrypted),
baseUrl: active.baseUrl ?? undefined,
model: active.model,
}
}
const [fallback] = await db
.select({
apiKeyEncrypted: aiProviders.apiKeyEncrypted,
baseUrl: aiProviders.baseUrl,
model: aiProviders.model,
})
.from(aiProviders)
.orderBy(desc(aiProviders.updatedAt))
.limit(1)
if (!fallback) throw new Error("AI provider not configured")
return {
apiKey: decryptAiApiKey(fallback.apiKeyEncrypted),
baseUrl: fallback.baseUrl ?? undefined,
model: fallback.model,
}
}
const getAiClient = async (config: { apiKey: string; baseUrl?: string }) => {
const baseUrl = String(config.baseUrl ?? "https://api.openai.com").replace(/\/+$/, "")
return new OpenAI({
apiKey: config.apiKey,
baseURL: baseUrl.length ? baseUrl : undefined,
})
}
export const testAiProviderConfig = async (input: { apiKey: string; baseUrl?: string; model: string }) => {
const client = await getAiClient({ apiKey: input.apiKey, baseUrl: input.baseUrl })
const result = await client.chat.completions.create({
model: input.model,
messages: [{ role: "user", content: "ping" }],
temperature: 0,
max_tokens: 1,
} as Parameters<typeof client.chat.completions.create>[0])
const hasChoices = "choices" in result && Array.isArray(result.choices) && result.choices.length > 0
if (!hasChoices) throw new Error("Empty response from provider. Check API URL, model, and API key.")
return true
}
export const testAiProviderById = async (
providerId: string,
overrides?: { baseUrl?: string; model?: string }
) => {
const config = await getAiProviderConfig(providerId)
const client = await getAiClient({ apiKey: config.apiKey, baseUrl: overrides?.baseUrl ?? config.baseUrl })
const result = await client.chat.completions.create({
model: overrides?.model ?? config.model,
messages: [{ role: "user", content: "ping" }],
temperature: 0,
max_tokens: 1,
} as Parameters<typeof client.chat.completions.create>[0])
const hasChoices = "choices" in result && Array.isArray(result.choices) && result.choices.length > 0
if (!hasChoices) throw new Error("Empty response from provider. Check API URL, model, and API key.")
return true
}
export const createAiChatCompletion = async (input: AiChatRequest) => {
const config = await getAiProviderConfig(input.providerId)
const client = await getAiClient(config)
const result = (await client.chat.completions.create({
model: config.model || input.model,
messages: input.messages,
temperature: input.temperature,
...(typeof input.maxTokens === "number" ? { max_tokens: input.maxTokens } : {}),
...(input.thinking ? { thinking: input.thinking } : {}),
} as Parameters<typeof client.chat.completions.create>[0])) as Awaited<
ReturnType<typeof client.chat.completions.create>
>
const hasChoices = "choices" in result && Array.isArray(result.choices) && result.choices.length > 0
if (!hasChoices) throw new Error("Empty response from provider. Check API URL, model, and API key.")
const content = extractMessageContent(result.choices?.[0]?.message)
if (!content.trim()) throw new Error("Empty response content. Check model output settings.")
const usage = "usage" in result ? result.usage ?? null : null
return { content, usage }
}
export const getAiErrorMessage = (v: unknown) => {
if (v instanceof Error) return v.message
if (!isRecord(v)) return "AI request failed"
const message = String(v.message ?? "")
return message.trim().length ? message : "AI request failed"
}

View File

@@ -0,0 +1,27 @@
import { expect, test } from "@playwright/test"
test.describe("auth business flow", () => {
test("register then login and reach protected area", async ({ page }) => {
test.skip(!process.env.DATABASE_URL, "requires DATABASE_URL for write-flow verification")
const id = Date.now()
const email = `e2e.user.${id}@example.com`
const password = "e2e-pass-123456"
await page.goto("/register")
await page.getByLabel("Full Name").fill("E2E User")
await page.getByLabel("Email").fill(email)
await page.getByLabel("Password").fill(password)
await page.getByRole("button", { name: "Create Account" }).click()
await expect(page).toHaveURL(/\/login(?:$|[/?#])/)
await page.getByLabel("Email").fill(email)
await page.getByLabel("Password").fill(password)
await page.getByRole("button", { name: "Sign In with Email" }).click()
await expect(page).toHaveURL(/\/(dashboard|student\/dashboard)(?:$|[/?#])/)
const profileResponse = await page.goto("/profile")
expect(profileResponse?.status() ?? 200).toBeLessThan(500)
await expect(page).not.toHaveURL(/\/login(?:$|[/?#])/)
})
})

View File

@@ -0,0 +1,75 @@
import fs from "node:fs"
import path from "node:path"
import { expect, test } from "@playwright/test"
const appRoot = path.resolve(__dirname, "../../src/app")
const publicRoutes = new Set(["/", "/login", "/register"])
const toRoute = (filePath: string) => {
const relative = path.relative(appRoot, filePath).replaceAll("\\", "/")
const segments = relative
.split("/")
.slice(0, -1)
.filter((segment) => !segment.startsWith("("))
if (segments.some((segment) => segment.startsWith("["))) return null
if (segments.length === 0) return "/"
return `/${segments.join("/")}`
}
const collectPageFiles = (dir: string): string[] => {
const entries = fs.readdirSync(dir, { withFileTypes: true })
return entries.flatMap((entry) => {
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) return collectPageFiles(fullPath)
return entry.name === "page.tsx" ? [fullPath] : []
})
}
const allStaticRoutes = Array.from(
new Set(
collectPageFiles(appRoot)
.map((file) => toRoute(file))
.filter((route): route is string => Boolean(route)),
),
).sort((a, b) => a.localeCompare(b))
const protectedRoutes = allStaticRoutes.filter((route) => !publicRoutes.has(route))
test.describe("full route regression", () => {
test("route inventory is complete", async () => {
expect(allStaticRoutes.length).toBeGreaterThanOrEqual(30)
expect(allStaticRoutes).toContain("/admin/dashboard")
expect(allStaticRoutes).toContain("/teacher/dashboard")
expect(allStaticRoutes).toContain("/student/dashboard")
expect(allStaticRoutes).toContain("/parent/dashboard")
expect(allStaticRoutes).toContain("/settings")
expect(allStaticRoutes).toContain("/profile")
})
for (const route of publicRoutes) {
test(`public route renders: ${route}`, async ({ page }) => {
const response = await page.goto(route)
expect(response?.status() ?? 200).toBeLessThan(500)
await expect(page).not.toHaveURL(/\/500(?:$|[/?#])/)
})
}
for (const route of protectedRoutes) {
test(`protected route guard: ${route}`, async ({ page }) => {
const response = await page.goto(route)
expect(response?.status() ?? 200).toBeLessThan(500)
const finalUrl = new URL(page.url())
const finalPath = finalUrl.pathname
expect(finalPath === route || finalPath === "/login").toBe(true)
if (finalPath === "/login") {
const callbackUrl = finalUrl.searchParams.get("callbackUrl") ?? ""
const normalizedCallback = callbackUrl.startsWith("http")
? new URL(callbackUrl).pathname
: callbackUrl
expect(normalizedCallback === "" || normalizedCallback === route).toBe(true)
}
})
}
})

View File

@@ -0,0 +1,20 @@
import { expect, test } from "@playwright/test"
test.describe("auth smoke", () => {
test("login page renders required controls", async ({ page }) => {
await page.goto("/login")
await expect(page).toHaveTitle(/Login/i)
await expect(page.getByLabel("Email")).toBeVisible()
await expect(page.getByLabel("Password")).toBeVisible()
await expect(page.getByRole("button", { name: "Sign In with Email" })).toBeVisible()
})
test("register page renders required controls", async ({ page }) => {
await page.goto("/register")
await expect(page).toHaveTitle(/Register/i)
await expect(page.getByLabel("Full Name")).toBeVisible()
await expect(page.getByLabel("Email")).toBeVisible()
await expect(page.getByLabel("Password")).toBeVisible()
await expect(page.getByRole("button", { name: "Create Account" })).toBeVisible()
})
})

View File

@@ -0,0 +1,84 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
const mocks = vi.hoisted(() => ({
authMock: vi.fn(),
parseAiChatPayloadMock: vi.fn(),
createAiChatCompletionMock: vi.fn(),
getAiErrorMessageMock: vi.fn(),
}))
vi.mock("@/auth", () => ({
auth: mocks.authMock,
}))
vi.mock("@/shared/lib/ai", () => ({
parseAiChatPayload: mocks.parseAiChatPayloadMock,
createAiChatCompletion: mocks.createAiChatCompletionMock,
getAiErrorMessage: mocks.getAiErrorMessageMock,
}))
import { POST } from "@/app/api/ai/chat/route"
describe("POST /api/ai/chat", () => {
beforeEach(() => {
vi.resetAllMocks()
})
it("returns 401 when session is missing", async () => {
mocks.authMock.mockResolvedValue(null)
const req = new Request("http://localhost/api/ai/chat", {
method: "POST",
body: JSON.stringify({ messages: [] }),
headers: { "Content-Type": "application/json" },
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(401)
expect(data).toEqual({ success: false, message: "Unauthorized" })
})
it("returns ai response content for valid input", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_1" } })
mocks.parseAiChatPayloadMock.mockReturnValue({ messages: [{ role: "user", content: "hello" }] })
mocks.createAiChatCompletionMock.mockResolvedValue({
content: "mocked-answer",
usage: { totalTokens: 10, promptTokens: 5, completionTokens: 5 },
})
const req = new Request("http://localhost/api/ai/chat", {
method: "POST",
body: JSON.stringify({ messages: [{ role: "user", content: "hello" }] }),
headers: { "Content-Type": "application/json" },
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(data.content).toBe("mocked-answer")
expect(mocks.createAiChatCompletionMock).toHaveBeenCalledTimes(1)
})
it("maps invalid payload errors to 400", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_1" } })
mocks.parseAiChatPayloadMock.mockImplementation(() => {
throw new Error("bad payload")
})
mocks.getAiErrorMessageMock.mockReturnValue("Invalid payload")
const req = new Request("http://localhost/api/ai/chat", {
method: "POST",
body: JSON.stringify({ messages: "bad" }),
headers: { "Content-Type": "application/json" },
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data).toEqual({ success: false, message: "Invalid payload" })
})
})

View File

@@ -0,0 +1,199 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
const mocks = vi.hoisted(() => {
const whereMock = vi.fn()
const innerJoinMock = vi.fn(() => ({ where: whereMock }))
const fromMock = vi.fn(() => ({ innerJoin: innerJoinMock, where: whereMock }))
const selectMock = vi.fn(() => ({ from: fromMock }))
const updateWhereMock = vi.fn()
const updateSetMock = vi.fn(() => ({ where: updateWhereMock }))
const updateMock = vi.fn(() => ({ set: updateSetMock }))
const insertOnDuplicateKeyUpdateMock = vi.fn()
const insertValuesMock = vi.fn(() => ({ onDuplicateKeyUpdate: insertOnDuplicateKeyUpdateMock }))
const insertMock = vi.fn(() => ({ values: insertValuesMock }))
const enrollStudentByInvitationCodeMock = vi.fn()
return {
authMock: vi.fn(),
whereMock,
fromMock,
selectMock,
roleFindFirstMock: vi.fn(),
updateWhereMock,
updateSetMock,
updateMock,
insertOnDuplicateKeyUpdateMock,
insertValuesMock,
insertMock,
enrollStudentByInvitationCodeMock,
}
})
vi.mock("@/auth", () => ({
auth: mocks.authMock,
}))
vi.mock("@/shared/db", () => ({
db: {
select: mocks.selectMock,
query: {
roles: {
findFirst: mocks.roleFindFirstMock,
},
},
update: mocks.updateMock,
insert: mocks.insertMock,
},
}))
vi.mock("@/shared/db/schema", () => ({
classes: { id: "id", invitationCode: "invitationCode" },
classSubjectTeachers: { classId: "classId", subjectId: "subjectId", teacherId: "teacherId", updatedAt: "updatedAt" },
roles: { id: "id", name: "name" },
users: { id: "id", onboardedAt: "onboardedAt", name: "name", phone: "phone", address: "address" },
usersToRoles: { userId: "userId", roleId: "roleId" },
subjects: { id: "id", name: "name" },
}))
vi.mock("@/modules/classes/data-access", () => ({
enrollStudentByInvitationCode: mocks.enrollStudentByInvitationCodeMock,
}))
import { POST } from "@/app/api/onboarding/complete/route"
describe("POST /api/onboarding/complete", () => {
beforeEach(() => {
vi.resetAllMocks()
mocks.whereMock.mockResolvedValue([])
mocks.roleFindFirstMock.mockResolvedValue({ id: "role_student" })
})
it("returns 401 when session is missing", async () => {
mocks.authMock.mockResolvedValue(null)
const req = new Request("http://localhost/api/onboarding/complete", {
method: "POST",
body: JSON.stringify({ role: "student", name: "A" }),
headers: { "Content-Type": "application/json" },
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(401)
expect(data).toEqual({ success: false, message: "Unauthorized" })
})
it("returns 400 for invalid payload structure", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_1" } })
const req = new Request("http://localhost/api/onboarding/complete", {
method: "POST",
body: "not-json",
headers: { "Content-Type": "application/json" },
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data).toEqual({ success: false, message: "Invalid payload" })
})
it("returns 400 for invalid role", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_1" } })
const req = new Request("http://localhost/api/onboarding/complete", {
method: "POST",
body: JSON.stringify({ role: "guest", name: "A" }),
headers: { "Content-Type": "application/json" },
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data).toEqual({ success: false, message: "Invalid role" })
})
it("returns 403 when non-admin selects admin role", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_2" } })
mocks.whereMock.mockResolvedValue([{ name: "teacher" }])
const req = new Request("http://localhost/api/onboarding/complete", {
method: "POST",
body: JSON.stringify({ role: "admin", name: "Admin User" }),
headers: { "Content-Type": "application/json" },
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(403)
expect(data).toEqual({ success: false, message: "Forbidden" })
})
it("returns 400 when name is missing", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_3" } })
const req = new Request("http://localhost/api/onboarding/complete", {
method: "POST",
body: JSON.stringify({ role: "student", name: " " }),
headers: { "Content-Type": "application/json" },
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data).toEqual({ success: false, message: "Name is required" })
})
it("completes student onboarding and enrolls deduplicated class codes", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_student" } })
const req = new Request("http://localhost/api/onboarding/complete", {
method: "POST",
body: JSON.stringify({
role: "student",
name: "Student A",
classCodes: "C1, C1;C2 C3",
}),
headers: { "Content-Type": "application/json" },
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toEqual({ success: true })
expect(mocks.enrollStudentByInvitationCodeMock).toHaveBeenCalledTimes(3)
expect(mocks.enrollStudentByInvitationCodeMock).toHaveBeenNthCalledWith(1, "u_student", "C1")
expect(mocks.enrollStudentByInvitationCodeMock).toHaveBeenNthCalledWith(2, "u_student", "C2")
expect(mocks.enrollStudentByInvitationCodeMock).toHaveBeenNthCalledWith(3, "u_student", "C3")
expect(mocks.updateSetMock).toHaveBeenCalled()
expect(mocks.insertValuesMock).toHaveBeenCalled()
})
it("completes teacher onboarding and upserts class-subject mapping", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_teacher" } })
mocks.whereMock
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: "class_1", invitationCode: "T1" }])
.mockResolvedValueOnce([
{ id: "sub_math", name: "数学" },
{ id: "sub_music", name: "音乐" },
])
const req = new Request("http://localhost/api/onboarding/complete", {
method: "POST",
body: JSON.stringify({
role: "teacher",
name: "Teacher A",
classCodes: "T1",
teacherSubjects: ["数学", "音乐", "invalid"],
}),
headers: { "Content-Type": "application/json" },
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toEqual({ success: true })
expect(mocks.enrollStudentByInvitationCodeMock).not.toHaveBeenCalled()
expect(mocks.insertOnDuplicateKeyUpdateMock).toHaveBeenCalledTimes(3)
})
})

View File

@@ -0,0 +1,78 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
const mocks = vi.hoisted(() => {
const whereMock = vi.fn()
const innerJoinMock = vi.fn(() => ({ where: whereMock }))
const fromMock = vi.fn(() => ({ innerJoin: innerJoinMock }))
const selectMock = vi.fn(() => ({ from: fromMock }))
return {
authMock: vi.fn(),
findFirstMock: vi.fn(),
whereMock,
selectMock,
}
})
vi.mock("@/auth", () => ({
auth: mocks.authMock,
}))
vi.mock("@/shared/db", () => ({
db: {
query: {
users: {
findFirst: mocks.findFirstMock,
},
},
select: mocks.selectMock,
},
}))
vi.mock("@/shared/db/schema", () => ({
roles: { name: "name", id: "id" },
users: { id: "id" },
usersToRoles: { roleId: "roleId", userId: "userId" },
}))
import { GET } from "@/app/api/onboarding/status/route"
describe("GET /api/onboarding/status", () => {
beforeEach(() => {
vi.resetAllMocks()
})
it("returns not required when user is unauthenticated", async () => {
mocks.authMock.mockResolvedValue(null)
const response = await GET()
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toEqual({ required: false })
expect(mocks.findFirstMock).not.toHaveBeenCalled()
})
it("returns teacher role for grade head and requires onboarding when missing onboardedAt", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_1" } })
mocks.findFirstMock.mockResolvedValue({ onboardedAt: null })
mocks.whereMock.mockResolvedValue([{ name: "grade_head" }])
const response = await GET()
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toEqual({ required: true, role: "teacher" })
})
it("prioritizes admin role and marks not required when onboarded", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_2" } })
mocks.findFirstMock.mockResolvedValue({ onboardedAt: new Date("2026-01-01") })
mocks.whereMock.mockResolvedValue([{ name: "student" }, { name: "admin" }])
const response = await GET()
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toEqual({ required: false, role: "admin" })
})
})

View File

@@ -0,0 +1,67 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
const mocks = vi.hoisted(() => ({
authMock: vi.fn(),
getUserProfileMock: vi.fn(),
redirectMock: vi.fn((target: string) => {
throw new Error(`REDIRECT:${target}`)
}),
}))
vi.mock("@/auth", () => ({
auth: mocks.authMock,
}))
vi.mock("@/modules/users/data-access", () => ({
getUserProfile: mocks.getUserProfileMock,
}))
vi.mock("next/navigation", () => ({
redirect: mocks.redirectMock,
}))
import DashboardPage from "@/app/(dashboard)/dashboard/page"
describe("dashboard route dispatcher", () => {
beforeEach(() => {
vi.resetAllMocks()
mocks.redirectMock.mockImplementation((target: string) => {
throw new Error(`REDIRECT:${target}`)
})
})
it("redirects to login when session is missing", async () => {
mocks.authMock.mockResolvedValue(null)
await expect(DashboardPage()).rejects.toThrow("REDIRECT:/login")
})
it("redirects to login when user profile is missing", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_1" } })
mocks.getUserProfileMock.mockResolvedValue(null)
await expect(DashboardPage()).rejects.toThrow("REDIRECT:/login")
})
it("redirects admin to admin dashboard", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_admin" } })
mocks.getUserProfileMock.mockResolvedValue({ role: "admin" })
await expect(DashboardPage()).rejects.toThrow("REDIRECT:/admin/dashboard")
})
it("redirects student to student dashboard", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_student" } })
mocks.getUserProfileMock.mockResolvedValue({ role: "student" })
await expect(DashboardPage()).rejects.toThrow("REDIRECT:/student/dashboard")
})
it("redirects parent to parent dashboard", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_parent" } })
mocks.getUserProfileMock.mockResolvedValue({ role: "parent" })
await expect(DashboardPage()).rejects.toThrow("REDIRECT:/parent/dashboard")
})
it("falls back to student dashboard when role is unknown", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_teacher" } })
mocks.getUserProfileMock.mockResolvedValue({ role: "" })
await expect(DashboardPage()).rejects.toThrow("REDIRECT:/student/dashboard")
})
})

View File

@@ -0,0 +1,290 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
const mocks = vi.hoisted(() => {
const authMock = vi.fn()
const revalidatePathMock = vi.fn()
const createIdMock = vi.fn()
const ensureLimitMock = vi.fn()
const ensureWhereMock = vi.fn(() => ({ limit: ensureLimitMock }))
const ensureInnerJoinSecondMock = vi.fn(() => ({ where: ensureWhereMock }))
const ensureInnerJoinFirstMock = vi.fn(() => ({ innerJoin: ensureInnerJoinSecondMock }))
const countWhereMock = vi.fn()
const selectFromMock = vi.fn(() => ({ innerJoin: ensureInnerJoinFirstMock, where: countWhereMock }))
const selectMock = vi.fn(() => ({ from: selectFromMock }))
const assignmentFindFirstMock = vi.fn()
const assignmentTargetFindFirstMock = vi.fn()
const submissionFindFirstMock = vi.fn()
const txAnswerFindFirstMock = vi.fn()
const insertValuesMock = vi.fn()
const insertMock = vi.fn(() => ({ values: insertValuesMock }))
const txInsertValuesMock = vi.fn()
const txInsertMock = vi.fn(() => ({ values: txInsertValuesMock }))
const updateWhereMock = vi.fn()
const updateSetMock = vi.fn(() => ({ where: updateWhereMock }))
const updateMock = vi.fn(() => ({ set: updateSetMock }))
const txUpdateWhereMock = vi.fn()
const txUpdateSetMock = vi.fn(() => ({ where: txUpdateWhereMock }))
const txUpdateMock = vi.fn(() => ({ set: txUpdateSetMock }))
const transactionMock = vi.fn(async (callback: (tx: unknown) => Promise<unknown>) =>
callback({
query: { homeworkAnswers: { findFirst: txAnswerFindFirstMock } },
update: txUpdateMock,
insert: txInsertMock,
})
)
return {
authMock,
revalidatePathMock,
createIdMock,
ensureLimitMock,
countWhereMock,
selectMock,
assignmentFindFirstMock,
assignmentTargetFindFirstMock,
submissionFindFirstMock,
txAnswerFindFirstMock,
insertValuesMock,
insertMock,
txInsertValuesMock,
txInsertMock,
updateSetMock,
updateMock,
txUpdateSetMock,
txUpdateMock,
transactionMock,
}
})
vi.mock("@/auth", () => ({
auth: mocks.authMock,
}))
vi.mock("next/cache", () => ({
revalidatePath: mocks.revalidatePathMock,
}))
vi.mock("@paralleldrive/cuid2", () => ({
createId: mocks.createIdMock,
}))
vi.mock("@/shared/db", () => ({
db: {
select: mocks.selectMock,
query: {
homeworkAssignments: { findFirst: mocks.assignmentFindFirstMock },
homeworkAssignmentTargets: { findFirst: mocks.assignmentTargetFindFirstMock },
homeworkSubmissions: { findFirst: mocks.submissionFindFirstMock },
},
insert: mocks.insertMock,
update: mocks.updateMock,
transaction: mocks.transactionMock,
},
}))
vi.mock("@/shared/db/schema", () => ({
classes: { id: "id", teacherId: "teacherId" },
classEnrollments: { classId: "classId", studentId: "studentId", status: "status" },
classSubjectTeachers: { classId: "classId", teacherId: "teacherId", subjectId: "subjectId" },
exams: { id: "id" },
homeworkAnswers: { id: "id", submissionId: "submissionId", questionId: "questionId" },
homeworkAssignmentQuestions: { assignmentId: "assignmentId" },
homeworkAssignmentTargets: { assignmentId: "assignmentId", studentId: "studentId" },
homeworkAssignments: { id: "id", status: "status" },
homeworkSubmissions: {
id: "id",
assignmentId: "assignmentId",
studentId: "studentId",
status: "status",
submittedAt: "submittedAt",
isLate: "isLate",
updatedAt: "updatedAt",
score: "score",
},
roles: { id: "id", name: "name" },
users: { id: "id" },
usersToRoles: { userId: "userId", roleId: "roleId" },
}))
import {
gradeHomeworkSubmissionAction,
saveHomeworkAnswerAction,
startHomeworkSubmissionAction,
submitHomeworkAction,
} from "@/modules/homework/actions"
describe("homework action flow", () => {
beforeEach(() => {
vi.resetAllMocks()
})
it("starts submission for assigned student", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_student" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_student" }])
mocks.assignmentFindFirstMock.mockResolvedValue({
id: "a_1",
status: "published",
availableAt: null,
maxAttempts: 2,
})
mocks.assignmentTargetFindFirstMock.mockResolvedValue({ assignmentId: "a_1", studentId: "u_student" })
mocks.countWhereMock.mockResolvedValue([{ c: 0 }])
mocks.createIdMock.mockReturnValue("sub_1")
const formData = new FormData()
formData.set("assignmentId", "a_1")
const result = await startHomeworkSubmissionAction(null, formData)
expect(result).toEqual({ success: true, message: "Started", data: "sub_1" })
expect(mocks.insertValuesMock).toHaveBeenCalledWith(
expect.objectContaining({
id: "sub_1",
assignmentId: "a_1",
studentId: "u_student",
attemptNo: 1,
status: "started",
})
)
expect(mocks.revalidatePathMock).toHaveBeenCalledWith("/student/learning/assignments")
})
it("blocks submission when assignment is past due", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_student" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_student" }])
mocks.submissionFindFirstMock.mockResolvedValue({
id: "sub_1",
studentId: "u_student",
status: "started",
assignment: {
dueAt: new Date(Date.now() - 60_000),
allowLate: false,
lateDueAt: null,
},
})
const formData = new FormData()
formData.set("submissionId", "sub_1")
const result = await submitHomeworkAction(null, formData)
expect(result).toEqual({ success: false, message: "Past due" })
})
it("submits started homework before due time", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_student" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_student" }])
mocks.submissionFindFirstMock.mockResolvedValue({
id: "sub_2",
studentId: "u_student",
status: "started",
assignment: {
dueAt: new Date(Date.now() + 60_000),
allowLate: false,
lateDueAt: null,
},
})
const formData = new FormData()
formData.set("submissionId", "sub_2")
const result = await submitHomeworkAction(null, formData)
expect(result).toEqual({ success: true, message: "Submitted", data: "sub_2" })
expect(mocks.updateSetMock).toHaveBeenCalledWith(expect.objectContaining({ status: "submitted", isLate: false }))
expect(mocks.revalidatePathMock).toHaveBeenCalledWith("/teacher/homework/submissions")
expect(mocks.revalidatePathMock).toHaveBeenCalledWith("/student/learning/assignments")
})
it("blocks start when attempts are exhausted", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_student" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_student" }])
mocks.assignmentFindFirstMock.mockResolvedValue({
id: "a_2",
status: "published",
availableAt: null,
maxAttempts: 1,
})
mocks.assignmentTargetFindFirstMock.mockResolvedValue({ assignmentId: "a_2", studentId: "u_student" })
mocks.countWhereMock.mockResolvedValue([{ c: 1 }])
const formData = new FormData()
formData.set("assignmentId", "a_2")
const result = await startHomeworkSubmissionAction(null, formData)
expect(result).toEqual({ success: false, message: "No attempts left" })
})
it("grades submission and writes total score", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_teacher" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_teacher", role: "teacher" }])
const formData = new FormData()
formData.set("submissionId", "sub_1")
formData.set(
"answersJson",
JSON.stringify([
{ id: "ans_1", score: 5, feedback: "good" },
{ id: "ans_2", score: 3, feedback: "" },
])
)
const result = await gradeHomeworkSubmissionAction(null, formData)
expect(result).toEqual({ success: true, message: "Grading saved" })
expect(mocks.updateMock).toHaveBeenCalledTimes(3)
expect(mocks.updateSetMock).toHaveBeenCalledWith(expect.objectContaining({ score: 8, status: "graded" }))
expect(mocks.revalidatePathMock).toHaveBeenCalledWith("/teacher/homework/submissions")
})
it("saves new answer for started submission", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_student" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_student" }])
mocks.submissionFindFirstMock.mockResolvedValue({
id: "sub_3",
studentId: "u_student",
status: "started",
assignment: {},
})
mocks.txAnswerFindFirstMock.mockResolvedValue(null)
mocks.createIdMock.mockReturnValue("ans_new")
const formData = new FormData()
formData.set("submissionId", "sub_3")
formData.set("questionId", "q_3")
formData.set("answerJson", JSON.stringify({ text: "answer content" }))
const result = await saveHomeworkAnswerAction(null, formData)
expect(result).toEqual({ success: true, message: "Saved", data: "sub_3" })
expect(mocks.txInsertValuesMock).toHaveBeenCalledWith(
expect.objectContaining({
id: "ans_new",
submissionId: "sub_3",
questionId: "q_3",
})
)
})
it("updates existing answer for started submission", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_student" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_student" }])
mocks.submissionFindFirstMock.mockResolvedValue({
id: "sub_4",
studentId: "u_student",
status: "started",
assignment: {},
})
mocks.txAnswerFindFirstMock.mockResolvedValue({ id: "ans_existing" })
const formData = new FormData()
formData.set("submissionId", "sub_4")
formData.set("questionId", "q_4")
formData.set("answerJson", JSON.stringify({ text: "updated answer" }))
const result = await saveHomeworkAnswerAction(null, formData)
expect(result).toEqual({ success: true, message: "Saved", data: "sub_4" })
expect(mocks.txUpdateMock).toHaveBeenCalledTimes(1)
expect(mocks.txUpdateSetMock).toHaveBeenCalledWith(expect.objectContaining({ answerContent: { text: "updated answer" } }))
})
})

View File

@@ -0,0 +1,298 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
const mocks = vi.hoisted(() => {
const authMock = vi.fn()
const revalidatePathMock = vi.fn()
const createIdMock = vi.fn()
const ensureLimitMock = vi.fn()
const classLimitMock = vi.fn()
const enrollmentWhereMock = vi.fn()
const subjectTeacherWhereMock = vi.fn()
const examFindFirstMock = vi.fn()
const txInsertValuesMock = vi.fn()
const txInsertMock = vi.fn(() => ({ values: txInsertValuesMock }))
const transactionMock = vi.fn(async (callback: (tx: { insert: typeof txInsertMock }) => unknown) =>
callback({ insert: txInsertMock })
)
const schema = {
classes: { id: "id", teacherId: "teacherId" },
classEnrollments: { classId: "classId", studentId: "studentId", status: "status" },
classSubjectTeachers: { classId: "classId", teacherId: "teacherId", subjectId: "subjectId" },
exams: { id: "id", subjectId: "subjectId", title: "title", structure: "structure" },
homeworkAnswers: { id: "id" },
homeworkAssignmentQuestions: { assignmentId: "assignmentId", questionId: "questionId" },
homeworkAssignmentTargets: { assignmentId: "assignmentId", studentId: "studentId" },
homeworkAssignments: { id: "id" },
homeworkSubmissions: { id: "id" },
roles: { id: "id", name: "name" },
users: { id: "id" },
usersToRoles: { userId: "userId", roleId: "roleId" },
}
return {
authMock,
revalidatePathMock,
createIdMock,
ensureLimitMock,
classLimitMock,
enrollmentWhereMock,
subjectTeacherWhereMock,
examFindFirstMock,
txInsertValuesMock,
txInsertMock,
transactionMock,
schema,
}
})
vi.mock("@/auth", () => ({
auth: mocks.authMock,
}))
vi.mock("next/cache", () => ({
revalidatePath: mocks.revalidatePathMock,
}))
vi.mock("@paralleldrive/cuid2", () => ({
createId: mocks.createIdMock,
}))
vi.mock("@/shared/db/schema", () => mocks.schema)
vi.mock("@/shared/db", () => ({
db: {
select: () => ({
from: (table: unknown) => {
if (table === mocks.schema.users) {
return {
innerJoin: () => ({
innerJoin: () => ({
where: () => ({
limit: mocks.ensureLimitMock,
}),
}),
}),
}
}
if (table === mocks.schema.classes) {
return {
where: () => ({
limit: mocks.classLimitMock,
}),
}
}
if (table === mocks.schema.classEnrollments) {
return {
innerJoin: () => ({
where: mocks.enrollmentWhereMock,
}),
}
}
if (table === mocks.schema.classSubjectTeachers) {
return {
where: mocks.subjectTeacherWhereMock,
}
}
return {
where: () => ({
limit: vi.fn().mockResolvedValue([]),
}),
}
},
}),
query: {
exams: {
findFirst: mocks.examFindFirstMock,
},
},
transaction: mocks.transactionMock,
},
}))
import { createHomeworkAssignmentAction } from "@/modules/homework/actions"
describe("createHomeworkAssignmentAction", () => {
beforeEach(() => {
vi.resetAllMocks()
})
it("creates published assignment from exam with targets", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_admin" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_admin", role: "admin" }])
mocks.classLimitMock.mockResolvedValue([{ id: "class_1", teacherId: "teacher_1" }])
mocks.examFindFirstMock.mockResolvedValue({
id: "exam_1",
title: "Exam A",
subjectId: "subject_1",
structure: { sections: [] },
questions: [{ questionId: "q_1", score: 10, order: 1 }],
})
mocks.enrollmentWhereMock.mockResolvedValue([{ studentId: "stu_1" }, { studentId: "stu_2" }])
mocks.createIdMock.mockReturnValue("assignment_1")
const formData = new FormData()
formData.set("sourceExamId", "exam_1")
formData.set("classId", "class_1")
formData.set("publish", "true")
const result = await createHomeworkAssignmentAction(null, formData)
expect(result).toEqual({ success: true, message: "Assignment created", data: "assignment_1" })
expect(mocks.txInsertValuesMock).toHaveBeenCalledTimes(3)
expect(mocks.revalidatePathMock).toHaveBeenCalledWith("/teacher/homework/assignments")
expect(mocks.revalidatePathMock).toHaveBeenCalledWith("/teacher/homework/submissions")
})
it("returns not found when source exam does not exist", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_admin" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_admin", role: "admin" }])
mocks.classLimitMock.mockResolvedValue([{ id: "class_1", teacherId: "teacher_1" }])
mocks.examFindFirstMock.mockResolvedValue(null)
const formData = new FormData()
formData.set("sourceExamId", "missing_exam")
formData.set("classId", "class_1")
formData.set("publish", "true")
const result = await createHomeworkAssignmentAction(null, formData)
expect(result).toEqual({ success: false, message: "Exam not found" })
expect(mocks.transactionMock).not.toHaveBeenCalled()
})
it("blocks publish when class has no active students", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_admin" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_admin", role: "admin" }])
mocks.classLimitMock.mockResolvedValue([{ id: "class_2", teacherId: "teacher_2" }])
mocks.examFindFirstMock.mockResolvedValue({
id: "exam_2",
title: "Exam B",
subjectId: "subject_2",
structure: { sections: [] },
questions: [{ questionId: "q_2", score: 5, order: 1 }],
})
mocks.enrollmentWhereMock.mockResolvedValue([])
const formData = new FormData()
formData.set("sourceExamId", "exam_2")
formData.set("classId", "class_2")
formData.set("publish", "true")
const result = await createHomeworkAssignmentAction(null, formData)
expect(result).toEqual({ success: false, message: "No active students in this class" })
expect(mocks.transactionMock).not.toHaveBeenCalled()
})
it("blocks teacher when not assigned to class", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_teacher" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_teacher", role: "teacher" }])
mocks.classLimitMock.mockResolvedValue([{ id: "class_3", teacherId: "owner_teacher" }])
mocks.examFindFirstMock.mockResolvedValue({
id: "exam_3",
title: "Exam C",
subjectId: "subject_3",
structure: { sections: [] },
questions: [{ questionId: "q_3", score: 5, order: 1 }],
})
mocks.subjectTeacherWhereMock.mockResolvedValue([])
const formData = new FormData()
formData.set("sourceExamId", "exam_3")
formData.set("classId", "class_3")
formData.set("publish", "true")
const result = await createHomeworkAssignmentAction(null, formData)
expect(result).toEqual({ success: false, message: "Not assigned to this class" })
expect(mocks.transactionMock).not.toHaveBeenCalled()
})
it("blocks teacher when exam subject is not assigned", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_teacher" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_teacher", role: "teacher" }])
mocks.classLimitMock.mockResolvedValue([{ id: "class_4", teacherId: "owner_teacher" }])
mocks.examFindFirstMock.mockResolvedValue({
id: "exam_4",
title: "Exam D",
subjectId: "subject_math",
structure: { sections: [] },
questions: [{ questionId: "q_4", score: 10, order: 1 }],
})
mocks.subjectTeacherWhereMock.mockResolvedValue([{ subjectId: "subject_english" }])
const formData = new FormData()
formData.set("sourceExamId", "exam_4")
formData.set("classId", "class_4")
formData.set("publish", "true")
const result = await createHomeworkAssignmentAction(null, formData)
expect(result).toEqual({ success: false, message: "Not assigned to this subject" })
expect(mocks.transactionMock).not.toHaveBeenCalled()
})
it("allows teacher assigned subject to publish", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_teacher" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_teacher", role: "teacher" }])
mocks.classLimitMock.mockResolvedValue([{ id: "class_5", teacherId: "owner_teacher" }])
mocks.examFindFirstMock.mockResolvedValue({
id: "exam_5",
title: "Exam E",
subjectId: "subject_science",
structure: { sections: [] },
questions: [{ questionId: "q_5", score: 8, order: 1 }],
})
mocks.subjectTeacherWhereMock.mockResolvedValue([{ subjectId: "subject_science" }])
mocks.enrollmentWhereMock.mockResolvedValue([{ studentId: "stu_5" }])
mocks.createIdMock.mockReturnValue("assignment_5")
const formData = new FormData()
formData.set("sourceExamId", "exam_5")
formData.set("classId", "class_5")
formData.set("publish", "true")
const result = await createHomeworkAssignmentAction(null, formData)
expect(result).toEqual({ success: true, message: "Assignment created", data: "assignment_5" })
expect(mocks.txInsertValuesMock).toHaveBeenCalledTimes(3)
})
it("returns exam subject missing for teacher-assigned class", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_teacher" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_teacher", role: "teacher" }])
mocks.classLimitMock.mockResolvedValue([{ id: "class_6", teacherId: "owner_teacher" }])
mocks.examFindFirstMock.mockResolvedValue({
id: "exam_6",
title: "Exam F",
subjectId: null,
structure: { sections: [] },
questions: [{ questionId: "q_6", score: 10, order: 1 }],
})
mocks.subjectTeacherWhereMock.mockResolvedValue([{ subjectId: "subject_history" }])
const formData = new FormData()
formData.set("sourceExamId", "exam_6")
formData.set("classId", "class_6")
formData.set("publish", "true")
const result = await createHomeworkAssignmentAction(null, formData)
expect(result).toEqual({ success: false, message: "Exam subject not set" })
expect(mocks.transactionMock).not.toHaveBeenCalled()
})
it("returns class not found when class is missing", async () => {
mocks.authMock.mockResolvedValue({ user: { id: "u_admin" } })
mocks.ensureLimitMock.mockResolvedValue([{ id: "u_admin", role: "admin" }])
mocks.classLimitMock.mockResolvedValue([])
const formData = new FormData()
formData.set("sourceExamId", "exam_7")
formData.set("classId", "missing_class")
const result = await createHomeworkAssignmentAction(null, formData)
expect(result).toEqual({ success: false, message: "Class not found" })
expect(mocks.transactionMock).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,46 @@
import { describe, expect, it, vi } from "vitest"
vi.mock("@/auth", () => ({
auth: (handler: (req: unknown) => unknown) => handler,
}))
import proxy from "@/proxy"
type SessionRole = "admin" | "teacher" | "student" | "parent"
const createRequest = (pathname: string, role?: SessionRole) => ({
nextUrl: {
pathname,
clone: () => new URL(`http://localhost${pathname}`),
},
auth: role ? { user: { role } } : null,
url: `http://localhost${pathname}`,
})
describe("proxy route guard", () => {
it("redirects unauthenticated requests to login with callback", async () => {
const response = await proxy(createRequest("/teacher/dashboard") as never)
expect(response.status).toBe(307)
const location = response.headers.get("location") ?? ""
expect(location).toContain("/login")
expect(location).toContain("callbackUrl=%2Fteacher%2Fdashboard")
})
it("redirects student away from admin routes", async () => {
const response = await proxy(createRequest("/admin/dashboard", "student") as never)
expect(response.status).toBe(307)
expect(response.headers.get("location")).toContain("/student/dashboard")
})
it("redirects parent away from management routes", async () => {
const response = await proxy(createRequest("/management/grade/insights", "parent") as never)
expect(response.status).toBe(307)
expect(response.headers.get("location")).toContain("/parent/dashboard")
})
it("allows teacher access to management routes", async () => {
const response = await proxy(createRequest("/management/grade/insights", "teacher") as never)
expect(response.status).toBe(200)
expect(response.headers.get("location")).toBeNull()
})
})

View File

@@ -0,0 +1,12 @@
import { afterEach, beforeAll, vi } from "vitest"
beforeAll(() => {
process.env.SKIP_ENV_VALIDATION = "1"
process.env.NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET ?? "test-nextauth-secret"
process.env.NEXTAUTH_URL = process.env.NEXTAUTH_URL ?? "http://127.0.0.1:3000"
process.env.DATABASE_URL = process.env.DATABASE_URL ?? "mysql://test:test@127.0.0.1:3306/test_db"
})
afterEach(() => {
vi.clearAllMocks()
})

31
vitest.config.ts Normal file
View File

@@ -0,0 +1,31 @@
import path from "node:path"
import { defineConfig } from "vitest/config"
export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
test: {
name: "integration",
environment: "node",
include: ["tests/integration/**/*.test.ts"],
setupFiles: ["tests/setup/integration.setup.ts"],
clearMocks: true,
restoreMocks: true,
mockReset: true,
coverage: {
provider: "v8",
enabled: false,
reporter: ["text", "html", "lcov"],
reportsDirectory: "./coverage/integration",
thresholds: {
lines: 80,
functions: 80,
branches: 75,
statements: 80,
},
},
},
})