Compare commits
10 Commits
bd8a4d39a6
...
99f116cb64
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99f116cb64 | ||
|
|
eb08c0ab68 | ||
|
|
538805bad0 | ||
|
|
8f974c04e0 | ||
|
|
16ebbbe924 | ||
|
|
175af10881 | ||
|
|
ef9b987653 | ||
|
|
dcc946f48c | ||
|
|
cc02ddf82e | ||
|
|
3986c5919c |
@@ -10,9 +10,10 @@ on:
|
||||
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
build-deploy:
|
||||
runs-on: CDCD
|
||||
container: dockerreg.eazygame.cn/node:22-bookworm
|
||||
# 合并 Job:统一使用带 Docker 的 Node 镜像
|
||||
container: dockerreg.eazygame.cn/node-with-docker:22
|
||||
env:
|
||||
SKIP_ENV_VALIDATION: "1"
|
||||
NEXT_TELEMETRY_DISABLED: "1"
|
||||
@@ -31,35 +32,48 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
|
||||
# 【保留增强】配置代理,确保 npm install 能通
|
||||
- name: Configure npm proxy
|
||||
run: |
|
||||
if [ -n "$HTTP_PROXY" ]; then npm config set proxy "$HTTP_PROXY"; fi
|
||||
if [ -n "$http_proxy" ]; then npm config set proxy "$http_proxy"; fi
|
||||
if [ -n "$HTTPS_PROXY" ]; then npm config set https-proxy "$HTTPS_PROXY"; fi
|
||||
if [ -n "$https_proxy" ]; then npm config set https-proxy "$https_proxy"; fi
|
||||
GATEWAY_IP=$(ip route show | grep default | awk '{print $3}')
|
||||
echo "Detected Docker Gateway: $GATEWAY_IP"
|
||||
|
||||
if [ -z "$GATEWAY_IP" ]; then
|
||||
echo "Warning: Could not detect gateway IP, falling back to 172.17.0.1"
|
||||
GATEWAY_IP="172.17.0.1"
|
||||
fi
|
||||
|
||||
- name: Show proxy status
|
||||
run: |
|
||||
if [ -n "$HTTP_PROXY" ] || [ -n "$http_proxy" ] || [ -n "$HTTPS_PROXY" ] || [ -n "$https_proxy" ]; then echo "proxy=on"; else echo "proxy=off"; fi
|
||||
PROXY_URL="http://$GATEWAY_IP:7890"
|
||||
echo "Using Proxy: $PROXY_URL"
|
||||
|
||||
# 设置 npm 代理
|
||||
npm config set proxy "$PROXY_URL"
|
||||
npm config set https-proxy "$PROXY_URL"
|
||||
|
||||
# 设置环境变量供后续步骤使用
|
||||
echo "http_proxy=$PROXY_URL" >> $GITHUB_ENV
|
||||
echo "https_proxy=$PROXY_URL" >> $GITHUB_ENV
|
||||
echo "HTTP_PROXY=$PROXY_URL" >> $GITHUB_ENV
|
||||
echo "HTTPS_PROXY=$PROXY_URL" >> $GITHUB_ENV
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Dump npm logs
|
||||
if: failure()
|
||||
run: |
|
||||
ls -la /root/.npm/_logs || true
|
||||
for f in /root/.npm/_logs/*-debug-*.log; do
|
||||
echo "===== $f ====="
|
||||
cat "$f" || true
|
||||
done
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: 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 构建缓存
|
||||
- name: Cache Next.js build
|
||||
uses: actions/cache@v3
|
||||
@@ -74,25 +88,6 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
# - name: 🔍 Debug - List Build Files
|
||||
# run: |
|
||||
# echo "======================="
|
||||
# echo "1. Root directory files:"
|
||||
# ls -la
|
||||
#
|
||||
# echo "======================="
|
||||
# echo "2. Checking .next directory:"
|
||||
# if [ -d ".next" ]; then
|
||||
# ls -la .next
|
||||
# else
|
||||
# echo "❌ Error: .next folder does not exist!"
|
||||
# fi
|
||||
|
||||
# echo "======================="
|
||||
# echo "3. Deep check of .next (excluding node_modules):"
|
||||
# # 查找 .next 目录下 4 层深度的文件,但排除 node_modules 避免日志太长
|
||||
# find .next -maxdepth 4 -not -path '*/node_modules*'
|
||||
|
||||
- name: Prepare standalone build
|
||||
run: |
|
||||
@@ -103,42 +98,22 @@ jobs:
|
||||
cp -r .next/static/* .next/standalone/.next/static/
|
||||
cp Dockerfile .next/standalone/Dockerfile
|
||||
|
||||
# - name: 🔍 Debug - List Build Files
|
||||
# run: |
|
||||
# echo "======================="
|
||||
# ls -la .next/standalone
|
||||
|
||||
- name: Upload production build artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: next-build
|
||||
path: .next/standalone
|
||||
include-hidden-files: true
|
||||
|
||||
deploy:
|
||||
needs: build-and-test
|
||||
runs-on: CDCD
|
||||
container:
|
||||
image: dockerreg.eazygame.cn/node-with-docker:22
|
||||
steps:
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: next-build
|
||||
|
||||
# 【核心变更】合并 Deploy 步骤,直接构建镜像,无需 artifact
|
||||
- name: Deploy to Docker
|
||||
run: |
|
||||
# 1. 使用 --no-cache 防止使用旧的构建层,确保部署的是最新代码
|
||||
# 2. 使用 --pull 确保基础镜像是最新的
|
||||
docker build --no-cache --pull -t nextjs-app .
|
||||
# 1. 进入 standalone 目录
|
||||
cd .next/standalone
|
||||
|
||||
# 3. 优雅停止:先尝试 stop,如果失败则无需处理 (|| true)
|
||||
# 2. 构建镜像 (使用 standalone 目录下的 Dockerfile)
|
||||
echo "Building Docker image from standalone..."
|
||||
docker build --no-cache --pull -t nextjs-app .
|
||||
|
||||
# 3. 优雅停止
|
||||
docker stop nextjs-app || true
|
||||
docker rm nextjs-app || true
|
||||
|
||||
# 4. 运行容器:
|
||||
# --init: 解决 Node.js PID 1 僵尸进程问题
|
||||
# --restart unless-stopped: 自动重启策略
|
||||
|
||||
# 4. 运行容器
|
||||
# 使用你后来补充的完整配置 (包含 network 和 NEXTAUTH)
|
||||
docker run -d \
|
||||
--init \
|
||||
-p 8015:3000 \
|
||||
@@ -151,4 +126,5 @@ jobs:
|
||||
-e NEXTAUTH_URL=${{ secrets.NEXTAUTH_URL }} \
|
||||
-e NEXT_TELEMETRY_DISABLED=1 \
|
||||
nextjs-app
|
||||
|
||||
|
||||
echo "Deploy complete!"
|
||||
|
||||
@@ -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*: 相比 Clerk,Auth.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
|
||||
@@ -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 关系定义
|
||||
```
|
||||
@@ -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`
|
||||
@@ -113,3 +113,15 @@ src/modules/
|
||||
1. **Refactor Dashboard**: 将 `src/app/(dashboard)/dashboard/page.tsx` 重构为 Dispatcher。
|
||||
2. **Create Role Directories**: 在 `src/app/(dashboard)` 下创建 `teacher`, `student`, `admin` 目录。
|
||||
3. **Move Components**: 确保 `src/modules` 结构清晰。
|
||||
|
||||
---
|
||||
|
||||
## 7. RBAC 扩展(2026-03-02)
|
||||
|
||||
为满足班级维度的权限差异,在教师角色下新增学科粒度的访问控制:
|
||||
|
||||
- 班主任(Class Head Teacher):可查看班级内所有学科相关的数据与统计。
|
||||
- 任课老师(Subject Teacher):仅可查看自己被分配的学科相关内容。
|
||||
- 实现要点:
|
||||
- 数据访问层通过“会话用户身份”与“学科分配表”联合过滤,防止越权。
|
||||
- 页面与组件保持不变,由后端/数据访问层保证返回范围正确的聚合数据。
|
||||
|
||||
@@ -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 + RSC(Server-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 异步组件与 Suspense(Streaming)
|
||||
|
||||
- 对于数据加载超过 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 State(nuqs 优先)
|
||||
|
||||
- 列表页筛选/分页/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)
|
||||
@@ -136,10 +136,10 @@ This release extends the Classes domain to support school-level sorting and per-
|
||||
|
||||
#### 2.2 Table: `class_subject_teachers`
|
||||
* **Action**: `CREATE TABLE`
|
||||
* **Primary Key**: (`class_id`, `subject`)
|
||||
* **Primary Key**: (`class_id`, `subject_id`)
|
||||
* **Columns**:
|
||||
* `class_id` (varchar(128), FK -> `classes.id`, cascade delete)
|
||||
* `subject` (enum: `语文/数学/英语/美术/体育/科学/社会/音乐`)
|
||||
* `subject_id` (varchar(128), FK -> `subjects.id`, cascade delete)
|
||||
* `teacher_id` (varchar(128), FK -> `users.id`, set null on delete)
|
||||
* `created_at`, `updated_at`
|
||||
* **Reason**: Maintain a stable default “subject list” per class while allowing admin/teacher to assign the actual teacher per subject.
|
||||
|
||||
@@ -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**.
|
||||
@@ -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`).
|
||||
@@ -240,3 +240,20 @@ Seed 脚本已覆盖班级相关数据,以便在开发环境快速验证页面
|
||||
- **移除 Insights**: 经评估,`src/app/(dashboard)/teacher/classes/insights` 模块功能冗余,已全量移除。
|
||||
- **保留核心数据**: 保留 `data-access.ts` 中的 `getClassHomeworkInsights` 函数,继续服务于班级详情页的统计卡片与图表。
|
||||
- **导航更新**: 从 `NAV_CONFIG` 中移除 Insights 入口。
|
||||
|
||||
---
|
||||
|
||||
## 9. 教师加入班级学科分配逻辑修复 (2026-03-03)
|
||||
|
||||
**日期**: 2026-03-03
|
||||
**范围**: 教师通过邀请码加入班级(含学科选择)的校验与分配
|
||||
|
||||
### 9.1 行为调整
|
||||
|
||||
- 教师已在班级中但选择学科加入时,不再直接返回成功,继续执行学科占用校验。
|
||||
- 班级未创建该学科映射时,先补齐映射再分配,避免误报“该班级不提供该学科”。
|
||||
- 学科已被其他老师占用时,返回明确提示。
|
||||
|
||||
### 9.2 影响代码
|
||||
|
||||
- [data-access.ts](file:///e:/Desktop/CICD/src/modules/classes/data-access.ts)
|
||||
|
||||
@@ -154,9 +154,10 @@ src/
|
||||
**目标**: 提升教师端教材管理的视觉质感与操作体验,对齐 "International Typographic Style" 设计语言。
|
||||
|
||||
### 9.1 卡片与列表 (Textbook Card & Filters)
|
||||
* **Dynamic Covers**: 卡片封面采用动态渐变色,根据科目 (Subject) 自动映射不同色系(如数学蓝、物理紫、生物绿),提升识别度。
|
||||
* **Pure Color Covers**: 卡片封面采用简洁纯色,根据科目 (Subject) 自动映射不同色系(如数学蓝、物理紫、生物绿),弱化纹理与渐变,整体更清爽。
|
||||
* **Information Density**: 增加元数据展示(Grade, Publisher, Chapter Count),并优化排版层级。
|
||||
* **Quick Actions**: 在卡片底部增加 "Edit Content" / "Delete" 快捷下拉菜单。
|
||||
* **Icon Polish**: 图标采用简洁方块底与统一尺寸,降低视觉噪音。
|
||||
* **Filters**: 简化筛选栏设计,移除厚重的容器背景,使其更轻量融入页面。
|
||||
|
||||
### 9.2 详情页工作台 (Detail Workbench)
|
||||
|
||||
@@ -130,3 +130,21 @@ type QuestionContent = {
|
||||
### 6.6 校验
|
||||
- `npm run lint`:0 errors(仓库其他位置仍存在 warnings)
|
||||
- `npm run typecheck`:通过
|
||||
|
||||
---
|
||||
|
||||
## 7. 实现更新(2026-03-03)
|
||||
|
||||
### 7.1 登录态与权限校验
|
||||
- 题库创建/更新/知识点加载统一使用会话身份;缺失会话时回退到首个教师账号以保持演示可用。
|
||||
- 主要修改:
|
||||
- [actions.ts](file:///c:/Users/xiner/Desktop/CICD/src/modules/questions/actions.ts)
|
||||
|
||||
### 7.2 弹窗稳定性
|
||||
- Create Question 弹窗在打开时仅在默认知识点变化时更新,避免重复 setState 造成循环更新。
|
||||
- 主要修改:
|
||||
- [create-question-dialog.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/questions/components/create-question-dialog.tsx)
|
||||
|
||||
### 7.3 校验
|
||||
- `npm run lint`:通过
|
||||
- `npm run typecheck`:通过
|
||||
|
||||
@@ -162,6 +162,12 @@ type ExamNode = {
|
||||
|
||||
## 7. 变更记录
|
||||
|
||||
**日期**:2026-03-03
|
||||
|
||||
- **题库列表稳定性**:
|
||||
- 题库卡片对题目 content/type 做解析兜底,避免异常数据导致运行时崩溃。
|
||||
- 主要修改: [question-bank-list.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/exams/components/assembly/question-bank-list.tsx)
|
||||
|
||||
**日期**:2026-01-12 (当前)
|
||||
|
||||
- **列表页优化 (`/teacher/exams/all`)**:
|
||||
|
||||
@@ -304,3 +304,18 @@
|
||||
- 问题:`isCorrect` 字段可能为 `boolean | null`,直接用于 JSX 渲染导致 "Type 'unknown' is not assignable to type 'ReactNode'" 错误。
|
||||
- 修复:增加显式布尔值检查 `opt.isCorrect === true`,确保 React 条件渲染接收到合法的 boolean 值。
|
||||
|
||||
---
|
||||
|
||||
## 14. 实现更新(2026-03-03)
|
||||
|
||||
### 14.1 学生作业状态修复
|
||||
- 提交作业与学生列表查询改为使用真实登录用户,避免提交后仍显示未开始。
|
||||
- 学生列表优先展示最近一次已提交/已评分记录,提升状态准确性。
|
||||
- 主要修改:
|
||||
- [actions.ts](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/actions.ts)
|
||||
- [data-access.ts](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/data-access.ts)
|
||||
|
||||
### 14.2 校验
|
||||
- `npm run lint`:通过
|
||||
- `npm run typecheck`:通过
|
||||
|
||||
|
||||
@@ -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`。
|
||||
342
docs/design/008_teacher_pages_implementation.md
Normal file
342
docs/design/008_teacher_pages_implementation.md
Normal file
@@ -0,0 +1,342 @@
|
||||
# 教师端页面实现分析文档
|
||||
|
||||
**日期**: 2026-03-03
|
||||
**范围**: Teacher 路由与页面实现(`src/app/(dashboard)/teacher`)
|
||||
|
||||
---
|
||||
|
||||
## 1. 总览
|
||||
|
||||
教师端页面采用服务端组件为主、按页面聚合数据的方式实现,页面负责:
|
||||
|
||||
- 读取数据(模块 data-access)
|
||||
- 组装 UI(模块 components)
|
||||
- 处理空状态与跳转
|
||||
|
||||
所有页面路由位于 `src/app/(dashboard)/teacher`,各业务能力落在 `src/modules/*` 中。
|
||||
|
||||
---
|
||||
|
||||
## 2. 路由总表
|
||||
|
||||
### 2.1 教师工作台
|
||||
- `/teacher/dashboard`
|
||||
实现:[dashboard/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/dashboard/page.tsx)
|
||||
|
||||
### 2.2 班级
|
||||
- `/teacher/classes` → `/teacher/classes/my`
|
||||
实现:[classes/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/page.tsx)
|
||||
- `/teacher/classes/my`
|
||||
实现:[classes/my/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/my/page.tsx)
|
||||
- `/teacher/classes/my/[id]`
|
||||
实现:[classes/my/[id]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/my/%5Bid%5D/page.tsx)
|
||||
- `/teacher/classes/students`
|
||||
实现:[classes/students/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/students/page.tsx)
|
||||
- `/teacher/classes/schedule`
|
||||
实现:[classes/schedule/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/schedule/page.tsx)
|
||||
|
||||
### 2.3 作业
|
||||
- `/teacher/homework` → `/teacher/homework/assignments`
|
||||
实现:[homework/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/page.tsx)
|
||||
- `/teacher/homework/assignments`
|
||||
实现:[homework/assignments/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/page.tsx)
|
||||
- `/teacher/homework/assignments/create`
|
||||
实现:[homework/assignments/create/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/create/page.tsx)
|
||||
- `/teacher/homework/assignments/[id]`
|
||||
实现:[homework/assignments/[id]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/%5Bid%5D/page.tsx)
|
||||
- `/teacher/homework/assignments/[id]/submissions`
|
||||
实现:[homework/assignments/[id]/submissions/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/%5Bid%5D/submissions/page.tsx)
|
||||
- `/teacher/homework/submissions`
|
||||
实现:[homework/submissions/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/submissions/page.tsx)
|
||||
- `/teacher/homework/submissions/[submissionId]`
|
||||
实现:[homework/submissions/[submissionId]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/submissions/%5BsubmissionId%5D/page.tsx)
|
||||
|
||||
### 2.4 考试
|
||||
- `/teacher/exams` → `/teacher/exams/all`
|
||||
实现:[exams/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/page.tsx)
|
||||
- `/teacher/exams/all`
|
||||
实现:[exams/all/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/all/page.tsx)
|
||||
- `/teacher/exams/create`
|
||||
实现:[exams/create/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/create/page.tsx)
|
||||
- `/teacher/exams/[id]/build`
|
||||
实现:[exams/[id]/build/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/%5Bid%5D/build/page.tsx)
|
||||
- `/teacher/exams/grading` → `/teacher/homework/submissions`
|
||||
实现:[exams/grading/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/grading/page.tsx)
|
||||
- `/teacher/exams/grading/[submissionId]` → `/teacher/homework/submissions`
|
||||
实现:[exams/grading/[submissionId]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/grading/%5BsubmissionId%5D/page.tsx)
|
||||
|
||||
### 2.5 题库
|
||||
- `/teacher/questions`
|
||||
实现:[questions/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/questions/page.tsx)
|
||||
|
||||
### 2.6 教材
|
||||
- `/teacher/textbooks`
|
||||
实现:[textbooks/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/textbooks/page.tsx)
|
||||
- `/teacher/textbooks/[id]`
|
||||
实现:[textbooks/[id]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/textbooks/%5Bid%5D/page.tsx)
|
||||
|
||||
---
|
||||
|
||||
## 3. 页面详解(逐页)
|
||||
|
||||
### 3.1 教师工作台 `/teacher/dashboard`
|
||||
实现:[dashboard/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/dashboard/page.tsx)
|
||||
|
||||
- **目的**: 教师总览工作台,展示班级、课表、作业、提交、成绩趋势与教师姓名。
|
||||
- **数据来源**:
|
||||
- 班级与课表:`getTeacherClasses`、`getClassSchedule`
|
||||
- 作业与提交:`getHomeworkAssignments`、`getHomeworkSubmissions`
|
||||
- 教师姓名:`users` 表查询
|
||||
- 成绩趋势:`getTeacherGradeTrends`
|
||||
- **关键组件**: `TeacherDashboardView`
|
||||
- **渲染策略**: `dynamic = "force-dynamic"`
|
||||
|
||||
### 3.2 班级入口 `/teacher/classes`
|
||||
实现:[classes/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/page.tsx)
|
||||
|
||||
- **目的**: 跳转入口,统一导向“我的班级”。
|
||||
- **行为**: `redirect("/teacher/classes/my")`
|
||||
|
||||
### 3.3 我的班级 `/teacher/classes/my`
|
||||
实现:[classes/my/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/my/page.tsx)
|
||||
|
||||
- **目的**: 展示教师负责班级的卡片列表,并支持学科筛选/加入。
|
||||
- **数据来源**:
|
||||
- 班级:`getTeacherClasses`
|
||||
- 学科选项:`getClassSubjects`
|
||||
- **关键组件**: `MyClassesGrid`
|
||||
- **渲染策略**: `dynamic = "force-dynamic"`
|
||||
|
||||
### 3.4 班级详情 `/teacher/classes/my/[id]`
|
||||
实现:[classes/my/[id]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/my/%5Bid%5D/page.tsx)
|
||||
|
||||
- **目的**: 展示班级概览(作业趋势、学生、课表、作业摘要)。
|
||||
- **数据来源**:
|
||||
- 班级作业洞察:`getClassHomeworkInsights`
|
||||
- 学生列表:`getClassStudents`
|
||||
- 课表:`getClassSchedule`
|
||||
- 学科成绩:`getClassStudentSubjectScoresV2`
|
||||
- **关键组件**:
|
||||
- `ClassHeader`
|
||||
- `ClassOverviewStats`
|
||||
- `ClassTrendsWidget`
|
||||
- `ClassStudentsWidget`
|
||||
- `ClassScheduleWidget`
|
||||
- `ClassAssignmentsWidget`
|
||||
- **空状态**: `insights` 缺失返回 `notFound()`
|
||||
- **渲染策略**: `dynamic = "force-dynamic"`
|
||||
|
||||
### 3.5 学生列表 `/teacher/classes/students`
|
||||
实现:[classes/students/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/students/page.tsx)
|
||||
|
||||
- **目的**: 按班级、关键词、状态筛选学生,并显示学科成绩。
|
||||
- **数据来源**:
|
||||
- 教师班级:`getTeacherClasses`
|
||||
- 学生列表:`getClassStudents`
|
||||
- 学科成绩:`getStudentsSubjectScores`
|
||||
- **关键组件**:
|
||||
- `StudentsFilters`
|
||||
- `StudentsTable`
|
||||
- `EmptyState` / `Skeleton`
|
||||
- **筛选逻辑**: 未显式选择班级时默认第一班级
|
||||
- **渲染策略**: `dynamic = "force-dynamic"`
|
||||
|
||||
### 3.6 课表 `/teacher/classes/schedule`
|
||||
实现:[classes/schedule/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/schedule/page.tsx)
|
||||
|
||||
- **目的**: 按班级查看课表。
|
||||
- **数据来源**:
|
||||
- 班级:`getTeacherClasses`
|
||||
- 课表:`getClassSchedule`
|
||||
- **关键组件**:
|
||||
- `ScheduleFilters`
|
||||
- `ScheduleView`
|
||||
- `EmptyState` / `Skeleton`
|
||||
- **渲染策略**: `dynamic = "force-dynamic"`
|
||||
|
||||
### 3.7 作业入口 `/teacher/homework`
|
||||
实现:[homework/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/page.tsx)
|
||||
|
||||
- **目的**: 统一导向作业列表。
|
||||
- **行为**: `redirect("/teacher/homework/assignments")`
|
||||
|
||||
### 3.8 作业列表 `/teacher/homework/assignments`
|
||||
实现:[homework/assignments/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/page.tsx)
|
||||
|
||||
- **目的**: 查看作业列表与状态,支持按班级筛选,提供创建入口。
|
||||
- **数据来源**:
|
||||
- 作业列表:`getHomeworkAssignments`
|
||||
- 班级列表(用于显示名称):`getTeacherClasses`
|
||||
- **关键组件**: `Table`、`Badge`、`EmptyState`
|
||||
- **空状态**: 无作业时提示创建
|
||||
- **渲染策略**: `dynamic = "force-dynamic"`
|
||||
|
||||
### 3.9 创建作业 `/teacher/homework/assignments/create`
|
||||
实现:[homework/assignments/create/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/create/page.tsx)
|
||||
|
||||
- **目的**: 从 Exam 派发作业。
|
||||
- **数据来源**:
|
||||
- 可派发的 Exam:`getExams`
|
||||
- 班级列表:`getTeacherClasses`
|
||||
- **关键组件**: `HomeworkAssignmentForm`
|
||||
- **空状态**:
|
||||
- 无 Exam:提示先创建考试
|
||||
- 无班级:提示先创建班级
|
||||
|
||||
### 3.10 作业详情 `/teacher/homework/assignments/[id]`
|
||||
实现:[homework/assignments/[id]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/%5Bid%5D/page.tsx)
|
||||
|
||||
- **目的**: 展示作业详情、错题概览与试卷内容。
|
||||
- **数据来源**: `getHomeworkAssignmentAnalytics`
|
||||
- **关键组件**:
|
||||
- `HomeworkAssignmentQuestionErrorOverviewCard`
|
||||
- `HomeworkAssignmentExamContentCard`
|
||||
- **空状态**: 查不到作业时 `notFound()`
|
||||
|
||||
### 3.11 作业提交列表 `/teacher/homework/assignments/[id]/submissions`
|
||||
实现:[homework/assignments/[id]/submissions/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/%5Bid%5D/submissions/page.tsx)
|
||||
|
||||
- **目的**: 按作业查看提交与评分进度。
|
||||
- **数据来源**:
|
||||
- 作业信息:`getHomeworkAssignmentById`
|
||||
- 提交列表:`getHomeworkSubmissions`
|
||||
- **关键组件**: `Table`、`Badge`
|
||||
- **空状态**: 作业不存在时 `notFound()`
|
||||
|
||||
### 3.12 全部提交 `/teacher/homework/submissions`
|
||||
实现:[homework/submissions/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/submissions/page.tsx)
|
||||
|
||||
- **目的**: 按作业汇总查看所有提交与批改入口。
|
||||
- **数据来源**:
|
||||
- 教师身份:`getTeacherIdForMutations`
|
||||
- 作业审阅列表:`getHomeworkAssignmentReviewList`
|
||||
- **关键组件**: `Table`、`Badge`、`EmptyState`
|
||||
|
||||
### 3.13 提交批改 `/teacher/homework/submissions/[submissionId]`
|
||||
实现:[homework/submissions/[submissionId]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/homework/submissions/%5BsubmissionId%5D/page.tsx)
|
||||
|
||||
- **目的**: 作业批改视图,按题打分与反馈。
|
||||
- **数据来源**: `getHomeworkSubmissionDetails`
|
||||
- **关键组件**: `HomeworkGradingView`
|
||||
- **空状态**: 查不到提交时 `notFound()`
|
||||
|
||||
### 3.14 考试入口 `/teacher/exams`
|
||||
实现:[exams/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/page.tsx)
|
||||
|
||||
- **目的**: 统一导向考试列表。
|
||||
- **行为**: `redirect("/teacher/exams/all")`
|
||||
|
||||
### 3.15 考试列表 `/teacher/exams/all`
|
||||
实现:[exams/all/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/all/page.tsx)
|
||||
|
||||
- **目的**: 列出考试并支持筛选、统计、创建入口。
|
||||
- **数据来源**: `getExams`(按关键词/状态/难度过滤)
|
||||
- **关键组件**:
|
||||
- `ExamFilters`
|
||||
- `ExamDataTable`
|
||||
- `EmptyState` / `Skeleton`
|
||||
- **统计**: 对列表结果进行状态数量统计(draft/published/archived)
|
||||
|
||||
### 3.16 创建考试 `/teacher/exams/create`
|
||||
实现:[exams/create/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/create/page.tsx)
|
||||
|
||||
- **目的**: 创建考试基础信息。
|
||||
- **关键组件**: `ExamForm`
|
||||
- **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`
|
||||
实现:[exams/[id]/build/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/%5Bid%5D/build/page.tsx)
|
||||
|
||||
- **目的**: 从题库选择题目并构建考试结构。
|
||||
- **数据来源**:
|
||||
- 考试数据:`getExamById`
|
||||
- 题库数据:`getQuestions`
|
||||
- **关键组件**: `ExamAssembly`
|
||||
- **关键逻辑**:
|
||||
- 读取已选题并初始化 `initialSelected`
|
||||
- 将题目数据映射为 `Question` 类型
|
||||
- 归一化 `structure` 并保证节点 `id` 唯一
|
||||
|
||||
### 3.18 阅卷入口 `/teacher/exams/grading*`
|
||||
实现:[exams/grading/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/grading/page.tsx)、[exams/grading/[submissionId]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/exams/grading/%5BsubmissionId%5D/page.tsx)
|
||||
|
||||
- **目的**: 统一重定向至作业批改视图。
|
||||
- **行为**: `redirect("/teacher/homework/submissions")`
|
||||
|
||||
### 3.19 题库 `/teacher/questions`
|
||||
实现:[questions/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/questions/page.tsx)
|
||||
|
||||
- **目的**: 题库管理与筛选。
|
||||
- **数据来源**: `getQuestions`(按关键词/题型/难度过滤)
|
||||
- **关键组件**:
|
||||
- `QuestionFilters`
|
||||
- `QuestionDataTable`
|
||||
- `CreateQuestionButton`
|
||||
- `EmptyState` / `Skeleton`
|
||||
|
||||
### 3.20 教材列表 `/teacher/textbooks`
|
||||
实现:[textbooks/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/textbooks/page.tsx)
|
||||
|
||||
- **目的**: 教材管理与筛选,创建入口。
|
||||
- **数据来源**: `getTextbooks`
|
||||
- **关键组件**:
|
||||
- `TextbookFilters`
|
||||
- `TextbookCard`
|
||||
- `TextbookFormDialog`
|
||||
- `EmptyState`
|
||||
- **渲染策略**: `dynamic = "force-dynamic"`
|
||||
|
||||
### 3.21 教材详情 `/teacher/textbooks/[id]`
|
||||
实现:[textbooks/[id]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/textbooks/%5Bid%5D/page.tsx)
|
||||
|
||||
- **目的**: 教材章节与知识点结构阅读与维护。
|
||||
- **数据来源**:
|
||||
- 教材:`getTextbookById`
|
||||
- 章节:`getChaptersByTextbookId`
|
||||
- 知识点:`getKnowledgePointsByTextbookId`
|
||||
- **关键组件**:
|
||||
- `TextbookReader`
|
||||
- `TextbookSettingsDialog`
|
||||
- **空状态**: 教材不存在时 `notFound()`
|
||||
|
||||
---
|
||||
|
||||
## 4. 依赖模块清单
|
||||
|
||||
教师端页面主要依赖以下模块:
|
||||
|
||||
- 班级与排课:`src/modules/classes`
|
||||
- 作业:`src/modules/homework`
|
||||
- 考试:`src/modules/exams`
|
||||
- 题库:`src/modules/questions`
|
||||
- 教材:`src/modules/textbooks`
|
||||
- 工作台:`src/modules/dashboard`
|
||||
|
||||
150
docs/design/009_feature_gap_analysis.md
Normal file
150
docs/design/009_feature_gap_analysis.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# 功能实现对比文档(已实现 vs 规划)
|
||||
|
||||
**日期**: 2026-03-03
|
||||
**范围**: 基于 PRD 与现有设计文档的功能落地对比
|
||||
|
||||
---
|
||||
|
||||
## 1. 依据与来源
|
||||
|
||||
本对比基于以下文档:
|
||||
|
||||
- 产品规划:PRD(`docs/product_requirements.md`)
|
||||
- 已实现模块:
|
||||
- 教师仪表盘与班级管理:[002_teacher_dashboard_implementation.md](file:///e:/Desktop/CICD/docs/design/002_teacher_dashboard_implementation.md)
|
||||
- 教材模块:[003_textbooks_module_implementation.md](file:///e:/Desktop/CICD/docs/design/003_textbooks_module_implementation.md)
|
||||
- 题库模块:[004_question_bank_implementation.md](file:///e:/Desktop/CICD/docs/design/004_question_bank_implementation.md)
|
||||
- 考试模块:[005_exam_module_implementation.md](file:///e:/Desktop/CICD/docs/design/005_exam_module_implementation.md)
|
||||
- 作业模块:[006_homework_module_implementation.md](file:///e:/Desktop/CICD/docs/design/006_homework_module_implementation.md)
|
||||
- 学校管理模块:[007_school_module_implementation.md](file:///e:/Desktop/CICD/docs/design/007_school_module_implementation.md)
|
||||
|
||||
---
|
||||
|
||||
## 2. 便利贴视图(按 Roles / Page / 功能)
|
||||
|
||||
> **🟨 Roles**
|
||||
>
|
||||
> - **教师(Teacher)**:备课、出卷、作业批改与班级管理
|
||||
> - **学生(Student)**:作业完成与教材阅读(只读)
|
||||
> - **管理端(Admin/校长/年级主任/教研组长/班主任)**:规划中,未完全落地
|
||||
|
||||
> **🟨 Page**
|
||||
>
|
||||
> - **教师工作台**:`/teacher/dashboard`
|
||||
> - **班级**:`/teacher/classes/*`
|
||||
> - **作业**:`/teacher/homework/*`
|
||||
> - **考试**:`/teacher/exams/*`
|
||||
> - **题库**:`/teacher/questions`
|
||||
> - **教材**:`/teacher/textbooks/*`
|
||||
|
||||
> **🟨 功能(规划目标)**
|
||||
>
|
||||
> - **权限与角色**:多角色矩阵 + RLS 行级隔离
|
||||
> - **智能题库**:嵌套题、知识点关联
|
||||
> - **知识图谱**:知识点树 + 题目/章节关联
|
||||
> - **教材映射**:章节 ↔ 知识点
|
||||
> - **组卷引擎**:筛选/分组/结构化试卷
|
||||
> - **作业闭环**:派发-提交-批改-统计
|
||||
> - **通知中心**:分级提醒策略
|
||||
|
||||
---
|
||||
|
||||
## 3. 已实现页面功能清单(简述)
|
||||
|
||||
> **🟨 教师工作台**
|
||||
>
|
||||
> - `/teacher/dashboard`
|
||||
> 功能:汇总班级、课表、作业、提交与成绩趋势
|
||||
> 目标:快速掌握教学全局
|
||||
|
||||
> **🟨 班级**
|
||||
>
|
||||
> - `/teacher/classes`
|
||||
> 功能:班级入口重定向
|
||||
> 目标:统一进入我的班级
|
||||
> - `/teacher/classes/my`
|
||||
> 功能:班级列表与学科选择
|
||||
> 目标:管理所教班级
|
||||
> - `/teacher/classes/my/[id]`
|
||||
> 功能:班级详情概览、作业趋势、学生与课表
|
||||
> 目标:掌握班级学习情况
|
||||
> - `/teacher/classes/students`
|
||||
> 功能:学生筛选与成绩查看
|
||||
> 目标:定位学生画像与状态
|
||||
> - `/teacher/classes/schedule`
|
||||
> 功能:班级课表查看
|
||||
> 目标:排课信息可视化
|
||||
|
||||
> **🟨 作业**
|
||||
>
|
||||
> - `/teacher/homework/assignments`
|
||||
> 功能:作业列表与状态
|
||||
> 目标:管理作业发布
|
||||
> - `/teacher/homework/assignments/create`
|
||||
> 功能:从考试派发作业
|
||||
> 目标:快速生成作业
|
||||
> - `/teacher/homework/assignments/[id]`
|
||||
> 功能:作业详情与错题概览
|
||||
> 目标:定位薄弱题型
|
||||
> - `/teacher/homework/assignments/[id]/submissions`
|
||||
> 功能:作业提交列表
|
||||
> 目标:查看班级完成度
|
||||
> - `/teacher/homework/submissions`
|
||||
> 功能:所有作业提交汇总
|
||||
> 目标:统一批改入口
|
||||
> - `/teacher/homework/submissions/[submissionId]`
|
||||
> 功能:作业批改与反馈
|
||||
> 目标:完成评分与讲评
|
||||
|
||||
> **🟨 考试**
|
||||
>
|
||||
> - `/teacher/exams/all`
|
||||
> 功能:考试列表与筛选
|
||||
> 目标:管理考试资产
|
||||
> - `/teacher/exams/create`
|
||||
> 功能:创建考试基础信息
|
||||
> 目标:建立试卷草稿
|
||||
> - `/teacher/exams/[id]/build`
|
||||
> 功能:题库选题与结构化组卷
|
||||
> 目标:完成试卷构建
|
||||
|
||||
> **🟨 题库**
|
||||
>
|
||||
> - `/teacher/questions`
|
||||
> 功能:题库检索与管理
|
||||
> 目标:积累与复用题目
|
||||
|
||||
> **🟨 教材**
|
||||
>
|
||||
> - `/teacher/textbooks`
|
||||
> 功能:教材管理与筛选
|
||||
> 目标:组织课程资源
|
||||
> - `/teacher/textbooks/[id]`
|
||||
> 功能:章节与知识点维护
|
||||
> 目标:构建教材结构
|
||||
|
||||
---
|
||||
|
||||
## 4. 差距清单(简述)
|
||||
|
||||
> **🟨 权限与治理**
|
||||
>
|
||||
> - 多角色 RBAC 细化权限未落地
|
||||
> - RLS 数据隔离策略未落地
|
||||
|
||||
> **🟨 教学质量与推荐**
|
||||
>
|
||||
> - 章节 → 知识点 → 题库的自动推荐链路未落地
|
||||
> - 知识点图谱深层能力未落地
|
||||
> - 学科维度与权重/标签机制未落地
|
||||
|
||||
> **🟨 组卷与作业高级能力**
|
||||
>
|
||||
> - AB 卷与乱序策略未落地
|
||||
> - 作业分层与交集筛选未落地
|
||||
> - 学习画像/成长档案层的评估闭环尚未体现
|
||||
|
||||
> **🟨 通知与消息闭环**
|
||||
>
|
||||
> - 分级通知体系未落地
|
||||
|
||||
154
docs/design/010_qa_test_plan_and_feedback.md
Normal file
154
docs/design/010_qa_test_plan_and_feedback.md
Normal 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` | 通过 | 本地优先使用系统 Chrome,2 条用例通过 |
|
||||
| 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 全量回归测试步骤
|
||||
@@ -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)
|
||||
@@ -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` 表为每个班级/学生生成状态记录。
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import { users, exams, questions, knowledgePoints, examSubmissions, examQuestion
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { faker } from "@faker-js/faker"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { hash } from "bcryptjs"
|
||||
|
||||
/**
|
||||
* Seed Script for Next_Edu
|
||||
@@ -19,6 +20,7 @@ const DIFFICULTY = [1, 2, 3, 4, 5]
|
||||
|
||||
async function seed() {
|
||||
console.log("🌱 Starting seed process...")
|
||||
const passwordHash = await hash("123456", 10)
|
||||
|
||||
// 1. Create a Teacher User if not exists
|
||||
const teacherEmail = "teacher@example.com"
|
||||
@@ -36,6 +38,7 @@ async function seed() {
|
||||
email: teacherEmail,
|
||||
role: "teacher",
|
||||
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Teacher",
|
||||
password: passwordHash,
|
||||
})
|
||||
} else {
|
||||
teacherId = existingTeacher.id
|
||||
@@ -54,6 +57,7 @@ async function seed() {
|
||||
email: faker.internet.email(),
|
||||
role: "student",
|
||||
image: `https://api.dicebear.com/7.x/avataaars/svg?seed=${sId}`,
|
||||
password: passwordHash,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,103 @@
|
||||
# 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
|
||||
|
||||
### 1. 教师加入班级学科分配逻辑修复
|
||||
- 修复教师已在班级中但选择学科加入时被直接返回成功的问题。
|
||||
- 班级未创建该学科映射时,先补齐映射再分配。
|
||||
- 学科已被其他老师占用时,返回明确提示。
|
||||
- 主要修改:
|
||||
- [data-access.ts](file:///e:/Desktop/CICD/src/modules/classes/data-access.ts)
|
||||
|
||||
### 2. 验证
|
||||
- 质量检查:`npm run lint`、`npm run typecheck` 均通过。
|
||||
|
||||
## 2026-03-02
|
||||
|
||||
### 1. 班级详情访问修复(基于会话身份)
|
||||
- 将数据获取中的教师 ID 来源改为会话用户,移除默认教师 ID 逻辑,修复“新加入班级无法查看班级详情”问题。
|
||||
- 主要修改:
|
||||
- [data-access.ts](file:///e:/Desktop/CICD/src/modules/classes/data-access.ts)
|
||||
- [page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/classes/my/%5Bid%5D/page.tsx)
|
||||
|
||||
### 2. 任课权限与班主任权限区分(学科可见范围)
|
||||
- 班主任可查看班级下所有学科;任课老师仅可查看自己负责的学科。
|
||||
- 新增并应用分配学科查询,过滤相应统计与列表。
|
||||
- 主要修改:
|
||||
- [data-access.ts](file:///e:/Desktop/CICD/src/modules/classes/data-access.ts)
|
||||
|
||||
### 3. 新注册学生默认班级问题修复
|
||||
- 调整示例学生获取逻辑,避免为新注册学生展示“默认班级”。
|
||||
- 主要修改:
|
||||
- [data-access.ts](file:///e:/Desktop/CICD/src/modules/homework/data-access.ts)
|
||||
|
||||
### 4. 教材列表 UI 精简为纯色风格
|
||||
- 将教材卡片头部背景从渐变与纹理改为简洁纯色;图标容器调整为简洁样式,视觉噪音更低。
|
||||
- 主要修改:
|
||||
- [textbook-card.tsx](file:///e:/Desktop/CICD/src/modules/textbooks/components/textbook-card.tsx)
|
||||
|
||||
### 5. 学生导航清理未实现入口
|
||||
- 移除学生侧导航的“Resources”入口(页面未实现,避免死链)。
|
||||
- 主要修改:
|
||||
- [navigation.ts](file:///e:/Desktop/CICD/src/modules/layout/config/navigation.ts)
|
||||
|
||||
### 6. 验证
|
||||
- 质量检查:`npm run lint`、`npm run typecheck` 均通过。
|
||||
|
||||
## 2026-02-24
|
||||
|
||||
### 1. Credentials 登录与密码安全修复
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "dotenv/config"
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
|
||||
@@ -1,183 +1 @@
|
||||
CREATE TABLE `accounts` (
|
||||
`userId` varchar(128) NOT NULL,
|
||||
`type` varchar(255) NOT NULL,
|
||||
`provider` varchar(255) NOT NULL,
|
||||
`providerAccountId` varchar(255) NOT NULL,
|
||||
`refresh_token` text,
|
||||
`access_token` text,
|
||||
`expires_at` int,
|
||||
`token_type` varchar(255),
|
||||
`scope` varchar(255),
|
||||
`id_token` text,
|
||||
`session_state` varchar(255),
|
||||
CONSTRAINT `accounts_provider_providerAccountId_pk` PRIMARY KEY(`provider`,`providerAccountId`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `chapters` (
|
||||
`id` varchar(128) NOT NULL,
|
||||
`textbook_id` varchar(128) NOT NULL,
|
||||
`title` varchar(255) NOT NULL,
|
||||
`order` int DEFAULT 0,
|
||||
`parent_id` varchar(128),
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `chapters_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `exam_questions` (
|
||||
`exam_id` varchar(128) NOT NULL,
|
||||
`question_id` varchar(128) NOT NULL,
|
||||
`score` int DEFAULT 0,
|
||||
`order` int DEFAULT 0,
|
||||
CONSTRAINT `exam_questions_exam_id_question_id_pk` PRIMARY KEY(`exam_id`,`question_id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `exam_submissions` (
|
||||
`id` varchar(128) NOT NULL,
|
||||
`exam_id` varchar(128) NOT NULL,
|
||||
`student_id` varchar(128) NOT NULL,
|
||||
`score` int,
|
||||
`status` varchar(50) DEFAULT 'started',
|
||||
`submitted_at` timestamp,
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `exam_submissions_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `exams` (
|
||||
`id` varchar(128) NOT NULL,
|
||||
`title` varchar(255) NOT NULL,
|
||||
`description` text,
|
||||
`creator_id` varchar(128) NOT NULL,
|
||||
`start_time` timestamp,
|
||||
`end_time` timestamp,
|
||||
`status` varchar(50) DEFAULT 'draft',
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `exams_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `knowledge_points` (
|
||||
`id` varchar(128) NOT NULL,
|
||||
`name` varchar(255) NOT NULL,
|
||||
`description` text,
|
||||
`parent_id` varchar(128),
|
||||
`level` int DEFAULT 0,
|
||||
`order` int DEFAULT 0,
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `knowledge_points_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `questions` (
|
||||
`id` varchar(128) NOT NULL,
|
||||
`content` json NOT NULL,
|
||||
`type` enum('single_choice','multiple_choice','text','judgment','composite') NOT NULL,
|
||||
`difficulty` int DEFAULT 1,
|
||||
`parent_id` varchar(128),
|
||||
`author_id` varchar(128) NOT NULL,
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `questions_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `questions_to_knowledge_points` (
|
||||
`question_id` varchar(128) NOT NULL,
|
||||
`knowledge_point_id` varchar(128) NOT NULL,
|
||||
CONSTRAINT `questions_to_knowledge_points_question_id_knowledge_point_id_pk` PRIMARY KEY(`question_id`,`knowledge_point_id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `roles` (
|
||||
`id` varchar(128) NOT NULL,
|
||||
`name` varchar(50) NOT NULL,
|
||||
`description` varchar(255),
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `roles_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `roles_name_unique` UNIQUE(`name`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `sessions` (
|
||||
`sessionToken` varchar(255) NOT NULL,
|
||||
`userId` varchar(128) NOT NULL,
|
||||
`expires` timestamp NOT NULL,
|
||||
CONSTRAINT `sessions_sessionToken` PRIMARY KEY(`sessionToken`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `submission_answers` (
|
||||
`id` varchar(128) NOT NULL,
|
||||
`submission_id` varchar(128) NOT NULL,
|
||||
`question_id` varchar(128) NOT NULL,
|
||||
`answer_content` json,
|
||||
`score` int,
|
||||
`feedback` text,
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `submission_answers_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `textbooks` (
|
||||
`id` varchar(128) NOT NULL,
|
||||
`title` varchar(255) NOT NULL,
|
||||
`subject` varchar(100) NOT NULL,
|
||||
`grade` varchar(50),
|
||||
`publisher` varchar(100),
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `textbooks_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `users` (
|
||||
`id` varchar(128) NOT NULL,
|
||||
`name` varchar(255),
|
||||
`email` varchar(255) NOT NULL,
|
||||
`emailVerified` timestamp,
|
||||
`image` varchar(255),
|
||||
`role` varchar(50) DEFAULT 'student',
|
||||
`password` varchar(255),
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `users_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `users_email_unique` UNIQUE(`email`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `users_to_roles` (
|
||||
`user_id` varchar(128) NOT NULL,
|
||||
`role_id` varchar(128) NOT NULL,
|
||||
CONSTRAINT `users_to_roles_user_id_role_id_pk` PRIMARY KEY(`user_id`,`role_id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `verificationTokens` (
|
||||
`identifier` varchar(255) NOT NULL,
|
||||
`token` varchar(255) NOT NULL,
|
||||
`expires` timestamp NOT NULL,
|
||||
CONSTRAINT `verificationTokens_identifier_token_pk` PRIMARY KEY(`identifier`,`token`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `accounts` ADD CONSTRAINT `accounts_userId_users_id_fk` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `chapters` ADD CONSTRAINT `chapters_textbook_id_textbooks_id_fk` FOREIGN KEY (`textbook_id`) REFERENCES `textbooks`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `exam_questions` ADD CONSTRAINT `exam_questions_exam_id_exams_id_fk` FOREIGN KEY (`exam_id`) REFERENCES `exams`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `exam_questions` ADD CONSTRAINT `exam_questions_question_id_questions_id_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `exam_submissions` ADD CONSTRAINT `exam_submissions_exam_id_exams_id_fk` FOREIGN KEY (`exam_id`) REFERENCES `exams`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `exam_submissions` ADD CONSTRAINT `exam_submissions_student_id_users_id_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `exams` ADD CONSTRAINT `exams_creator_id_users_id_fk` FOREIGN KEY (`creator_id`) REFERENCES `users`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `questions` ADD CONSTRAINT `questions_author_id_users_id_fk` FOREIGN KEY (`author_id`) REFERENCES `users`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `questions_to_knowledge_points` ADD CONSTRAINT `questions_to_knowledge_points_question_id_questions_id_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `questions_to_knowledge_points` ADD CONSTRAINT `questions_to_knowledge_points_knowledge_point_id_knowledge_points_id_fk` FOREIGN KEY (`knowledge_point_id`) REFERENCES `knowledge_points`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `sessions` ADD CONSTRAINT `sessions_userId_users_id_fk` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `submission_answers` ADD CONSTRAINT `submission_answers_submission_id_exam_submissions_id_fk` FOREIGN KEY (`submission_id`) REFERENCES `exam_submissions`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `submission_answers` ADD CONSTRAINT `submission_answers_question_id_questions_id_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `users_to_roles` ADD CONSTRAINT `users_to_roles_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `users_to_roles` ADD CONSTRAINT `users_to_roles_role_id_roles_id_fk` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX `account_userId_idx` ON `accounts` (`userId`);--> statement-breakpoint
|
||||
CREATE INDEX `textbook_idx` ON `chapters` (`textbook_id`);--> statement-breakpoint
|
||||
CREATE INDEX `parent_id_idx` ON `chapters` (`parent_id`);--> statement-breakpoint
|
||||
CREATE INDEX `exam_student_idx` ON `exam_submissions` (`exam_id`,`student_id`);--> statement-breakpoint
|
||||
CREATE INDEX `parent_id_idx` ON `knowledge_points` (`parent_id`);--> statement-breakpoint
|
||||
CREATE INDEX `parent_id_idx` ON `questions` (`parent_id`);--> statement-breakpoint
|
||||
CREATE INDEX `author_id_idx` ON `questions` (`author_id`);--> statement-breakpoint
|
||||
CREATE INDEX `kp_idx` ON `questions_to_knowledge_points` (`knowledge_point_id`);--> statement-breakpoint
|
||||
CREATE INDEX `session_userId_idx` ON `sessions` (`userId`);--> statement-breakpoint
|
||||
CREATE INDEX `submission_idx` ON `submission_answers` (`submission_id`);--> statement-breakpoint
|
||||
CREATE INDEX `email_idx` ON `users` (`email`);--> statement-breakpoint
|
||||
CREATE INDEX `user_id_idx` ON `users_to_roles` (`user_id`);
|
||||
SELECT 1;--> statement-breakpoint
|
||||
|
||||
@@ -1 +1 @@
|
||||
ALTER TABLE `exams` ADD `structure` json;
|
||||
SELECT 1;--> statement-breakpoint
|
||||
|
||||
@@ -1,274 +1 @@
|
||||
CREATE TABLE IF NOT EXISTS `homework_answers` (
|
||||
`id` varchar(128) NOT NULL,
|
||||
`submission_id` varchar(128) NOT NULL,
|
||||
`question_id` varchar(128) NOT NULL,
|
||||
`answer_content` json,
|
||||
`score` int,
|
||||
`feedback` text,
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `homework_answers_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS `homework_assignment_questions` (
|
||||
`assignment_id` varchar(128) NOT NULL,
|
||||
`question_id` varchar(128) NOT NULL,
|
||||
`score` int DEFAULT 0,
|
||||
`order` int DEFAULT 0,
|
||||
CONSTRAINT `homework_assignment_questions_assignment_id_question_id_pk` PRIMARY KEY(`assignment_id`,`question_id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS `homework_assignment_targets` (
|
||||
`assignment_id` varchar(128) NOT NULL,
|
||||
`student_id` varchar(128) NOT NULL,
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
CONSTRAINT `homework_assignment_targets_assignment_id_student_id_pk` PRIMARY KEY(`assignment_id`,`student_id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS `homework_assignments` (
|
||||
`id` varchar(128) NOT NULL,
|
||||
`source_exam_id` varchar(128) NOT NULL,
|
||||
`title` varchar(255) NOT NULL,
|
||||
`description` text,
|
||||
`structure` json,
|
||||
`status` varchar(50) DEFAULT 'draft',
|
||||
`creator_id` varchar(128) NOT NULL,
|
||||
`available_at` timestamp,
|
||||
`due_at` timestamp,
|
||||
`allow_late` boolean NOT NULL DEFAULT false,
|
||||
`late_due_at` timestamp,
|
||||
`max_attempts` int NOT NULL DEFAULT 1,
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `homework_assignments_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS `homework_submissions` (
|
||||
`id` varchar(128) NOT NULL,
|
||||
`assignment_id` varchar(128) NOT NULL,
|
||||
`student_id` varchar(128) NOT NULL,
|
||||
`attempt_no` int NOT NULL DEFAULT 1,
|
||||
`score` int,
|
||||
`status` varchar(50) DEFAULT 'started',
|
||||
`started_at` timestamp NOT NULL DEFAULT (now()),
|
||||
`submitted_at` timestamp,
|
||||
`is_late` boolean NOT NULL DEFAULT false,
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `homework_submissions_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
SET @__qkp_drop_qid := (
|
||||
SELECT IF(
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM information_schema.referential_constraints
|
||||
WHERE constraint_schema = DATABASE()
|
||||
AND constraint_name = 'questions_to_knowledge_points_question_id_questions_id_fk'
|
||||
),
|
||||
'ALTER TABLE `questions_to_knowledge_points` DROP FOREIGN KEY `questions_to_knowledge_points_question_id_questions_id_fk`;',
|
||||
'SELECT 1;'
|
||||
)
|
||||
);--> statement-breakpoint
|
||||
PREPARE __stmt FROM @__qkp_drop_qid;--> statement-breakpoint
|
||||
EXECUTE __stmt;--> statement-breakpoint
|
||||
DEALLOCATE PREPARE __stmt;--> statement-breakpoint
|
||||
SET @__qkp_drop_kpid := (
|
||||
SELECT IF(
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM information_schema.referential_constraints
|
||||
WHERE constraint_schema = DATABASE()
|
||||
AND constraint_name = 'questions_to_knowledge_points_knowledge_point_id_knowledge_points_id_fk'
|
||||
),
|
||||
'ALTER TABLE `questions_to_knowledge_points` DROP FOREIGN KEY `questions_to_knowledge_points_knowledge_point_id_knowledge_points_id_fk`;',
|
||||
'SELECT 1;'
|
||||
)
|
||||
);--> statement-breakpoint
|
||||
PREPARE __stmt2 FROM @__qkp_drop_kpid;--> statement-breakpoint
|
||||
EXECUTE __stmt2;--> statement-breakpoint
|
||||
DEALLOCATE PREPARE __stmt2;--> statement-breakpoint
|
||||
ALTER TABLE `homework_answers` ADD CONSTRAINT `hw_ans_sub_fk` FOREIGN KEY (`submission_id`) REFERENCES `homework_submissions`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `homework_answers` ADD CONSTRAINT `hw_ans_q_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `homework_assignment_questions` ADD CONSTRAINT `hw_aq_a_fk` FOREIGN KEY (`assignment_id`) REFERENCES `homework_assignments`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `homework_assignment_questions` ADD CONSTRAINT `hw_aq_q_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `homework_assignment_targets` ADD CONSTRAINT `hw_at_a_fk` FOREIGN KEY (`assignment_id`) REFERENCES `homework_assignments`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `homework_assignment_targets` ADD CONSTRAINT `hw_at_s_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `homework_assignments` ADD CONSTRAINT `hw_asg_exam_fk` FOREIGN KEY (`source_exam_id`) REFERENCES `exams`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `homework_assignments` ADD CONSTRAINT `hw_asg_creator_fk` FOREIGN KEY (`creator_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `homework_submissions` ADD CONSTRAINT `hw_sub_a_fk` FOREIGN KEY (`assignment_id`) REFERENCES `homework_assignments`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `homework_submissions` ADD CONSTRAINT `hw_sub_student_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
SET @__idx_hw_answer_submission := (
|
||||
SELECT IF(
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'homework_answers'
|
||||
AND index_name = 'hw_answer_submission_idx'
|
||||
),
|
||||
'SELECT 1;',
|
||||
'CREATE INDEX `hw_answer_submission_idx` ON `homework_answers` (`submission_id`);'
|
||||
)
|
||||
);--> statement-breakpoint
|
||||
PREPARE __stmt3 FROM @__idx_hw_answer_submission;--> statement-breakpoint
|
||||
EXECUTE __stmt3;--> statement-breakpoint
|
||||
DEALLOCATE PREPARE __stmt3;--> statement-breakpoint
|
||||
SET @__idx_hw_answer_submission_question := (
|
||||
SELECT IF(
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'homework_answers'
|
||||
AND index_name = 'hw_answer_submission_question_idx'
|
||||
),
|
||||
'SELECT 1;',
|
||||
'CREATE INDEX `hw_answer_submission_question_idx` ON `homework_answers` (`submission_id`,`question_id`);'
|
||||
)
|
||||
);--> statement-breakpoint
|
||||
PREPARE __stmt4 FROM @__idx_hw_answer_submission_question;--> statement-breakpoint
|
||||
EXECUTE __stmt4;--> statement-breakpoint
|
||||
DEALLOCATE PREPARE __stmt4;--> statement-breakpoint
|
||||
SET @__idx_hw_assignment_questions_assignment := (
|
||||
SELECT IF(
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'homework_assignment_questions'
|
||||
AND index_name = 'hw_assignment_questions_assignment_idx'
|
||||
),
|
||||
'SELECT 1;',
|
||||
'CREATE INDEX `hw_assignment_questions_assignment_idx` ON `homework_assignment_questions` (`assignment_id`);'
|
||||
)
|
||||
);--> statement-breakpoint
|
||||
PREPARE __stmt5 FROM @__idx_hw_assignment_questions_assignment;--> statement-breakpoint
|
||||
EXECUTE __stmt5;--> statement-breakpoint
|
||||
DEALLOCATE PREPARE __stmt5;--> statement-breakpoint
|
||||
SET @__idx_hw_assignment_targets_assignment := (
|
||||
SELECT IF(
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'homework_assignment_targets'
|
||||
AND index_name = 'hw_assignment_targets_assignment_idx'
|
||||
),
|
||||
'SELECT 1;',
|
||||
'CREATE INDEX `hw_assignment_targets_assignment_idx` ON `homework_assignment_targets` (`assignment_id`);'
|
||||
)
|
||||
);--> statement-breakpoint
|
||||
PREPARE __stmt6 FROM @__idx_hw_assignment_targets_assignment;--> statement-breakpoint
|
||||
EXECUTE __stmt6;--> statement-breakpoint
|
||||
DEALLOCATE PREPARE __stmt6;--> statement-breakpoint
|
||||
SET @__idx_hw_assignment_targets_student := (
|
||||
SELECT IF(
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'homework_assignment_targets'
|
||||
AND index_name = 'hw_assignment_targets_student_idx'
|
||||
),
|
||||
'SELECT 1;',
|
||||
'CREATE INDEX `hw_assignment_targets_student_idx` ON `homework_assignment_targets` (`student_id`);'
|
||||
)
|
||||
);--> statement-breakpoint
|
||||
PREPARE __stmt7 FROM @__idx_hw_assignment_targets_student;--> statement-breakpoint
|
||||
EXECUTE __stmt7;--> statement-breakpoint
|
||||
DEALLOCATE PREPARE __stmt7;--> statement-breakpoint
|
||||
SET @__idx_hw_assignment_creator := (
|
||||
SELECT IF(
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'homework_assignments'
|
||||
AND index_name = 'hw_assignment_creator_idx'
|
||||
),
|
||||
'SELECT 1;',
|
||||
'CREATE INDEX `hw_assignment_creator_idx` ON `homework_assignments` (`creator_id`);'
|
||||
)
|
||||
);--> statement-breakpoint
|
||||
PREPARE __stmt8 FROM @__idx_hw_assignment_creator;--> statement-breakpoint
|
||||
EXECUTE __stmt8;--> statement-breakpoint
|
||||
DEALLOCATE PREPARE __stmt8;--> statement-breakpoint
|
||||
SET @__idx_hw_assignment_source_exam := (
|
||||
SELECT IF(
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'homework_assignments'
|
||||
AND index_name = 'hw_assignment_source_exam_idx'
|
||||
),
|
||||
'SELECT 1;',
|
||||
'CREATE INDEX `hw_assignment_source_exam_idx` ON `homework_assignments` (`source_exam_id`);'
|
||||
)
|
||||
);--> statement-breakpoint
|
||||
PREPARE __stmt9 FROM @__idx_hw_assignment_source_exam;--> statement-breakpoint
|
||||
EXECUTE __stmt9;--> statement-breakpoint
|
||||
DEALLOCATE PREPARE __stmt9;--> statement-breakpoint
|
||||
SET @__idx_hw_assignment_status := (
|
||||
SELECT IF(
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'homework_assignments'
|
||||
AND index_name = 'hw_assignment_status_idx'
|
||||
),
|
||||
'SELECT 1;',
|
||||
'CREATE INDEX `hw_assignment_status_idx` ON `homework_assignments` (`status`);'
|
||||
)
|
||||
);--> statement-breakpoint
|
||||
PREPARE __stmt10 FROM @__idx_hw_assignment_status;--> statement-breakpoint
|
||||
EXECUTE __stmt10;--> statement-breakpoint
|
||||
DEALLOCATE PREPARE __stmt10;--> statement-breakpoint
|
||||
SET @__idx_hw_assignment_student := (
|
||||
SELECT IF(
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'homework_submissions'
|
||||
AND index_name = 'hw_assignment_student_idx'
|
||||
),
|
||||
'SELECT 1;',
|
||||
'CREATE INDEX `hw_assignment_student_idx` ON `homework_submissions` (`assignment_id`,`student_id`);'
|
||||
)
|
||||
);--> statement-breakpoint
|
||||
PREPARE __stmt11 FROM @__idx_hw_assignment_student;--> statement-breakpoint
|
||||
EXECUTE __stmt11;--> statement-breakpoint
|
||||
DEALLOCATE PREPARE __stmt11;--> statement-breakpoint
|
||||
SET @__qkp_add_qid := (
|
||||
SELECT IF(
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM information_schema.referential_constraints
|
||||
WHERE constraint_schema = DATABASE()
|
||||
AND constraint_name = 'q_kp_qid_fk'
|
||||
),
|
||||
'SELECT 1;',
|
||||
'ALTER TABLE `questions_to_knowledge_points` ADD CONSTRAINT `q_kp_qid_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE cascade ON UPDATE no action;'
|
||||
)
|
||||
);--> statement-breakpoint
|
||||
PREPARE __stmt12 FROM @__qkp_add_qid;--> statement-breakpoint
|
||||
EXECUTE __stmt12;--> statement-breakpoint
|
||||
DEALLOCATE PREPARE __stmt12;--> statement-breakpoint
|
||||
SET @__qkp_add_kpid := (
|
||||
SELECT IF(
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM information_schema.referential_constraints
|
||||
WHERE constraint_schema = DATABASE()
|
||||
AND constraint_name = 'q_kp_kpid_fk'
|
||||
),
|
||||
'SELECT 1;',
|
||||
'ALTER TABLE `questions_to_knowledge_points` ADD CONSTRAINT `q_kp_kpid_fk` FOREIGN KEY (`knowledge_point_id`) REFERENCES `knowledge_points`(`id`) ON DELETE cascade ON UPDATE no action;'
|
||||
)
|
||||
);--> statement-breakpoint
|
||||
PREPARE __stmt13 FROM @__qkp_add_kpid;--> statement-breakpoint
|
||||
EXECUTE __stmt13;--> statement-breakpoint
|
||||
DEALLOCATE PREPARE __stmt13;
|
||||
SELECT 1;--> statement-breakpoint
|
||||
|
||||
@@ -1,43 +1 @@
|
||||
CREATE TABLE `class_enrollments` (
|
||||
`class_id` varchar(128) NOT NULL,
|
||||
`student_id` varchar(128) NOT NULL,
|
||||
`class_enrollment_status` enum('active','inactive') NOT NULL DEFAULT 'active',
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
CONSTRAINT `class_enrollments_class_id_student_id_pk` PRIMARY KEY(`class_id`,`student_id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `class_schedule` (
|
||||
`id` varchar(128) NOT NULL,
|
||||
`class_id` varchar(128) NOT NULL,
|
||||
`weekday` int NOT NULL,
|
||||
`start_time` varchar(5) NOT NULL,
|
||||
`end_time` varchar(5) NOT NULL,
|
||||
`course` varchar(255) NOT NULL,
|
||||
`location` varchar(100),
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `class_schedule_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `classes` (
|
||||
`id` varchar(128) NOT NULL,
|
||||
`name` varchar(255) NOT NULL,
|
||||
`grade` varchar(50) NOT NULL,
|
||||
`homeroom` varchar(50),
|
||||
`room` varchar(50),
|
||||
`teacher_id` varchar(128) NOT NULL,
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `classes_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `class_enrollments` ADD CONSTRAINT `ce_c_fk` FOREIGN KEY (`class_id`) REFERENCES `classes`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `class_enrollments` ADD CONSTRAINT `ce_s_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `class_schedule` ADD CONSTRAINT `cs_c_fk` FOREIGN KEY (`class_id`) REFERENCES `classes`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `classes` ADD CONSTRAINT `classes_teacher_id_users_id_fk` FOREIGN KEY (`teacher_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX `class_enrollments_class_idx` ON `class_enrollments` (`class_id`);--> statement-breakpoint
|
||||
CREATE INDEX `class_enrollments_student_idx` ON `class_enrollments` (`student_id`);--> statement-breakpoint
|
||||
CREATE INDEX `class_schedule_class_idx` ON `class_schedule` (`class_id`);--> statement-breakpoint
|
||||
CREATE INDEX `class_schedule_class_day_idx` ON `class_schedule` (`class_id`,`weekday`);--> statement-breakpoint
|
||||
CREATE INDEX `classes_teacher_idx` ON `classes` (`teacher_id`);--> statement-breakpoint
|
||||
CREATE INDEX `classes_grade_idx` ON `classes` (`grade`);
|
||||
SELECT 1;--> statement-breakpoint
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
ALTER TABLE `chapters` ADD `content` text;--> statement-breakpoint
|
||||
ALTER TABLE `knowledge_points` ADD `chapter_id` varchar(128);--> statement-breakpoint
|
||||
CREATE INDEX `kp_chapter_id_idx` ON `knowledge_points` (`chapter_id`);
|
||||
SELECT 1;--> statement-breakpoint
|
||||
|
||||
@@ -1,52 +1 @@
|
||||
CREATE TABLE `academic_years` (
|
||||
`id` varchar(128) NOT NULL,
|
||||
`name` varchar(100) NOT NULL,
|
||||
`start_date` timestamp NOT NULL,
|
||||
`end_date` timestamp NOT NULL,
|
||||
`is_active` boolean NOT NULL DEFAULT false,
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `academic_years_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `academic_years_name_unique` UNIQUE(`name`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `class_subject_teachers` (
|
||||
`class_id` varchar(128) NOT NULL,
|
||||
`subject` enum('语文','数学','英语','美术','体育','科学','社会','音乐') NOT NULL,
|
||||
`teacher_id` varchar(128),
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `class_subject_teachers_class_id_subject_pk` PRIMARY KEY(`class_id`,`subject`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `classrooms` (
|
||||
`id` varchar(128) NOT NULL,
|
||||
`name` varchar(255) NOT NULL,
|
||||
`building` varchar(100),
|
||||
`floor` int,
|
||||
`capacity` int,
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `classrooms_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `classrooms_name_unique` UNIQUE(`name`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `departments` (
|
||||
`id` varchar(128) NOT NULL,
|
||||
`name` varchar(255) NOT NULL,
|
||||
`description` text,
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `departments_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `departments_name_unique` UNIQUE(`name`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `classes` ADD `school_name` varchar(255);--> statement-breakpoint
|
||||
ALTER TABLE `class_subject_teachers` ADD CONSTRAINT `class_subject_teachers_teacher_id_users_id_fk` FOREIGN KEY (`teacher_id`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `class_subject_teachers` ADD CONSTRAINT `cst_c_fk` FOREIGN KEY (`class_id`) REFERENCES `classes`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX `academic_years_name_idx` ON `academic_years` (`name`);--> statement-breakpoint
|
||||
CREATE INDEX `academic_years_active_idx` ON `academic_years` (`is_active`);--> statement-breakpoint
|
||||
CREATE INDEX `class_subject_teachers_class_idx` ON `class_subject_teachers` (`class_id`);--> statement-breakpoint
|
||||
CREATE INDEX `class_subject_teachers_teacher_idx` ON `class_subject_teachers` (`teacher_id`);--> statement-breakpoint
|
||||
CREATE INDEX `classrooms_name_idx` ON `classrooms` (`name`);--> statement-breakpoint
|
||||
CREATE INDEX `departments_name_idx` ON `departments` (`name`);
|
||||
SELECT 1;--> statement-breakpoint
|
||||
|
||||
@@ -1,38 +1 @@
|
||||
CREATE TABLE `grades` (
|
||||
`id` varchar(128) NOT NULL,
|
||||
`school_id` varchar(128) NOT NULL,
|
||||
`name` varchar(100) NOT NULL,
|
||||
`order` int NOT NULL DEFAULT 0,
|
||||
`grade_head_id` varchar(128),
|
||||
`teaching_head_id` varchar(128),
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `grades_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `schools` (
|
||||
`id` varchar(128) NOT NULL,
|
||||
`name` varchar(255) NOT NULL,
|
||||
`code` varchar(50),
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `schools_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `schools_name_unique` UNIQUE(`name`),
|
||||
CONSTRAINT `schools_code_unique` UNIQUE(`code`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `classes` ADD `school_id` varchar(128);--> statement-breakpoint
|
||||
ALTER TABLE `classes` ADD `grade_id` varchar(128);--> statement-breakpoint
|
||||
ALTER TABLE `grades` ADD CONSTRAINT `g_s_fk` FOREIGN KEY (`school_id`) REFERENCES `schools`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `grades` ADD CONSTRAINT `g_gh_fk` FOREIGN KEY (`grade_head_id`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `grades` ADD CONSTRAINT `g_th_fk` FOREIGN KEY (`teaching_head_id`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX `grades_school_idx` ON `grades` (`school_id`);--> statement-breakpoint
|
||||
CREATE INDEX `grades_school_name_uniq` ON `grades` (`school_id`,`name`);--> statement-breakpoint
|
||||
CREATE INDEX `grades_grade_head_idx` ON `grades` (`grade_head_id`);--> statement-breakpoint
|
||||
CREATE INDEX `grades_teaching_head_idx` ON `grades` (`teaching_head_id`);--> statement-breakpoint
|
||||
CREATE INDEX `schools_name_idx` ON `schools` (`name`);--> statement-breakpoint
|
||||
CREATE INDEX `schools_code_idx` ON `schools` (`code`);--> statement-breakpoint
|
||||
ALTER TABLE `classes` ADD CONSTRAINT `c_s_fk` FOREIGN KEY (`school_id`) REFERENCES `schools`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `classes` ADD CONSTRAINT `c_g_fk` FOREIGN KEY (`grade_id`) REFERENCES `grades`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX `classes_school_idx` ON `classes` (`school_id`);--> statement-breakpoint
|
||||
CREATE INDEX `classes_grade_id_idx` ON `classes` (`grade_id`);
|
||||
SELECT 1;--> statement-breakpoint
|
||||
|
||||
@@ -1,6 +1 @@
|
||||
ALTER TABLE `exams` ADD `subject_id` varchar(128);--> 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`);
|
||||
SELECT 1;--> statement-breakpoint
|
||||
|
||||
@@ -1 +1 @@
|
||||
ALTER TABLE `knowledge_points` ADD `anchor_text` varchar(255);
|
||||
SELECT 1;--> statement-breakpoint
|
||||
|
||||
88
drizzle/0009_smart_mephistopheles.sql
Normal file
88
drizzle/0009_smart_mephistopheles.sql
Normal file
@@ -0,0 +1,88 @@
|
||||
SET @has_exams_subject := (
|
||||
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'exams'
|
||||
AND COLUMN_NAME = 'subject_id'
|
||||
);--> statement-breakpoint
|
||||
SET @sql := IF(@has_exams_subject = 0, 'ALTER TABLE `exams` ADD `subject_id` varchar(128);', 'SELECT 1');--> statement-breakpoint
|
||||
PREPARE stmt FROM @sql;--> statement-breakpoint
|
||||
EXECUTE stmt;--> statement-breakpoint
|
||||
DEALLOCATE PREPARE stmt;--> statement-breakpoint
|
||||
|
||||
SET @has_exams_grade := (
|
||||
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'exams'
|
||||
AND COLUMN_NAME = 'grade_id'
|
||||
);--> statement-breakpoint
|
||||
SET @sql := IF(@has_exams_grade = 0, 'ALTER TABLE `exams` ADD `grade_id` varchar(128);', 'SELECT 1');--> statement-breakpoint
|
||||
PREPARE stmt FROM @sql;--> statement-breakpoint
|
||||
EXECUTE stmt;--> statement-breakpoint
|
||||
DEALLOCATE PREPARE stmt;--> statement-breakpoint
|
||||
|
||||
SET @has_kp_anchor := (
|
||||
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'knowledge_points'
|
||||
AND COLUMN_NAME = 'anchor_text'
|
||||
);--> statement-breakpoint
|
||||
SET @sql := IF(@has_kp_anchor = 0, 'ALTER TABLE `knowledge_points` ADD `anchor_text` varchar(255);', 'SELECT 1');--> statement-breakpoint
|
||||
PREPARE stmt FROM @sql;--> statement-breakpoint
|
||||
EXECUTE stmt;--> statement-breakpoint
|
||||
DEALLOCATE PREPARE stmt;--> statement-breakpoint
|
||||
|
||||
SET @has_exams_subject_fk := (
|
||||
SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'exams'
|
||||
AND CONSTRAINT_NAME = 'exams_subject_id_subjects_id_fk'
|
||||
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
|
||||
);--> statement-breakpoint
|
||||
SET @sql := IF(@has_exams_subject_fk = 0, 'ALTER TABLE `exams` ADD CONSTRAINT `exams_subject_id_subjects_id_fk` FOREIGN KEY (`subject_id`) REFERENCES `subjects`(`id`) ON DELETE no action ON UPDATE no action;', 'SELECT 1');--> statement-breakpoint
|
||||
PREPARE stmt FROM @sql;--> statement-breakpoint
|
||||
EXECUTE stmt;--> statement-breakpoint
|
||||
DEALLOCATE PREPARE stmt;--> statement-breakpoint
|
||||
|
||||
SET @has_exams_grade_fk := (
|
||||
SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'exams'
|
||||
AND CONSTRAINT_NAME = 'exams_grade_id_grades_id_fk'
|
||||
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
|
||||
);--> statement-breakpoint
|
||||
SET @sql := IF(@has_exams_grade_fk = 0, 'ALTER TABLE `exams` ADD CONSTRAINT `exams_grade_id_grades_id_fk` FOREIGN KEY (`grade_id`) REFERENCES `grades`(`id`) ON DELETE no action ON UPDATE no action;', 'SELECT 1');--> statement-breakpoint
|
||||
PREPARE stmt FROM @sql;--> statement-breakpoint
|
||||
EXECUTE stmt;--> statement-breakpoint
|
||||
DEALLOCATE PREPARE stmt;--> statement-breakpoint
|
||||
|
||||
SET @has_exams_subject_idx := (
|
||||
SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'exams'
|
||||
AND INDEX_NAME = 'exams_subject_idx'
|
||||
);--> statement-breakpoint
|
||||
SET @sql := IF(@has_exams_subject_idx = 0, 'CREATE INDEX `exams_subject_idx` ON `exams` (`subject_id`);', 'SELECT 1');--> statement-breakpoint
|
||||
PREPARE stmt FROM @sql;--> statement-breakpoint
|
||||
EXECUTE stmt;--> statement-breakpoint
|
||||
DEALLOCATE PREPARE stmt;--> statement-breakpoint
|
||||
|
||||
SET @has_exams_grade_idx := (
|
||||
SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'exams'
|
||||
AND INDEX_NAME = 'exams_grade_idx'
|
||||
);--> statement-breakpoint
|
||||
SET @sql := IF(@has_exams_grade_idx = 0, 'CREATE INDEX `exams_grade_idx` ON `exams` (`grade_id`);', 'SELECT 1');--> statement-breakpoint
|
||||
PREPARE stmt FROM @sql;--> statement-breakpoint
|
||||
EXECUTE stmt;--> statement-breakpoint
|
||||
DEALLOCATE PREPARE stmt;--> statement-breakpoint
|
||||
INSERT IGNORE INTO `roles` (`id`, `name`, `created_at`, `updated_at`)
|
||||
SELECT UUID(), LOWER(TRIM(`role`)), NOW(), NOW()
|
||||
FROM `users`
|
||||
WHERE `role` IS NOT NULL AND TRIM(`role`) <> '';--> statement-breakpoint
|
||||
INSERT IGNORE INTO `users_to_roles` (`user_id`, `role_id`)
|
||||
SELECT `users`.`id`, `roles`.`id`
|
||||
FROM `users`
|
||||
INNER JOIN `roles` ON `roles`.`name` = LOWER(TRIM(`users`.`role`))
|
||||
WHERE `users`.`role` IS NOT NULL AND TRIM(`users`.`role`) <> '';--> statement-breakpoint
|
||||
ALTER TABLE `users` DROP COLUMN `role`;
|
||||
110
drizzle/0010_subject_id_switch.sql
Normal file
110
drizzle/0010_subject_id_switch.sql
Normal file
@@ -0,0 +1,110 @@
|
||||
SET @has_subject_id := (
|
||||
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'class_subject_teachers'
|
||||
AND COLUMN_NAME = 'subject_id'
|
||||
);--> statement-breakpoint
|
||||
SET @sql := IF(@has_subject_id = 0, 'ALTER TABLE `class_subject_teachers` ADD COLUMN `subject_id` VARCHAR(128) NULL;', 'SELECT 1');--> statement-breakpoint
|
||||
PREPARE stmt FROM @sql;--> statement-breakpoint
|
||||
EXECUTE stmt;--> statement-breakpoint
|
||||
DEALLOCATE PREPARE stmt;--> statement-breakpoint
|
||||
|
||||
INSERT INTO `subjects` (`id`, `name`, `code`)
|
||||
SELECT UUID(), '语文', 'CHINESE' FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `subjects` WHERE `name` = '语文' OR `code` = 'CHINESE')
|
||||
UNION ALL
|
||||
SELECT UUID(), '数学', 'MATH' FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `subjects` WHERE `name` = '数学' OR `code` = 'MATH')
|
||||
UNION ALL
|
||||
SELECT UUID(), '英语', 'ENG' FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `subjects` WHERE `name` = '英语' OR `code` = 'ENG')
|
||||
UNION ALL
|
||||
SELECT UUID(), '美术', 'ART' FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `subjects` WHERE `name` = '美术' OR `code` = 'ART')
|
||||
UNION ALL
|
||||
SELECT UUID(), '体育', 'PE' FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `subjects` WHERE `name` = '体育' OR `code` = 'PE')
|
||||
UNION ALL
|
||||
SELECT UUID(), '科学', 'SCI' FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `subjects` WHERE `name` = '科学' OR `code` = 'SCI')
|
||||
UNION ALL
|
||||
SELECT UUID(), '社会', 'SOC' FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `subjects` WHERE `name` = '社会' OR `code` = 'SOC')
|
||||
UNION ALL
|
||||
SELECT UUID(), '音乐', 'MUSIC' FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `subjects` WHERE `name` = '音乐' OR `code` = 'MUSIC');--> statement-breakpoint
|
||||
|
||||
SET @has_subject_col := (
|
||||
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'class_subject_teachers'
|
||||
AND COLUMN_NAME = 'subject'
|
||||
);--> statement-breakpoint
|
||||
SET @sql := IF(@has_subject_col = 1, '
|
||||
UPDATE `class_subject_teachers` cst
|
||||
JOIN `subjects` s ON (
|
||||
s.`name` = cst.`subject`
|
||||
OR s.`code` = CASE cst.`subject`
|
||||
WHEN ''语文'' THEN ''CHINESE''
|
||||
WHEN ''数学'' THEN ''MATH''
|
||||
WHEN ''英语'' THEN ''ENG''
|
||||
WHEN ''美术'' THEN ''ART''
|
||||
WHEN ''体育'' THEN ''PE''
|
||||
WHEN ''科学'' THEN ''SCI''
|
||||
WHEN ''社会'' THEN ''SOC''
|
||||
WHEN ''音乐'' THEN ''MUSIC''
|
||||
ELSE NULL
|
||||
END
|
||||
)
|
||||
SET cst.`subject_id` = s.`id`
|
||||
WHERE cst.`subject_id` IS NULL;
|
||||
', 'SELECT 1');--> statement-breakpoint
|
||||
PREPARE stmt FROM @sql;--> statement-breakpoint
|
||||
EXECUTE stmt;--> statement-breakpoint
|
||||
DEALLOCATE PREPARE stmt;--> statement-breakpoint
|
||||
|
||||
SET @subject_id_nulls := (
|
||||
SELECT COUNT(*) FROM `class_subject_teachers`
|
||||
WHERE `subject_id` IS NULL
|
||||
);--> statement-breakpoint
|
||||
SET @sql := IF(@subject_id_nulls = 0, 'ALTER TABLE `class_subject_teachers` MODIFY COLUMN `subject_id` VARCHAR(128) NOT NULL;', 'SELECT 1');--> statement-breakpoint
|
||||
PREPARE stmt FROM @sql;--> statement-breakpoint
|
||||
EXECUTE stmt;--> statement-breakpoint
|
||||
DEALLOCATE PREPARE stmt;--> statement-breakpoint
|
||||
|
||||
SET @sql := IF(@subject_id_nulls = 0, 'ALTER TABLE `class_subject_teachers` DROP PRIMARY KEY;', 'SELECT 1');--> statement-breakpoint
|
||||
PREPARE stmt FROM @sql;--> statement-breakpoint
|
||||
EXECUTE stmt;--> statement-breakpoint
|
||||
DEALLOCATE PREPARE stmt;--> statement-breakpoint
|
||||
SET @sql := IF(@subject_id_nulls = 0, 'ALTER TABLE `class_subject_teachers` ADD PRIMARY KEY (`class_id`, `subject_id`);', 'SELECT 1');--> statement-breakpoint
|
||||
PREPARE stmt FROM @sql;--> statement-breakpoint
|
||||
EXECUTE stmt;--> statement-breakpoint
|
||||
DEALLOCATE PREPARE stmt;--> statement-breakpoint
|
||||
|
||||
SET @sql := IF(@subject_id_nulls = 0 AND @has_subject_col = 1, 'ALTER TABLE `class_subject_teachers` DROP COLUMN `subject`;', 'SELECT 1');--> statement-breakpoint
|
||||
PREPARE stmt FROM @sql;--> statement-breakpoint
|
||||
EXECUTE stmt;--> statement-breakpoint
|
||||
DEALLOCATE PREPARE stmt;--> statement-breakpoint
|
||||
|
||||
SET @has_subject_id_idx := (
|
||||
SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'class_subject_teachers'
|
||||
AND INDEX_NAME = 'class_subject_teachers_subject_id_idx'
|
||||
);--> statement-breakpoint
|
||||
SET @sql := IF(@subject_id_nulls = 0 AND @has_subject_id_idx = 0, 'CREATE INDEX `class_subject_teachers_subject_id_idx` ON `class_subject_teachers` (`subject_id`);', 'SELECT 1');--> statement-breakpoint
|
||||
PREPARE stmt FROM @sql;--> statement-breakpoint
|
||||
EXECUTE stmt;--> statement-breakpoint
|
||||
DEALLOCATE PREPARE stmt;--> statement-breakpoint
|
||||
|
||||
SET @has_subject_fk := (
|
||||
SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'class_subject_teachers'
|
||||
AND CONSTRAINT_NAME = 'cst_s_fk'
|
||||
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
|
||||
);--> statement-breakpoint
|
||||
SET @sql := IF(@subject_id_nulls = 0 AND @has_subject_fk = 0, 'ALTER TABLE `class_subject_teachers` ADD CONSTRAINT `cst_s_fk` FOREIGN KEY (`subject_id`) REFERENCES `subjects`(`id`) ON DELETE CASCADE;', 'SELECT 1');--> statement-breakpoint
|
||||
PREPARE stmt FROM @sql;--> statement-breakpoint
|
||||
EXECUTE stmt;--> statement-breakpoint
|
||||
DEALLOCATE PREPARE stmt;
|
||||
46
drizzle/0011_ai_providers.sql
Normal file
46
drizzle/0011_ai_providers.sql
Normal 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
@@ -2,7 +2,7 @@
|
||||
"version": "5",
|
||||
"dialect": "mysql",
|
||||
"id": "2cf4c7e4-f538-499e-a5a5-9281d556bc9d",
|
||||
"prevId": "5eaf9185-8a1e-4e35-8144-553aec7ff31f",
|
||||
"prevId": "a6d95d47-4400-464e-bc53-45735dd6e3e3",
|
||||
"tables": {
|
||||
"academic_years": {
|
||||
"name": "academic_years",
|
||||
@@ -3068,4 +3068,4 @@
|
||||
"tables": {},
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"version": "5",
|
||||
"dialect": "mysql",
|
||||
"id": "5eaf9185-8a1e-4e35-8144-553aec7ff31f",
|
||||
"prevId": "3b23e056-3d79-4ea9-a03e-d1b5d56bafda",
|
||||
"id": "551f3408-945e-4f1d-984c-bfd35fe9d0ea",
|
||||
"prevId": "2cf4c7e4-f538-499e-a5a5-9281d556bc9d",
|
||||
"tables": {
|
||||
"academic_years": {
|
||||
"name": "academic_years",
|
||||
@@ -1180,6 +1180,20 @@
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"subject_id": {
|
||||
"name": "subject_id",
|
||||
"type": "varchar(128)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"grade_id": {
|
||||
"name": "grade_id",
|
||||
"type": "varchar(128)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"start_time": {
|
||||
"name": "start_time",
|
||||
"type": "timestamp",
|
||||
@@ -1220,7 +1234,22 @@
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"indexes": {
|
||||
"exams_subject_idx": {
|
||||
"name": "exams_subject_idx",
|
||||
"columns": [
|
||||
"subject_id"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"exams_grade_idx": {
|
||||
"name": "exams_grade_idx",
|
||||
"columns": [
|
||||
"grade_id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"exams_creator_id_users_id_fk": {
|
||||
"name": "exams_creator_id_users_id_fk",
|
||||
@@ -1234,6 +1263,32 @@
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"exams_subject_id_subjects_id_fk": {
|
||||
"name": "exams_subject_id_subjects_id_fk",
|
||||
"tableFrom": "exams",
|
||||
"tableTo": "subjects",
|
||||
"columnsFrom": [
|
||||
"subject_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"exams_grade_id_grades_id_fk": {
|
||||
"name": "exams_grade_id_grades_id_fk",
|
||||
"tableFrom": "exams",
|
||||
"tableTo": "grades",
|
||||
"columnsFrom": [
|
||||
"grade_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
@@ -2009,6 +2064,13 @@
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"anchor_text": {
|
||||
"name": "anchor_text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"parent_id": {
|
||||
"name": "parent_id",
|
||||
"type": "varchar(128)",
|
||||
@@ -2765,14 +2827,6 @@
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'student'"
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "varchar(255)",
|
||||
@@ -3006,4 +3060,4 @@
|
||||
"tables": {},
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,20 @@
|
||||
"when": 1768470966367,
|
||||
"tag": "0008_thin_madrox",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "5",
|
||||
"when": 1772162908476,
|
||||
"tag": "0009_smart_mephistopheles",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "5",
|
||||
"when": 1772439600000,
|
||||
"tag": "0010_subject_id_switch",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,22 @@ const eslintConfig = defineConfig([
|
||||
"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.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
@@ -18,6 +34,8 @@ const eslintConfig = defineConfig([
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
"docs/scripts/**",
|
||||
"playwright-report/**",
|
||||
"test-results/**",
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
1849
package-lock.json
generated
1849
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@@ -8,6 +8,14 @@
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "npm run test:integration && npm run test:e2e",
|
||||
"test:ci": "npm run test:integration && npm run test:e2e",
|
||||
"test: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:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate"
|
||||
@@ -41,9 +49,9 @@
|
||||
"@tiptap/pm": "^3.15.3",
|
||||
"@tiptap/react": "^3.15.3",
|
||||
"@tiptap/starter-kit": "^3.15.3",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"mysql2": "^3.16.0",
|
||||
@@ -51,6 +59,8 @@
|
||||
"next-auth": "^5.0.0-beta.30",
|
||||
"next-themes": "^0.4.6",
|
||||
"nuqs": "^2.8.5",
|
||||
"openai": "^6.25.0",
|
||||
"p-queue": "^9.1.0",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1",
|
||||
"react-hook-form": "^7.69.0",
|
||||
@@ -67,12 +77,14 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^10.1.0",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitest/coverage-v8": "^4.1.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"eslint": "^9",
|
||||
@@ -80,6 +92,7 @@
|
||||
"prettier": "^3.7.4",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
37
playwright.config.ts
Normal file
37
playwright.config.ts
Normal 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",
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
38
scripts/check_cst_schema.ts
Normal file
38
scripts/check_cst_schema.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { config } from "dotenv"
|
||||
import mysql from "mysql2/promise"
|
||||
|
||||
async function main() {
|
||||
config()
|
||||
const url = process.env.DATABASE_URL
|
||||
if (!url) {
|
||||
console.error("Missing DATABASE_URL")
|
||||
process.exit(1)
|
||||
return
|
||||
}
|
||||
const conn = await mysql.createConnection(url)
|
||||
try {
|
||||
const [rows] = await conn.query("SHOW COLUMNS FROM class_subject_teachers")
|
||||
const [keys] = await conn.query("SHOW KEYS FROM class_subject_teachers")
|
||||
let migrations: Array<{ id: number | string; hash: string; created_at: number | string }> | null = null
|
||||
try {
|
||||
const [m] = await conn.query("SELECT id, hash, created_at FROM __drizzle_migrations ORDER BY id DESC LIMIT 5")
|
||||
migrations = m as Array<{ id: number | string; hash: string; created_at: number | string }>
|
||||
} catch (error: unknown) {
|
||||
console.error(error)
|
||||
}
|
||||
const columns = rows as Array<{ Field: string; Type: string; Null: string; Key: string }>
|
||||
const indexes = keys as Array<{ Key_name: string; Column_name: string }>
|
||||
console.log(columns.map((r) => `${r.Field}:${r.Type}:${r.Null}:${r.Key}`).join("\n"))
|
||||
console.log(indexes.map((r) => `${r.Key_name}:${r.Column_name}`).join("\n"))
|
||||
if (migrations) {
|
||||
console.log(migrations.map((r) => `${r.id}:${r.hash}:${r.created_at}`).join("\n"))
|
||||
}
|
||||
} finally {
|
||||
await conn.end()
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
58
scripts/migrate_cst_subjectid.ts
Normal file
58
scripts/migrate_cst_subjectid.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { config } from "dotenv"
|
||||
import { db } from "@/shared/db"
|
||||
import { sql } from "drizzle-orm"
|
||||
|
||||
async function main() {
|
||||
config()
|
||||
// 1) add subject_id column if not exists (nullable first)
|
||||
await db.execute(sql`ALTER TABLE class_subject_teachers ADD COLUMN IF NOT EXISTS subject_id VARCHAR(128) NULL;`)
|
||||
|
||||
// 2) backfill subject_id from subjects.name matching existing 'subject' column
|
||||
// This assumes existing data uses subjects.name 值;若不匹配,将在 NOT NULL 约束处失败
|
||||
await db.execute(sql`
|
||||
UPDATE class_subject_teachers cst
|
||||
JOIN subjects s ON (
|
||||
s.name = cst.subject
|
||||
OR s.code = CASE cst.subject
|
||||
WHEN '语文' THEN 'CHINESE'
|
||||
WHEN '数学' THEN 'MATH'
|
||||
WHEN '英语' THEN 'ENG'
|
||||
WHEN '美术' THEN 'ART'
|
||||
WHEN '体育' THEN 'PE'
|
||||
WHEN '科学' THEN 'SCI'
|
||||
WHEN '社会' THEN 'SOC'
|
||||
WHEN '音乐' THEN 'MUSIC'
|
||||
ELSE NULL
|
||||
END
|
||||
)
|
||||
SET cst.subject_id = s.id
|
||||
WHERE cst.subject_id IS NULL
|
||||
`)
|
||||
|
||||
// 3) enforce NOT NULL
|
||||
await db.execute(sql`ALTER TABLE class_subject_teachers MODIFY COLUMN subject_id VARCHAR(128) NOT NULL;`)
|
||||
|
||||
// 4) drop old PK and create new PK (class_id, subject_id)
|
||||
try { await db.execute(sql`ALTER TABLE class_subject_teachers DROP PRIMARY KEY;`) } catch {}
|
||||
await db.execute(sql`ALTER TABLE class_subject_teachers ADD PRIMARY KEY (class_id, subject_id);`)
|
||||
|
||||
// 5) drop old subject column if exists
|
||||
await db.execute(sql`ALTER TABLE class_subject_teachers DROP COLUMN IF EXISTS subject;`)
|
||||
|
||||
// 6) add index and FK
|
||||
try { await db.execute(sql`CREATE INDEX class_subject_teachers_subject_id_idx ON class_subject_teachers (subject_id);`) } catch {}
|
||||
try { await db.execute(sql`ALTER TABLE class_subject_teachers DROP FOREIGN KEY cst_s_fk;`) } catch {}
|
||||
await db.execute(sql`
|
||||
ALTER TABLE class_subject_teachers
|
||||
ADD CONSTRAINT cst_s_fk
|
||||
FOREIGN KEY (subject_id) REFERENCES subjects(id)
|
||||
ON DELETE CASCADE
|
||||
`)
|
||||
|
||||
console.log("Migration completed: class_subject_teachers now uses subject_id mapping.")
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { hash } from "bcryptjs";
|
||||
|
||||
/**
|
||||
* Enterprise-Grade Seed Script for Next_Edu
|
||||
@@ -77,27 +78,31 @@ async function seed() {
|
||||
]);
|
||||
|
||||
// Users
|
||||
const passwordHash = await hash("123456", 10);
|
||||
const usersData = [
|
||||
{
|
||||
id: "user_admin",
|
||||
name: "Admin User",
|
||||
email: "admin@next-edu.com",
|
||||
role: "admin", // Legacy field
|
||||
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Admin"
|
||||
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Admin",
|
||||
password: passwordHash
|
||||
},
|
||||
{
|
||||
id: "user_teacher_math",
|
||||
name: "Mr. Math",
|
||||
email: "math@next-edu.com",
|
||||
role: "teacher",
|
||||
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Math"
|
||||
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Math",
|
||||
password: passwordHash
|
||||
},
|
||||
{
|
||||
id: "user_student_1",
|
||||
name: "Alice Student",
|
||||
email: "alice@next-edu.com",
|
||||
role: "student",
|
||||
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Alice"
|
||||
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Alice",
|
||||
password: passwordHash
|
||||
}
|
||||
];
|
||||
|
||||
@@ -122,6 +127,7 @@ async function seed() {
|
||||
email: faker.internet.email().toLowerCase(),
|
||||
role: "student",
|
||||
image: `https://api.dicebear.com/7.x/avataaars/svg?seed=${studentId}`,
|
||||
password: passwordHash,
|
||||
});
|
||||
await db.insert(usersToRoles).values({ userId: studentId, roleId: roleMap.student });
|
||||
}
|
||||
@@ -136,13 +142,14 @@ async function seed() {
|
||||
|
||||
// --- Seeding Subjects ---
|
||||
await db.insert(subjects).values([
|
||||
{ id: createId(), name: "Mathematics", code: "MATH", order: 1 },
|
||||
{ id: createId(), name: "Physics", code: "PHYS", order: 2 },
|
||||
{ id: createId(), name: "Chemistry", code: "CHEM", order: 3 },
|
||||
{ id: createId(), name: "English", code: "ENG", order: 4 },
|
||||
{ id: createId(), name: "History", code: "HIST", order: 5 },
|
||||
{ id: createId(), name: "Geography", code: "GEO", order: 6 },
|
||||
{ id: createId(), name: "Biology", code: "BIO", order: 7 },
|
||||
{ id: createId(), name: "语文", code: "CHINESE", order: 1 },
|
||||
{ id: createId(), name: "数学", code: "MATH", order: 2 },
|
||||
{ id: createId(), name: "英语", code: "ENG", order: 3 },
|
||||
{ id: createId(), name: "美术", code: "ART", order: 4 },
|
||||
{ id: createId(), name: "体育", code: "PE", order: 5 },
|
||||
{ id: createId(), name: "科学", code: "SCI", order: 6 },
|
||||
{ id: createId(), name: "社会", code: "SOC", order: 7 },
|
||||
{ id: createId(), name: "音乐", code: "MUSIC", order: 8 },
|
||||
])
|
||||
|
||||
await db.insert(grades).values([
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function RegisterPage() {
|
||||
if (!databaseUrl) return { success: false, message: "DATABASE_URL 未配置" }
|
||||
|
||||
try {
|
||||
const [{ db }, { users }] = await Promise.all([
|
||||
const [{ db }, { roles, users, usersToRoles }] = await Promise.all([
|
||||
import("@/shared/db"),
|
||||
import("@/shared/db/schema"),
|
||||
])
|
||||
@@ -45,13 +45,25 @@ export default function RegisterPage() {
|
||||
if (existing) return { success: false, message: "该邮箱已注册" }
|
||||
|
||||
const hashedPassword = normalizeBcryptHash(await hash(password, 10))
|
||||
const userId = createId()
|
||||
await db.insert(users).values({
|
||||
id: createId(),
|
||||
id: userId,
|
||||
name: name.length ? name : null,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
role: "student",
|
||||
})
|
||||
const roleRow = await db.query.roles.findFirst({
|
||||
where: eq(roles.name, "student"),
|
||||
columns: { id: true },
|
||||
})
|
||||
if (!roleRow) {
|
||||
await db.insert(roles).values({ name: "student" })
|
||||
}
|
||||
const resolvedRole = roleRow
|
||||
?? (await db.query.roles.findFirst({ where: eq(roles.name, "student"), columns: { id: true } }))
|
||||
if (resolvedRole?.id) {
|
||||
await db.insert(usersToRoles).values({ userId, roleId: resolvedRole.id })
|
||||
}
|
||||
|
||||
return { success: true, message: "账户创建成功" }
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { redirect } from "next/navigation"
|
||||
import { auth } from "@/auth"
|
||||
import { getUserProfile } from "@/modules/users/data-access"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
const normalizeRole = (value: unknown) => {
|
||||
const role = String(value ?? "").trim().toLowerCase()
|
||||
if (role === "admin" || role === "student" || role === "teacher" || role === "parent") return role
|
||||
return "student"
|
||||
}
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await auth()
|
||||
if (!session?.user) redirect("/login")
|
||||
|
||||
const role = normalizeRole(session.user.role)
|
||||
const userId = String(session.user.id ?? "").trim()
|
||||
if (!userId) redirect("/login")
|
||||
const profile = await getUserProfile(userId)
|
||||
if (!profile) redirect("/login")
|
||||
const role = profile.role || "student"
|
||||
|
||||
if (role === "admin") redirect("/admin/dashboard")
|
||||
if (role === "student") redirect("/student/dashboard")
|
||||
|
||||
@@ -2,7 +2,7 @@ import Link from "next/link"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { auth } from "@/auth"
|
||||
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"
|
||||
import { getStudentClasses, getStudentSchedule, getTeacherClasses, getTeacherTeachingSubjects } from "@/modules/classes/data-access"
|
||||
import { StudentGradesCard } from "@/modules/dashboard/components/student-dashboard/student-grades-card"
|
||||
import { StudentStatsGrid } from "@/modules/dashboard/components/student-dashboard/student-stats-grid"
|
||||
import { StudentTodayScheduleCard } from "@/modules/dashboard/components/student-dashboard/student-today-schedule-card"
|
||||
@@ -44,6 +44,7 @@ export default async function ProfilePage() {
|
||||
|
||||
const role = userProfile.role || "student"
|
||||
const isStudent = role === "student"
|
||||
const isTeacher = role === "teacher"
|
||||
|
||||
const studentData =
|
||||
isStudent
|
||||
@@ -107,6 +108,14 @@ export default async function ProfilePage() {
|
||||
})()
|
||||
: null
|
||||
|
||||
const teacherData =
|
||||
isTeacher
|
||||
? await (async () => {
|
||||
const [subjects, classes] = await Promise.all([getTeacherTeachingSubjects(), getTeacherClasses()])
|
||||
return { subjects, classes }
|
||||
})()
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-8 p-8">
|
||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||
@@ -231,6 +240,65 @@ export default async function ProfilePage() {
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{teacherData ? (
|
||||
<div className="space-y-6">
|
||||
<Separator />
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-xl font-semibold tracking-tight">Teacher Overview</h2>
|
||||
<div className="text-sm text-muted-foreground">Your teaching subjects and classes.</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Teaching Subjects</CardTitle>
|
||||
<CardDescription>Subjects you are currently assigned to teach.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{teacherData.subjects.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">No subjects assigned yet.</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{teacherData.subjects.map((subject) => (
|
||||
<Badge key={subject} variant="secondary">
|
||||
{subject}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Teaching Classes</CardTitle>
|
||||
<CardDescription>Classes you are currently managing.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{teacherData.classes.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">No classes assigned yet.</div>
|
||||
) : (
|
||||
teacherData.classes.map((cls) => (
|
||||
<div key={cls.id} className="flex items-center justify-between gap-4 rounded-md border px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{cls.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{cls.grade}
|
||||
{cls.homeroom ? ` • ${cls.homeroom}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/teacher/classes/my/${encodeURIComponent(cls.id)}`}>View</Link>
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,14 +3,7 @@ import Link from "next/link"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/components/ui/table"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { getDemoStudentUser, getStudentHomeworkAssignments } from "@/modules/homework/data-access"
|
||||
import { Inbox } from "lucide-react"
|
||||
@@ -43,18 +36,14 @@ const getActionVariant = (status: string): "default" | "secondary" | "outline" =
|
||||
return "default"
|
||||
}
|
||||
|
||||
const isAnswered = (status: string) => status === "submitted" || status === "graded"
|
||||
|
||||
export default async function StudentAssignmentsPage() {
|
||||
const student = await getDemoStudentUser()
|
||||
|
||||
if (!student) {
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Assignments</h2>
|
||||
<p className="text-muted-foreground">Your homework assignments.</p>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState title="No user found" description="Create a student user to see assignments." icon={Inbox} />
|
||||
</div>
|
||||
)
|
||||
@@ -62,63 +51,115 @@ export default async function StudentAssignmentsPage() {
|
||||
|
||||
const assignments = await getStudentHomeworkAssignments(student.id)
|
||||
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 (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Assignments</h2>
|
||||
<p className="text-muted-foreground">Your homework assignments.</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/student/dashboard">Back</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!hasAssignments ? (
|
||||
<EmptyState title="No assignments" description="You have no assigned homework right now." icon={Inbox} />
|
||||
) : (
|
||||
<div className="rounded-md border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Due</TableHead>
|
||||
<TableHead>Attempts</TableHead>
|
||||
<TableHead>Score</TableHead>
|
||||
<TableHead className="text-right">Action</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{assignments.map((a) => (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell className="font-medium">
|
||||
<Link href={`/student/learning/assignments/${a.id}`} className="hover:underline">
|
||||
{a.title}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={getStatusVariant(a.progressStatus)} className="capitalize">
|
||||
{getStatusLabel(a.progressStatus)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{a.dueAt ? formatDate(a.dueAt) : "-"}</TableCell>
|
||||
<TableCell className="tabular-nums text-muted-foreground">
|
||||
{a.attemptsUsed}/{a.maxAttempts}
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">{a.latestScore ?? "-"}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button asChild size="sm" variant={getActionVariant(a.progressStatus)}>
|
||||
<Link href={`/student/learning/assignments/${a.id}`}>
|
||||
{getActionLabel(a.progressStatus)}
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="space-y-6">
|
||||
{subjectEntries.map(([subject, items]) => {
|
||||
const answeredItems = items.filter((a) => isAnswered(a.progressStatus))
|
||||
const unansweredItems = items.filter((a) => !isAnswered(a.progressStatus))
|
||||
|
||||
return (
|
||||
<div key={subject} className="space-y-3">
|
||||
<div className="text-sm font-semibold text-muted-foreground">{subject}</div>
|
||||
{unansweredItems.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">
|
||||
{unansweredItems.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>
|
||||
)}
|
||||
{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>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import Link from "next/link"
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import { BookOpen, Inbox } from "lucide-react"
|
||||
|
||||
@@ -5,8 +5,6 @@ import { TextbookCard } from "@/modules/textbooks/components/textbook-card"
|
||||
import { TextbookFilters } from "@/modules/textbooks/components/textbook-filters"
|
||||
import { getDemoStudentUser } from "@/modules/homework/data-access"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import Link from "next/link"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -27,12 +25,6 @@ export default async function StudentTextbooksPage({
|
||||
if (!student) {
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Textbooks</h2>
|
||||
<p className="text-muted-foreground">Browse your course textbooks.</p>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState title="No user found" description="Create a student user to see textbooks." icon={Inbox} />
|
||||
</div>
|
||||
)
|
||||
@@ -47,7 +39,7 @@ export default async function StudentTextbooksPage({
|
||||
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
{/* <div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Textbooks</h2>
|
||||
<p className="text-muted-foreground">Browse your course textbooks.</p>
|
||||
@@ -55,7 +47,7 @@ export default async function StudentTextbooksPage({
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/student/dashboard">Back</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
<TextbookFilters />
|
||||
|
||||
|
||||
@@ -5,26 +5,21 @@ import { ClassAssignmentsWidget } from "@/modules/classes/components/class-detai
|
||||
import { ClassTrendsWidget } from "@/modules/classes/components/class-detail/class-trends-widget"
|
||||
import { ClassHeader } from "@/modules/classes/components/class-detail/class-header"
|
||||
import { ClassOverviewStats } from "@/modules/classes/components/class-detail/class-overview-stats"
|
||||
import { ClassQuickActions } from "@/modules/classes/components/class-detail/class-quick-actions"
|
||||
import { ClassScheduleWidget } from "@/modules/classes/components/class-detail/class-schedule-widget"
|
||||
import { ClassStudentsWidget } from "@/modules/classes/components/class-detail/class-students-widget"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||
|
||||
export default async function ClassDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
const { id } = await params
|
||||
|
||||
// Parallel data fetching
|
||||
const [insights, students, schedule] = await Promise.all([
|
||||
getClassHomeworkInsights({ classId: id, limit: 20 }), // Limit increased to 20 for better list view
|
||||
getClassHomeworkInsights({ classId: id, limit: 20 }),
|
||||
getClassStudents({ classId: id }),
|
||||
getClassSchedule({ classId: id }),
|
||||
])
|
||||
@@ -32,7 +27,7 @@ export default async function ClassDetailPage({
|
||||
if (!insights) return notFound()
|
||||
|
||||
// Fetch subject scores
|
||||
const studentScores = await getClassStudentSubjectScoresV2(id)
|
||||
const studentScores = await getClassStudentSubjectScoresV2({ classId: id })
|
||||
|
||||
// Data mapping for widgets
|
||||
const assignmentSummaries = insights.assignments.map(a => ({
|
||||
@@ -91,10 +86,7 @@ export default async function ClassDetailPage({
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Main Content Area (Left 2/3) */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
<ClassTrendsWidget
|
||||
classId={insights.class.id}
|
||||
assignments={assignmentSummaries}
|
||||
/>
|
||||
<ClassTrendsWidget assignments={assignmentSummaries} />
|
||||
<ClassStudentsWidget
|
||||
classId={insights.class.id}
|
||||
students={studentSummaries}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import { getTeacherClasses } from "@/modules/classes/data-access"
|
||||
import { getClassSubjects, getTeacherClasses } from "@/modules/classes/data-access"
|
||||
import { MyClassesGrid } from "@/modules/classes/components/my-classes-grid"
|
||||
import { auth } from "@/auth"
|
||||
import { db } from "@/shared/db"
|
||||
import { grades } from "@/shared/db/schema"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -12,11 +8,11 @@ export default function MyClassesPage() {
|
||||
}
|
||||
|
||||
async function MyClassesPageImpl() {
|
||||
const classes = await getTeacherClasses()
|
||||
const [classes, subjectOptions] = await Promise.all([getTeacherClasses(), getClassSubjects()])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-4 p-8">
|
||||
<MyClassesGrid classes={classes} canCreateClass={false} />
|
||||
<MyClassesGrid classes={classes} subjectOptions={subjectOptions} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -82,7 +82,6 @@ function StudentsResultsFallback() {
|
||||
|
||||
export default async function StudentsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||
const classes = await getTeacherClasses()
|
||||
const params = await searchParams
|
||||
|
||||
// Logic to determine default class (first one available)
|
||||
const defaultClassId = classes.length > 0 ? classes[0].id : undefined
|
||||
|
||||
@@ -1,37 +1,9 @@
|
||||
import { ExamForm } from "@/modules/exams/components/exam-form"
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/shared/components/ui/breadcrumb"
|
||||
|
||||
export default function CreateExamPage() {
|
||||
return (
|
||||
<div className="flex flex-col space-y-8 p-8 max-w-[1200px] mx-auto">
|
||||
<div className="space-y-4">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="/teacher/exams/all">Exams</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Create</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Create Exam</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Set up a new exam draft and choose your assembly method.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex w-full justify-center items-center min-h-[calc(100vh-160px)] p-8 max-w-[1200px] mx-auto">
|
||||
<ExamForm />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -5,7 +5,6 @@ import { HomeworkAssignmentExamContentCard } from "@/modules/homework/components
|
||||
import { HomeworkAssignmentQuestionErrorOverviewCard } from "@/modules/homework/components/homework-assignment-question-error-overview-card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { ChevronLeft, Users, Calendar, BarChart3, CheckCircle2 } from "lucide-react"
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { formatDate } from "@/shared/lib/utils"
|
||||
import { getHomeworkAssignments } from "@/modules/homework/data-access"
|
||||
import { getTeacherClasses } from "@/modules/classes/data-access"
|
||||
import { PenTool, PlusCircle } from "lucide-react"
|
||||
import { getTeacherIdForMutations } from "@/modules/classes/data-access"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -27,9 +28,10 @@ const getParam = (params: SearchParams, key: string) => {
|
||||
export default async function AssignmentsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||
const sp = await searchParams
|
||||
const classId = getParam(sp, "classId") || undefined
|
||||
const creatorId = await getTeacherIdForMutations()
|
||||
|
||||
const [assignments, classes] = await Promise.all([
|
||||
getHomeworkAssignments({ classId: classId && classId !== "all" ? classId : undefined }),
|
||||
getHomeworkAssignments({ creatorId, classId: classId && classId !== "all" ? classId : undefined }),
|
||||
classId && classId !== "all" ? getTeacherClasses() : Promise.resolve([]),
|
||||
])
|
||||
const hasAssignments = assignments.length > 0
|
||||
|
||||
@@ -23,6 +23,7 @@ async function QuestionBankResults({ searchParams }: { searchParams: Promise<Sea
|
||||
const q = getParam(params, "q")
|
||||
const type = getParam(params, "type")
|
||||
const difficulty = getParam(params, "difficulty")
|
||||
const knowledgePointId = getParam(params, "kp")
|
||||
|
||||
const questionType: QuestionType | undefined =
|
||||
type === "single_choice" ||
|
||||
@@ -37,10 +38,16 @@ async function QuestionBankResults({ searchParams }: { searchParams: Promise<Sea
|
||||
q: q || undefined,
|
||||
type: questionType,
|
||||
difficulty: difficulty && difficulty !== "all" ? Number(difficulty) : undefined,
|
||||
knowledgePointId: knowledgePointId && knowledgePointId !== "all" ? knowledgePointId : undefined,
|
||||
pageSize: 200,
|
||||
})
|
||||
|
||||
const hasFilters = Boolean(q || (type && type !== "all") || (difficulty && difficulty !== "all"))
|
||||
const hasFilters = Boolean(
|
||||
q ||
|
||||
(type && type !== "all") ||
|
||||
(difficulty && difficulty !== "all") ||
|
||||
(knowledgePointId && knowledgePointId !== "all")
|
||||
)
|
||||
|
||||
if (questions.length === 0) {
|
||||
return (
|
||||
|
||||
28
src/app/api/ai/chat/route.ts
Normal file
28
src/app/api/ai/chat/route.ts
Normal 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) })
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { eq, inArray } from "drizzle-orm"
|
||||
|
||||
import { auth } from "@/auth"
|
||||
import { db } from "@/shared/db"
|
||||
import { classes, classSubjectTeachers, users } from "@/shared/db/schema"
|
||||
import { classes, classSubjectTeachers, roles, users, usersToRoles, subjects } from "@/shared/db/schema"
|
||||
import { DEFAULT_CLASS_SUBJECTS, type ClassSubject } from "@/modules/classes/types"
|
||||
import { enrollStudentByInvitationCode } from "@/modules/classes/data-access"
|
||||
|
||||
@@ -34,13 +34,14 @@ export async function POST(req: Request) {
|
||||
const role = (allowedRoles as readonly string[]).includes(roleRaw) ? roleRaw : null
|
||||
if (!role) return NextResponse.json({ success: false, message: "Invalid role" }, { status: 400 })
|
||||
|
||||
const current = await db.query.users.findFirst({
|
||||
where: eq(users.id, userId),
|
||||
columns: { role: true },
|
||||
})
|
||||
const currentRole = String(current?.role ?? "student")
|
||||
const currentRoleRows = await db
|
||||
.select({ name: roles.name })
|
||||
.from(usersToRoles)
|
||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||
.where(eq(usersToRoles.userId, userId))
|
||||
const currentMapped = currentRoleRows.map((r) => String(r.name ?? "").trim().toLowerCase())
|
||||
|
||||
if (role === "admin" && currentRole !== "admin") {
|
||||
if (role === "admin" && !currentMapped.includes("admin")) {
|
||||
return NextResponse.json({ success: false, message: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
|
||||
@@ -58,16 +59,33 @@ export async function POST(req: Request) {
|
||||
.map((s) => String(s).trim())
|
||||
.filter((s): s is ClassSubject => DEFAULT_CLASS_SUBJECTS.includes(s as ClassSubject))
|
||||
|
||||
const roleRow = await db.query.roles.findFirst({
|
||||
where: eq(roles.name, role),
|
||||
columns: { id: true },
|
||||
})
|
||||
if (!roleRow) {
|
||||
await db.insert(roles).values({ name: role })
|
||||
}
|
||||
const resolvedRole = roleRow
|
||||
?? (await db.query.roles.findFirst({ where: eq(roles.name, role), columns: { id: true } }))
|
||||
const roleId = resolvedRole?.id
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
role,
|
||||
name,
|
||||
phone: phone.length ? phone : null,
|
||||
address: address.length ? address : null,
|
||||
})
|
||||
.where(eq(users.id, userId))
|
||||
|
||||
if (roleId) {
|
||||
await db
|
||||
.insert(usersToRoles)
|
||||
.values({ userId, roleId })
|
||||
.onDuplicateKeyUpdate({ set: { roleId } })
|
||||
}
|
||||
|
||||
if (role === "student" && codes.length) {
|
||||
for (const code of codes) {
|
||||
await enrollStudentByInvitationCode(userId, code)
|
||||
@@ -87,14 +105,26 @@ export async function POST(req: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve subject ids when possible (by name exact match)
|
||||
const subjectsFound = await db
|
||||
.select({ id: subjects.id, name: subjects.name })
|
||||
.from(subjects)
|
||||
.where(inArray(subjects.name, teacherSubjects))
|
||||
const subjectIdByName = new Map<string, string>()
|
||||
for (const s of subjectsFound) {
|
||||
if (s.name && s.id) subjectIdByName.set(String(s.name), String(s.id))
|
||||
}
|
||||
|
||||
for (const code of codes) {
|
||||
const classId = byCode.get(code)
|
||||
if (!classId) continue
|
||||
for (const subject of teacherSubjects) {
|
||||
const subjectId = subjectIdByName.get(subject)
|
||||
if (!subjectId) continue
|
||||
await db
|
||||
.insert(classSubjectTeachers)
|
||||
.values({ classId, subject, teacherId: userId })
|
||||
.onDuplicateKeyUpdate({ set: { teacherId: userId, updatedAt: new Date() } })
|
||||
.values({ classId, subjectId, teacherId: userId })
|
||||
.onDuplicateKeyUpdate({ set: { teacherId: userId, subjectId, updatedAt: new Date() } })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,4 +136,3 @@ export async function POST(req: Request) {
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { eq } from "drizzle-orm"
|
||||
|
||||
import { auth } from "@/auth"
|
||||
import { db } from "@/shared/db"
|
||||
import { users } from "@/shared/db/schema"
|
||||
import { roles, users, usersToRoles } from "@/shared/db/schema"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -14,12 +14,32 @@ export async function GET() {
|
||||
return NextResponse.json({ required: false })
|
||||
}
|
||||
|
||||
const row = await db.query.users.findFirst({
|
||||
where: eq(users.id, userId),
|
||||
columns: { onboardedAt: true, role: true },
|
||||
})
|
||||
const [row, roleRows] = await Promise.all([
|
||||
db.query.users.findFirst({
|
||||
where: eq(users.id, userId),
|
||||
columns: { onboardedAt: true },
|
||||
}),
|
||||
db
|
||||
.select({ name: roles.name })
|
||||
.from(usersToRoles)
|
||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||
.where(eq(usersToRoles.userId, userId)),
|
||||
])
|
||||
|
||||
const normalizeRole = (value: string) => {
|
||||
const role = value.trim().toLowerCase()
|
||||
if (role === "grade_head" || role === "teaching_head") return "teacher"
|
||||
if (role === "admin" || role === "student" || role === "teacher" || role === "parent") return role
|
||||
return ""
|
||||
}
|
||||
|
||||
const mappedRoles = roleRows.map((r) => normalizeRole(r.name)).filter(Boolean)
|
||||
const resolvedRole = mappedRoles.find((r) => r === "admin")
|
||||
?? mappedRoles.find((r) => r === "teacher")
|
||||
?? mappedRoles.find((r) => r === "parent")
|
||||
?? mappedRoles.find((r) => r === "student")
|
||||
?? "student"
|
||||
|
||||
const required = !row?.onboardedAt
|
||||
return NextResponse.json({ required, role: row?.role ?? "student" })
|
||||
return NextResponse.json({ required, role: resolvedRole })
|
||||
}
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
@apply bg-background text-foreground h-screen overflow-hidden;
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export default function RootLayout({
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={`antialiased`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
|
||||
41
src/auth.ts
41
src/auth.ts
@@ -1,13 +1,23 @@
|
||||
import { compare, hash } from "bcryptjs"
|
||||
import { compare } from "bcryptjs"
|
||||
import NextAuth from "next-auth"
|
||||
import Credentials from "next-auth/providers/credentials"
|
||||
|
||||
const normalizeRole = (value: unknown) => {
|
||||
const role = String(value ?? "").trim().toLowerCase()
|
||||
if (role === "grade_head" || role === "teaching_head") return "teacher"
|
||||
if (role === "admin" || role === "student" || role === "teacher" || role === "parent") return role
|
||||
return "student"
|
||||
}
|
||||
|
||||
const resolvePrimaryRole = (roleNames: string[]) => {
|
||||
const mapped = roleNames.map((name) => normalizeRole(name)).filter(Boolean)
|
||||
if (mapped.includes("admin")) return "admin"
|
||||
if (mapped.includes("teacher")) return "teacher"
|
||||
if (mapped.includes("parent")) return "parent"
|
||||
if (mapped.includes("student")) return "student"
|
||||
return "student"
|
||||
}
|
||||
|
||||
const normalizeBcryptHash = (value: string) => {
|
||||
if (value.startsWith("$2")) return value
|
||||
if (value.startsWith("$")) return `$2b${value}`
|
||||
@@ -30,7 +40,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
const password = String(credentials?.password ?? "")
|
||||
if (!email || !password) return null
|
||||
|
||||
const [{ eq }, { db }, { users }] = await Promise.all([
|
||||
const [{ eq }, { db }, { roles, users, usersToRoles }] = await Promise.all([
|
||||
import("drizzle-orm"),
|
||||
import("@/shared/db"),
|
||||
import("@/shared/db/schema"),
|
||||
@@ -48,11 +58,19 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
const ok = await compare(password, normalizedPassword)
|
||||
if (!ok) return null
|
||||
|
||||
const roleRows = await db
|
||||
.select({ name: roles.name })
|
||||
.from(usersToRoles)
|
||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||
.where(eq(usersToRoles.userId, user.id))
|
||||
|
||||
const resolvedRole = resolvePrimaryRole(roleRows.map((r) => r.name))
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name ?? undefined,
|
||||
email: user.email,
|
||||
role: normalizeRole(user.role),
|
||||
role: resolvedRole,
|
||||
}
|
||||
},
|
||||
}),
|
||||
@@ -67,19 +85,26 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
|
||||
const userId = String(token.id ?? "").trim()
|
||||
if (userId) {
|
||||
const [{ eq }, { db }, { users }] = await Promise.all([
|
||||
const [{ eq }, { db }, { roles, users, usersToRoles }] = await Promise.all([
|
||||
import("drizzle-orm"),
|
||||
import("@/shared/db"),
|
||||
import("@/shared/db/schema"),
|
||||
])
|
||||
|
||||
const fresh = await db.query.users.findFirst({
|
||||
const [fresh, roleRows] = await Promise.all([
|
||||
db.query.users.findFirst({
|
||||
where: eq(users.id, userId),
|
||||
columns: { role: true, name: true },
|
||||
})
|
||||
columns: { name: true },
|
||||
}),
|
||||
db
|
||||
.select({ name: roles.name })
|
||||
.from(usersToRoles)
|
||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||
.where(eq(usersToRoles.userId, userId)),
|
||||
])
|
||||
|
||||
if (fresh) {
|
||||
token.role = normalizeRole(fresh.role ?? token.role)
|
||||
token.role = resolvePrimaryRole(roleRows.map((r) => r.name))
|
||||
token.name = fresh.name ?? token.name
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ export const env = createEnv({
|
||||
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||
NEXTAUTH_SECRET: z.string().min(1).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: {
|
||||
NEXT_PUBLIC_APP_URL: z.string().url().optional(),
|
||||
@@ -17,6 +20,9 @@ export const env = createEnv({
|
||||
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
|
||||
NEXTAUTH_URL: process.env.NEXTAUTH_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,
|
||||
emptyStringAsUndefined: true,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { and, eq, sql, or, inArray } from "drizzle-orm"
|
||||
import { and, eq, sql, or } from "drizzle-orm"
|
||||
import { auth } from "@/auth"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
deleteTeacherClass,
|
||||
enrollStudentByEmail,
|
||||
enrollStudentByInvitationCode,
|
||||
enrollTeacherByInvitationCode,
|
||||
ensureClassInvitationCode,
|
||||
regenerateClassInvitationCode,
|
||||
setClassSubjectTeachers,
|
||||
@@ -371,8 +372,18 @@ export async function joinClassByInvitationCodeAction(
|
||||
return { success: false, message: "Unauthorized" }
|
||||
}
|
||||
|
||||
const subjectValue = formData.get("subject")
|
||||
const subject = role === "teacher" && typeof subjectValue === "string" ? subjectValue.trim() : null
|
||||
|
||||
if (role === "teacher" && (!subject || subject.length === 0)) {
|
||||
return { success: false, message: "Subject is required" }
|
||||
}
|
||||
|
||||
try {
|
||||
const classId = await enrollStudentByInvitationCode(session.user.id, code)
|
||||
const classId =
|
||||
role === "teacher"
|
||||
? await enrollTeacherByInvitationCode(session.user.id, code, subject)
|
||||
: await enrollStudentByInvitationCode(session.user.id, code)
|
||||
if (role === "student") {
|
||||
revalidatePath("/student/learning/courses")
|
||||
revalidatePath("/student/schedule")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
|
||||
import Link from "next/link"
|
||||
import { Calendar, FilePlus, Mail, MessageSquare, Settings } from "lucide-react"
|
||||
import { Calendar, FilePlus, MessageSquare, Settings } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
|
||||
@@ -42,7 +42,7 @@ export function ClassScheduleGrid({ schedule, compact = false }: { schedule: Cla
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-5 gap-1 text-center h-full grid-rows-[auto_1fr]">
|
||||
{WEEKDAYS.slice(0, 5).map((day, i) => (
|
||||
{WEEKDAYS.slice(0, 5).map((day) => (
|
||||
<div key={day} className="text-[10px] font-medium text-muted-foreground uppercase py-0.5 border-b bg-muted/20 h-fit">
|
||||
{day}
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avat
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
|
||||
interface StudentSummary {
|
||||
id: string
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useMemo } from "react"
|
||||
import { useState } from "react"
|
||||
import { Area, AreaChart, CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/shared/components/ui/chart"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -31,7 +30,6 @@ interface AssignmentSummary {
|
||||
}
|
||||
|
||||
interface ClassTrendsWidgetProps {
|
||||
classId: string
|
||||
assignments: AssignmentSummary[]
|
||||
compact?: boolean
|
||||
className?: string
|
||||
@@ -77,7 +75,7 @@ export function ClassSubmissionTrendChart({
|
||||
data,
|
||||
className
|
||||
}: {
|
||||
data: any[]
|
||||
data: Record<string, string | number>[]
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
@@ -121,7 +119,7 @@ export function ClassSubmissionTrendChart({
|
||||
)
|
||||
}
|
||||
|
||||
export function ClassTrendsWidget({ classId, assignments, compact, className }: ClassTrendsWidgetProps) {
|
||||
export function ClassTrendsWidget({ assignments, compact, className }: ClassTrendsWidgetProps) {
|
||||
const [chartTab, setChartTab] = useState<"submission" | "score">("submission")
|
||||
const [selectedSubject, setSelectedSubject] = useState<string>("all")
|
||||
|
||||
@@ -142,11 +140,11 @@ export function ClassTrendsWidget({ classId, assignments, compact, className }:
|
||||
const lastAssignment = chartData[chartData.length - 1]
|
||||
|
||||
let metricValue = "0%"
|
||||
let metricLabel = "Latest"
|
||||
const metricLabel = "Latest"
|
||||
|
||||
if (lastAssignment) {
|
||||
if (chartTab === "submission") {
|
||||
metricValue = lastAssignment.target > 0
|
||||
metricValue = lastAssignment.target > 0
|
||||
? `${Math.round((lastAssignment.submitted / lastAssignment.target) * 100)}%`
|
||||
: "0%"
|
||||
} else {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { useMemo, useState } from "react"
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import {
|
||||
Plus,
|
||||
@@ -10,11 +10,9 @@ import {
|
||||
Users,
|
||||
MapPin,
|
||||
GraduationCap,
|
||||
Search,
|
||||
} from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
@@ -30,30 +28,35 @@ import {
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/components/ui/tooltip"
|
||||
import type { TeacherClass, ClassScheduleItem } from "../types"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
||||
import type { TeacherClass } from "../types"
|
||||
import {
|
||||
ensureClassInvitationCodeAction,
|
||||
regenerateClassInvitationCodeAction,
|
||||
joinClassByInvitationCodeAction,
|
||||
} from "../actions"
|
||||
|
||||
const GRADIENTS = [
|
||||
"bg-card border-border",
|
||||
"bg-card border-border",
|
||||
"bg-card border-border",
|
||||
"bg-card border-border",
|
||||
"bg-card border-border",
|
||||
]
|
||||
|
||||
function getClassGradient(id: string) {
|
||||
return "bg-card border-border shadow-sm hover:shadow-md"
|
||||
const getSeededValue = (seed: string, index: number) => {
|
||||
let h = 2166136261
|
||||
const str = `${seed}:${index}`
|
||||
for (let i = 0; i < str.length; i += 1) {
|
||||
h ^= str.charCodeAt(i)
|
||||
h = Math.imul(h, 16777619)
|
||||
}
|
||||
return (h >>> 0) / 4294967296
|
||||
}
|
||||
|
||||
export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherClass[]; canCreateClass: boolean }) {
|
||||
export function MyClassesGrid({
|
||||
classes,
|
||||
subjectOptions,
|
||||
}: {
|
||||
classes: TeacherClass[]
|
||||
subjectOptions: string[]
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [isWorking, setIsWorking] = useState(false)
|
||||
const [joinOpen, setJoinOpen] = useState(false)
|
||||
const [joinSubject, setJoinSubject] = useState("")
|
||||
|
||||
const handleJoin = async (formData: FormData) => {
|
||||
setIsWorking(true)
|
||||
@@ -62,6 +65,7 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
|
||||
if (res.success) {
|
||||
toast.success(res.message || "Joined class successfully")
|
||||
setJoinOpen(false)
|
||||
setJoinSubject("")
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to join class")
|
||||
@@ -83,6 +87,7 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
|
||||
onOpenChange={(open) => {
|
||||
if (isWorking) return
|
||||
setJoinOpen(open)
|
||||
if (!open) setJoinSubject("")
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
@@ -137,15 +142,33 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Ask your administrator for the code if you don't have one.
|
||||
Ask your administrator for the code if you don't have one.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="join-subject" className="text-sm font-medium">
|
||||
教学科目
|
||||
</Label>
|
||||
<Select value={joinSubject} onValueChange={(v) => setJoinSubject(v)}>
|
||||
<SelectTrigger id="join-subject" className="h-12">
|
||||
<SelectValue placeholder={subjectOptions.length === 0 ? "暂无可选科目" : "选择教学科目"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{subjectOptions.map((subject) => (
|
||||
<SelectItem key={subject} value={subject}>
|
||||
{subject}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="subject" value={joinSubject} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="p-6 pt-2 bg-muted/5 border-t border-border/50">
|
||||
<Button type="button" variant="ghost" onClick={() => setJoinOpen(false)} disabled={isWorking}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isWorking} className="min-w-[100px]">
|
||||
<Button type="submit" disabled={isWorking || !joinSubject || subjectOptions.length === 0} className="min-w-[100px]">
|
||||
{isWorking ? "Joining..." : "Join Class"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -167,7 +190,7 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
|
||||
/>
|
||||
) : (
|
||||
classes.map((c) => (
|
||||
<ClassTicket key={c.id} c={c} onWorkingChange={setIsWorking} isWorking={isWorking} />
|
||||
<ClassTicket key={c.id} c={c} onWorkingChange={setIsWorking} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
@@ -182,11 +205,9 @@ import { ClassTrendsWidget } from "./class-detail/class-trends-widget"
|
||||
|
||||
function ClassTicket({
|
||||
c,
|
||||
isWorking,
|
||||
onWorkingChange,
|
||||
}: {
|
||||
c: TeacherClass
|
||||
isWorking: boolean
|
||||
onWorkingChange: (v: boolean) => void
|
||||
}) {
|
||||
const router = useRouter()
|
||||
@@ -256,7 +277,11 @@ function ClassTicket({
|
||||
{/* Decorative Barcode Strip */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1.5 bg-primary/10 flex flex-col justify-between py-2 pointer-events-none">
|
||||
{Array.from({ length: 20 }).map((_, i) => (
|
||||
<div key={i} className="w-full h-px bg-primary/20" style={{ marginBottom: Math.random() * 8 + 2 + 'px' }}></div>
|
||||
<div
|
||||
key={i}
|
||||
className="w-full h-px bg-primary/20"
|
||||
style={{ marginBottom: `${2 + getSeededValue(c.id, i) * 8}px` }}
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -320,7 +345,7 @@ function ClassTicket({
|
||||
<div className="absolute right-10 top-1/2 -translate-y-1/2 opacity-[0.03]">
|
||||
<div className="w-8 h-8 bg-current grid grid-cols-4 grid-rows-4 gap-px">
|
||||
{Array.from({ length: 16 }).map((_, i) => (
|
||||
<div key={i} className={cn("bg-transparent", Math.random() > 0.5 && "bg-black")}></div>
|
||||
<div key={i} className={cn("bg-transparent", getSeededValue(`${c.id}-qr`, i) > 0.5 && "bg-black")}></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -373,12 +398,7 @@ function ClassTicket({
|
||||
|
||||
{/* Real Chart */}
|
||||
<div className="h-[140px] w-full">
|
||||
<ClassTrendsWidget
|
||||
classId={c.id}
|
||||
assignments={recentAssignments}
|
||||
compact
|
||||
className="h-full w-full"
|
||||
/>
|
||||
<ClassTrendsWidget assignments={recentAssignments} compact className="h-full w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Clock, MapPin, MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
|
||||
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
@@ -518,4 +516,4 @@ export function ScheduleView({
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
import { Search, UserPlus, X, ChevronDown, Check } from "lucide-react"
|
||||
import { Search, UserPlus, ChevronDown, Check } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import type { TeacherClass } from "../types"
|
||||
import { enrollStudentByEmailAction } from "../actions"
|
||||
|
||||
@@ -78,8 +77,6 @@ export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherC
|
||||
|
||||
const statusLabel = status === "all" ? "All Status" : (status === "active" ? "Active" : "Inactive")
|
||||
|
||||
const hasFilters = search || classId !== "all" || status !== "all"
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -5,11 +5,10 @@ import { useRouter } from "next/navigation"
|
||||
import { MoreHorizontal, UserCheck, UserX } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
||||
import { Card, CardContent, CardFooter, CardHeader } from "@/shared/components/ui/card"
|
||||
import { cn, formatDate } from "@/shared/lib/utils"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
||||
@@ -2,9 +2,10 @@ import "server-only";
|
||||
|
||||
import { randomInt } from "node:crypto"
|
||||
import { cache } from "react"
|
||||
import { and, asc, desc, eq, inArray, or, sql, type SQL } from "drizzle-orm"
|
||||
import { and, asc, desc, eq, inArray, isNull, or, sql, type SQL } from "drizzle-orm"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
|
||||
import { auth } from "@/auth"
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
classes,
|
||||
@@ -19,7 +20,9 @@ import {
|
||||
schools,
|
||||
subjects,
|
||||
exams,
|
||||
roles,
|
||||
users,
|
||||
usersToRoles,
|
||||
} from "@/shared/db/schema"
|
||||
import { DEFAULT_CLASS_SUBJECTS } from "./types"
|
||||
import type {
|
||||
@@ -43,16 +46,22 @@ import type {
|
||||
UpdateTeacherClassInput,
|
||||
} from "./types"
|
||||
|
||||
const getDefaultTeacherId = cache(async () => {
|
||||
const [row] = await db
|
||||
const getSessionTeacherId = async (): Promise<string | null> => {
|
||||
const session = await auth()
|
||||
const userId = String(session?.user?.id ?? "").trim()
|
||||
if (!userId) return null
|
||||
|
||||
const [teacher] = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.role, "teacher"))
|
||||
.orderBy(asc(users.createdAt))
|
||||
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||
.where(and(eq(users.id, userId), eq(roles.name, "teacher")))
|
||||
.limit(1)
|
||||
return teacher?.id ?? null
|
||||
}
|
||||
|
||||
return row?.id
|
||||
})
|
||||
// Strict subjectId-based mapping: no aliasing
|
||||
|
||||
const isDuplicateInvitationCodeError = (err: unknown) => {
|
||||
if (!err) return false
|
||||
@@ -80,11 +89,20 @@ const generateUniqueInvitationCode = async (): Promise<string> => {
|
||||
}
|
||||
|
||||
export const getTeacherIdForMutations = async (): Promise<string> => {
|
||||
const teacherId = await getDefaultTeacherId()
|
||||
if (!teacherId) throw new Error("No teacher available")
|
||||
const teacherId = await getSessionTeacherId()
|
||||
if (!teacherId) throw new Error("Teacher not found")
|
||||
return teacherId
|
||||
}
|
||||
|
||||
export const getClassSubjects = async (): Promise<string[]> => {
|
||||
const rows = await db.query.subjects.findMany({
|
||||
orderBy: (subjects, { asc }) => [asc(subjects.order), asc(subjects.name)],
|
||||
})
|
||||
|
||||
const names = rows.map((r) => r.name.trim()).filter((n) => n.length > 0)
|
||||
return Array.from(new Set(names))
|
||||
}
|
||||
|
||||
const normalizeSortText = (v: string | null | undefined) => (typeof v === "string" ? v.trim().toLowerCase() : "")
|
||||
|
||||
const parseFirstInt = (v: string) => {
|
||||
@@ -118,23 +136,30 @@ const compareClassLike = (
|
||||
return normalizeSortText(a.room).localeCompare(normalizeSortText(b.room))
|
||||
}
|
||||
|
||||
const getAccessibleClassIdsForTeacher = async (teacherId: string): Promise<string[]> => {
|
||||
const ownedIds = await db.select({ id: classes.id }).from(classes).where(eq(classes.teacherId, teacherId))
|
||||
const assignedIds = await db
|
||||
.select({ id: classSubjectTeachers.classId })
|
||||
.from(classSubjectTeachers)
|
||||
.where(eq(classSubjectTeachers.teacherId, teacherId))
|
||||
return Array.from(new Set([...ownedIds.map((x) => x.id), ...assignedIds.map((x) => x.id)]))
|
||||
}
|
||||
|
||||
const getTeacherSubjectIdsForClass = async (teacherId: string, classId: string): Promise<string[]> => {
|
||||
const rows = await db
|
||||
.select({ subjectId: classSubjectTeachers.subjectId })
|
||||
.from(classSubjectTeachers)
|
||||
.where(and(eq(classSubjectTeachers.teacherId, teacherId), eq(classSubjectTeachers.classId, classId)))
|
||||
return Array.from(new Set(rows.map((r) => String(r.subjectId))))
|
||||
}
|
||||
|
||||
export const getTeacherClasses = cache(async (params?: { teacherId?: string }): Promise<TeacherClass[]> => {
|
||||
const teacherId = params?.teacherId ?? (await getDefaultTeacherId())
|
||||
const teacherId = params?.teacherId ?? (await getSessionTeacherId())
|
||||
if (!teacherId) return []
|
||||
|
||||
const rows = await (async () => {
|
||||
try {
|
||||
const ownedIds = await db
|
||||
.select({ id: classes.id })
|
||||
.from(classes)
|
||||
.where(eq(classes.teacherId, teacherId))
|
||||
|
||||
const enrolledIds = await db
|
||||
.select({ id: classEnrollments.classId })
|
||||
.from(classEnrollments)
|
||||
.where(and(eq(classEnrollments.studentId, teacherId), eq(classEnrollments.status, "active")))
|
||||
|
||||
const allIds = Array.from(new Set([...ownedIds.map((x) => x.id), ...enrolledIds.map((x) => x.id)]))
|
||||
const allIds = await getAccessibleClassIdsForTeacher(teacherId)
|
||||
|
||||
if (allIds.length === 0) return []
|
||||
|
||||
@@ -206,7 +231,9 @@ export const getTeacherOptions = cache(async (): Promise<TeacherOption[]> => {
|
||||
const rows = await db
|
||||
.select({ id: users.id, name: users.name, email: users.email })
|
||||
.from(users)
|
||||
.where(eq(users.role, "teacher"))
|
||||
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||
.where(eq(roles.name, "teacher"))
|
||||
.orderBy(asc(users.createdAt))
|
||||
|
||||
return rows.map((r) => ({
|
||||
@@ -216,6 +243,23 @@ export const getTeacherOptions = cache(async (): Promise<TeacherOption[]> => {
|
||||
}))
|
||||
})
|
||||
|
||||
export const getTeacherTeachingSubjects = cache(async (): Promise<ClassSubject[]> => {
|
||||
const teacherId = await getSessionTeacherId()
|
||||
if (!teacherId) return []
|
||||
|
||||
const rows = await db
|
||||
.select({ subject: subjects.name })
|
||||
.from(classSubjectTeachers)
|
||||
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
|
||||
.where(eq(classSubjectTeachers.teacherId, teacherId))
|
||||
.groupBy(subjects.name)
|
||||
.orderBy(asc(subjects.name))
|
||||
|
||||
return rows
|
||||
.map((r) => r.subject as ClassSubject)
|
||||
.filter((s) => DEFAULT_CLASS_SUBJECTS.includes(s))
|
||||
})
|
||||
|
||||
export const getAdminClasses = cache(async (): Promise<AdminClassListItem[]> => {
|
||||
const [rows, subjectRows] = await Promise.all([
|
||||
(async () => {
|
||||
@@ -304,14 +348,15 @@ export const getAdminClasses = cache(async (): Promise<AdminClassListItem[]> =>
|
||||
db
|
||||
.select({
|
||||
classId: classSubjectTeachers.classId,
|
||||
subject: classSubjectTeachers.subject,
|
||||
subject: subjects.name,
|
||||
teacherId: users.id,
|
||||
teacherName: users.name,
|
||||
teacherEmail: users.email,
|
||||
})
|
||||
.from(classSubjectTeachers)
|
||||
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
|
||||
.leftJoin(users, eq(users.id, classSubjectTeachers.teacherId))
|
||||
.orderBy(asc(classSubjectTeachers.classId), asc(classSubjectTeachers.subject)),
|
||||
.orderBy(asc(classSubjectTeachers.classId), asc(subjects.name)),
|
||||
])
|
||||
|
||||
const subjectsByClassId = new Map<string, Map<ClassSubject, TeacherOption | null>>()
|
||||
@@ -425,16 +470,17 @@ export const getGradeManagedClasses = cache(async (userId: string): Promise<Admi
|
||||
db
|
||||
.select({
|
||||
classId: classSubjectTeachers.classId,
|
||||
subject: classSubjectTeachers.subject,
|
||||
subject: subjects.name,
|
||||
teacherId: users.id,
|
||||
teacherName: users.name,
|
||||
teacherEmail: users.email,
|
||||
})
|
||||
.from(classSubjectTeachers)
|
||||
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
|
||||
.innerJoin(classes, eq(classes.id, classSubjectTeachers.classId))
|
||||
.leftJoin(users, eq(users.id, classSubjectTeachers.teacherId))
|
||||
.where(inArray(classes.gradeId, gradeIds))
|
||||
.orderBy(asc(classSubjectTeachers.classId), asc(classSubjectTeachers.subject)),
|
||||
.orderBy(asc(classSubjectTeachers.classId), asc(subjects.name)),
|
||||
])
|
||||
|
||||
const subjectsByClassId = new Map<string, Map<ClassSubject, TeacherOption | null>>()
|
||||
@@ -589,14 +635,17 @@ export const getStudentSchedule = cache(async (studentId: string): Promise<Stude
|
||||
|
||||
export const getClassStudents = cache(
|
||||
async (params?: { classId?: string; q?: string; status?: string; teacherId?: string }): Promise<ClassStudent[]> => {
|
||||
const teacherId = params?.teacherId ?? (await getDefaultTeacherId())
|
||||
const teacherId = params?.teacherId ?? (await getSessionTeacherId())
|
||||
if (!teacherId) return []
|
||||
|
||||
const classId = params?.classId?.trim()
|
||||
const q = params?.q?.trim().toLowerCase()
|
||||
const status = params?.status?.trim().toLowerCase()
|
||||
|
||||
const conditions: SQL[] = [eq(classes.teacherId, teacherId)]
|
||||
const accessibleIds = await getAccessibleClassIdsForTeacher(teacherId)
|
||||
if (accessibleIds.length === 0) return []
|
||||
|
||||
const conditions: SQL[] = [inArray(classes.id, accessibleIds)]
|
||||
|
||||
if (classId) {
|
||||
conditions.push(eq(classes.id, classId))
|
||||
@@ -647,12 +696,15 @@ export const getClassStudents = cache(
|
||||
|
||||
export const getClassSchedule = cache(
|
||||
async (params?: { classId?: string; teacherId?: string }): Promise<ClassScheduleItem[]> => {
|
||||
const teacherId = params?.teacherId ?? (await getDefaultTeacherId())
|
||||
const teacherId = params?.teacherId ?? (await getSessionTeacherId())
|
||||
if (!teacherId) return []
|
||||
|
||||
const classId = params?.classId?.trim()
|
||||
|
||||
const conditions: SQL[] = [eq(classes.teacherId, teacherId)]
|
||||
const accessibleIds = await getAccessibleClassIdsForTeacher(teacherId)
|
||||
if (accessibleIds.length === 0) return []
|
||||
|
||||
const conditions: SQL[] = [inArray(classes.id, accessibleIds)]
|
||||
if (classId) conditions.push(eq(classSchedule.classId, classId))
|
||||
|
||||
const rows = await db
|
||||
@@ -707,11 +759,13 @@ const toScoreStats = (scores: number[]): ScoreStats => {
|
||||
|
||||
export const getClassHomeworkInsights = cache(
|
||||
async (params: { classId: string; teacherId?: string; limit?: number }): Promise<ClassHomeworkInsights | null> => {
|
||||
const teacherId = params.teacherId ?? (await getDefaultTeacherId())
|
||||
const teacherId = params.teacherId ?? (await getSessionTeacherId())
|
||||
if (!teacherId) return null
|
||||
|
||||
const classId = params.classId.trim()
|
||||
if (!classId) return null
|
||||
const accessibleIds = await getAccessibleClassIdsForTeacher(teacherId)
|
||||
if (accessibleIds.length === 0 || !accessibleIds.includes(classId)) return null
|
||||
|
||||
const [classRow] = await db
|
||||
.select({
|
||||
@@ -721,12 +775,15 @@ export const getClassHomeworkInsights = cache(
|
||||
homeroom: classes.homeroom,
|
||||
room: classes.room,
|
||||
invitationCode: classes.invitationCode,
|
||||
teacherId: classes.teacherId,
|
||||
})
|
||||
.from(classes)
|
||||
.where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId)))
|
||||
.where(and(eq(classes.id, classId), inArray(classes.id, accessibleIds)))
|
||||
.limit(1)
|
||||
|
||||
if (!classRow) return null
|
||||
const isHomeroomTeacher = classRow.teacherId === teacherId
|
||||
const subjectIdFilter = isHomeroomTeacher ? [] : await getTeacherSubjectIdsForClass(teacherId, classId)
|
||||
|
||||
const enrollments = await db
|
||||
.select({
|
||||
@@ -735,12 +792,29 @@ export const getClassHomeworkInsights = cache(
|
||||
})
|
||||
.from(classEnrollments)
|
||||
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
|
||||
.where(and(eq(classes.teacherId, teacherId), eq(classEnrollments.classId, classId)))
|
||||
.where(and(inArray(classes.id, accessibleIds), eq(classEnrollments.classId, classId)))
|
||||
|
||||
const activeStudentIds = enrollments.filter((e) => e.status === "active").map((e) => e.studentId)
|
||||
const inactiveStudentIds = enrollments.filter((e) => e.status !== "active").map((e) => e.studentId)
|
||||
const studentIds = enrollments.map((e) => e.studentId)
|
||||
|
||||
if (!isHomeroomTeacher && subjectIdFilter.length === 0) {
|
||||
return {
|
||||
class: {
|
||||
id: classRow.id,
|
||||
name: classRow.name,
|
||||
grade: classRow.grade,
|
||||
homeroom: classRow.homeroom,
|
||||
room: classRow.room,
|
||||
invitationCode: classRow.invitationCode ?? null,
|
||||
},
|
||||
studentCounts: { total: studentIds.length, active: activeStudentIds.length, inactive: inactiveStudentIds.length },
|
||||
assignments: [],
|
||||
latest: null,
|
||||
overallScores: { count: 0, avg: null, median: null, min: null, max: null },
|
||||
}
|
||||
}
|
||||
|
||||
if (studentIds.length === 0) {
|
||||
return {
|
||||
class: {
|
||||
@@ -782,6 +856,10 @@ export const getClassHomeworkInsights = cache(
|
||||
}
|
||||
|
||||
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 50
|
||||
const assignmentConditions: SQL[] = [inArray(homeworkAssignments.id, assignmentIds)]
|
||||
if (subjectIdFilter.length > 0) {
|
||||
assignmentConditions.push(inArray(exams.subjectId, subjectIdFilter))
|
||||
}
|
||||
const assignments = await db
|
||||
.select({
|
||||
id: homeworkAssignments.id,
|
||||
@@ -795,7 +873,7 @@ export const getClassHomeworkInsights = cache(
|
||||
.from(homeworkAssignments)
|
||||
.innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id))
|
||||
.leftJoin(subjects, eq(exams.subjectId, subjects.id))
|
||||
.where(and(inArray(homeworkAssignments.id, assignmentIds), eq(homeworkAssignments.creatorId, teacherId)))
|
||||
.where(and(...assignmentConditions))
|
||||
.orderBy(desc(homeworkAssignments.createdAt))
|
||||
.limit(limit)
|
||||
|
||||
@@ -1239,6 +1317,12 @@ export async function createTeacherClass(data: CreateTeacherClassInput): Promise
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
const invitationCode = await generateUniqueInvitationCode()
|
||||
try {
|
||||
const subjectRows = await db
|
||||
.select({ id: subjects.id, name: subjects.name })
|
||||
.from(subjects)
|
||||
.where(inArray(subjects.name, DEFAULT_CLASS_SUBJECTS))
|
||||
const idByName = new Map(subjectRows.map((r) => [r.name as ClassSubject, r.id]))
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(classes).values({
|
||||
id,
|
||||
@@ -1253,13 +1337,14 @@ export async function createTeacherClass(data: CreateTeacherClassInput): Promise
|
||||
teacherId,
|
||||
})
|
||||
|
||||
await tx.insert(classSubjectTeachers).values(
|
||||
DEFAULT_CLASS_SUBJECTS.map((subject) => ({
|
||||
const values = DEFAULT_CLASS_SUBJECTS
|
||||
.filter((name) => idByName.has(name))
|
||||
.map((name) => ({
|
||||
classId: id,
|
||||
subject,
|
||||
subjectId: idByName.get(name)!,
|
||||
teacherId: null,
|
||||
}))
|
||||
)
|
||||
await tx.insert(classSubjectTeachers).values(values)
|
||||
})
|
||||
return id
|
||||
} catch (err) {
|
||||
@@ -1291,13 +1376,21 @@ export async function createAdminClass(data: CreateTeacherClassInput & { teacher
|
||||
const [teacher] = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(and(eq(users.id, teacherId), eq(users.role, "teacher")))
|
||||
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||
.where(and(eq(users.id, teacherId), eq(roles.name, "teacher")))
|
||||
.limit(1)
|
||||
if (!teacher) throw new Error("Teacher not found")
|
||||
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
const invitationCode = await generateUniqueInvitationCode()
|
||||
try {
|
||||
const subjectRows = await db
|
||||
.select({ id: subjects.id, name: subjects.name })
|
||||
.from(subjects)
|
||||
.where(inArray(subjects.name, DEFAULT_CLASS_SUBJECTS))
|
||||
const idByName = new Map(subjectRows.map((r) => [r.name as ClassSubject, r.id]))
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(classes).values({
|
||||
id,
|
||||
@@ -1312,13 +1405,14 @@ export async function createAdminClass(data: CreateTeacherClassInput & { teacher
|
||||
teacherId,
|
||||
})
|
||||
|
||||
await tx.insert(classSubjectTeachers).values(
|
||||
DEFAULT_CLASS_SUBJECTS.map((subject) => ({
|
||||
const values = DEFAULT_CLASS_SUBJECTS
|
||||
.filter((name) => idByName.has(name))
|
||||
.map((name) => ({
|
||||
classId: id,
|
||||
subject,
|
||||
subjectId: idByName.get(name)!,
|
||||
teacherId: null,
|
||||
}))
|
||||
)
|
||||
await tx.insert(classSubjectTeachers).values(values)
|
||||
})
|
||||
return id
|
||||
} catch (err) {
|
||||
@@ -1410,6 +1504,123 @@ export async function enrollStudentByInvitationCode(studentId: string, invitatio
|
||||
return cls.id
|
||||
}
|
||||
|
||||
export async function enrollTeacherByInvitationCode(
|
||||
teacherId: string,
|
||||
invitationCode: string,
|
||||
subject: string | null
|
||||
): Promise<string> {
|
||||
const tid = teacherId.trim()
|
||||
const code = invitationCode.trim()
|
||||
if (!tid) throw new Error("Missing teacher id")
|
||||
if (!/^\d{6}$/.test(code)) throw new Error("Invalid invitation code")
|
||||
|
||||
const [teacher] = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||
.where(and(eq(users.id, tid), eq(roles.name, "teacher")))
|
||||
.limit(1)
|
||||
|
||||
if (!teacher) throw new Error("Teacher not found")
|
||||
|
||||
const [cls] = await db
|
||||
.select({ id: classes.id, teacherId: classes.teacherId })
|
||||
.from(classes)
|
||||
.where(eq(classes.invitationCode, code))
|
||||
.limit(1)
|
||||
|
||||
if (!cls) throw new Error("Invalid invitation code")
|
||||
if (cls.teacherId === tid) return cls.id
|
||||
|
||||
const subjectValue = typeof subject === "string" ? subject.trim() : ""
|
||||
const [existing] = await db
|
||||
.select({ id: classSubjectTeachers.classId })
|
||||
.from(classSubjectTeachers)
|
||||
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.teacherId, tid)))
|
||||
.limit(1)
|
||||
|
||||
if (existing && !subjectValue) return cls.id
|
||||
if (subjectValue) {
|
||||
const [subRow] = await db.select({ id: subjects.id }).from(subjects).where(eq(subjects.name, subjectValue)).limit(1)
|
||||
if (!subRow) throw new Error("Subject not found")
|
||||
const sid = subRow.id
|
||||
|
||||
const [mapping] = await db
|
||||
.select({ teacherId: classSubjectTeachers.teacherId })
|
||||
.from(classSubjectTeachers)
|
||||
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid)))
|
||||
.limit(1)
|
||||
|
||||
if (mapping?.teacherId && mapping.teacherId !== tid) throw new Error("Subject already assigned")
|
||||
if (mapping?.teacherId === tid) return cls.id
|
||||
if (!mapping) {
|
||||
await db
|
||||
.insert(classSubjectTeachers)
|
||||
.values({ classId: cls.id, subjectId: sid, teacherId: null })
|
||||
.onDuplicateKeyUpdate({ set: { teacherId: sql`${classSubjectTeachers.teacherId}` } })
|
||||
}
|
||||
|
||||
const [existingSubject] = await db
|
||||
.select({ id: classSubjectTeachers.classId })
|
||||
.from(classSubjectTeachers)
|
||||
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid), eq(classSubjectTeachers.teacherId, tid)))
|
||||
.limit(1)
|
||||
|
||||
if (existingSubject) return cls.id
|
||||
|
||||
await db
|
||||
.update(classSubjectTeachers)
|
||||
.set({ teacherId: tid })
|
||||
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid), isNull(classSubjectTeachers.teacherId)))
|
||||
|
||||
const [assigned] = await db
|
||||
.select({ id: classSubjectTeachers.classId })
|
||||
.from(classSubjectTeachers)
|
||||
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid), eq(classSubjectTeachers.teacherId, tid)))
|
||||
.limit(1)
|
||||
|
||||
if (!assigned) throw new Error("Subject already assigned")
|
||||
} else {
|
||||
const subjectRows = await db
|
||||
.select({ id: classSubjectTeachers.subjectId, name: subjects.name })
|
||||
.from(classSubjectTeachers)
|
||||
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
|
||||
.where(and(eq(classSubjectTeachers.classId, cls.id), isNull(classSubjectTeachers.teacherId)))
|
||||
|
||||
const preferred = DEFAULT_CLASS_SUBJECTS.find((s) => subjectRows.some((r) => r.name === s))
|
||||
if (!preferred) throw new Error("Class already has assigned teachers")
|
||||
const sid = subjectRows.find((r) => r.name === preferred)!.id
|
||||
|
||||
await db
|
||||
.update(classSubjectTeachers)
|
||||
.set({ teacherId: tid })
|
||||
.where(
|
||||
and(
|
||||
eq(classSubjectTeachers.classId, cls.id),
|
||||
eq(classSubjectTeachers.subjectId, sid),
|
||||
isNull(classSubjectTeachers.teacherId)
|
||||
)
|
||||
)
|
||||
|
||||
const [assigned] = await db
|
||||
.select({ id: classSubjectTeachers.classId })
|
||||
.from(classSubjectTeachers)
|
||||
.where(
|
||||
and(
|
||||
eq(classSubjectTeachers.classId, cls.id),
|
||||
eq(classSubjectTeachers.subjectId, sid),
|
||||
eq(classSubjectTeachers.teacherId, tid)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!assigned) throw new Error("Class already has assigned teachers")
|
||||
}
|
||||
|
||||
return cls.id
|
||||
}
|
||||
|
||||
export async function updateTeacherClass(classId: string, data: UpdateTeacherClassInput): Promise<void> {
|
||||
const teacherId = await getTeacherIdForMutations()
|
||||
|
||||
@@ -1468,7 +1679,9 @@ export async function updateAdminClass(
|
||||
const [teacher] = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(and(eq(users.id, nextTeacherId), eq(users.role, "teacher")))
|
||||
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||
.where(and(eq(users.id, nextTeacherId), eq(roles.name, "teacher")))
|
||||
.limit(1)
|
||||
if (!teacher) throw new Error("Teacher not found")
|
||||
|
||||
@@ -1498,7 +1711,9 @@ export async function setClassSubjectTeachers(params: {
|
||||
const rows = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(and(eq(users.role, "teacher"), inArray(users.id, teacherIds)))
|
||||
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||
.where(and(eq(roles.name, "teacher"), inArray(users.id, teacherIds)))
|
||||
if (rows.length !== new Set(teacherIds).size) throw new Error("Teacher not found")
|
||||
}
|
||||
|
||||
@@ -1508,15 +1723,24 @@ export async function setClassSubjectTeachers(params: {
|
||||
teacherBySubject.set(a.subject, typeof a.teacherId === "string" && a.teacherId.trim().length > 0 ? a.teacherId.trim() : null)
|
||||
}
|
||||
|
||||
// Map subject names to ids
|
||||
const subjectRows = await db
|
||||
.select({ id: subjects.id, name: subjects.name })
|
||||
.from(subjects)
|
||||
.where(inArray(subjects.name, DEFAULT_CLASS_SUBJECTS))
|
||||
const idByName = new Map(subjectRows.map((r) => [r.name as ClassSubject, r.id]))
|
||||
|
||||
const values = DEFAULT_CLASS_SUBJECTS
|
||||
.filter((name) => idByName.has(name))
|
||||
.map((name) => ({
|
||||
classId,
|
||||
subjectId: idByName.get(name)!,
|
||||
teacherId: teacherBySubject.get(name) ?? null,
|
||||
}))
|
||||
|
||||
await db
|
||||
.insert(classSubjectTeachers)
|
||||
.values(
|
||||
DEFAULT_CLASS_SUBJECTS.map((subject) => ({
|
||||
classId,
|
||||
subject,
|
||||
teacherId: teacherBySubject.get(subject) ?? null,
|
||||
}))
|
||||
)
|
||||
.values(values)
|
||||
.onDuplicateKeyUpdate({ set: { teacherId: sql`VALUES(${classSubjectTeachers.teacherId})` } })
|
||||
}
|
||||
|
||||
@@ -1564,13 +1788,19 @@ export async function enrollStudentByEmail(classId: string, email: string): Prom
|
||||
if (!owned) throw new Error("Class not found")
|
||||
|
||||
const [student] = await db
|
||||
.select({ id: users.id, role: users.role })
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.email, normalized))
|
||||
.limit(1)
|
||||
|
||||
if (!student) throw new Error("Student not found")
|
||||
if (student.role !== "student") throw new Error("User is not a student")
|
||||
const [studentRole] = await db
|
||||
.select({ id: usersToRoles.userId })
|
||||
.from(usersToRoles)
|
||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||
.where(and(eq(usersToRoles.userId, student.id), eq(roles.name, "student")))
|
||||
.limit(1)
|
||||
if (!studentRole) throw new Error("User is not a student")
|
||||
|
||||
await db
|
||||
.insert(classEnrollments)
|
||||
@@ -1823,8 +2053,26 @@ export const getStudentsSubjectScores = cache(
|
||||
)
|
||||
|
||||
export const getClassStudentSubjectScoresV2 = cache(
|
||||
async (classId: string): Promise<Map<string, Record<string, number | null>>> => {
|
||||
// 1. Get student IDs in the class
|
||||
async (params: { classId: string; teacherId?: string }): Promise<Map<string, Record<string, number | null>>> => {
|
||||
const teacherId = params.teacherId ?? (await getSessionTeacherId())
|
||||
if (!teacherId) return new Map()
|
||||
const classId = params.classId.trim()
|
||||
if (!classId) return new Map()
|
||||
|
||||
const accessibleIds = await getAccessibleClassIdsForTeacher(teacherId)
|
||||
if (accessibleIds.length === 0 || !accessibleIds.includes(classId)) return new Map()
|
||||
|
||||
const [classRow] = await db
|
||||
.select({ id: classes.id, teacherId: classes.teacherId })
|
||||
.from(classes)
|
||||
.where(eq(classes.id, classId))
|
||||
.limit(1)
|
||||
if (!classRow) return new Map()
|
||||
|
||||
const isHomeroomTeacher = classRow.teacherId === teacherId
|
||||
const subjectIds = isHomeroomTeacher ? [] : await getTeacherSubjectIdsForClass(teacherId, classId)
|
||||
if (!isHomeroomTeacher && subjectIds.length === 0) return new Map()
|
||||
|
||||
const enrollments = await db
|
||||
.select({ studentId: classEnrollments.studentId })
|
||||
.from(classEnrollments)
|
||||
@@ -1833,7 +2081,24 @@ export const getClassStudentSubjectScoresV2 = cache(
|
||||
eq(classEnrollments.status, "active")
|
||||
))
|
||||
|
||||
const studentIds = enrollments.map(e => e.studentId)
|
||||
return getStudentsSubjectScores(studentIds)
|
||||
const studentIds = enrollments.map((e) => e.studentId)
|
||||
const studentScores = await getStudentsSubjectScores(studentIds)
|
||||
if (subjectIds.length === 0) return studentScores
|
||||
|
||||
// Map subjectIds to names for filtering
|
||||
const subjectRows = await db
|
||||
.select({ id: subjects.id, name: subjects.name })
|
||||
.from(subjects)
|
||||
.where(inArray(subjects.id, subjectIds))
|
||||
const allowed = new Set(subjectRows.map((s) => s.name))
|
||||
const filtered = new Map<string, Record<string, number | null>>()
|
||||
for (const [studentId, scores] of studentScores.entries()) {
|
||||
const nextScores: Record<string, number | null> = {}
|
||||
for (const [subject, score] of Object.entries(scores)) {
|
||||
if (allowed.has(subject)) nextScores[subject] = score
|
||||
}
|
||||
filtered.set(studentId, nextScores)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
)
|
||||
|
||||
@@ -15,10 +15,8 @@ type Stat = {
|
||||
}
|
||||
|
||||
export function StudentStatsGrid({
|
||||
enrolledClassCount,
|
||||
dueSoonCount,
|
||||
overdueCount,
|
||||
gradedCount,
|
||||
ranking,
|
||||
}: {
|
||||
enrolledClassCount: number
|
||||
|
||||
@@ -7,8 +7,6 @@ import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import type { TeacherClass } from "@/modules/classes/types"
|
||||
|
||||
export function TeacherClassesCard({ classes }: { classes: TeacherClass[] }) {
|
||||
const totalStudents = classes.reduce((sum, c) => sum + (c.studentCount ?? 0), 0)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
|
||||
@@ -14,7 +14,6 @@ const toWeekday = (d: Date): 1 | 2 | 3 | 4 | 5 | 6 | 7 => {
|
||||
}
|
||||
|
||||
export function TeacherDashboardView({ data }: { data: TeacherDashboardData }) {
|
||||
const totalStudents = data.classes.reduce((sum, c) => sum + (c.studentCount ?? 0), 0)
|
||||
const todayWeekday = toWeekday(new Date())
|
||||
|
||||
const classNameById = new Map(data.classes.map((c) => [c.id, c.name] as const))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import "server-only"
|
||||
|
||||
import { cache } from "react"
|
||||
import { count, desc, eq, gt } from "drizzle-orm"
|
||||
import { count, desc, eq, gt, inArray } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
@@ -11,9 +11,11 @@ import {
|
||||
homeworkAssignments,
|
||||
homeworkSubmissions,
|
||||
questions,
|
||||
roles,
|
||||
sessions,
|
||||
textbooks,
|
||||
users,
|
||||
usersToRoles,
|
||||
} from "@/shared/db/schema"
|
||||
import type { AdminDashboardData } from "./types"
|
||||
|
||||
@@ -23,7 +25,7 @@ export const getAdminDashboardData = cache(async (): Promise<AdminDashboardData>
|
||||
const [
|
||||
activeSessionsRow,
|
||||
userCountRow,
|
||||
userRoleRows,
|
||||
userRoleCountRows,
|
||||
classCountRow,
|
||||
textbookCountRow,
|
||||
chapterCountRow,
|
||||
@@ -37,7 +39,11 @@ export const getAdminDashboardData = cache(async (): Promise<AdminDashboardData>
|
||||
] = await Promise.all([
|
||||
db.select({ value: count() }).from(sessions).where(gt(sessions.expires, now)),
|
||||
db.select({ value: count() }).from(users),
|
||||
db.select({ role: users.role, value: count() }).from(users).groupBy(users.role),
|
||||
db
|
||||
.select({ role: roles.name, value: count() })
|
||||
.from(usersToRoles)
|
||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||
.groupBy(roles.name),
|
||||
db.select({ value: count() }).from(classes),
|
||||
db.select({ value: count() }).from(textbooks),
|
||||
db.select({ value: count() }).from(chapters),
|
||||
@@ -52,7 +58,6 @@ export const getAdminDashboardData = cache(async (): Promise<AdminDashboardData>
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
email: users.email,
|
||||
role: users.role,
|
||||
createdAt: users.createdAt,
|
||||
})
|
||||
.from(users)
|
||||
@@ -72,17 +77,55 @@ export const getAdminDashboardData = cache(async (): Promise<AdminDashboardData>
|
||||
const homeworkSubmissionCount = Number(homeworkSubmissionCountRow[0]?.value ?? 0)
|
||||
const homeworkSubmissionToGradeCount = Number(homeworkSubmissionToGradeCountRow[0]?.value ?? 0)
|
||||
|
||||
const userRoleCounts = userRoleRows
|
||||
const userRoleCounts = userRoleCountRows
|
||||
.map((r) => ({ role: r.role ?? "unknown", count: Number(r.value ?? 0) }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
|
||||
const recentUsers = recentUserRows.map((u) => ({
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
email: u.email,
|
||||
role: u.role,
|
||||
createdAt: u.createdAt.toISOString(),
|
||||
}))
|
||||
const normalizeRole = (value: string) => {
|
||||
const role = value.trim().toLowerCase()
|
||||
if (role === "grade_head" || role === "teaching_head") return "teacher"
|
||||
if (role === "admin" || role === "student" || role === "teacher" || role === "parent") return role
|
||||
return ""
|
||||
}
|
||||
|
||||
const recentUserIds = recentUserRows.map((u) => u.id)
|
||||
const recentRoleRows = recentUserIds.length
|
||||
? await db
|
||||
.select({
|
||||
userId: usersToRoles.userId,
|
||||
roleName: roles.name,
|
||||
})
|
||||
.from(usersToRoles)
|
||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||
.where(inArray(usersToRoles.userId, recentUserIds))
|
||||
: []
|
||||
|
||||
const rolesByUserId = new Map<string, string[]>()
|
||||
for (const row of recentRoleRows) {
|
||||
const list = rolesByUserId.get(row.userId) ?? []
|
||||
list.push(row.roleName)
|
||||
rolesByUserId.set(row.userId, list)
|
||||
}
|
||||
|
||||
const resolvePrimaryRole = (roleNames: string[]) => {
|
||||
const mapped = roleNames.map(normalizeRole).filter(Boolean)
|
||||
if (mapped.includes("admin")) return "admin"
|
||||
if (mapped.includes("teacher")) return "teacher"
|
||||
if (mapped.includes("parent")) return "parent"
|
||||
if (mapped.includes("student")) return "student"
|
||||
return "student"
|
||||
}
|
||||
|
||||
const recentUsers = recentUserRows.map((u) => {
|
||||
const roleNames = rolesByUserId.get(u.id) ?? []
|
||||
return {
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
email: u.email,
|
||||
role: resolvePrimaryRole(roleNames),
|
||||
createdAt: u.createdAt.toISOString(),
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
activeSessionsCount,
|
||||
|
||||
@@ -5,9 +5,24 @@ import { ActionState } from "@/shared/types/action-state"
|
||||
import { z } from "zod"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
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 { 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({
|
||||
title: z.string().min(1),
|
||||
@@ -27,6 +42,213 @@ const ExamCreateSchema = z.object({
|
||||
.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: {
|
||||