剥离学校端/教师端/学生端/3D建模模块,清理跨模块引用

- 移除 backend: school/ (schools, departments, grades, classes, teachers, students)
- 移除 backend: ai-3d/ (controller, service, providers, utils)
- 移除 frontend: views/school/, views/workbench/ai-3d/, views/model/
- 移除 prisma schema: School, Grade, Department, Class, Teacher, Student, StudentInterestClass, AI3DTask 共8个模型
- 移除 app.module.ts: SchoolModule, AI3DModule 导入
- 移除 router/index.ts: 3D建模4条路由
- 移除 menu.ts: componentMap 中学校/3D映射
- 修复 registrations.service.ts: 教师判断从 Teacher 模型改为角色判断
- 修复 results.service.ts: 移除 student include
- 修复 homework services: 移除 student/class/grade 相关 Prisma 查询
- 保留 students.ts/teachers.ts/ai-3d.ts 最小类型存根供赛事组件引用
- 原始代码备份至 competition-management-system-stripped-modules/

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
aid 2026-04-02 14:04:40 +08:00
parent 9215465bd5
commit bead1cf4dc
63 changed files with 176 additions and 15305 deletions

View File

@ -31,12 +31,6 @@ model Tenant {
permissions Permission[]
dicts Dict[]
configs Config[]
school School? /// 学校信息(一对一)
grades Grade[] /// 年级
departments Department[] /// 部门
classes Class[] /// 班级
teachers Teacher[] /// 教师
students Student[] /// 学生
contestTeams ContestTeam[] /// 赛事团队
contestTeamMembers ContestTeamMember[] /// 团队成员
contestRegistrations ContestRegistration[] /// 赛事报名
@ -50,8 +44,6 @@ model Tenant {
homeworkSubmissions HomeworkSubmission[] /// 作业提交记录
homeworkReviewRules HomeworkReviewRule[] /// 作业评审规则
homeworkScores HomeworkScore[] /// 作业评分
// AI 3D 生成关联
ai3dTasks AI3DTask[] /// AI 3D 生成任务
creatorUser User? @relation("TenantCreator", fields: [creator], references: [id], onDelete: SetNull)
modifierUser User? @relation("TenantModifier", fields: [modifier], references: [id], onDelete: SetNull)
@ -105,20 +97,6 @@ model User {
modifiedConfigs Config[] @relation("ConfigModifier")
createdTenants Tenant[] @relation("TenantCreator")
modifiedTenants Tenant[] @relation("TenantModifier")
teacher Teacher? /// 教师信息(一对一)
student Student? /// 学生信息(一对一)
createdSchools School[] @relation("SchoolCreator")
modifiedSchools School[] @relation("SchoolModifier")
createdGrades Grade[] @relation("GradeCreator")
modifiedGrades Grade[] @relation("GradeModifier")
createdDepartments Department[] @relation("DepartmentCreator")
modifiedDepartments Department[] @relation("DepartmentModifier")
createdClasses Class[] @relation("ClassCreator")
modifiedClasses Class[] @relation("ClassModifier")
createdTeachers Teacher[] @relation("TeacherCreator")
modifiedTeachers Teacher[] @relation("TeacherModifier")
createdStudents Student[] @relation("StudentCreator")
modifiedStudents Student[] @relation("StudentModifier")
// 赛事相关关联
createdContests Contest[] @relation("ContestCreator")
modifiedContests Contest[] @relation("ContestModifier")
@ -164,8 +142,6 @@ model User {
homeworkScoresAsReviewer HomeworkScore[] @relation("HomeworkScoreReviewer")
createdHomeworkScores HomeworkScore[] @relation("HomeworkScoreCreator")
modifiedHomeworkScores HomeworkScore[] @relation("HomeworkScoreModifier")
// AI 3D 生成关联
ai3dTasks AI3DTask[] @relation("AI3DTaskUser") /// 用户的 AI 3D 生成任务
// 预设评语关联
presetComments PresetComment[] @relation("PresetCommentJudge") /// 评委的预设评语
// 子女关联
@ -378,183 +354,6 @@ model Log {
@@map("logs")
}
/// 学校信息表(扩展租户信息)
model School {
id Int @id @default(autoincrement())
tenantId Int @unique @map("tenant_id") /// 租户ID一对一
address String? /// 学校地址
phone String? /// 联系电话
principal String? /// 校长姓名
established DateTime? /// 建校时间
description String? @db.Text /// 学校描述
logo String? /// 学校Logo URL
website String? /// 学校网站
creator Int? /// 创建人ID
modifier Int? /// 修改人ID
createTime DateTime @default(now()) @map("create_time") /// 创建时间
modifyTime DateTime @updatedAt @map("modify_time") /// 修改时间
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
creatorUser User? @relation("SchoolCreator", fields: [creator], references: [id], onDelete: SetNull)
modifierUser User? @relation("SchoolModifier", fields: [modifier], references: [id], onDelete: SetNull)
@@map("schools")
}
/// 年级表
model Grade {
id Int @id @default(autoincrement())
tenantId Int @map("tenant_id") /// 租户ID
name String /// 年级名称(如:一年级、二年级)
code String /// 年级编码在租户内唯一grade_1, grade_2
level Int /// 年级级别用于排序1, 2, 3
description String? /// 年级描述
validState Int @default(1) @map("valid_state") /// 有效状态1-有效2-失效
creator Int? /// 创建人ID
modifier Int? /// 修改人ID
createTime DateTime @default(now()) @map("create_time") /// 创建时间
modifyTime DateTime @updatedAt @map("modify_time") /// 修改时间
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
classes Class[] /// 班级
creatorUser User? @relation("GradeCreator", fields: [creator], references: [id], onDelete: SetNull)
modifierUser User? @relation("GradeModifier", fields: [modifier], references: [id], onDelete: SetNull)
@@unique([tenantId, code])
@@unique([tenantId, level])
@@map("grades")
}
/// 部门表(支持树形结构)
model Department {
id Int @id @default(autoincrement())
tenantId Int @map("tenant_id") /// 租户ID
name String /// 部门名称
code String /// 部门编码(在租户内唯一)
parentId Int? @map("parent_id") /// 父部门ID支持树形结构
description String? /// 部门描述
sort Int @default(0) /// 排序
validState Int @default(1) @map("valid_state") /// 有效状态1-有效2-失效
creator Int? /// 创建人ID
modifier Int? /// 修改人ID
createTime DateTime @default(now()) @map("create_time") /// 创建时间
modifyTime DateTime @updatedAt @map("modify_time") /// 修改时间
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
parent Department? @relation("DepartmentTree", fields: [parentId], references: [id])
children Department[] @relation("DepartmentTree")
teachers Teacher[] /// 教师
creatorUser User? @relation("DepartmentCreator", fields: [creator], references: [id], onDelete: SetNull)
modifierUser User? @relation("DepartmentModifier", fields: [modifier], references: [id], onDelete: SetNull)
@@unique([tenantId, code])
@@map("departments")
}
/// 班级表
model Class {
id Int @id @default(autoincrement())
tenantId Int @map("tenant_id") /// 租户ID
gradeId Int @map("grade_id") /// 年级ID
name String /// 班级名称一年级1班、二年级2班
code String /// 班级编码(在租户内唯一)
type Int @default(1) @map("type") /// 班级类型1-行政班级教学班级2-兴趣班
capacity Int? /// 班级容量(可选)
description String? /// 班级描述
validState Int @default(1) @map("valid_state") /// 有效状态1-有效2-失效
creator Int? /// 创建人ID
modifier Int? /// 修改人ID
createTime DateTime @default(now()) @map("create_time") /// 创建时间
modifyTime DateTime @updatedAt @map("modify_time") /// 修改时间
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
grade Grade @relation(fields: [gradeId], references: [id], onDelete: Cascade)
students Student[] /// 学生(行政班级)
studentInterestClasses StudentInterestClass[] /// 学生兴趣班关联
creatorUser User? @relation("ClassCreator", fields: [creator], references: [id], onDelete: SetNull)
modifierUser User? @relation("ClassModifier", fields: [modifier], references: [id], onDelete: SetNull)
@@unique([tenantId, code])
@@map("classes")
}
/// 教师表
model Teacher {
id Int @id @default(autoincrement())
userId Int @unique @map("user_id") /// 用户ID一对一
tenantId Int @map("tenant_id") /// 租户ID
departmentId Int @map("department_id") /// 部门ID
employeeNo String? @map("employee_no") /// 工号(在租户内唯一)
phone String? /// 联系电话
idCard String? @map("id_card") /// 身份证号
gender Int? /// 性别1-男2-女
birthDate DateTime? @map("birth_date") /// 出生日期
hireDate DateTime? @map("hire_date") /// 入职日期
subject String? /// 任教科目(可选,如:语文、数学)
title String? /// 职称(可选,如:高级教师、一级教师)
description String? @db.Text /// 教师描述
validState Int @default(1) @map("valid_state") /// 有效状态1-有效2-失效
creator Int? /// 创建人ID
modifier Int? /// 修改人ID
createTime DateTime @default(now()) @map("create_time") /// 创建时间
modifyTime DateTime @updatedAt @map("modify_time") /// 修改时间
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
department Department @relation(fields: [departmentId], references: [id], onDelete: Restrict)
creatorUser User? @relation("TeacherCreator", fields: [creator], references: [id], onDelete: SetNull)
modifierUser User? @relation("TeacherModifier", fields: [modifier], references: [id], onDelete: SetNull)
@@unique([tenantId, employeeNo])
@@map("teachers")
}
/// 学生表
model Student {
id Int @id @default(autoincrement())
userId Int @unique @map("user_id") /// 用户ID一对一
tenantId Int @map("tenant_id") /// 租户ID
classId Int @map("class_id") /// 行政班级ID
studentNo String? @map("student_no") /// 学号(在租户内唯一)
phone String? /// 联系电话
idCard String? @map("id_card") /// 身份证号
gender Int? /// 性别1-男2-女
birthDate DateTime? @map("birth_date") /// 出生日期
enrollmentDate DateTime? @map("enrollment_date") /// 入学日期
parentName String? @map("parent_name") /// 家长姓名
parentPhone String? @map("parent_phone") /// 家长电话
address String? /// 家庭地址
description String? @db.Text /// 学生描述
validState Int @default(1) @map("valid_state") /// 有效状态1-有效2-失效
creator Int? /// 创建人ID
modifier Int? /// 修改人ID
createTime DateTime @default(now()) @map("create_time") /// 创建时间
modifyTime DateTime @updatedAt @map("modify_time") /// 修改时间
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
class Class @relation(fields: [classId], references: [id], onDelete: Restrict)
interestClasses StudentInterestClass[] /// 兴趣班关联
creatorUser User? @relation("StudentCreator", fields: [creator], references: [id], onDelete: SetNull)
modifierUser User? @relation("StudentModifier", fields: [modifier], references: [id], onDelete: SetNull)
@@unique([tenantId, studentNo])
@@map("students")
}
/// 学生兴趣班关联表(多对多)
model StudentInterestClass {
id Int @id @default(autoincrement())
studentId Int @map("student_id") /// 学生ID
classId Int @map("class_id") /// 兴趣班ID
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
@@unique([studentId, classId])
@@map("student_interest_classes")
}
// ============================================
// 赛事管理模块
// ============================================
@ -1107,35 +906,6 @@ model HomeworkScore {
// AI 3D 模型生成模块
// ============================================
/// AI 3D 生成任务表
model AI3DTask {
id Int @id @default(autoincrement())
tenantId Int @map("tenant_id") /// 租户ID
userId Int @map("user_id") /// 用户ID任务归属用户
inputType String @map("input_type") /// 输入类型text | image
inputContent String @map("input_content") @db.Text /// 输入内容文字描述或图片URL
generateType String @default("Normal") @map("generate_type") /// 生成类型Normal(带纹理) | Geometry(白模) | LowPoly(低多边形) | Sketch(草图)
status String @default("pending") /// 任务状态pending | processing | completed | failed | timeout
resultUrl String? @map("result_url") @db.Text /// 生成的3D模型URL单个结果兼容旧数据
previewUrl String? @map("preview_url") @db.Text /// 预览图URL单个结果兼容旧数据
resultUrls Json? @map("result_urls") /// 生成的3D模型URL数组多个结果文生3D生成4个
previewUrls Json? @map("preview_urls") /// 预览图URL数组多个结果
errorMessage String? @map("error_message") @db.Text /// 失败时的错误信息
externalTaskId String? @map("external_task_id") /// 外部AI服务的任务ID
retryCount Int @default(0) @map("retry_count") /// 已重试次数
createTime DateTime @default(now()) @map("create_time") /// 创建时间
completeTime DateTime? @map("complete_time") /// 完成时间
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
user User @relation("AI3DTaskUser", fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([tenantId])
@@index([status])
@@index([createTime])
@@map("t_ai_3d_task")
}
/// 预设评语表
model PresetComment {
id Int @id @default(autoincrement())

View File

@ -1,375 +0,0 @@
import {
Controller,
Get,
Post,
Delete,
Body,
Param,
Query,
UseGuards,
Request,
ParseIntPipe,
Res,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Response } from 'express';
import axios from 'axios';
import AdmZip from 'adm-zip';
import { AI3DService } from './ai-3d.service';
import { CreateTaskDto } from './dto/create-task.dto';
import { QueryTaskDto } from './dto/query-task.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentTenantId } from '../auth/decorators/current-tenant-id.decorator';
import { Public } from '../auth/decorators/public.decorator';
@Controller('ai-3d')
@UseGuards(JwtAuthGuard)
export class AI3DController {
private readonly logger = new Logger(AI3DController.name);
constructor(private readonly ai3dService: AI3DService) {}
/**
*
* POST /api/ai-3d/generate
*/
@Post('generate')
createTask(
@Body() createTaskDto: CreateTaskDto,
@CurrentTenantId() tenantId: number,
@Request() req,
) {
const userId = req?.user?.userId;
return this.ai3dService.createTask(userId, tenantId, createTaskDto);
}
/**
*
* GET /api/ai-3d/tasks
*/
@Get('tasks')
getTasks(@Query() queryDto: QueryTaskDto, @Request() req) {
const userId = req?.user?.userId;
return this.ai3dService.getTasks(userId, queryDto);
}
/**
*
* GET /api/ai-3d/tasks/:id
*/
@Get('tasks/:id')
getTask(@Param('id', ParseIntPipe) id: number, @Request() req) {
const userId = req?.user?.userId;
return this.ai3dService.getTask(userId, id);
}
/**
*
* POST /api/ai-3d/tasks/:id/retry
*/
@Post('tasks/:id/retry')
retryTask(@Param('id', ParseIntPipe) id: number, @Request() req) {
const userId = req?.user?.userId;
return this.ai3dService.retryTask(userId, id);
}
/**
*
* DELETE /api/ai-3d/tasks/:id
*/
@Delete('tasks/:id')
deleteTask(@Param('id', ParseIntPipe) id: number, @Request() req) {
const userId = req?.user?.userId;
return this.ai3dService.deleteTask(userId, id);
}
/**
* CORS问题
* GET /api/ai-3d/proxy-model
*/
@Get('proxy-model')
@Public() // 允许公开访问因为模型URL已经包含签名
async proxyModel(@Query('url') url: string, @Res() res: Response) {
if (!url) {
throw new HttpException('URL参数不能为空', HttpStatus.BAD_REQUEST);
}
this.logger.log(`[proxy-model] 开始处理请求URL长度: ${url.length}`);
try {
// URL解码处理URL编码
let decodedUrl: string;
try {
decodedUrl = decodeURIComponent(url);
this.logger.log(`[proxy-model] URL解码成功`);
} catch (e) {
// 如果解码失败使用原始URL
decodedUrl = url;
this.logger.warn(`[proxy-model] URL解码失败使用原始URL`);
}
// 验证URL是否为腾讯云COS链接安全验证
if (
!decodedUrl.includes('tencentcos.cn') &&
!decodedUrl.includes('qcloud.com')
) {
throw new HttpException(
'不支持的URL来源仅支持腾讯云COS链接',
HttpStatus.BAD_REQUEST,
);
}
// 验证URL格式
try {
new URL(decodedUrl);
} catch (e) {
throw new HttpException('URL格式无效', HttpStatus.BAD_REQUEST);
}
this.logger.log(`[proxy-model] 开始下载文件...`);
// 从源URL获取文件
const response = await axios.get(decodedUrl, {
responseType: 'arraybuffer', // 使用arraybuffer以便处理ZIP
timeout: 120000, // 120秒超时ZIP文件可能较大
maxContentLength: 100 * 1024 * 1024, // 最大100MB
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
Accept: '*/*',
},
});
this.logger.log(
`[proxy-model] 文件下载成功,大小: ${response.data.byteLength} bytes`,
);
let fileData: Buffer = Buffer.from(response.data);
let contentType =
response.headers['content-type'] || 'application/octet-stream';
let contentLength = fileData.length;
this.logger.log(`[proxy-model] Content-Type: ${contentType}`);
// 如果是ZIP文件解压并提取3D模型文件
if (
decodedUrl.toLowerCase().includes('.zip') ||
contentType.includes('zip') ||
contentType.includes('application/zip')
) {
this.logger.log(`[proxy-model] 检测到ZIP文件开始解压...`);
try {
const zip = new AdmZip(fileData);
const zipEntries = zip.getEntries();
this.logger.log(
`[proxy-model] ZIP解压成功包含 ${zipEntries.length} 个文件`,
);
// 列出所有文件便于调试
const allFiles = zipEntries.map((e) => e.entryName);
this.logger.log(`[proxy-model] ZIP文件列表: ${allFiles.join(', ')}`);
// 按优先级查找3D模型文件: GLB > GLTF > OBJ
let modelEntry = zipEntries.find((entry) =>
entry.entryName.toLowerCase().endsWith('.glb'),
);
if (!modelEntry) {
modelEntry = zipEntries.find((entry) =>
entry.entryName.toLowerCase().endsWith('.gltf'),
);
}
if (!modelEntry) {
modelEntry = zipEntries.find((entry) =>
entry.entryName.toLowerCase().endsWith('.obj'),
);
}
if (modelEntry) {
this.logger.log(
`[proxy-model] 找到模型文件: ${modelEntry.entryName}`,
);
fileData = modelEntry.getData();
const entryName = modelEntry.entryName.toLowerCase();
let modelType = 'glb'; // 默认类型
if (entryName.endsWith('.glb')) {
contentType = 'model/gltf-binary';
modelType = 'glb';
} else if (entryName.endsWith('.gltf')) {
contentType = 'model/gltf+json';
modelType = 'gltf';
} else if (entryName.endsWith('.obj')) {
contentType = 'text/plain'; // OBJ 是文本格式
modelType = 'obj';
}
contentLength = fileData.length;
this.logger.log(
`[proxy-model] 模型类型: ${modelType}, 大小: ${contentLength} bytes`,
);
// 添加自定义头部,告知前端实际的模型类型
res.setHeader('X-Model-Type', modelType);
} else {
// 列出ZIP中的所有文件便于调试
const fileList = zipEntries.map((e) => e.entryName).join(', ');
this.logger.error(
`[proxy-model] ZIP中未找到3D模型文件。ZIP内容: ${fileList}`,
);
throw new HttpException(
`ZIP文件中未找到3D模型文件GLB/GLTF/OBJ。ZIP内容: ${fileList}`,
HttpStatus.BAD_REQUEST,
);
}
} catch (zipError: any) {
this.logger.error(
`[proxy-model] ZIP处理失败: ${zipError.message}`,
zipError.stack,
);
if (zipError instanceof HttpException) {
throw zipError;
}
throw new HttpException(
`ZIP解压失败: ${zipError.message}`,
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
// 设置响应头
res.setHeader('Content-Type', contentType);
res.setHeader('Content-Length', contentLength);
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
res.setHeader('Cache-Control', 'public, max-age=3600'); // 缓存1小时
// 发送文件数据
this.logger.log(`[proxy-model] 发送响应,大小: ${contentLength} bytes`);
res.send(fileData);
} catch (error: any) {
this.logger.error(
`[proxy-model] 请求处理失败: ${error.message}`,
error.stack,
);
if (error instanceof HttpException) {
throw error;
}
if (error.response) {
res.status(error.response.status).json({
message: `代理请求失败: ${error.response.statusText}`,
status: error.response.status,
});
} else if (error.code === 'ECONNABORTED') {
res.status(HttpStatus.REQUEST_TIMEOUT).json({
message: '请求超时,请稍后重试',
});
} else if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') {
res.status(HttpStatus.BAD_GATEWAY).json({
message: `无法连接到目标服务器: ${error.message}`,
});
} else {
res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
message: `代理请求失败: ${error.message}`,
error:
process.env.NODE_ENV === 'development' ? error.stack : undefined,
});
}
}
}
/**
* CORS问题
* GET /api/ai-3d/proxy-preview
*/
@Get('proxy-preview')
@Public() // 允许公开访问因为预览图URL已经包含签名
async proxyPreview(@Query('url') url: string, @Res() res: Response) {
if (!url) {
throw new HttpException('URL参数不能为空', HttpStatus.BAD_REQUEST);
}
try {
// URL解码处理URL编码
let decodedUrl: string;
try {
decodedUrl = decodeURIComponent(url);
} catch (e) {
// 如果解码失败使用原始URL
decodedUrl = url;
}
// 验证URL是否为腾讯云COS链接安全验证
if (
!decodedUrl.includes('tencentcos.cn') &&
!decodedUrl.includes('qcloud.com')
) {
throw new HttpException(
'不支持的URL来源仅支持腾讯云COS链接',
HttpStatus.BAD_REQUEST,
);
}
// 验证URL格式
try {
new URL(decodedUrl);
} catch (e) {
throw new HttpException('URL格式无效', HttpStatus.BAD_REQUEST);
}
// 从源URL获取图片
const response = await axios.get(decodedUrl, {
responseType: 'arraybuffer',
timeout: 30000, // 30秒超时
maxContentLength: 10 * 1024 * 1024, // 最大10MB
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
Accept: 'image/*',
},
});
const imageData = Buffer.from(response.data);
const contentType = response.headers['content-type'] || 'image/jpeg';
const contentLength = imageData.length;
// 设置响应头
res.setHeader('Content-Type', contentType);
res.setHeader('Content-Length', contentLength);
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
res.setHeader('Cache-Control', 'public, max-age=86400'); // 缓存24小时
// 发送图片数据
res.send(imageData);
} catch (error: any) {
if (error instanceof HttpException) {
throw error;
}
if (error.response) {
res.status(error.response.status).json({
message: `代理请求失败: ${error.response.statusText}`,
status: error.response.status,
});
} else if (error.code === 'ECONNABORTED') {
res.status(HttpStatus.REQUEST_TIMEOUT).json({
message: '请求超时,请稍后重试',
});
} else if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') {
res.status(HttpStatus.BAD_GATEWAY).json({
message: `无法连接到目标服务器: ${error.message}`,
});
} else {
res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
message: `代理请求失败: ${error.message}`,
error:
process.env.NODE_ENV === 'development' ? error.stack : undefined,
});
}
}
}
}

View File

@ -1,39 +0,0 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AI3DController } from './ai-3d.controller';
import { AI3DService } from './ai-3d.service';
import { MockAI3DProvider } from './providers/mock.provider';
import { HunyuanAI3DProvider } from './providers/hunyuan.provider';
import { AI3D_PROVIDER } from './providers/ai-3d-provider.interface';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule, ConfigModule],
controllers: [AI3DController],
providers: [
AI3DService,
MockAI3DProvider,
HunyuanAI3DProvider,
{
provide: AI3D_PROVIDER,
useFactory: (
configService: ConfigService,
mockProvider: MockAI3DProvider,
hunyuanProvider: HunyuanAI3DProvider,
) => {
const provider = configService.get<string>('AI_3D_PROVIDER') || 'mock';
switch (provider.toLowerCase()) {
case 'hunyuan':
return hunyuanProvider;
case 'mock':
default:
return mockProvider;
}
},
inject: [ConfigService, MockAI3DProvider, HunyuanAI3DProvider],
},
],
exports: [AI3DService],
})
export class AI3DModule {}

View File

@ -1,525 +0,0 @@
import {
Injectable,
Inject,
NotFoundException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { OssService } from '../oss/oss.service';
import { CreateTaskDto } from './dto/create-task.dto';
import { QueryTaskDto } from './dto/query-task.dto';
import { AI3DProvider, AI3D_PROVIDER } from './providers/ai-3d-provider.interface';
// 配置常量
const MAX_USER_TASKS = 3; // 每用户最大任务数pending + processing
const API_MAX_CONCURRENT = 3; // 混元API全局最大并发数所有用户共享
const TASK_TIMEOUT_MS = 10 * 60 * 1000; // 10分钟超时
const MAX_RETRY_COUNT = 3; // 最大重试次数
const QUEUE_CHECK_INTERVAL = 3000; // 队列检查间隔(毫秒)
@Injectable()
export class AI3DService {
private readonly logger = new Logger(AI3DService.name);
private queueCheckTimer: NodeJS.Timeout | null = null;
private isProcessingQueue = false; // 防止并发处理队列
constructor(
private prisma: PrismaService,
private ossService: OssService,
@Inject(AI3D_PROVIDER) private ai3dProvider: AI3DProvider,
) {
// 启动队列检查定时器
this.startQueueChecker();
}
/**
*
*/
private startQueueChecker() {
if (this.queueCheckTimer) return;
this.queueCheckTimer = setInterval(async () => {
await this.processQueuedTasks();
}, QUEUE_CHECK_INTERVAL);
this.logger.log('队列检查器已启动');
}
/**
* API执行的任务数
*/
private async getGlobalProcessingCount(): Promise<number> {
return this.prisma.aI3DTask.count({
where: {
status: 'processing', // 只统计已提交到API的任务
},
});
}
/**
*
*/
private async processQueuedTasks() {
// 防止并发处理
if (this.isProcessingQueue) return;
this.isProcessingQueue = true;
try {
// 检查当前全局并发数
const processingCount = await this.getGlobalProcessingCount();
const availableSlots = API_MAX_CONCURRENT - processingCount;
if (availableSlots <= 0) {
return; // 没有可用槽位
}
// 获取等待中的任务(按创建时间排序,先进先出)
const pendingTasks = await this.prisma.aI3DTask.findMany({
where: { status: 'pending' },
orderBy: { createTime: 'asc' },
take: availableSlots,
});
if (pendingTasks.length === 0) return;
this.logger.log(
`队列处理: ${pendingTasks.length} 个任务待提交,可用槽位: ${availableSlots}`,
);
// 逐个提交任务
for (const task of pendingTasks) {
// 再次检查并发数(防止并发提交)
const currentProcessing = await this.getGlobalProcessingCount();
if (currentProcessing >= API_MAX_CONCURRENT) {
this.logger.log('全局并发已满,停止提交');
break;
}
await this.submitTaskToAPI(task);
}
} catch (error) {
this.logger.error(`处理队列任务出错: ${error.message}`);
} finally {
this.isProcessingQueue = false;
}
}
/**
* API
*/
private async submitTaskToAPI(task: any) {
try {
// 构建生成选项
const options: any = {
generateType: task.generateType,
};
const externalTaskId = await this.ai3dProvider.submitTask(
task.inputType as 'text' | 'image',
task.inputContent,
options,
);
// 更新状态为处理中
await this.prisma.aI3DTask.update({
where: { id: task.id },
data: {
status: 'processing',
externalTaskId,
},
});
// 启动轮询检查任务状态
this.pollTaskStatus(task.id, externalTaskId, Date.now());
this.logger.log(`任务 ${task.id} 已提交到API外部ID: ${externalTaskId}`);
} catch (error) {
// 提交失败,标记为失败
await this.prisma.aI3DTask.update({
where: { id: task.id },
data: {
status: 'failed',
errorMessage: error.message || 'AI服务提交失败',
completeTime: new Date(),
},
});
this.logger.error(`任务 ${task.id} 提交API失败: ${error.message}`);
}
}
/**
*
*/
async createTask(
userId: number,
tenantId: number,
dto: CreateTaskDto,
) {
// 1. 检查用户当前任务数量pending + processing
const userTaskCount = await this.prisma.aI3DTask.count({
where: {
userId,
status: { in: ['pending', 'processing'] },
},
});
if (userTaskCount >= MAX_USER_TASKS) {
throw new BadRequestException(
`您当前有 ${userTaskCount} 个任务正在排队或处理中,最多同时 ${MAX_USER_TASKS} 个任务,请等待完成后再提交`,
);
}
// 2. 创建数据库记录(初始状态为 pending表示排队中
const task = await this.prisma.aI3DTask.create({
data: {
userId,
tenantId,
inputType: dto.inputType,
inputContent: dto.inputContent,
generateType: dto.generateType || 'Normal',
status: 'pending',
},
});
this.logger.log(`任务 ${task.id} 已创建,进入队列`);
// 3. 检查全局并发数,决定是立即提交还是等待队列处理
const processingCount = await this.getGlobalProcessingCount();
if (processingCount < API_MAX_CONCURRENT) {
// 有空闲槽位,立即提交
try {
// 构建生成选项
const options: any = {
generateType: dto.generateType,
faceCount: dto.faceCount,
};
// 处理多视图图片图生3D支持
if (dto.inputType === 'image' && dto.multiViewImages) {
const viewKeyMap: Record<string, string> = {
left: 'left',
right: 'right',
back: 'back',
top: 'top',
bottom: 'bottom',
left45: 'left_front',
right45: 'right_front',
};
const multiViewImages: { viewType: string; imageUrl: string }[] = [];
for (const [key, url] of Object.entries(dto.multiViewImages)) {
if (url && key !== 'front' && viewKeyMap[key]) {
multiViewImages.push({
viewType: viewKeyMap[key],
imageUrl: url,
});
}
}
if (multiViewImages.length > 0) {
options.multiViewImages = multiViewImages;
this.logger.log(`多视图模式: ${multiViewImages.length} 张额外视图`);
}
}
const externalTaskId = await this.ai3dProvider.submitTask(
dto.inputType,
dto.inputContent,
options,
);
// 更新状态为处理中
await this.prisma.aI3DTask.update({
where: { id: task.id },
data: {
status: 'processing',
externalTaskId,
},
});
// 启动轮询检查任务状态
this.pollTaskStatus(task.id, externalTaskId, Date.now());
this.logger.log(`任务 ${task.id} 已提交到API外部ID: ${externalTaskId}`);
} catch (error) {
// 提交失败,标记为失败
await this.prisma.aI3DTask.update({
where: { id: task.id },
data: {
status: 'failed',
errorMessage: error.message || 'AI服务提交失败',
completeTime: new Date(),
},
});
this.logger.error(`任务 ${task.id} 提交失败: ${error.message}`);
throw error;
}
} else {
// 全局并发已满,任务保持 pending 状态,等待队列调度
this.logger.log(
`全局并发已满 (${processingCount}/${API_MAX_CONCURRENT}),任务 ${task.id} 进入排队`,
);
}
return this.getTask(userId, task.id);
}
/**
*
*/
async getTasks(userId: number, query: QueryTaskDto) {
const { page = 1, pageSize = 10, status } = query;
const where: any = { userId };
if (status) {
where.status = status;
}
const [list, total] = await Promise.all([
this.prisma.aI3DTask.findMany({
where,
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { createTime: 'desc' },
}),
this.prisma.aI3DTask.count({ where }),
]);
return {
list,
total,
page,
pageSize,
};
}
/**
*
*/
async getTask(userId: number, id: number) {
const task = await this.prisma.aI3DTask.findFirst({
where: { id, userId },
});
if (!task) {
throw new NotFoundException('任务不存在');
}
// 如果任务在排队中,计算队列位置
if (task.status === 'pending') {
const queuePosition = await this.getQueuePosition(task.id, task.createTime);
return {
...task,
queuePosition,
};
}
return task;
}
/**
*
*/
private async getQueuePosition(taskId: number, createTime: Date): Promise<number> {
// 统计在当前任务之前创建的、仍在排队的任务数量
const position = await this.prisma.aI3DTask.count({
where: {
status: 'pending',
createTime: { lte: createTime },
},
});
return position;
}
/**
*
*/
async deleteTask(userId: number, id: number) {
const task = await this.getTask(userId, id);
await this.prisma.aI3DTask.delete({
where: { id: task.id },
});
this.logger.log(`任务 ${id} 已删除`);
return null;
}
/**
*
*/
async retryTask(userId: number, id: number) {
const task = await this.prisma.aI3DTask.findFirst({
where: { id, userId },
});
if (!task) {
throw new NotFoundException('任务不存在');
}
// 只有失败或超时的任务可以重试
if (!['failed', 'timeout'].includes(task.status)) {
throw new BadRequestException('只有失败或超时的任务可以重试');
}
// 检查重试次数
if (task.retryCount >= MAX_RETRY_COUNT) {
throw new BadRequestException(
`已达到最大重试次数 ${MAX_RETRY_COUNT} 次,请创建新任务`,
);
}
// 检查用户任务数限制
const userTaskCount = await this.prisma.aI3DTask.count({
where: {
userId,
status: { in: ['pending', 'processing'] },
},
});
if (userTaskCount >= MAX_USER_TASKS) {
throw new BadRequestException(
`您当前有 ${userTaskCount} 个任务正在排队或处理中,请等待完成后再重试`,
);
}
// 重置任务状态为 pending进入队列
await this.prisma.aI3DTask.update({
where: { id },
data: {
status: 'pending',
errorMessage: null,
completeTime: null,
externalTaskId: null,
retryCount: { increment: 1 },
},
});
this.logger.log(`任务 ${id} 已重新加入队列,等待处理`);
// 检查是否可以立即提交
const processingCount = await this.getGlobalProcessingCount();
if (processingCount < API_MAX_CONCURRENT) {
// 有空闲槽位,立即提交
const updatedTask = await this.prisma.aI3DTask.findUnique({
where: { id },
});
if (updatedTask) {
await this.submitTaskToAPI(updatedTask);
}
}
return this.getTask(userId, id);
}
/**
*
*/
private async pollTaskStatus(
taskId: number,
externalTaskId: string,
startTime: number,
) {
const checkStatus = async () => {
// 1. 检查是否超时
if (Date.now() - startTime > TASK_TIMEOUT_MS) {
await this.prisma.aI3DTask.update({
where: { id: taskId },
data: {
status: 'timeout',
errorMessage: '任务处理超时,请重试',
completeTime: new Date(),
},
});
this.logger.warn(`任务 ${taskId} 超时`);
return;
}
// 2. 查询外部任务状态
try {
const result = await this.ai3dProvider.queryTask(externalTaskId);
if (result.status === 'completed' || result.status === 'failed') {
let finalResultUrl = result.resultUrl;
let finalPreviewUrl = result.previewUrl;
let finalResultUrls = result.resultUrls || null;
let finalPreviewUrls = result.previewUrls || null;
// 3. 如果任务成功且COS已启用转存文件到自己的COS
if (result.status === 'completed' && this.ossService.isEnabled()) {
try {
// 转存所有模型文件
if (result.resultUrls && result.resultUrls.length > 0) {
this.logger.log(`开始转存 ${result.resultUrls.length} 个模型文件到COS: ${taskId}`);
const uploadedModelUrls: string[] = [];
for (let i = 0; i < result.resultUrls.length; i++) {
const modelResult = await this.ossService.uploadAI3DModel(
result.resultUrls[i],
taskId,
i,
);
uploadedModelUrls.push(modelResult.modelUrl);
this.logger.log(`模型文件 ${i + 1} 转存完成: ${modelResult.modelUrl}`);
}
finalResultUrl = uploadedModelUrls[0];
finalResultUrls = uploadedModelUrls;
}
// 转存所有预览图
if (result.previewUrls && result.previewUrls.length > 0) {
this.logger.log(`开始转存 ${result.previewUrls.length} 个预览图到COS: ${taskId}`);
const uploadedPreviewUrls: string[] = [];
for (let i = 0; i < result.previewUrls.length; i++) {
const previewResult = await this.ossService.uploadAI3DPreview(
result.previewUrls[i],
taskId,
i,
);
uploadedPreviewUrls.push(previewResult.previewUrl);
this.logger.log(`预览图 ${i + 1} 转存完成: ${previewResult.previewUrl}`);
}
finalPreviewUrl = uploadedPreviewUrls[0];
finalPreviewUrls = uploadedPreviewUrls;
}
} catch (transferError) {
// 转存失败不影响任务完成,只记录日志
this.logger.error(
`文件转存失败使用原始URL: ${transferError.message}`,
);
}
}
await this.prisma.aI3DTask.update({
where: { id: taskId },
data: {
status: result.status,
resultUrl: finalResultUrl,
previewUrl: finalPreviewUrl,
resultUrls: finalResultUrls,
previewUrls: finalPreviewUrls,
errorMessage: result.errorMessage,
completeTime: new Date(),
},
});
this.logger.log(
`任务 ${taskId} ${result.status === 'completed' ? '完成' : '失败'}`,
);
} else {
// 继续轮询每2秒检查一次
setTimeout(checkStatus, 2000);
}
} catch (error) {
this.logger.error(`轮询任务 ${taskId} 状态出错: ${error.message}`);
// 出错后延长轮询间隔每5秒重试
setTimeout(checkStatus, 5000);
}
};
// 首次检查延迟2秒
setTimeout(checkStatus, 2000);
}
}

View File

@ -1,63 +0,0 @@
import {
IsString,
IsIn,
IsNotEmpty,
MaxLength,
IsOptional,
IsInt,
Min,
Max,
IsObject,
} from 'class-validator';
/**
*
* Normal: 带纹理
* LowPoly: 低多边形
* Geometry: 白模
* Sketch: 草图
*/
export type GenerateType = 'Normal' | 'LowPoly' | 'Geometry' | 'Sketch';
/**
*
* left, right, back, top, bottom, left_front (45°), right_front (45°)
*/
export type MultiViewImages = {
front?: string; // 正图(作为主图)
left?: string; // 左视图
right?: string; // 右视图
back?: string; // 后视图
top?: string; // 顶视图
bottom?: string; // 底视图
left45?: string; // 左前45°视图 -> left_front
right45?: string; // 右前45°视图 -> right_front
};
export class CreateTaskDto {
@IsString()
@IsIn(['text', 'image'], { message: '输入类型必须是 text 或 image' })
inputType: 'text' | 'image';
@IsString()
@IsNotEmpty({ message: '输入内容不能为空' })
@MaxLength(2000, { message: '输入内容最多2000个字符' })
inputContent: string;
@IsOptional()
@IsString()
@IsIn(['Normal', 'LowPoly', 'Geometry', 'Sketch'], {
message: '模型类型必须是 Normal、LowPoly、Geometry 或 Sketch',
})
generateType?: GenerateType;
@IsOptional()
@IsInt({ message: '模型面数必须是整数' })
@Min(10000, { message: '模型面数最小为10000' })
@Max(1500000, { message: '模型面数最大为1500000' })
faceCount?: number;
@IsOptional()
@IsObject({ message: '多视图图片必须是对象' })
multiViewImages?: MultiViewImages;
}

View File

@ -1,23 +0,0 @@
import { IsOptional, IsString, IsInt, Min, IsIn } from 'class-validator';
import { Type } from 'class-transformer';
export class QueryTaskDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
pageSize?: number = 10;
@IsOptional()
@IsString()
@IsIn(['pending', 'processing', 'completed', 'failed', 'timeout'], {
message: '状态必须是 pending、processing、completed、failed 或 timeout',
})
status?: string;
}

View File

@ -1,63 +0,0 @@
/**
* AI 3D
*/
export interface AI3DGenerateResult {
taskId: string; // 外部任务ID
status: 'pending' | 'processing' | 'completed' | 'failed';
resultUrl?: string; // 3D模型URL单个结果兼容旧数据
previewUrl?: string; // 预览图URL单个结果兼容旧数据
resultUrls?: string[]; // 3D模型URL数组多个结果文生3D生成4个
previewUrls?: string[]; // 预览图URL数组多个结果
errorMessage?: string; // 错误信息
}
/**
*
*/
export interface MultiViewImage {
viewType: string; // left, right, back, top, bottom, left_front, right_front
imageUrl: string;
}
/**
*
*/
export interface AI3DGenerateOptions {
/** 模型生成类型Normal-带纹理, LowPoly-低多边形, Geometry-白模, Sketch-草图 */
generateType?: 'Normal' | 'LowPoly' | 'Geometry' | 'Sketch';
/** 模型面数10000-1500000默认500000 */
faceCount?: number;
/** 多视图图片图生3D支持 */
multiViewImages?: MultiViewImage[];
}
/**
* AI 3D
* MockMeshy
*/
export interface AI3DProvider {
/**
*
* @param inputType text | image
* @param inputContent URL
* @param options 3D支持
* @returns ID
*/
submitTask(
inputType: 'text' | 'image',
inputContent: string,
options?: AI3DGenerateOptions,
): Promise<string>;
/**
*
* @param taskId ID
* @returns
*/
queryTask(taskId: string): Promise<AI3DGenerateResult>;
}
/**
* AI 3D Provider
*/
export const AI3D_PROVIDER = 'AI3D_PROVIDER';

View File

@ -1,269 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import {
AI3DProvider,
AI3DGenerateResult,
AI3DGenerateOptions,
} from './ai-3d-provider.interface';
import { TencentCloudSigner } from '../utils/tencent-cloud-sign';
/**
* 3D Provider
* https://cloud.tencent.com/document/api/1804/123447
* API概览https://cloud.tencent.com/document/product/1804/120838
*
*
* - 33
* -
* - "资源不足"
*/
@Injectable()
export class HunyuanAI3DProvider implements AI3DProvider {
private readonly logger = new Logger(HunyuanAI3DProvider.name);
private readonly apiHost = 'ai3d.tencentcloudapi.com';
private readonly apiVersion = '2025-05-13'; // 使用正确的API版本
private readonly secretId: string;
private readonly secretKey: string;
private readonly region: string;
constructor(private configService: ConfigService) {
this.secretId = this.configService.get<string>('TENCENT_SECRET_ID');
this.secretKey = this.configService.get<string>('TENCENT_SECRET_KEY');
this.region =
this.configService.get<string>('TENCENT_REGION') || 'ap-guangzhou';
if (!this.secretId || !this.secretKey) {
this.logger.warn(
'未配置腾讯云密钥,请设置 TENCENT_SECRET_ID 和 TENCENT_SECRET_KEY 环境变量',
);
}
}
/**
*
*/
async submitTask(
inputType: 'text' | 'image',
inputContent: string,
options?: AI3DGenerateOptions,
): Promise<string> {
try {
// 构造请求参数
const payload: any = {};
if (inputType === 'text') {
// 文生3D使用 Prompt
payload.Prompt = inputContent;
// 文生3D支持额外参数
if (options?.generateType) {
payload.GenerateType = options.generateType;
}
if (options?.faceCount) {
payload.FaceCount = options.faceCount;
}
this.logger.log(
`提交文生3D任务: ${inputContent.substring(0, 50)}... ` +
`[类型: ${options?.generateType || 'Normal'}, 面数: ${options?.faceCount || 500000}]`,
);
} else {
// 图生3D使用 ImageUrl 或 ImageBase64
if (
inputContent.startsWith('http://') ||
inputContent.startsWith('https://')
) {
payload.ImageUrl = inputContent;
} else {
// 假设是 Base64 编码的图片
payload.ImageBase64 = inputContent;
}
// 图生3D也支持模型类型
if (options?.generateType) {
payload.GenerateType = options.generateType;
}
// 多视图图片支持
if (options?.multiViewImages && options.multiViewImages.length > 0) {
// 检查是否包含扩展视角(需要 v3.1
const extendedViewTypes = ['top', 'bottom', 'left_front', 'right_front'];
const hasExtendedViews = options.multiViewImages.some(
(img) => extendedViewTypes.includes(img.viewType)
);
// 如果有扩展视角,使用模型版本 3.1
if (hasExtendedViews) {
payload.ModelVersion = '3.1';
}
payload.MultiViewImages = options.multiViewImages.map((img) => ({
ViewType: img.viewType,
ViewImageUrl: img.imageUrl,
}));
this.logger.log(
`提交图生3D任务多视图: ${options.multiViewImages.length} 张图片 ` +
`[类型: ${options?.generateType || 'Normal'}]` +
(hasExtendedViews ? ' [模型版本: 3.1]' : ''),
);
} else {
this.logger.log(
`提交图生3D任务: ${inputContent.substring(0, 50)}... ` +
`[类型: ${options?.generateType || 'Normal'}]`,
);
}
}
// 生成签名和请求头
const headers = TencentCloudSigner.sign({
secretId: this.secretId,
secretKey: this.secretKey,
service: 'ai3d',
host: this.apiHost,
region: this.region,
action: 'SubmitHunyuanTo3DProJob',
version: this.apiVersion,
payload,
});
// 发送请求
const response = await axios.post(`https://${this.apiHost}`, payload, {
headers,
});
// 检查响应
if (response.data.Response?.Error) {
const error = response.data.Response.Error;
this.logger.error(`混元3D API错误: ${error.Code} - ${error.Message}`);
// 对特定错误提供更友好的提示
if (error.Code === 'ResourceInsufficient') {
const friendlyMessage =
'资源不足。可能原因1) 并发任务数已达到上限默认3个请等待其他任务完成' +
'2) 积分余额不足,请检查腾讯云控制台的积分余额;' +
'3) 服务暂时不可用,请稍后重试。';
throw new Error(`混元3D API错误: ${friendlyMessage}`);
}
throw new Error(`混元3D API错误: ${error.Message}`);
}
const jobId = response.data.Response?.JobId;
if (!jobId) {
this.logger.error('混元3D API未返回JobId');
throw new Error('混元3D API未返回任务ID');
}
this.logger.log(`混元3D任务创建成功: ${jobId}`);
return jobId;
} catch (error) {
this.logger.error(`提交混元3D任务失败: ${error.message}`, error.stack);
throw error;
}
}
/**
*
*/
async queryTask(taskId: string): Promise<AI3DGenerateResult> {
try {
// 构造请求参数
const payload = {
JobId: taskId,
};
// 生成签名和请求头
const headers = TencentCloudSigner.sign({
secretId: this.secretId,
secretKey: this.secretKey,
service: 'ai3d',
host: this.apiHost,
region: this.region,
action: 'QueryHunyuanTo3DProJob',
version: this.apiVersion,
payload,
});
// 发送请求
const response = await axios.post(`https://${this.apiHost}`, payload, {
headers,
});
// 检查响应
if (response.data.Response?.Error) {
const error = response.data.Response.Error;
this.logger.error(`混元3D查询错误: ${error.Code} - ${error.Message}`);
return {
taskId,
status: 'failed',
errorMessage: error.Message,
};
}
const result = response.data.Response;
// 映射任务状态
// 混元状态: WAIT等待中| RUN运行中| FAIL失败| DONE完成
const statusMap: Record<
string,
'pending' | 'processing' | 'completed' | 'failed'
> = {
WAIT: 'processing',
RUN: 'processing',
FAIL: 'failed',
DONE: 'completed',
};
const status = statusMap[result.Status] || 'processing';
// 构造返回结果
const generateResult: AI3DGenerateResult = {
taskId,
status,
};
// 如果任务完成提取模型URL
// 根据API文档返回的是 ResultFile3Ds 数组
// 注意这里只返回原始URLCOS转存由AI3DService统一处理
if (status === 'completed' && result.ResultFile3Ds?.length > 0) {
const file3Ds = result.ResultFile3Ds;
// 提取所有模型URL和预览图URL
const urls = file3Ds.map((file: any) => file.Url).filter(Boolean);
const previewUrls = file3Ds
.map((file: any) => file.PreviewImageUrl)
.filter(Boolean);
if (urls.length > 0) {
generateResult.resultUrl = urls[0];
generateResult.resultUrls = urls;
}
if (previewUrls.length > 0) {
generateResult.previewUrl = previewUrls[0];
generateResult.previewUrls = previewUrls;
}
this.logger.log(
`混元3D任务 ${taskId} 完成: ${urls.length} 个模型文件, ${previewUrls.length} 个预览图`,
);
} else if (status === 'failed') {
// 失败原因:根据文档,错误信息在 ErrorMessage 字段
generateResult.errorMessage =
result.ErrorMessage || result.ErrorCode || '生成失败';
this.logger.warn(
`混元3D任务 ${taskId} 失败: ${generateResult.errorMessage}`,
);
}
return generateResult;
} catch (error) {
this.logger.error(`查询混元3D任务失败: ${error.message}`, error.stack);
return {
taskId,
status: 'failed',
errorMessage: `查询任务失败: ${error.message}`,
};
}
}
}

View File

@ -1,179 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { AI3DProvider, AI3DGenerateResult } from './ai-3d-provider.interface';
import { v4 as uuidv4 } from 'uuid';
interface MockTask {
status: 'pending' | 'processing' | 'completed' | 'failed';
startTime: number;
inputType: string;
inputContent: string;
resultUrl?: string;
previewUrl?: string;
resultUrls?: string[];
previewUrls?: string[];
errorMessage?: string;
}
/**
* Mock AI 3D Provider
* AI 3D
*/
@Injectable()
export class MockAI3DProvider implements AI3DProvider {
private readonly logger = new Logger(MockAI3DProvider.name);
private tasks = new Map<string, MockTask>();
// 模拟完成时间范围(毫秒)
private readonly MIN_COMPLETION_TIME = 5000; // 5秒
private readonly MAX_COMPLETION_TIME = 15000; // 15秒
// 模拟成功率
private readonly SUCCESS_RATE = 0.9; // 90% 成功率
// 示例 3D 模型 URL使用公开可访问的 GLB 文件)
private readonly SAMPLE_MODELS = [
// three.js 官方示例模型
'https://threejs.org/examples/models/gltf/DamagedHelmet/glTF/DamagedHelmet.gltf',
'https://threejs.org/examples/models/gltf/LittlestTokyo.glb',
'https://threejs.org/examples/models/gltf/Soldier.glb',
'https://threejs.org/examples/models/gltf/RobotExpressive/RobotExpressive.glb',
];
// 示例预览图(使用占位图服务)
private readonly SAMPLE_PREVIEWS = [
'https://picsum.photos/seed/model1/400/300',
'https://picsum.photos/seed/model2/400/300',
'https://picsum.photos/seed/model3/400/300',
'https://picsum.photos/seed/model4/400/300',
];
async submitTask(
inputType: 'text' | 'image',
inputContent: string,
): Promise<string> {
const taskId = uuidv4();
this.logger.log(
`Mock: 创建任务 ${taskId}, 类型: ${inputType}, 内容: ${inputContent.substring(0, 50)}...`,
);
// 创建任务记录
this.tasks.set(taskId, {
status: 'processing',
startTime: Date.now(),
inputType,
inputContent,
});
// 模拟异步完成
const completionTime =
this.MIN_COMPLETION_TIME +
Math.random() * (this.MAX_COMPLETION_TIME - this.MIN_COMPLETION_TIME);
setTimeout(() => {
this.completeTask(taskId);
}, completionTime);
return taskId;
}
async queryTask(taskId: string): Promise<AI3DGenerateResult> {
const task = this.tasks.get(taskId);
if (!task) {
this.logger.warn(`Mock: 任务 ${taskId} 不存在`);
return {
taskId,
status: 'failed',
errorMessage: '任务不存在',
};
}
return {
taskId,
status: task.status,
resultUrl: task.resultUrl,
previewUrl: task.previewUrl,
resultUrls: task.resultUrls,
previewUrls: task.previewUrls,
errorMessage: task.errorMessage,
};
}
/**
*
*/
private completeTask(taskId: string): void {
const task = this.tasks.get(taskId);
if (!task) return;
// 根据成功率决定是否成功
const isSuccess = Math.random() < this.SUCCESS_RATE;
if (isSuccess) {
// 文生3D生成4个不同角度的模型
if (task.inputType === 'text') {
const resultUrls: string[] = [];
const previewUrls: string[] = [];
// 生成4个模型结果使用不同的示例模型
for (let i = 0; i < 4; i++) {
const modelIndex = i % this.SAMPLE_MODELS.length;
const previewIndex = i % this.SAMPLE_PREVIEWS.length;
resultUrls.push(this.SAMPLE_MODELS[modelIndex]);
previewUrls.push(this.SAMPLE_PREVIEWS[previewIndex]);
}
task.status = 'completed';
task.resultUrls = resultUrls;
task.previewUrls = previewUrls;
// 兼容旧字段,使用第一个结果
task.resultUrl = resultUrls[0];
task.previewUrl = previewUrls[0];
this.logger.log(
`Mock: 文生3D任务 ${taskId} 完成, 生成 ${resultUrls.length} 个模型`,
);
} else {
// 图生3D只生成1个模型
const modelIndex = Math.floor(
Math.random() * this.SAMPLE_MODELS.length,
);
const modelUrl = this.SAMPLE_MODELS[modelIndex];
const previewUrl = this.SAMPLE_PREVIEWS[modelIndex % this.SAMPLE_PREVIEWS.length];
task.status = 'completed';
task.resultUrl = modelUrl;
task.previewUrl = previewUrl;
task.resultUrls = [modelUrl];
task.previewUrls = [previewUrl];
this.logger.log(`Mock: 图生3D任务 ${taskId} 完成, 模型: ${modelUrl}`);
}
} else {
task.status = 'failed';
task.errorMessage = '模拟生成失败AI 服务暂时不可用';
this.logger.warn(`Mock: 任务 ${taskId} 失败`);
}
this.tasks.set(taskId, task);
// 清理过期任务保留1小时
this.cleanupOldTasks();
}
/**
* 1
*/
private cleanupOldTasks(): void {
const oneHourAgo = Date.now() - 60 * 60 * 1000;
for (const [taskId, task] of this.tasks.entries()) {
if (task.startTime < oneHourAgo) {
this.tasks.delete(taskId);
this.logger.debug(`Mock: 清理过期任务 ${taskId}`);
}
}
}
}

View File

@ -1,101 +0,0 @@
import * as crypto from 'crypto';
export interface TencentCloudSignOptions {
secretId: string;
secretKey: string;
service: string;
host: string;
region?: string;
action: string;
version: string;
payload?: any;
timestamp?: number;
}
/**
* API v3
* https://cloud.tencent.com/document/api/213/30654
*/
export class TencentCloudSigner {
private static readonly ALGORITHM = 'TC3-HMAC-SHA256';
private static readonly SIGNED_HEADERS = 'content-type;host;x-tc-action';
/**
*
*/
static sign(options: TencentCloudSignOptions): Record<string, string> {
const timestamp = options.timestamp || Math.floor(Date.now() / 1000);
const date = new Date(timestamp * 1000)
.toISOString()
.substr(0, 10);
// 1. 拼接规范请求串
const payload = options.payload ? JSON.stringify(options.payload) : '';
const hashedRequestPayload = this.sha256Hex(payload);
const canonicalRequest = [
'POST',
'/',
'',
`content-type:application/json`,
`host:${options.host}`,
`x-tc-action:${options.action.toLowerCase()}`,
'',
this.SIGNED_HEADERS,
hashedRequestPayload,
].join('\n');
// 2. 拼接待签名字符串
const hashedCanonicalRequest = this.sha256Hex(canonicalRequest);
const credentialScope = `${date}/${options.service}/tc3_request`;
const stringToSign = [
this.ALGORITHM,
timestamp.toString(),
credentialScope,
hashedCanonicalRequest,
].join('\n');
// 3. 计算签名
const secretDate = this.hmacSha256(
`TC3${options.secretKey}`,
date,
);
const secretService = this.hmacSha256(secretDate, options.service);
const secretSigning = this.hmacSha256(secretService, 'tc3_request');
const signature = this.hmacSha256Hex(secretSigning, stringToSign);
// 4. 拼接 Authorization
const authorization = `${this.ALGORITHM} Credential=${options.secretId}/${credentialScope}, SignedHeaders=${this.SIGNED_HEADERS}, Signature=${signature}`;
// 5. 返回请求头
return {
'Content-Type': 'application/json',
'Host': options.host,
'X-TC-Action': options.action,
'X-TC-Version': options.version,
'X-TC-Timestamp': timestamp.toString(),
'X-TC-Region': options.region || 'ap-guangzhou',
'Authorization': authorization,
};
}
/**
* SHA256
*/
private static sha256Hex(data: string): string {
return crypto.createHash('sha256').update(data, 'utf8').digest('hex');
}
/**
* HMAC-SHA256Buffer
*/
private static hmacSha256(key: string | Buffer, data: string): Buffer {
return crypto.createHmac('sha256', key).update(data, 'utf8').digest();
}
/**
* HMAC-SHA256
*/
private static hmacSha256Hex(key: Buffer, data: string): string {
return crypto.createHmac('sha256', key).update(data, 'utf8').digest('hex');
}
}

View File

@ -1,176 +0,0 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import AdmZip from 'adm-zip';
import axios from 'axios';
import { Logger } from '@nestjs/common';
export class ZipHandler {
private static readonly logger = new Logger(ZipHandler.name);
/**
* .zip文件3D模型文件
* @param zipUrl ZIP文件的URL
* @param outputDir
* @returns 3D模型文件路径Buffer
*/
static async downloadAndExtract(
zipUrl: string,
outputDir?: string,
): Promise<{
modelPath: string;
previewPath?: string;
modelBuffer: Buffer;
previewBuffer?: Buffer;
}> {
// 使用系统临时目录
const baseDir =
outputDir ||
path.join(os.tmpdir(), 'ai-3d', Date.now().toString());
try {
if (!fs.existsSync(baseDir)) {
fs.mkdirSync(baseDir, { recursive: true });
}
// 1. 下载ZIP文件
this.logger.log(`开始下载ZIP文件: ${zipUrl}`);
const zipPath = path.join(baseDir, 'model.zip');
await this.downloadFile(zipUrl, zipPath);
this.logger.log(`ZIP文件下载完成: ${zipPath}`);
// 2. 解压ZIP文件
this.logger.log(`开始解压ZIP文件`);
const extractDir = path.join(baseDir, 'extracted');
await this.extractZip(zipPath, extractDir);
this.logger.log(`ZIP文件解压完成: ${extractDir}`);
// 3. 查找3D模型文件和预览图
const files = this.getAllFiles(extractDir);
const modelFile = this.findModelFile(files);
const previewFile = this.findPreviewImage(files);
if (!modelFile) {
throw new Error('在ZIP文件中未找到3D模型文件.glb, .gltf');
}
this.logger.log(`找到3D模型文件: ${modelFile}`);
if (previewFile) {
this.logger.log(`找到预览图: ${previewFile}`);
}
// 4. 读取文件Buffer用于上传到COS
const modelBuffer = fs.readFileSync(modelFile);
const previewBuffer = previewFile ? fs.readFileSync(previewFile) : undefined;
return {
modelPath: modelFile,
previewPath: previewFile,
modelBuffer,
previewBuffer,
};
} catch (error) {
this.logger.error(`处理ZIP文件失败: ${error.message}`, error.stack);
throw error;
} finally {
// 清理临时目录
try {
if (fs.existsSync(baseDir)) {
fs.rmSync(baseDir, { recursive: true, force: true });
this.logger.log(`已清理临时目录: ${baseDir}`);
}
} catch (err) {
this.logger.warn(`清理临时目录失败: ${err.message}`);
}
}
}
/**
*
*/
private static async downloadFile(
url: string,
outputPath: string,
): Promise<void> {
const response = await axios.get(url, {
responseType: 'arraybuffer',
timeout: 60000, // 60秒超时
});
fs.writeFileSync(outputPath, response.data);
}
/**
* ZIP文件
*/
private static async extractZip(
zipPath: string,
outputDir: string,
): Promise<void> {
const zip = new AdmZip(zipPath);
zip.extractAllTo(outputDir, true);
}
/**
*
*/
private static getAllFiles(dir: string): string[] {
const files: string[] = [];
const traverse = (currentDir: string) => {
const items = fs.readdirSync(currentDir);
for (const item of items) {
const fullPath = path.join(currentDir, item);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
traverse(fullPath);
} else {
files.push(fullPath);
}
}
};
traverse(dir);
return files;
}
/**
* 3D模型文件.glb, .gltf
*/
private static findModelFile(files: string[]): string | undefined {
// 优先查找.glb文件二进制格式更常用
const glbFile = files.find((file) => file.toLowerCase().endsWith('.glb'));
if (glbFile) return glbFile;
// 其次查找.gltf文件
const gltfFile = files.find((file) =>
file.toLowerCase().endsWith('.gltf'),
);
if (gltfFile) return gltfFile;
// 其他可能的3D格式
const otherFormats = ['.obj', '.fbx', '.stl'];
for (const format of otherFormats) {
const file = files.find((f) => f.toLowerCase().endsWith(format));
if (file) return file;
}
return undefined;
}
/**
* .jpg, .jpeg, .png
*/
private static findPreviewImage(files: string[]): string | undefined {
const imageExtensions = ['.jpg', '.jpeg', '.png', '.webp'];
for (const ext of imageExtensions) {
const imageFile = files.find((file) => file.toLowerCase().endsWith(ext));
if (imageFile) return imageFile;
}
return undefined;
}
}

View File

@ -11,14 +11,12 @@ import { DictModule } from './dict/dict.module';
import { ConfigModule as SystemConfigModule } from './config/config.module';
import { LogsModule } from './logs/logs.module';
import { TenantsModule } from './tenants/tenants.module';
import { SchoolModule } from './school/school.module';
import { ContestsModule } from './contests/contests.module';
import { AnalyticsModule } from './contests/analytics/analytics.module';
import { JudgesManagementModule } from './judges-management/judges-management.module';
import { UploadModule } from './upload/upload.module';
import { HomeworkModule } from './homework/homework.module';
import { OssModule } from './oss/oss.module';
import { AI3DModule } from './ai-3d/ai-3d.module';
import { PublicModule } from './public/public.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { RolesGuard } from './auth/guards/roles.guard';
@ -46,14 +44,12 @@ import { HttpExceptionFilter } from './common/filters/http-exception.filter';
SystemConfigModule,
LogsModule,
TenantsModule,
SchoolModule,
ContestsModule,
AnalyticsModule,
JudgesManagementModule,
UploadModule,
HomeworkModule,
OssModule,
AI3DModule,
PublicModule,
],
providers: [

View File

@ -104,16 +104,19 @@ export class RegistrationsService {
// 检查当前登录用户是否是老师如果是则设置为指导老师registrant
let registrantId = createRegistrationDto.userId;
if (creatorId) {
// 检查创建者是否是老师
const creator = await this.prisma.user.findUnique({
// 检查创建者是否是老师(通过角色判断)
const creatorWithRoles = await this.prisma.user.findUnique({
where: { id: creatorId },
include: {
teacher: true,
roles: { include: { role: true } },
},
});
const isTeacher = creatorWithRoles?.roles?.some(
(ur) => ur.role.code === 'teacher',
);
// 如果创建者是老师,则设置为指导老师
if (creator?.teacher) {
if (isTeacher) {
registrantId = creatorId;
}
}
@ -148,10 +151,12 @@ export class RegistrationsService {
if (creatorId) {
const creator = await tx.user.findUnique({
where: { id: creatorId },
include: { teacher: true },
include: { roles: { include: { role: true } } },
});
if (creator?.teacher) {
const isTeacher = creator?.roles?.some(
(ur) => ur.role.code === 'teacher',
);
if (isTeacher) {
shouldCreateDefaultTeacher = true;
}
}
@ -411,20 +416,6 @@ export class RegistrationsService {
name: true,
},
},
student: {
include: {
class: {
include: {
grade: {
select: {
id: true,
name: true,
},
},
},
},
},
},
},
},
child: {
@ -510,20 +501,6 @@ export class RegistrationsService {
name: true,
},
},
student: {
include: {
class: {
include: {
grade: {
select: {
id: true,
name: true,
},
},
},
},
},
},
},
},
team: {
@ -642,13 +619,16 @@ export class RegistrationsService {
throw new NotFoundException('报名记录不存在');
}
// 验证用户是否是老师
const teacher = await this.prisma.user.findUnique({
// 验证用户是否是老师(通过角色判断)
const teacherUser = await this.prisma.user.findUnique({
where: { id: teacherUserId },
include: { teacher: true },
include: { roles: { include: { role: true } } },
});
const isTeacher = teacherUser?.roles?.some(
(ur) => ur.role.code === 'teacher',
);
if (!teacher?.teacher) {
if (!isTeacher) {
throw new BadRequestException('该用户不是老师');
}

View File

@ -491,20 +491,6 @@ export class ResultsService {
name: true,
},
},
student: {
include: {
class: {
include: {
grade: {
select: {
id: true,
name: true,
},
},
},
},
},
},
},
},
team: {

View File

@ -303,18 +303,9 @@ export class HomeworksService {
? JSON.parse(homework.publishScope)
: homework.publishScope;
// 注意:学校模块已剥离,班级名称暂不可用
if (Array.isArray(classIds) && classIds.length > 0) {
const classes = await this.prisma.class.findMany({
where: {
id: { in: classIds },
validState: 1,
},
select: {
id: true,
name: true,
},
});
publishScopeNames = classes.map((c) => c.name);
publishScopeNames = classIds.map((id) => `班级${id}`);
}
} catch (e) {
// 解析失败,保持空数组
@ -343,20 +334,8 @@ export class HomeworksService {
const { page = 1, pageSize = 10, name } = queryDto;
const skip = (page - 1) * pageSize;
// 获取学生信息,包括班级
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: {
student: {
select: {
id: true,
classId: true,
},
},
},
});
const studentClassId = user?.student?.classId;
// 注意: 学校模块已剥离,班级过滤暂不可用
const studentClassId: number | undefined = undefined;
// 构建查询条件
const where: any = {

View File

@ -43,24 +43,6 @@ export class SubmissionsService {
id: true,
username: true,
nickname: true,
student: {
select: {
id: true,
studentNo: true,
class: {
select: {
id: true,
name: true,
grade: {
select: {
id: true,
name: true,
},
},
},
},
},
},
},
},
scores: {
@ -126,7 +108,7 @@ export class SubmissionsService {
};
}
if (studentAccount || studentName || classIds || gradeId) {
if (studentAccount || studentName) {
where.student = {};
if (studentAccount) {
@ -140,20 +122,6 @@ export class SubmissionsService {
contains: studentName,
};
}
if (classIds && classIds.length > 0) {
where.student.student = {
classId: {
in: classIds,
},
};
} else if (gradeId) {
where.student.student = {
class: {
gradeId: gradeId,
},
};
}
}
if (status) {
@ -177,23 +145,6 @@ export class SubmissionsService {
id: true,
username: true,
nickname: true,
student: {
select: {
studentNo: true,
class: {
select: {
id: true,
name: true,
grade: {
select: {
id: true,
name: true,
},
},
},
},
},
},
},
},
_count: {
@ -219,46 +170,10 @@ export class SubmissionsService {
/**
*
*
*/
async getClassTree(tenantId: number) {
const grades = await this.prisma.grade.findMany({
where: {
tenantId,
validState: 1,
},
include: {
classes: {
where: {
validState: 1,
type: 1, // 只获取行政班级
},
select: {
id: true,
name: true,
code: true,
},
orderBy: {
code: 'asc',
},
},
},
orderBy: {
level: 'asc',
},
});
return grades.map((grade) => ({
id: `grade_${grade.id}`,
name: grade.name,
type: 'grade',
gradeId: grade.id,
children: grade.classes.map((cls) => ({
id: cls.id,
name: cls.name,
type: 'class',
classId: cls.id,
})),
}));
async getClassTree(_tenantId: number) {
return [];
}
/**

View File

@ -1,74 +0,0 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
Query,
UseGuards,
Request,
} from '@nestjs/common';
import { ClassesService } from './classes.service';
import { CreateClassDto } from './dto/create-class.dto';
import { UpdateClassDto } from './dto/update-class.dto';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
@Controller('classes')
@UseGuards(JwtAuthGuard)
export class ClassesController {
constructor(private readonly classesService: ClassesService) {}
@Post()
create(@Body() createClassDto: CreateClassDto, @Request() req) {
const tenantId = req.tenantId || req.user?.tenantId;
if (!tenantId) {
throw new Error('无法确定租户信息');
}
const creatorId = req.user?.id;
return this.classesService.create(createClassDto, tenantId, creatorId);
}
@Get()
findAll(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('gradeId') gradeId?: string,
@Query('type') type?: string,
@Request() req?: any,
) {
const tenantId = req?.tenantId || req?.user?.tenantId;
return this.classesService.findAll(
page ? parseInt(page) : 1,
pageSize ? parseInt(pageSize) : 10,
tenantId,
gradeId ? parseInt(gradeId) : undefined,
type ? parseInt(type) : undefined,
);
}
@Get(':id')
findOne(@Param('id') id: string, @Request() req) {
const tenantId = req.tenantId || req.user?.tenantId;
return this.classesService.findOne(+id, tenantId);
}
@Patch(':id')
update(
@Param('id') id: string,
@Body() updateClassDto: UpdateClassDto,
@Request() req,
) {
const tenantId = req.tenantId || req.user?.tenantId;
const modifierId = req.user?.id;
return this.classesService.update(+id, updateClassDto, tenantId, modifierId);
}
@Delete(':id')
remove(@Param('id') id: string, @Request() req) {
const tenantId = req.tenantId || req.user?.tenantId;
return this.classesService.remove(+id, tenantId);
}
}

View File

@ -1,13 +0,0 @@
import { Module } from '@nestjs/common';
import { ClassesService } from './classes.service';
import { ClassesController } from './classes.controller';
import { PrismaModule } from '../../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [ClassesController],
providers: [ClassesService],
exports: [ClassesService],
})
export class ClassesModule {}

View File

@ -1,282 +0,0 @@
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateClassDto } from './dto/create-class.dto';
import { UpdateClassDto } from './dto/update-class.dto';
@Injectable()
export class ClassesService {
constructor(private prisma: PrismaService) {}
async create(createClassDto: CreateClassDto, tenantId: number, creatorId?: number) {
// 验证年级是否存在且属于该租户
const grade = await this.prisma.grade.findFirst({
where: {
id: createClassDto.gradeId,
tenantId,
validState: 1,
},
});
if (!grade) {
throw new NotFoundException('年级不存在或不属于该租户');
}
// 检查班级编码是否已存在
const existingByCode = await this.prisma.class.findFirst({
where: {
tenantId,
code: createClassDto.code,
},
});
if (existingByCode) {
throw new ConflictException('班级编码已存在');
}
const data: any = {
...createClassDto,
tenantId,
};
if (creatorId) {
data.creator = creatorId;
}
return this.prisma.class.create({
data,
include: {
grade: true,
_count: {
select: {
students: {
where: {
validState: 1,
},
},
studentInterestClasses: true,
},
},
},
});
}
async findAll(
page: number = 1,
pageSize: number = 10,
tenantId?: number,
gradeId?: number,
type?: number,
) {
const skip = (page - 1) * pageSize;
const where: any = { validState: 1 };
if (tenantId) {
where.tenantId = tenantId;
}
if (gradeId) {
where.gradeId = gradeId;
}
if (type !== undefined) {
where.type = type;
}
const [list, total] = await Promise.all([
this.prisma.class.findMany({
where,
skip,
take: pageSize,
orderBy: [
{ grade: { level: 'asc' } },
{ name: 'asc' },
],
include: {
grade: true,
_count: {
select: {
students: {
where: {
validState: 1,
},
},
studentInterestClasses: true,
},
},
},
}),
this.prisma.class.count({ where }),
]);
return {
list,
total,
page,
pageSize,
};
}
async findOne(id: number, tenantId?: number) {
const where: any = { id, validState: 1 };
if (tenantId) {
where.tenantId = tenantId;
}
const classEntity = await this.prisma.class.findFirst({
where,
include: {
grade: true,
students: {
where: {
validState: 1,
},
include: {
user: true,
},
},
studentInterestClasses: {
include: {
student: {
include: {
user: true,
},
},
},
},
},
});
if (!classEntity) {
throw new NotFoundException('班级不存在');
}
return classEntity;
}
async update(id: number, updateClassDto: UpdateClassDto, tenantId?: number, modifierId?: number) {
// 验证班级是否存在
const existingClass = await this.findOne(id, tenantId);
const data: any = {};
// 如果更新年级,验证年级是否存在
if (updateClassDto.gradeId && updateClassDto.gradeId !== existingClass.gradeId) {
const grade = await this.prisma.grade.findFirst({
where: {
id: updateClassDto.gradeId,
tenantId: tenantId || existingClass.tenantId,
validState: 1,
},
});
if (!grade) {
throw new NotFoundException('年级不存在或不属于该租户');
}
data.gradeId = updateClassDto.gradeId;
}
// 如果更新编码,检查是否冲突
if (updateClassDto.code && updateClassDto.code !== existingClass.code) {
const existingByCode = await this.prisma.class.findFirst({
where: {
tenantId: tenantId || existingClass.tenantId,
code: updateClassDto.code,
id: { not: id },
},
});
if (existingByCode) {
throw new ConflictException('班级编码已存在');
}
data.code = updateClassDto.code;
}
if (updateClassDto.name) {
data.name = updateClassDto.name;
}
if (updateClassDto.type !== undefined) {
// 如果从行政班级改为兴趣班,需要检查是否有学生
if (existingClass.type === 1 && updateClassDto.type === 2) {
const studentCount = await this.prisma.student.count({
where: {
classId: id,
validState: 1,
},
});
if (studentCount > 0) {
throw new BadRequestException('无法将包含学生的行政班级改为兴趣班');
}
}
data.type = updateClassDto.type;
}
if (updateClassDto.capacity !== undefined) {
data.capacity = updateClassDto.capacity;
}
if (updateClassDto.description !== undefined) {
data.description = updateClassDto.description;
}
if (modifierId) {
data.modifier = modifierId;
}
return this.prisma.class.update({
where: { id },
data,
include: {
grade: true,
_count: {
select: {
students: {
where: {
validState: 1,
},
},
studentInterestClasses: true,
},
},
},
});
}
async remove(id: number, tenantId?: number) {
// 验证班级是否存在
const classEntity = await this.findOne(id, tenantId);
// 如果是行政班级,检查是否有学生
if (classEntity.type === 1) {
const studentCount = await this.prisma.student.count({
where: {
classId: id,
validState: 1,
},
});
if (studentCount > 0) {
throw new BadRequestException('删除班级前需先转移或删除所有学生');
}
}
// 如果是兴趣班,删除关联关系
if (classEntity.type === 2) {
await this.prisma.studentInterestClass.deleteMany({
where: {
classId: id,
},
});
}
// 软删除
return this.prisma.class.update({
where: { id },
data: {
validState: 2,
},
});
}
}

View File

@ -1,32 +0,0 @@
import {
IsString,
IsInt,
IsOptional,
Min,
IsIn,
} from 'class-validator';
export class CreateClassDto {
@IsInt()
gradeId: number;
@IsString()
name: string;
@IsString()
code: string;
@IsInt()
@IsIn([1, 2])
type: number; // 1-行政班级2-兴趣班
@IsInt()
@Min(1)
@IsOptional()
capacity?: number;
@IsString()
@IsOptional()
description?: string;
}

View File

@ -1,36 +0,0 @@
import {
IsString,
IsInt,
IsOptional,
Min,
IsIn,
} from 'class-validator';
export class UpdateClassDto {
@IsInt()
@IsOptional()
gradeId?: number;
@IsString()
@IsOptional()
name?: string;
@IsString()
@IsOptional()
code?: string;
@IsInt()
@IsIn([1, 2])
@IsOptional()
type?: number; // 1-行政班级2-兴趣班
@IsInt()
@Min(1)
@IsOptional()
capacity?: number;
@IsString()
@IsOptional()
description?: string;
}

View File

@ -1,78 +0,0 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
Query,
UseGuards,
Request,
} from '@nestjs/common';
import { DepartmentsService } from './departments.service';
import { CreateDepartmentDto } from './dto/create-department.dto';
import { UpdateDepartmentDto } from './dto/update-department.dto';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
@Controller('departments')
@UseGuards(JwtAuthGuard)
export class DepartmentsController {
constructor(private readonly departmentsService: DepartmentsService) {}
@Post()
create(@Body() createDepartmentDto: CreateDepartmentDto, @Request() req) {
const tenantId = req.tenantId || req.user?.tenantId;
if (!tenantId) {
throw new Error('无法确定租户信息');
}
const creatorId = req.user?.id;
return this.departmentsService.create(createDepartmentDto, tenantId, creatorId);
}
@Get()
findAll(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('parentId') parentId?: string,
@Request() req?: any,
) {
const tenantId = req?.tenantId || req?.user?.tenantId;
return this.departmentsService.findAll(
page ? parseInt(page) : 1,
pageSize ? parseInt(pageSize) : 10,
tenantId,
parentId ? parseInt(parentId) : undefined,
);
}
@Get('tree')
findTree(@Request() req?: any) {
const tenantId = req?.tenantId || req?.user?.tenantId;
return this.departmentsService.findTree(tenantId);
}
@Get(':id')
findOne(@Param('id') id: string, @Request() req) {
const tenantId = req.tenantId || req.user?.tenantId;
return this.departmentsService.findOne(+id, tenantId);
}
@Patch(':id')
update(
@Param('id') id: string,
@Body() updateDepartmentDto: UpdateDepartmentDto,
@Request() req,
) {
const tenantId = req.tenantId || req.user?.tenantId;
const modifierId = req.user?.id;
return this.departmentsService.update(+id, updateDepartmentDto, tenantId, modifierId);
}
@Delete(':id')
remove(@Param('id') id: string, @Request() req) {
const tenantId = req.tenantId || req.user?.tenantId;
return this.departmentsService.remove(+id, tenantId);
}
}

View File

@ -1,13 +0,0 @@
import { Module } from '@nestjs/common';
import { DepartmentsService } from './departments.service';
import { DepartmentsController } from './departments.controller';
import { PrismaModule } from '../../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [DepartmentsController],
providers: [DepartmentsService],
exports: [DepartmentsService],
})
export class DepartmentsModule {}

View File

@ -1,361 +0,0 @@
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateDepartmentDto } from './dto/create-department.dto';
import { UpdateDepartmentDto } from './dto/update-department.dto';
@Injectable()
export class DepartmentsService {
constructor(private prisma: PrismaService) {}
async create(createDepartmentDto: CreateDepartmentDto, tenantId: number, creatorId?: number) {
// 检查部门编码是否已存在
const existingByCode = await this.prisma.department.findFirst({
where: {
tenantId,
code: createDepartmentDto.code,
},
});
if (existingByCode) {
throw new ConflictException('部门编码已存在');
}
// 如果指定了父部门,验证父部门是否存在且属于该租户
if (createDepartmentDto.parentId) {
const parent = await this.prisma.department.findFirst({
where: {
id: createDepartmentDto.parentId,
tenantId,
validState: 1,
},
});
if (!parent) {
throw new NotFoundException('父部门不存在或不属于该租户');
}
}
const data: any = {
...createDepartmentDto,
tenantId,
};
if (creatorId) {
data.creator = creatorId;
}
return this.prisma.department.create({
data,
include: {
parent: true,
_count: {
select: {
teachers: {
where: {
validState: 1,
},
},
children: {
where: {
validState: 1,
},
},
},
},
},
});
}
async findAll(page: number = 1, pageSize: number = 10, tenantId?: number, parentId?: number) {
const skip = (page - 1) * pageSize;
const where: any = { validState: 1 };
if (tenantId) {
where.tenantId = tenantId;
}
if (parentId !== undefined) {
where.parentId = parentId;
}
const [list, total] = await Promise.all([
this.prisma.department.findMany({
where,
skip,
take: pageSize,
orderBy: [
{ sort: 'desc' },
{ name: 'asc' },
],
include: {
parent: true,
_count: {
select: {
teachers: {
where: {
validState: 1,
},
},
children: {
where: {
validState: 1,
},
},
},
},
},
}),
this.prisma.department.count({ where }),
]);
return {
list,
total,
page,
pageSize,
};
}
async findTree(tenantId?: number) {
const where: any = { validState: 1 };
if (tenantId) {
where.tenantId = tenantId;
}
const departments = await this.prisma.department.findMany({
where,
orderBy: [
{ sort: 'desc' },
{ name: 'asc' },
],
include: {
_count: {
select: {
teachers: {
where: {
validState: 1,
},
},
children: {
where: {
validState: 1,
},
},
},
},
},
});
// 构建树形结构
const buildTree = (items: any[], parentId: number | null = null): any[] => {
return items
.filter(item => item.parentId === parentId)
.map(item => ({
...item,
children: buildTree(items, item.id),
}));
};
return buildTree(departments);
}
async findOne(id: number, tenantId?: number) {
const where: any = { id, validState: 1 };
if (tenantId) {
where.tenantId = tenantId;
}
const department = await this.prisma.department.findFirst({
where,
include: {
parent: true,
children: {
where: {
validState: 1,
},
},
teachers: {
where: {
validState: 1,
},
include: {
user: true,
},
},
},
});
if (!department) {
throw new NotFoundException('部门不存在');
}
return department;
}
async update(id: number, updateDepartmentDto: UpdateDepartmentDto, tenantId?: number, modifierId?: number) {
// 验证部门是否存在
const existingDepartment = await this.findOne(id, tenantId);
const data: any = {};
// 如果更新编码,检查是否冲突
if (updateDepartmentDto.code && updateDepartmentDto.code !== existingDepartment.code) {
const existingByCode = await this.prisma.department.findFirst({
where: {
tenantId: tenantId || existingDepartment.tenantId,
code: updateDepartmentDto.code,
id: { not: id },
},
});
if (existingByCode) {
throw new ConflictException('部门编码已存在');
}
data.code = updateDepartmentDto.code;
}
// 如果更新父部门,验证父部门是否存在且不会造成循环引用
if (updateDepartmentDto.parentId !== undefined) {
if (updateDepartmentDto.parentId === id) {
throw new BadRequestException('不能将部门设置为自己的父部门');
}
if (updateDepartmentDto.parentId !== null) {
const parent = await this.prisma.department.findFirst({
where: {
id: updateDepartmentDto.parentId,
tenantId: tenantId || existingDepartment.tenantId,
validState: 1,
},
});
if (!parent) {
throw new NotFoundException('父部门不存在或不属于该租户');
}
// 检查是否会造成循环引用(父部门不能是当前部门的子部门)
const isDescendant = await this.checkIsDescendant(
updateDepartmentDto.parentId,
id,
tenantId || existingDepartment.tenantId,
);
if (isDescendant) {
throw new BadRequestException('不能将部门设置为其子部门的父部门');
}
}
data.parentId = updateDepartmentDto.parentId;
}
if (updateDepartmentDto.name) {
data.name = updateDepartmentDto.name;
}
if (updateDepartmentDto.description !== undefined) {
data.description = updateDepartmentDto.description;
}
if (updateDepartmentDto.sort !== undefined) {
data.sort = updateDepartmentDto.sort;
}
if (modifierId) {
data.modifier = modifierId;
}
return this.prisma.department.update({
where: { id },
data,
include: {
parent: true,
_count: {
select: {
teachers: {
where: {
validState: 1,
},
},
children: {
where: {
validState: 1,
},
},
},
},
},
});
}
async remove(id: number, tenantId?: number) {
// 验证部门是否存在
const department = await this.findOne(id, tenantId);
// 检查是否有子部门
const childrenCount = await this.prisma.department.count({
where: {
parentId: id,
validState: 1,
},
});
if (childrenCount > 0) {
throw new BadRequestException('删除部门前需先删除或转移所有子部门');
}
// 检查是否有教师
const teacherCount = await this.prisma.teacher.count({
where: {
departmentId: id,
validState: 1,
},
});
if (teacherCount > 0) {
throw new BadRequestException('删除部门前需先转移或删除该部门下的所有教师');
}
// 软删除
return this.prisma.department.update({
where: { id },
data: {
validState: 2,
},
});
}
private async checkIsDescendant(parentId: number, childId: number, tenantId: number): Promise<boolean> {
const parent = await this.prisma.department.findFirst({
where: {
id: parentId,
tenantId,
validState: 1,
},
include: {
children: {
where: {
validState: 1,
},
},
},
});
if (!parent) {
return false;
}
// 检查直接子部门
if (parent.children.some(child => child.id === childId)) {
return true;
}
// 递归检查所有子部门
for (const child of parent.children) {
if (await this.checkIsDescendant(child.id, childId, tenantId)) {
return true;
}
}
return false;
}
}

View File

@ -1,28 +0,0 @@
import {
IsString,
IsInt,
IsOptional,
Min,
} from 'class-validator';
export class CreateDepartmentDto {
@IsString()
name: string;
@IsString()
code: string;
@IsInt()
@IsOptional()
parentId?: number;
@IsString()
@IsOptional()
description?: string;
@IsInt()
@Min(0)
@IsOptional()
sort?: number;
}

View File

@ -1,30 +0,0 @@
import {
IsString,
IsInt,
IsOptional,
Min,
} from 'class-validator';
export class UpdateDepartmentDto {
@IsString()
@IsOptional()
name?: string;
@IsString()
@IsOptional()
code?: string;
@IsInt()
@IsOptional()
parentId?: number | null;
@IsString()
@IsOptional()
description?: string;
@IsInt()
@Min(0)
@IsOptional()
sort?: number;
}

View File

@ -1,23 +0,0 @@
import {
IsString,
IsInt,
IsOptional,
Min,
} from 'class-validator';
export class CreateGradeDto {
@IsString()
name: string;
@IsString()
code: string;
@IsInt()
@Min(1)
level: number;
@IsString()
@IsOptional()
description?: string;
}

View File

@ -1,26 +0,0 @@
import {
IsString,
IsInt,
IsOptional,
Min,
} from 'class-validator';
export class UpdateGradeDto {
@IsString()
@IsOptional()
name?: string;
@IsString()
@IsOptional()
code?: string;
@IsInt()
@Min(1)
@IsOptional()
level?: number;
@IsString()
@IsOptional()
description?: string;
}

View File

@ -1,70 +0,0 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
Query,
UseGuards,
Request,
} from '@nestjs/common';
import { GradesService } from './grades.service';
import { CreateGradeDto } from './dto/create-grade.dto';
import { UpdateGradeDto } from './dto/update-grade.dto';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
@Controller('grades')
@UseGuards(JwtAuthGuard)
export class GradesController {
constructor(private readonly gradesService: GradesService) {}
@Post()
create(@Body() createGradeDto: CreateGradeDto, @Request() req) {
const tenantId = req.tenantId || req.user?.tenantId;
if (!tenantId) {
throw new Error('无法确定租户信息');
}
const creatorId = req.user?.id;
return this.gradesService.create(createGradeDto, tenantId, creatorId);
}
@Get()
findAll(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Request() req?: any,
) {
const tenantId = req?.tenantId || req?.user?.tenantId;
return this.gradesService.findAll(
page ? parseInt(page) : 1,
pageSize ? parseInt(pageSize) : 10,
tenantId,
);
}
@Get(':id')
findOne(@Param('id') id: string, @Request() req) {
const tenantId = req.tenantId || req.user?.tenantId;
return this.gradesService.findOne(+id, tenantId);
}
@Patch(':id')
update(
@Param('id') id: string,
@Body() updateGradeDto: UpdateGradeDto,
@Request() req,
) {
const tenantId = req.tenantId || req.user?.tenantId;
const modifierId = req.user?.id;
return this.gradesService.update(+id, updateGradeDto, tenantId, modifierId);
}
@Delete(':id')
remove(@Param('id') id: string, @Request() req) {
const tenantId = req.tenantId || req.user?.tenantId;
return this.gradesService.remove(+id, tenantId);
}
}

View File

@ -1,13 +0,0 @@
import { Module } from '@nestjs/common';
import { GradesService } from './grades.service';
import { GradesController } from './grades.controller';
import { PrismaModule } from '../../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [GradesController],
providers: [GradesService],
exports: [GradesService],
})
export class GradesModule {}

View File

@ -1,203 +0,0 @@
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateGradeDto } from './dto/create-grade.dto';
import { UpdateGradeDto } from './dto/update-grade.dto';
@Injectable()
export class GradesService {
constructor(private prisma: PrismaService) {}
async create(createGradeDto: CreateGradeDto, tenantId: number, creatorId?: number) {
// 检查年级编码是否已存在
const existingByCode = await this.prisma.grade.findFirst({
where: {
tenantId,
code: createGradeDto.code,
},
});
if (existingByCode) {
throw new ConflictException('年级编码已存在');
}
// 检查年级级别是否已存在
const existingByLevel = await this.prisma.grade.findFirst({
where: {
tenantId,
level: createGradeDto.level,
},
});
if (existingByLevel) {
throw new ConflictException('年级级别已存在');
}
const data: any = {
...createGradeDto,
tenantId,
};
if (creatorId) {
data.creator = creatorId;
}
return this.prisma.grade.create({
data,
include: {
classes: {
where: {
validState: 1,
},
},
},
});
}
async findAll(page: number = 1, pageSize: number = 10, tenantId?: number) {
const skip = (page - 1) * pageSize;
const where = tenantId ? { tenantId, validState: 1 } : { validState: 1 };
const [list, total] = await Promise.all([
this.prisma.grade.findMany({
where,
skip,
take: pageSize,
orderBy: {
level: 'asc',
},
include: {
_count: {
select: {
classes: {
where: {
validState: 1,
},
},
},
},
},
}),
this.prisma.grade.count({ where }),
]);
return {
list,
total,
page,
pageSize,
};
}
async findOne(id: number, tenantId?: number) {
const where: any = { id, validState: 1 };
if (tenantId) {
where.tenantId = tenantId;
}
const grade = await this.prisma.grade.findFirst({
where,
include: {
classes: {
where: {
validState: 1,
},
},
},
});
if (!grade) {
throw new NotFoundException('年级不存在');
}
return grade;
}
async update(id: number, updateGradeDto: UpdateGradeDto, tenantId?: number, modifierId?: number) {
// 验证年级是否存在
const existingGrade = await this.findOne(id, tenantId);
const data: any = {};
// 如果更新编码,检查是否冲突
if (updateGradeDto.code && updateGradeDto.code !== existingGrade.code) {
const existingByCode = await this.prisma.grade.findFirst({
where: {
tenantId: tenantId || existingGrade.tenantId,
code: updateGradeDto.code,
id: { not: id },
},
});
if (existingByCode) {
throw new ConflictException('年级编码已存在');
}
data.code = updateGradeDto.code;
}
// 如果更新级别,检查是否冲突
if (updateGradeDto.level && updateGradeDto.level !== existingGrade.level) {
const existingByLevel = await this.prisma.grade.findFirst({
where: {
tenantId: tenantId || existingGrade.tenantId,
level: updateGradeDto.level,
id: { not: id },
},
});
if (existingByLevel) {
throw new ConflictException('年级级别已存在');
}
data.level = updateGradeDto.level;
}
if (updateGradeDto.name) {
data.name = updateGradeDto.name;
}
if (updateGradeDto.description !== undefined) {
data.description = updateGradeDto.description;
}
if (modifierId) {
data.modifier = modifierId;
}
return this.prisma.grade.update({
where: { id },
data,
include: {
classes: {
where: {
validState: 1,
},
},
},
});
}
async remove(id: number, tenantId?: number) {
// 验证年级是否存在
const grade = await this.findOne(id, tenantId);
// 检查是否有班级关联
const classCount = await this.prisma.class.count({
where: {
gradeId: id,
validState: 1,
},
});
if (classCount > 0) {
throw new BadRequestException('删除年级前需先删除所有班级');
}
// 软删除
return this.prisma.grade.update({
where: { id },
data: {
validState: 2,
},
});
}
}

View File

@ -1,28 +0,0 @@
import { Module } from '@nestjs/common';
import { SchoolsModule } from './schools/schools.module';
import { GradesModule } from './grades/grades.module';
import { ClassesModule } from './classes/classes.module';
import { DepartmentsModule } from './departments/departments.module';
import { TeachersModule } from './teachers/teachers.module';
import { StudentsModule } from './students/students.module';
@Module({
imports: [
SchoolsModule,
GradesModule,
ClassesModule,
DepartmentsModule,
TeachersModule,
StudentsModule,
],
exports: [
SchoolsModule,
GradesModule,
ClassesModule,
DepartmentsModule,
TeachersModule,
StudentsModule,
],
})
export class SchoolModule {}

View File

@ -1,37 +0,0 @@
import {
IsString,
IsOptional,
IsDateString,
IsUrl,
} from 'class-validator';
export class CreateSchoolDto {
@IsString()
@IsOptional()
address?: string;
@IsString()
@IsOptional()
phone?: string;
@IsString()
@IsOptional()
principal?: string;
@IsDateString()
@IsOptional()
established?: string;
@IsString()
@IsOptional()
description?: string;
@IsUrl()
@IsOptional()
logo?: string;
@IsUrl()
@IsOptional()
website?: string;
}

View File

@ -1,37 +0,0 @@
import {
IsString,
IsOptional,
IsDateString,
IsUrl,
} from 'class-validator';
export class UpdateSchoolDto {
@IsString()
@IsOptional()
address?: string;
@IsString()
@IsOptional()
phone?: string;
@IsString()
@IsOptional()
principal?: string;
@IsDateString()
@IsOptional()
established?: string;
@IsString()
@IsOptional()
description?: string;
@IsUrl()
@IsOptional()
logo?: string;
@IsUrl()
@IsOptional()
website?: string;
}

View File

@ -1,59 +0,0 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Delete,
UseGuards,
Request,
} from '@nestjs/common';
import { SchoolsService } from './schools.service';
import { CreateSchoolDto } from './dto/create-school.dto';
import { UpdateSchoolDto } from './dto/update-school.dto';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
@Controller('schools')
@UseGuards(JwtAuthGuard)
export class SchoolsController {
constructor(private readonly schoolsService: SchoolsService) {}
@Post()
create(@Body() createSchoolDto: CreateSchoolDto, @Request() req) {
const tenantId = req.tenantId || req.user?.tenantId;
if (!tenantId) {
throw new Error('无法确定租户信息');
}
const creatorId = req.user?.id;
return this.schoolsService.create(createSchoolDto, tenantId, creatorId);
}
@Get()
findOne(@Request() req) {
const tenantId = req.tenantId || req.user?.tenantId;
if (!tenantId) {
throw new Error('无法确定租户信息');
}
return this.schoolsService.findOne(tenantId);
}
@Patch()
update(@Body() updateSchoolDto: UpdateSchoolDto, @Request() req) {
const tenantId = req.tenantId || req.user?.tenantId;
if (!tenantId) {
throw new Error('无法确定租户信息');
}
const modifierId = req.user?.id;
return this.schoolsService.update(tenantId, updateSchoolDto, modifierId);
}
@Delete()
remove(@Request() req) {
const tenantId = req.tenantId || req.user?.tenantId;
if (!tenantId) {
throw new Error('无法确定租户信息');
}
return this.schoolsService.remove(tenantId);
}
}

View File

@ -1,13 +0,0 @@
import { Module } from '@nestjs/common';
import { SchoolsService } from './schools.service';
import { SchoolsController } from './schools.controller';
import { PrismaModule } from '../../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [SchoolsController],
providers: [SchoolsService],
exports: [SchoolsService],
})
export class SchoolsModule {}

View File

@ -1,112 +0,0 @@
import {
Injectable,
NotFoundException,
ConflictException,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateSchoolDto } from './dto/create-school.dto';
import { UpdateSchoolDto } from './dto/update-school.dto';
@Injectable()
export class SchoolsService {
constructor(private prisma: PrismaService) {}
async create(
createSchoolDto: CreateSchoolDto,
tenantId: number,
creatorId?: number,
) {
// 检查租户是否已有学校信息
const existingSchool = await this.prisma.school.findUnique({
where: { tenantId },
});
if (existingSchool) {
throw new ConflictException('该租户已存在学校信息');
}
// 检查租户是否存在
const tenant = await this.prisma.tenant.findUnique({
where: { id: tenantId },
});
if (!tenant) {
throw new NotFoundException('租户不存在');
}
const data: any = {
...createSchoolDto,
tenantId,
};
if (createSchoolDto.established) {
data.established = new Date(createSchoolDto.established);
}
if (creatorId) {
data.creator = creatorId;
}
return this.prisma.school.create({
data,
include: {
tenant: true,
},
});
}
async findOne(tenantId: number) {
const school = await this.prisma.school.findUnique({
where: { tenantId },
include: {
tenant: true,
},
});
// 学校信息不存在时返回 null而不是抛出异常
// 因为这是一个正常的业务状态(学校信息可能还没有创建)
return school || null;
}
async update(
tenantId: number,
updateSchoolDto: UpdateSchoolDto,
modifierId?: number,
) {
// 验证学校是否存在
const school = await this.findOne(tenantId);
if (!school) {
throw new NotFoundException('学校信息不存在');
}
const data: any = { ...updateSchoolDto };
if (updateSchoolDto.established) {
data.established = new Date(updateSchoolDto.established);
}
if (modifierId) {
data.modifier = modifierId;
}
return this.prisma.school.update({
where: { tenantId },
data,
include: {
tenant: true,
},
});
}
async remove(tenantId: number) {
// 验证学校是否存在
const school = await this.findOne(tenantId);
if (!school) {
throw new NotFoundException('学校信息不存在');
}
return this.prisma.school.delete({
where: { tenantId },
});
}
}

View File

@ -1,81 +0,0 @@
import {
IsString,
IsInt,
IsOptional,
IsEmail,
IsDateString,
IsIn,
IsArray,
} from 'class-validator';
export class CreateStudentDto {
@IsString()
username: string;
@IsString()
password: string;
@IsString()
nickname: string;
@IsEmail()
@IsOptional()
email?: string;
@IsString()
@IsOptional()
avatar?: string;
@IsInt()
classId: number; // 行政班级ID
@IsString()
@IsOptional()
studentNo?: string;
@IsString()
@IsOptional()
phone?: string;
@IsString()
@IsOptional()
idCard?: string;
@IsInt()
@IsIn([1, 2])
@IsOptional()
gender?: number; // 1-男2-女
@IsDateString()
@IsOptional()
birthDate?: string;
@IsDateString()
@IsOptional()
enrollmentDate?: string;
@IsString()
@IsOptional()
parentName?: string;
@IsString()
@IsOptional()
parentPhone?: string;
@IsString()
@IsOptional()
address?: string;
@IsString()
@IsOptional()
description?: string;
@IsArray()
@IsInt({ each: true })
@IsOptional()
interestClassIds?: number[]; // 兴趣班ID数组
@IsInt()
@IsOptional()
validState?: number; // 有效状态1-有效0-无效
}

View File

@ -1,79 +0,0 @@
import {
IsString,
IsInt,
IsOptional,
IsEmail,
IsDateString,
IsIn,
IsArray,
} from 'class-validator';
export class UpdateStudentDto {
@IsString()
@IsOptional()
nickname?: string;
@IsEmail()
@IsOptional()
email?: string;
@IsString()
@IsOptional()
avatar?: string;
@IsInt()
@IsOptional()
classId?: number; // 行政班级ID
@IsString()
@IsOptional()
studentNo?: string;
@IsString()
@IsOptional()
phone?: string;
@IsString()
@IsOptional()
idCard?: string;
@IsInt()
@IsIn([1, 2])
@IsOptional()
gender?: number;
@IsDateString()
@IsOptional()
birthDate?: string;
@IsDateString()
@IsOptional()
enrollmentDate?: string;
@IsString()
@IsOptional()
parentName?: string;
@IsString()
@IsOptional()
parentPhone?: string;
@IsString()
@IsOptional()
address?: string;
@IsString()
@IsOptional()
description?: string;
@IsInt()
@IsIn([1, 2])
@IsOptional()
validState?: number; // 1-有效2-失效
@IsArray()
@IsInt({ each: true })
@IsOptional()
interestClassIds?: number[]; // 兴趣班ID数组
}

View File

@ -1,78 +0,0 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
Query,
UseGuards,
Request,
} from '@nestjs/common';
import { StudentsService } from './students.service';
import { CreateStudentDto } from './dto/create-student.dto';
import { UpdateStudentDto } from './dto/update-student.dto';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
@Controller('students')
@UseGuards(JwtAuthGuard)
export class StudentsController {
constructor(private readonly studentsService: StudentsService) {}
@Post()
create(@Body() createStudentDto: CreateStudentDto, @Request() req) {
const tenantId = req.tenantId || req.user?.tenantId;
if (!tenantId) {
throw new Error('无法确定租户信息');
}
const creatorId = req.user?.id;
return this.studentsService.create(createStudentDto, tenantId, creatorId);
}
@Get()
findAll(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('classId') classId?: string,
@Request() req?: any,
) {
const tenantId = req?.tenantId || req?.user?.tenantId;
return this.studentsService.findAll(
page ? parseInt(page) : 1,
pageSize ? parseInt(pageSize) : 10,
tenantId,
classId ? parseInt(classId) : undefined,
);
}
@Get('user/:userId')
findByUserId(@Param('userId') userId: string, @Request() req) {
const tenantId = req.tenantId || req.user?.tenantId;
return this.studentsService.findByUserId(+userId, tenantId);
}
@Get(':id')
findOne(@Param('id') id: string, @Request() req) {
const tenantId = req.tenantId || req.user?.tenantId;
return this.studentsService.findOne(+id, tenantId);
}
@Patch(':id')
update(
@Param('id') id: string,
@Body() updateStudentDto: UpdateStudentDto,
@Request() req,
) {
const tenantId = req.tenantId || req.user?.tenantId;
const modifierId = req.user?.id;
return this.studentsService.update(+id, updateStudentDto, tenantId, modifierId);
}
@Delete(':id')
remove(@Param('id') id: string, @Request() req) {
const tenantId = req.tenantId || req.user?.tenantId;
return this.studentsService.remove(+id, tenantId);
}
}

View File

@ -1,13 +0,0 @@
import { Module } from '@nestjs/common';
import { StudentsService } from './students.service';
import { StudentsController } from './students.controller';
import { PrismaModule } from '../../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [StudentsController],
providers: [StudentsService],
exports: [StudentsService],
})
export class StudentsModule {}

View File

@ -1,542 +0,0 @@
import {
Injectable,
NotFoundException,
ConflictException,
BadRequestException,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateStudentDto } from './dto/create-student.dto';
import { UpdateStudentDto } from './dto/update-student.dto';
import * as bcrypt from 'bcrypt';
@Injectable()
export class StudentsService {
constructor(private prisma: PrismaService) {}
async create(
createStudentDto: CreateStudentDto,
tenantId: number,
creatorId?: number,
) {
// 验证行政班级是否存在且属于该租户,且是行政班级
const classEntity = await this.prisma.class.findFirst({
where: {
id: createStudentDto.classId,
tenantId,
type: 1, // 必须是行政班级
validState: 1,
},
});
if (!classEntity) {
throw new BadRequestException('行政班级不存在或不属于该租户');
}
// 检查用户名是否已存在
const existingUser = await this.prisma.user.findFirst({
where: {
tenantId,
username: createStudentDto.username,
},
});
if (existingUser) {
throw new ConflictException('用户名已存在');
}
// 如果提供了学号,检查学号是否已存在
if (createStudentDto.studentNo) {
const existingStudent = await this.prisma.student.findFirst({
where: {
tenantId,
studentNo: createStudentDto.studentNo,
},
});
if (existingStudent) {
throw new ConflictException('学号已存在');
}
}
// 如果提供了兴趣班,验证兴趣班是否存在且是兴趣班类型
if (
createStudentDto.interestClassIds &&
createStudentDto.interestClassIds.length > 0
) {
const interestClasses = await this.prisma.class.findMany({
where: {
id: { in: createStudentDto.interestClassIds },
tenantId,
type: 2, // 必须是兴趣班
validState: 1,
},
});
if (interestClasses.length !== createStudentDto.interestClassIds.length) {
throw new NotFoundException('部分兴趣班不存在或不属于该租户');
}
}
// 加密密码
const hashedPassword = await bcrypt.hash(createStudentDto.password, 10);
// 创建 User 记录
const userData: any = {
username: createStudentDto.username,
password: hashedPassword,
nickname: createStudentDto.nickname,
tenantId,
};
if (createStudentDto.email) {
userData.email = createStudentDto.email;
}
if (createStudentDto.avatar) {
userData.avatar = createStudentDto.avatar;
}
if (creatorId) {
userData.creator = creatorId;
}
// 创建 Student 记录
const studentData: any = {
classId: createStudentDto.classId,
tenantId,
};
if (createStudentDto.studentNo) {
studentData.studentNo = createStudentDto.studentNo;
}
if (createStudentDto.phone) {
studentData.phone = createStudentDto.phone;
}
if (createStudentDto.idCard) {
studentData.idCard = createStudentDto.idCard;
}
if (createStudentDto.gender) {
studentData.gender = createStudentDto.gender;
}
if (createStudentDto.birthDate) {
studentData.birthDate = new Date(createStudentDto.birthDate);
}
if (createStudentDto.enrollmentDate) {
studentData.enrollmentDate = new Date(createStudentDto.enrollmentDate);
}
if (createStudentDto.parentName) {
studentData.parentName = createStudentDto.parentName;
}
if (createStudentDto.parentPhone) {
studentData.parentPhone = createStudentDto.parentPhone;
}
if (createStudentDto.address) {
studentData.address = createStudentDto.address;
}
if (createStudentDto.description) {
studentData.description = createStudentDto.description;
}
if (creatorId) {
studentData.creator = creatorId;
}
// 使用事务创建 User、Student 和兴趣班关联
return this.prisma.$transaction(async (tx) => {
const user = await tx.user.create({
data: userData,
});
const student = await tx.student.create({
data: {
...studentData,
userId: user.id,
},
include: {
user: true,
class: {
include: {
grade: true,
},
},
},
});
// 创建兴趣班关联
if (
createStudentDto.interestClassIds &&
createStudentDto.interestClassIds.length > 0
) {
await tx.studentInterestClass.createMany({
data: createStudentDto.interestClassIds.map((classId) => ({
studentId: student.id,
classId,
})),
});
}
// 重新查询以包含兴趣班信息
return tx.student.findUnique({
where: { id: student.id },
include: {
user: true,
class: {
include: {
grade: true,
},
},
interestClasses: {
include: {
class: {
include: {
grade: true,
},
},
},
},
},
});
});
}
async findAll(
page: number = 1,
pageSize: number = 10,
tenantId?: number,
classId?: number,
) {
const skip = (page - 1) * pageSize;
const where: any = { validState: 1 };
if (tenantId) {
where.tenantId = tenantId;
}
if (classId) {
where.classId = classId;
}
const [list, total] = await Promise.all([
this.prisma.student.findMany({
where,
skip,
take: pageSize,
orderBy: {
createTime: 'desc',
},
include: {
user: true,
class: {
include: {
grade: true,
},
},
interestClasses: {
include: {
class: {
include: {
grade: true,
},
},
},
},
},
}),
this.prisma.student.count({ where }),
]);
return {
list,
total,
page,
pageSize,
};
}
async findOne(id: number, tenantId?: number) {
const where: any = { id, validState: 1 };
if (tenantId) {
where.tenantId = tenantId;
}
const student = await this.prisma.student.findFirst({
where,
include: {
user: true,
class: {
include: {
grade: true,
},
},
interestClasses: {
include: {
class: {
include: {
grade: true,
},
},
},
},
},
});
if (!student) {
throw new NotFoundException('学生不存在');
}
return student;
}
async findByUserId(userId: number, tenantId?: number) {
const where: any = { userId, validState: 1 };
if (tenantId) {
where.tenantId = tenantId;
}
const student = await this.prisma.student.findFirst({
where,
include: {
user: true,
class: {
include: {
grade: true,
},
},
interestClasses: {
include: {
class: {
include: {
grade: true,
},
},
},
},
},
});
if (!student) {
throw new NotFoundException('学生不存在');
}
return student;
}
async update(
id: number,
updateStudentDto: UpdateStudentDto,
tenantId?: number,
modifierId?: number,
) {
// 验证学生是否存在
const existingStudent = await this.findOne(id, tenantId);
// 如果更新行政班级,验证班级是否存在且是行政班级
if (
updateStudentDto.classId &&
updateStudentDto.classId !== existingStudent.classId
) {
const classEntity = await this.prisma.class.findFirst({
where: {
id: updateStudentDto.classId,
tenantId: tenantId || existingStudent.tenantId,
type: 1, // 必须是行政班级
validState: 1,
},
});
if (!classEntity) {
throw new NotFoundException('行政班级不存在或不属于该租户');
}
}
// 如果更新学号,检查是否冲突
if (
updateStudentDto.studentNo &&
updateStudentDto.studentNo !== existingStudent.studentNo
) {
const existingByStudentNo = await this.prisma.student.findFirst({
where: {
tenantId: tenantId || existingStudent.tenantId,
studentNo: updateStudentDto.studentNo,
id: { not: id },
},
});
if (existingByStudentNo) {
throw new ConflictException('学号已存在');
}
}
// 更新 Student 记录
const studentData: any = {};
if (updateStudentDto.classId !== undefined) {
studentData.classId = updateStudentDto.classId;
}
if (updateStudentDto.studentNo !== undefined) {
studentData.studentNo = updateStudentDto.studentNo;
}
if (updateStudentDto.phone !== undefined) {
studentData.phone = updateStudentDto.phone;
}
if (updateStudentDto.idCard !== undefined) {
studentData.idCard = updateStudentDto.idCard;
}
if (updateStudentDto.gender !== undefined) {
studentData.gender = updateStudentDto.gender;
}
if (updateStudentDto.birthDate !== undefined) {
studentData.birthDate = new Date(updateStudentDto.birthDate);
}
if (updateStudentDto.enrollmentDate !== undefined) {
studentData.enrollmentDate = new Date(updateStudentDto.enrollmentDate);
}
if (updateStudentDto.parentName !== undefined) {
studentData.parentName = updateStudentDto.parentName;
}
if (updateStudentDto.parentPhone !== undefined) {
studentData.parentPhone = updateStudentDto.parentPhone;
}
if (updateStudentDto.address !== undefined) {
studentData.address = updateStudentDto.address;
}
if (updateStudentDto.description !== undefined) {
studentData.description = updateStudentDto.description;
}
if (updateStudentDto.validState !== undefined) {
studentData.validState = updateStudentDto.validState;
}
if (modifierId) {
studentData.modifier = modifierId;
}
// 更新 User 记录
const userData: any = {};
if (updateStudentDto.nickname !== undefined) {
userData.nickname = updateStudentDto.nickname;
}
if (updateStudentDto.email !== undefined) {
userData.email = updateStudentDto.email;
}
if (updateStudentDto.avatar !== undefined) {
userData.avatar = updateStudentDto.avatar;
}
if (updateStudentDto.validState !== undefined) {
userData.validState = updateStudentDto.validState;
}
if (modifierId) {
userData.modifier = modifierId;
}
// 使用事务更新 User、Student 和兴趣班关联
return this.prisma.$transaction(async (tx) => {
if (Object.keys(userData).length > 0) {
await tx.user.update({
where: { id: existingStudent.userId },
data: userData,
});
}
const student = await tx.student.update({
where: { id },
data: studentData,
});
// 如果提供了兴趣班ID数组更新兴趣班关联
if (updateStudentDto.interestClassIds !== undefined) {
// 验证兴趣班是否存在且是兴趣班类型
if (updateStudentDto.interestClassIds.length > 0) {
const interestClasses = await tx.class.findMany({
where: {
id: { in: updateStudentDto.interestClassIds },
tenantId: tenantId || existingStudent.tenantId,
type: 2, // 必须是兴趣班
validState: 1,
},
});
if (
interestClasses.length !== updateStudentDto.interestClassIds.length
) {
throw new NotFoundException('部分兴趣班不存在或不属于该租户');
}
}
// 删除现有关联
await tx.studentInterestClass.deleteMany({
where: {
studentId: id,
},
});
// 创建新关联
if (updateStudentDto.interestClassIds.length > 0) {
await tx.studentInterestClass.createMany({
data: updateStudentDto.interestClassIds.map((classId) => ({
studentId: id,
classId,
})),
});
}
}
// 重新查询以包含所有关联信息
return tx.student.findUnique({
where: { id },
include: {
user: true,
class: {
include: {
grade: true,
},
},
interestClasses: {
include: {
class: {
include: {
grade: true,
},
},
},
},
},
});
});
}
async remove(id: number, tenantId?: number) {
// 验证学生是否存在
await this.findOne(id, tenantId);
// 删除学生会级联删除 User 和兴趣班关联(通过 Prisma schema 的 onDelete: Cascade
return this.prisma.student.delete({
where: { id },
});
}
}

View File

@ -1,71 +0,0 @@
import {
IsString,
IsInt,
IsOptional,
IsEmail,
IsDateString,
IsIn,
} from 'class-validator';
export class CreateTeacherDto {
@IsString()
username: string;
@IsString()
password: string;
@IsString()
nickname: string;
@IsEmail()
@IsOptional()
email?: string;
@IsString()
@IsOptional()
avatar?: string;
@IsInt()
departmentId: number;
@IsString()
@IsOptional()
employeeNo?: string;
@IsString()
@IsOptional()
phone?: string;
@IsString()
@IsOptional()
idCard?: string;
@IsInt()
@IsIn([1, 2])
@IsOptional()
gender?: number; // 1-男2-女
@IsDateString()
@IsOptional()
birthDate?: string;
@IsDateString()
@IsOptional()
hireDate?: string;
@IsString()
@IsOptional()
subject?: string;
@IsString()
@IsOptional()
title?: string;
@IsString()
@IsOptional()
description?: string;
@IsInt()
@IsOptional()
validState?: number; // 有效状态1-有效0-无效
}

View File

@ -1,69 +0,0 @@
import {
IsString,
IsInt,
IsOptional,
IsEmail,
IsDateString,
IsIn,
} from 'class-validator';
export class UpdateTeacherDto {
@IsString()
@IsOptional()
nickname?: string;
@IsEmail()
@IsOptional()
email?: string;
@IsString()
@IsOptional()
avatar?: string;
@IsInt()
@IsOptional()
departmentId?: number;
@IsString()
@IsOptional()
employeeNo?: string;
@IsString()
@IsOptional()
phone?: string;
@IsString()
@IsOptional()
idCard?: string;
@IsInt()
@IsIn([1, 2])
@IsOptional()
gender?: number;
@IsDateString()
@IsOptional()
birthDate?: string;
@IsDateString()
@IsOptional()
hireDate?: string;
@IsString()
@IsOptional()
subject?: string;
@IsString()
@IsOptional()
title?: string;
@IsString()
@IsOptional()
description?: string;
@IsInt()
@IsIn([1, 2])
@IsOptional()
validState?: number; // 1-有效2-失效
}

View File

@ -1,82 +0,0 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
Query,
UseGuards,
Request,
} from '@nestjs/common';
import { TeachersService } from './teachers.service';
import { CreateTeacherDto } from './dto/create-teacher.dto';
import { UpdateTeacherDto } from './dto/update-teacher.dto';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
@Controller('teachers')
@UseGuards(JwtAuthGuard)
export class TeachersController {
constructor(private readonly teachersService: TeachersService) {}
@Post()
create(@Body() createTeacherDto: CreateTeacherDto, @Request() req) {
const tenantId = req.tenantId || req.user?.tenantId;
if (!tenantId) {
throw new Error('无法确定租户信息');
}
const creatorId = req.user?.id;
return this.teachersService.create(createTeacherDto, tenantId, creatorId);
}
@Get()
findAll(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('departmentId') departmentId?: string,
@Query('nickname') nickname?: string,
@Query('username') username?: string,
@Request() req?: any,
) {
const tenantId = req?.tenantId || req?.user?.tenantId;
return this.teachersService.findAll(
page ? parseInt(page) : 1,
pageSize ? parseInt(pageSize) : 10,
tenantId,
departmentId ? parseInt(departmentId) : undefined,
nickname,
username,
);
}
@Get('user/:userId')
findByUserId(@Param('userId') userId: string, @Request() req) {
const tenantId = req.tenantId || req.user?.tenantId;
return this.teachersService.findByUserId(+userId, tenantId);
}
@Get(':id')
findOne(@Param('id') id: string, @Request() req) {
const tenantId = req.tenantId || req.user?.tenantId;
return this.teachersService.findOne(+id, tenantId);
}
@Patch(':id')
update(
@Param('id') id: string,
@Body() updateTeacherDto: UpdateTeacherDto,
@Request() req,
) {
const tenantId = req.tenantId || req.user?.tenantId;
const modifierId = req.user?.id;
return this.teachersService.update(+id, updateTeacherDto, tenantId, modifierId);
}
@Delete(':id')
remove(@Param('id') id: string, @Request() req) {
const tenantId = req.tenantId || req.user?.tenantId;
return this.teachersService.remove(+id, tenantId);
}
}

View File

@ -1,13 +0,0 @@
import { Module } from '@nestjs/common';
import { TeachersService } from './teachers.service';
import { TeachersController } from './teachers.controller';
import { PrismaModule } from '../../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [TeachersController],
providers: [TeachersService],
exports: [TeachersService],
})
export class TeachersModule {}

View File

@ -1,395 +0,0 @@
import {
Injectable,
NotFoundException,
ConflictException,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateTeacherDto } from './dto/create-teacher.dto';
import { UpdateTeacherDto } from './dto/update-teacher.dto';
import * as bcrypt from 'bcrypt';
@Injectable()
export class TeachersService {
constructor(private prisma: PrismaService) {}
async create(
createTeacherDto: CreateTeacherDto,
tenantId: number,
creatorId?: number,
) {
// 验证部门是否存在且属于该租户
const department = await this.prisma.department.findFirst({
where: {
id: createTeacherDto.departmentId,
tenantId,
validState: 1,
},
});
if (!department) {
throw new NotFoundException('部门不存在或不属于该租户');
}
// 检查用户名是否已存在
const existingUser = await this.prisma.user.findFirst({
where: {
tenantId,
username: createTeacherDto.username,
},
});
if (existingUser) {
throw new ConflictException('用户名已存在');
}
// 如果提供了工号,检查工号是否已存在
if (createTeacherDto.employeeNo) {
const existingTeacher = await this.prisma.teacher.findFirst({
where: {
tenantId,
employeeNo: createTeacherDto.employeeNo,
},
});
if (existingTeacher) {
throw new ConflictException('工号已存在');
}
}
// 加密密码
const hashedPassword = await bcrypt.hash(createTeacherDto.password, 10);
// 创建 User 记录
const userData: any = {
username: createTeacherDto.username,
password: hashedPassword,
nickname: createTeacherDto.nickname,
tenantId,
};
if (createTeacherDto.email) {
userData.email = createTeacherDto.email;
}
if (createTeacherDto.avatar) {
userData.avatar = createTeacherDto.avatar;
}
if (creatorId) {
userData.creator = creatorId;
}
// 创建 Teacher 记录
const teacherData: any = {
departmentId: createTeacherDto.departmentId,
tenantId,
};
if (createTeacherDto.employeeNo) {
teacherData.employeeNo = createTeacherDto.employeeNo;
}
if (createTeacherDto.phone) {
teacherData.phone = createTeacherDto.phone;
}
if (createTeacherDto.idCard) {
teacherData.idCard = createTeacherDto.idCard;
}
if (createTeacherDto.gender) {
teacherData.gender = createTeacherDto.gender;
}
if (createTeacherDto.birthDate) {
teacherData.birthDate = new Date(createTeacherDto.birthDate);
}
if (createTeacherDto.hireDate) {
teacherData.hireDate = new Date(createTeacherDto.hireDate);
}
if (createTeacherDto.subject) {
teacherData.subject = createTeacherDto.subject;
}
if (createTeacherDto.title) {
teacherData.title = createTeacherDto.title;
}
if (createTeacherDto.description) {
teacherData.description = createTeacherDto.description;
}
if (creatorId) {
teacherData.creator = creatorId;
}
// 使用事务创建 User 和 Teacher
return this.prisma.$transaction(async (tx) => {
const user = await tx.user.create({
data: userData,
});
const teacher = await tx.teacher.create({
data: {
...teacherData,
userId: user.id,
},
include: {
user: true,
department: true,
},
});
return teacher;
});
}
async findAll(
page: number = 1,
pageSize: number = 10,
tenantId?: number,
departmentId?: number,
nickname?: string,
username?: string,
) {
const skip = (page - 1) * pageSize;
const where: any = { validState: 1 };
if (tenantId) {
where.tenantId = tenantId;
}
if (departmentId) {
where.departmentId = departmentId;
}
// 支持通过用户昵称和用户名搜索
if (nickname || username) {
where.user = {};
if (nickname) {
where.user.nickname = { contains: nickname };
}
if (username) {
where.user.username = { contains: username };
}
}
const [list, total] = await Promise.all([
this.prisma.teacher.findMany({
where,
skip,
take: pageSize,
orderBy: {
createTime: 'desc',
},
include: {
user: true,
department: true,
},
}),
this.prisma.teacher.count({ where }),
]);
return {
list,
total,
page,
pageSize,
};
}
async findOne(id: number, tenantId?: number) {
const where: any = { id, validState: 1 };
if (tenantId) {
where.tenantId = tenantId;
}
const teacher = await this.prisma.teacher.findFirst({
where,
include: {
user: true,
department: true,
},
});
if (!teacher) {
throw new NotFoundException('教师不存在');
}
return teacher;
}
async findByUserId(userId: number, tenantId?: number) {
const where: any = { userId, validState: 1 };
if (tenantId) {
where.tenantId = tenantId;
}
const teacher = await this.prisma.teacher.findFirst({
where,
include: {
user: true,
department: true,
},
});
if (!teacher) {
throw new NotFoundException('教师不存在');
}
return teacher;
}
async update(
id: number,
updateTeacherDto: UpdateTeacherDto,
tenantId?: number,
modifierId?: number,
) {
// 验证教师是否存在
const existingTeacher = await this.findOne(id, tenantId);
// 如果更新部门,验证部门是否存在
if (
updateTeacherDto.departmentId &&
updateTeacherDto.departmentId !== existingTeacher.departmentId
) {
const department = await this.prisma.department.findFirst({
where: {
id: updateTeacherDto.departmentId,
tenantId: tenantId || existingTeacher.tenantId,
validState: 1,
},
});
if (!department) {
throw new NotFoundException('部门不存在或不属于该租户');
}
}
// 如果更新工号,检查是否冲突
if (
updateTeacherDto.employeeNo &&
updateTeacherDto.employeeNo !== existingTeacher.employeeNo
) {
const existingByEmployeeNo = await this.prisma.teacher.findFirst({
where: {
tenantId: tenantId || existingTeacher.tenantId,
employeeNo: updateTeacherDto.employeeNo,
id: { not: id },
},
});
if (existingByEmployeeNo) {
throw new ConflictException('工号已存在');
}
}
// 更新 Teacher 记录
const teacherData: any = {};
if (updateTeacherDto.departmentId !== undefined) {
teacherData.departmentId = updateTeacherDto.departmentId;
}
if (updateTeacherDto.employeeNo !== undefined) {
teacherData.employeeNo = updateTeacherDto.employeeNo;
}
if (updateTeacherDto.phone !== undefined) {
teacherData.phone = updateTeacherDto.phone;
}
if (updateTeacherDto.idCard !== undefined) {
teacherData.idCard = updateTeacherDto.idCard;
}
if (updateTeacherDto.gender !== undefined) {
teacherData.gender = updateTeacherDto.gender;
}
if (updateTeacherDto.birthDate !== undefined) {
teacherData.birthDate = new Date(updateTeacherDto.birthDate);
}
if (updateTeacherDto.hireDate !== undefined) {
teacherData.hireDate = new Date(updateTeacherDto.hireDate);
}
if (updateTeacherDto.subject !== undefined) {
teacherData.subject = updateTeacherDto.subject;
}
if (updateTeacherDto.title !== undefined) {
teacherData.title = updateTeacherDto.title;
}
if (updateTeacherDto.description !== undefined) {
teacherData.description = updateTeacherDto.description;
}
if (updateTeacherDto.validState !== undefined) {
teacherData.validState = updateTeacherDto.validState;
}
if (modifierId) {
teacherData.modifier = modifierId;
}
// 更新 User 记录
const userData: any = {};
if (updateTeacherDto.nickname !== undefined) {
userData.nickname = updateTeacherDto.nickname;
}
if (updateTeacherDto.email !== undefined) {
userData.email = updateTeacherDto.email;
}
if (updateTeacherDto.avatar !== undefined) {
userData.avatar = updateTeacherDto.avatar;
}
if (updateTeacherDto.validState !== undefined) {
userData.validState = updateTeacherDto.validState;
}
if (modifierId) {
userData.modifier = modifierId;
}
// 使用事务更新 User 和 Teacher
return this.prisma.$transaction(async (tx) => {
if (Object.keys(userData).length > 0) {
await tx.user.update({
where: { id: existingTeacher.userId },
data: userData,
});
}
const teacher = await tx.teacher.update({
where: { id },
data: teacherData,
include: {
user: true,
department: true,
},
});
return teacher;
});
}
async remove(id: number, tenantId?: number) {
// 验证教师是否存在
const teacher = await this.findOne(id, tenantId);
// 删除教师会级联删除 User通过 Prisma schema 的 onDelete: Cascade
return this.prisma.teacher.delete({
where: { id },
});
}
}

View File

@ -1,119 +1,39 @@
import request from "@/utils/request";
import type { PaginationParams } from "@/types/api";
// ==================== AI 3D 任务相关类型 ====================
/**
* AI 3D
*/
export type AI3DTaskStatus =
| "pending"
| "processing"
| "completed"
| "failed"
| "timeout";
/**
* AI 3D
*/
export type AI3DInputType = "text" | "image";
/**
* AI 3D
*/
export interface AI3DTask {
id: number;
tenantId: number;
userId: number;
inputType: AI3DInputType;
inputContent: string;
status: AI3DTaskStatus;
resultUrl?: string;
previewUrl?: string;
// 多结果支持文生3D会生成4个不同角度的模型
resultUrls?: string[];
previewUrls?: string[];
errorMessage?: string;
externalTaskId?: string;
retryCount: number;
createTime: string;
completeTime?: string;
// 队列位置(仅 pending 状态时返回)
queuePosition?: number;
}
/**
*
*/
export type AI3DGenerateType = "Normal" | "LowPoly" | "Geometry" | "Sketch";
/**
*
*/
export interface CreateAI3DTaskParams {
inputType: AI3DInputType;
inputContent: string;
/** 模型生成类型Normal-带纹理, LowPoly-低多边形, Geometry-白模, Sketch-草图 */
generateType?: AI3DGenerateType;
/** 模型面数10000-1500000默认500000 */
faceCount?: number;
}
/**
*
*/
export interface QueryAI3DTaskParams extends PaginationParams {
status?: AI3DTaskStatus;
}
/**
*
*/
export interface AI3DTaskListResponse {
list: AI3DTask[];
total: number;
page: number;
pageSize: number;
}
// ==================== API 接口 ====================
/**
*
* POST /api/ai-3d/generate
*/
export function createAI3DTask(data: CreateAI3DTaskParams) {
return request.post<AI3DTask>("/ai-3d/generate", data);
}
/**
*
* GET /api/ai-3d/tasks
*/
export function getAI3DTasks(params?: QueryAI3DTaskParams) {
return request.get<AI3DTaskListResponse>("/ai-3d/tasks", { params });
}
/**
*
* GET /api/ai-3d/tasks/:id
*/
export function getAI3DTask(id: number) {
return request.get<AI3DTask>(`/ai-3d/tasks/${id}`);
}
/**
*
* POST /api/ai-3d/tasks/:id/retry
*/
export function retryAI3DTask(id: number) {
return request.post<AI3DTask>(`/ai-3d/tasks/${id}/retry`);
}
/**
*
* DELETE /api/ai-3d/tasks/:id
*/
export function deleteAI3DTask(id: number) {
return request.delete(`/ai-3d/tasks/${id}`);
}
/**
* AI 3D API
* competition-management-system-stripped-modules/
*
*/
import request from "@/utils/request";
import type { PaginationParams } from "@/types/api";
export type AI3DTaskStatus = "pending" | "processing" | "completed" | "failed" | "timeout";
export type AI3DInputType = "text" | "image";
export interface AI3DTask {
id: number;
tenantId: number;
userId: number;
inputType: AI3DInputType;
inputContent: string;
status: AI3DTaskStatus;
resultUrl?: string;
previewUrl?: string;
resultUrls?: string[];
previewUrls?: string[];
errorMessage?: string;
retryCount: number;
createTime: string;
completeTime?: string;
queuePosition?: number;
}
export interface AI3DTaskListResponse {
list: AI3DTask[];
total: number;
page: number;
pageSize: number;
}
export function getAI3DTasks(params?: PaginationParams & { status?: AI3DTaskStatus }) {
return request.get<AI3DTaskListResponse>("/ai-3d/tasks", { params });
}

View File

@ -1,73 +0,0 @@
import request from "@/utils/request";
export interface School {
id: number;
tenantId: number;
address?: string;
phone?: string;
principal?: string;
established?: string;
description?: string;
logo?: string;
website?: string;
creator?: number;
modifier?: number;
createTime?: string;
modifyTime?: string;
tenant?: {
id: number;
name: string;
code: string;
};
}
export interface CreateSchoolForm {
address?: string;
phone?: string;
principal?: string;
established?: string;
description?: string;
logo?: string;
website?: string;
}
export interface UpdateSchoolForm {
address?: string;
phone?: string;
principal?: string;
established?: string;
description?: string;
logo?: string;
website?: string;
}
// 获取学校信息
export async function getSchool(): Promise<School> {
const response = await request.get<any, School>("/schools");
return response;
}
// 创建学校信息
export async function createSchool(data: CreateSchoolForm): Promise<School> {
const response = await request.post<any, School>("/schools", data);
return response;
}
// 更新学校信息
export async function updateSchool(data: UpdateSchoolForm): Promise<School> {
const response = await request.patch<any, School>("/schools", data);
return response;
}
// 删除学校信息
export async function deleteSchool(): Promise<void> {
return await request.delete<any, void>("/schools");
}
export const schoolsApi = {
get: getSchool,
create: createSchool,
update: updateSchool,
delete: deleteSchool,
};

View File

@ -1,150 +1,60 @@
import request from "@/utils/request";
import type { PaginationParams, PaginationResponse } from "@/types/api";
export interface Student {
id: number;
userId: number;
tenantId: number;
classId: number;
studentNo?: string;
phone?: string;
idCard?: string;
gender?: number; // 1-男2-女
birthDate?: string;
enrollmentDate?: string;
parentName?: string;
parentPhone?: string;
address?: string;
description?: string;
validState: number;
creator?: number;
modifier?: number;
createTime?: string;
modifyTime?: string;
user?: {
id: number;
username: string;
nickname: string;
email?: string;
avatar?: string;
validState: number;
};
class?: {
id: number;
name: string;
code: string;
type: number;
grade?: {
id: number;
name: string;
code: string;
level: number;
};
};
interestClasses?: Array<{
id: number;
class: {
id: number;
name: string;
code: string;
type: number;
grade?: {
id: number;
name: string;
code: string;
level: number;
};
};
}>;
}
export interface CreateStudentForm {
username: string;
password: string;
nickname: string;
email?: string;
avatar?: string;
classId: number;
studentNo?: string;
phone?: string;
idCard?: string;
gender?: number;
birthDate?: string;
enrollmentDate?: string;
parentName?: string;
parentPhone?: string;
address?: string;
description?: string;
interestClassIds?: number[];
}
export interface UpdateStudentForm {
nickname?: string;
email?: string;
avatar?: string;
classId?: number;
studentNo?: string;
phone?: string;
idCard?: string;
gender?: number;
birthDate?: string;
enrollmentDate?: string;
parentName?: string;
parentPhone?: string;
address?: string;
description?: string;
validState?: number;
interestClassIds?: number[];
}
// 获取学生列表
export async function getStudentsList(
params: PaginationParams & { classId?: number }
): Promise<PaginationResponse<Student>> {
const response = await request.get<any, PaginationResponse<Student>>("/students", {
params,
});
return response;
}
// 获取单个学生详情
export async function getStudentDetail(id: number): Promise<Student> {
const response = await request.get<any, Student>(`/students/${id}`);
return response;
}
// 根据用户ID获取学生信息
export async function getStudentByUserId(userId: number): Promise<Student> {
const response = await request.get<any, Student>(`/students/user/${userId}`);
return response;
}
// 创建学生
export async function createStudent(data: CreateStudentForm): Promise<Student> {
const response = await request.post<any, Student>("/students", data);
return response;
}
// 更新学生
export async function updateStudent(
id: number,
data: UpdateStudentForm
): Promise<Student> {
const response = await request.patch<any, Student>(`/students/${id}`, data);
return response;
}
// 删除学生
export async function deleteStudent(id: number): Promise<void> {
return await request.delete<any, void>(`/students/${id}`);
}
export const studentsApi = {
getList: getStudentsList,
getDetail: getStudentDetail,
getByUserId: getStudentByUserId,
create: createStudent,
update: updateStudent,
delete: deleteStudent,
};
/**
* API
* competition-management-system-stripped-modules/
*
*/
import request from "@/utils/request";
import type { PaginationParams, PaginationResponse } from "@/types/api";
export interface Student {
id: number;
userId: number;
tenantId: number;
classId: number;
studentNo?: string;
phone?: string;
idCard?: string;
gender?: number;
birthDate?: string;
enrollmentDate?: string;
parentName?: string;
parentPhone?: string;
address?: string;
description?: string;
validState: number;
user?: {
id: number;
username: string;
nickname: string;
email?: string;
avatar?: string;
validState: number;
};
class?: {
id: number;
name: string;
code: string;
type: number;
grade?: {
id: number;
name: string;
code: string;
level: number;
};
};
}
export async function getStudentsList(
params: PaginationParams & { classId?: number }
): Promise<PaginationResponse<Student>> {
return request.get<any, PaginationResponse<Student>>("/students", { params });
}
export async function getStudentByUserId(userId: number): Promise<Student> {
return request.get<any, Student>(`/students/user/${userId}`);
}
export const studentsApi = {
getList: getStudentsList,
getByUserId: getStudentByUserId,
};

View File

@ -1,123 +1,49 @@
import request from "@/utils/request";
import type { PaginationParams, PaginationResponse } from "@/types/api";
export interface Teacher {
id: number;
userId: number;
tenantId: number;
departmentId: number;
employeeNo?: string;
phone?: string;
idCard?: string;
gender?: number; // 1-男2-女
birthDate?: string;
hireDate?: string;
subject?: string;
title?: string;
description?: string;
validState: number;
creator?: number;
modifier?: number;
createTime?: string;
modifyTime?: string;
user?: {
id: number;
username: string;
nickname: string;
email?: string;
avatar?: string;
validState: number;
};
department?: {
id: number;
name: string;
code: string;
};
}
export interface CreateTeacherForm {
username: string;
password: string;
nickname: string;
email?: string;
avatar?: string;
departmentId: number;
employeeNo?: string;
phone?: string;
idCard?: string;
gender?: number;
birthDate?: string;
hireDate?: string;
subject?: string;
title?: string;
description?: string;
}
export interface UpdateTeacherForm {
nickname?: string;
email?: string;
avatar?: string;
departmentId?: number;
employeeNo?: string;
phone?: string;
idCard?: string;
gender?: number;
birthDate?: string;
hireDate?: string;
subject?: string;
title?: string;
description?: string;
validState?: number;
}
// 获取教师列表
export async function getTeachersList(
params: PaginationParams & { departmentId?: number; nickname?: string; username?: string }
): Promise<PaginationResponse<Teacher>> {
const response = await request.get<any, PaginationResponse<Teacher>>("/teachers", {
params,
});
return response;
}
// 获取单个教师详情
export async function getTeacherDetail(id: number): Promise<Teacher> {
const response = await request.get<any, Teacher>(`/teachers/${id}`);
return response;
}
// 根据用户ID获取教师信息
export async function getTeacherByUserId(userId: number): Promise<Teacher> {
const response = await request.get<any, Teacher>(`/teachers/user/${userId}`);
return response;
}
// 创建教师
export async function createTeacher(data: CreateTeacherForm): Promise<Teacher> {
const response = await request.post<any, Teacher>("/teachers", data);
return response;
}
// 更新教师
export async function updateTeacher(
id: number,
data: UpdateTeacherForm
): Promise<Teacher> {
const response = await request.patch<any, Teacher>(`/teachers/${id}`, data);
return response;
}
// 删除教师
export async function deleteTeacher(id: number): Promise<void> {
return await request.delete<any, void>(`/teachers/${id}`);
}
export const teachersApi = {
getList: getTeachersList,
getDetail: getTeacherDetail,
getByUserId: getTeacherByUserId,
create: createTeacher,
update: updateTeacher,
delete: deleteTeacher,
};
/**
* API
* competition-management-system-stripped-modules/
*
*/
import request from "@/utils/request";
import type { PaginationParams, PaginationResponse } from "@/types/api";
export interface Teacher {
id: number;
userId: number;
tenantId: number;
departmentId: number;
employeeNo?: string;
phone?: string;
gender?: number;
subject?: string;
title?: string;
description?: string;
validState: number;
user?: {
id: number;
username: string;
nickname: string;
email?: string;
avatar?: string;
validState: number;
};
department?: {
id: number;
name: string;
code: string;
};
}
export async function getTeachersList(
params: PaginationParams & { departmentId?: number; nickname?: string; username?: string }
): Promise<PaginationResponse<Teacher>> {
return request.get<any, PaginationResponse<Teacher>>("/teachers", { params });
}
export async function getTeacherByUserId(userId: number): Promise<Teacher> {
return request.get<any, Teacher>(`/teachers/user/${userId}`);
}
export const teachersApi = {
getList: getTeachersList,
getByUserId: getTeacherByUserId,
};

View File

@ -310,50 +310,6 @@ const baseRoutes: RouteRecordRaw[] = [
requiresAuth: true,
},
},
// 3D建模实验室路由工作台模块下
{
path: "workbench/3d-lab",
name: "3DModelingLab",
component: () => import("@/views/workbench/ai-3d/Index.vue"),
meta: {
title: "3D建模实验室",
requiresAuth: true,
hideSidebar: true,
},
},
// 3D模型生成页面
{
path: "workbench/3d-lab/generate/:taskId",
name: "AI3DGenerate",
component: () => import("@/views/workbench/ai-3d/Generate.vue"),
meta: {
title: "3D模型生成",
requiresAuth: true,
hideSidebar: true,
},
},
// 3D创作历史页面
{
path: "workbench/3d-lab/history",
name: "AI3DHistory",
component: () => import("@/views/workbench/ai-3d/History.vue"),
meta: {
title: "创作历史",
requiresAuth: true,
hideSidebar: true,
},
},
// 3D模型预览页面
{
path: "workbench/model-viewer",
name: "ModelViewer",
component: () => import("@/views/model/ModelViewer.vue"),
meta: {
title: "3D模型预览",
requiresAuth: true,
hideSidebar: true,
},
},
// 动态路由将在这里添加
],
},

View File

@ -19,15 +19,6 @@ const componentMap: Record<string, () => Promise<any>> = {
"analytics/Overview": () => import("@/views/analytics/Overview.vue"),
"analytics/Review": () => import("@/views/analytics/Review.vue"),
"system/tenant-info/Index": () => import("@/views/system/tenant-info/Index.vue"),
"workbench/ai-3d/Index": () => import("@/views/workbench/ai-3d/Index.vue"),
// 学校管理模块
"school/schools/Index": () => import("@/views/school/schools/Index.vue"),
"school/departments/Index": () =>
import("@/views/school/departments/Index.vue"),
"school/grades/Index": () => import("@/views/school/grades/Index.vue"),
"school/classes/Index": () => import("@/views/school/classes/Index.vue"),
"school/teachers/Index": () => import("@/views/school/teachers/Index.vue"),
"school/students/Index": () => import("@/views/school/students/Index.vue"),
// 活动管理模块
"contests/Index": () => import("@/views/contests/Index.vue"),
"contests/Activities": () => import("@/views/contests/Activities.vue"),

File diff suppressed because it is too large Load Diff

View File

@ -1,369 +0,0 @@
<template>
<div class="classes-page">
<a-card class="mb-4">
<template #title>班级管理</template>
<template #extra>
<a-space>
<a-select
v-model:value="searchParams.gradeId"
placeholder="选择年级"
allow-clear
style="width: 150px"
@change="handleSearch"
>
<a-select-option
v-for="grade in gradeOptions"
:key="grade.id"
:value="grade.id"
>
{{ grade.name }}
</a-select-option>
</a-select>
<a-select
v-model:value="searchParams.type"
placeholder="选择类型"
allow-clear
style="width: 120px"
@change="handleSearch"
>
<a-select-option :value="1">行政班级</a-select-option>
<a-select-option :value="2">兴趣班</a-select-option>
</a-select>
<a-button
v-permission="'class:create'"
type="primary"
@click="handleAdd"
>新增班级</a-button
>
</a-space>
</template>
</a-card>
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'grade'">
<span>{{ record.grade?.name || "-" }}</span>
</template>
<template v-if="column.key === 'type'">
<a-tag :color="record.type === 1 ? 'blue' : 'green'">
{{ record.type === 1 ? "行政班级" : "兴趣班" }}
</a-tag>
</template>
<template v-if="column.key === 'studentCount'">
<span v-if="record.type === 1"
>{{ record._count?.students || 0 }}</span
>
<span v-else>{{ record._count?.studentInterestClasses || 0 }}</span>
</template>
<template v-if="column.key === 'validState'">
<a-tag :color="record.validState === 1 ? 'green' : 'red'">
{{ record.validState === 1 ? "有效" : "失效" }}
</a-tag>
</template>
<template v-if="column.key === 'createTime'">
<span>{{ formatDate(record.createTime) }}</span>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button
v-permission="'class:update'"
type="link"
size="small"
@click="handleEdit(record)"
>
编辑
</a-button>
<a-button
v-permission="'class:delete'"
type="link"
size="small"
danger
@click="handleDelete(record)"
>
删除
</a-button>
</a-space>
</template>
</template>
</a-table>
<!-- 新增/编辑班级弹窗 -->
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
:confirm-loading="submitLoading"
@ok="handleSubmit"
@cancel="handleCancel"
width="600px"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 18 }"
>
<a-form-item label="年级" name="gradeId">
<a-select
v-model:value="form.gradeId"
placeholder="请选择年级"
:options="gradeOptions"
:loading="gradesLoading"
/>
</a-form-item>
<a-form-item label="班级名称" name="name">
<a-input
v-model:value="form.name"
placeholder="请输入班级名称一年级1班"
:maxlength="50"
/>
</a-form-item>
<a-form-item label="班级编码" name="code">
<a-input
v-model:value="form.code"
placeholder="请输入班级编码"
:maxlength="50"
/>
</a-form-item>
<a-form-item label="班级类型" name="type">
<a-radio-group v-model:value="form.type">
<a-radio :value="1">行政班级</a-radio>
<a-radio :value="2">兴趣班</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="班级容量" name="capacity">
<a-input-number
v-model:value="form.capacity"
placeholder="请输入班级容量"
:min="1"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="班级描述" name="description">
<a-textarea
v-model:value="form.description"
placeholder="请输入班级描述"
:rows="3"
:maxlength="500"
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, nextTick, onMounted } from "vue"
import { message, Modal } from "ant-design-vue"
import type { TableColumnsType, FormInstance } from "ant-design-vue"
import {
classesApi,
type Class,
type CreateClassForm,
type UpdateClassForm,
} from "@/api/classes"
import { gradesApi, type Grade } from "@/api/grades"
import { useListRequest } from "@/composables/useListRequest"
import { useAuthStore } from "@/stores/auth"
const authStore = useAuthStore()
const submitLoading = ref(false)
const modalVisible = ref(false)
const modalTitle = ref("新增班级")
const formRef = ref<FormInstance>()
const editingId = ref<number | null>(null)
const gradesLoading = ref(false)
const gradeOptions = ref<Array<{ label: string; value: number }>>([])
const searchParams = reactive<{ gradeId?: number; type?: number }>({})
const {
loading,
dataSource,
pagination,
handleTableChange,
refresh: refreshList,
search,
} = useListRequest<Class, { gradeId?: number; type?: number }>({
requestFn: classesApi.getList,
errorMessage: "获取班级列表失败",
defaultSearchParams: {},
})
const form = reactive<CreateClassForm>({
gradeId: 0,
name: "",
code: "",
type: 1,
capacity: undefined,
description: "",
})
const rules = {
gradeId: [{ required: true, message: "请选择年级", trigger: "change" }],
name: [{ required: true, message: "请输入班级名称", trigger: "blur" }],
code: [
{ required: true, message: "请输入班级编码", trigger: "blur" },
{
pattern: /^[a-zA-Z][a-zA-Z0-9_]*$/,
message: "编码只能包含字母、数字和下划线,且必须以字母开头",
trigger: "blur",
},
],
type: [{ required: true, message: "请选择班级类型", trigger: "change" }],
}
const columns: TableColumnsType = [
{ title: "ID", dataIndex: "id", key: "id", width: 80 },
{ title: "年级", key: "grade", width: 120 },
{ title: "班级名称", dataIndex: "name", key: "name" },
{ title: "班级编码", dataIndex: "code", key: "code" },
{ title: "班级类型", dataIndex: "type", key: "type", width: 120 },
{ title: "学生数量", key: "studentCount", width: 100 },
{ title: "容量", dataIndex: "capacity", key: "capacity", width: 80 },
{ title: "状态", dataIndex: "validState", key: "validState", width: 80 },
{ title: "创建时间", dataIndex: "createTime", key: "createTime", width: 180 },
{ title: "操作", key: "action", width: 150, fixed: "right" },
]
const formatDate = (dateStr?: string) => {
if (!dateStr) return "-"
const date = new Date(dateStr)
return date.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
}
const fetchGrades = async () => {
gradesLoading.value = true
try {
const response = await gradesApi.getList({ page: 1, pageSize: 100 })
gradeOptions.value = response.list.map((grade: Grade) => ({
label: grade.name,
value: grade.id,
}))
} catch (error) {
message.error("获取年级列表失败")
} finally {
gradesLoading.value = false
}
}
const handleSearch = () => {
search(searchParams)
}
const handleAdd = () => {
if (!authStore.hasPermission("class:create")) {
message.error("您没有创建班级的权限")
return
}
modalTitle.value = "新增班级"
editingId.value = null
modalVisible.value = true
nextTick(() => {
formRef.value?.resetFields()
form.gradeId = 0
form.name = ""
form.code = ""
form.type = 1
form.capacity = undefined
form.description = ""
})
}
const handleEdit = async (record: Class) => {
modalTitle.value = "编辑班级"
editingId.value = record.id
modalVisible.value = true
try {
const detail = await classesApi.getDetail(record.id)
form.gradeId = detail.gradeId
form.name = detail.name
form.code = detail.code
form.type = detail.type
form.capacity = detail.capacity || undefined
form.description = detail.description || ""
} catch (error) {
message.error("获取班级详情失败")
modalVisible.value = false
}
}
const handleDelete = (record: Class) => {
if (!authStore.hasPermission("class:delete")) {
message.error("您没有删除班级的权限")
return
}
Modal.confirm({
title: "确认删除",
content: `确定要删除班级 "${record.name}" 吗?删除前需先转移或删除所有学生。`,
okText: "确定",
okType: "danger",
cancelText: "取消",
onOk: async () => {
try {
await classesApi.delete(record.id)
message.success("删除成功")
refreshList()
} catch (error: any) {
const errorMessage = error?.response?.data?.message || "删除失败"
message.error(errorMessage)
}
},
})
}
const handleSubmit = async () => {
try {
await formRef.value?.validate()
submitLoading.value = true
if (editingId.value) {
await classesApi.update(editingId.value, form as UpdateClassForm)
message.success("编辑成功")
} else {
await classesApi.create(form)
message.success("新增成功")
}
modalVisible.value = false
refreshList()
} catch (error: any) {
if (error?.errorFields) {
return
}
const errorMessage =
error?.response?.data?.message ||
(editingId.value ? "编辑失败" : "新增失败")
message.error(errorMessage)
} finally {
submitLoading.value = false
}
}
const handleCancel = () => {
modalVisible.value = false
formRef.value?.resetFields()
}
onMounted(() => {
fetchGrades()
})
</script>
<style scoped></style>

View File

@ -1,351 +0,0 @@
<template>
<div class="departments-page">
<a-card class="mb-4">
<template #title>部门管理</template>
<template #extra>
<a-space>
<a-button @click="showTree = !showTree">{{
showTree ? "列表视图" : "树形视图"
}}</a-button>
<a-button
v-permission="'department:create'"
type="primary"
@click="handleAdd"
>新增部门</a-button
>
</a-space>
</template>
</a-card>
<!-- 树形视图 -->
<a-card v-if="showTree">
<a-spin :spinning="treeLoading">
<a-tree
:tree-data="treeData"
:field-names="{ children: 'children', title: 'name', key: 'id' }"
default-expand-all
>
<template #title="{ name, _count }">
<span>{{ name }} ({{ _count?.teachers || 0 }})</span>
</template>
<template #switcherIcon="{ expanded }">
<span>{{ expanded ? "📂" : "📁" }}</span>
</template>
</a-tree>
</a-spin>
</a-card>
<!-- 列表视图 -->
<a-table
v-else
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'parent'">
<span>{{ record.parent?.name || "-" }}</span>
</template>
<template v-if="column.key === 'teacherCount'">
<span>{{ record._count?.teachers || 0 }}</span>
</template>
<template v-if="column.key === 'validState'">
<a-tag :color="record.validState === 1 ? 'green' : 'red'">
{{ record.validState === 1 ? "有效" : "失效" }}
</a-tag>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button
v-permission="'department:update'"
type="link"
size="small"
@click="handleEdit(record)"
>
编辑
</a-button>
<a-button
v-permission="'department:delete'"
type="link"
size="small"
danger
@click="handleDelete(record)"
>
删除
</a-button>
</a-space>
</template>
</template>
</a-table>
<!-- 新增/编辑部门弹窗 -->
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
:confirm-loading="submitLoading"
@ok="handleSubmit"
@cancel="handleCancel"
width="600px"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 18 }"
>
<a-form-item label="父部门" name="parentId">
<a-tree-select
v-model:value="form.parentId"
:tree-data="departmentTreeOptions"
placeholder="请选择父部门(不选则为顶级部门)"
allow-clear
tree-default-expand-all
style="width: 100%"
/>
</a-form-item>
<a-form-item label="部门名称" name="name">
<a-input
v-model:value="form.name"
placeholder="请输入部门名称"
:maxlength="50"
/>
</a-form-item>
<a-form-item label="部门编码" name="code">
<a-input
v-model:value="form.code"
placeholder="请输入部门编码"
:maxlength="50"
/>
</a-form-item>
<a-form-item label="排序" name="sort">
<a-input-number
v-model:value="form.sort"
placeholder="请输入排序值"
:min="0"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="部门描述" name="description">
<a-textarea
v-model:value="form.description"
placeholder="请输入部门描述"
:rows="3"
:maxlength="500"
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, nextTick, onMounted, computed } from "vue"
import { message, Modal } from "ant-design-vue"
import type { TableColumnsType, FormInstance } from "ant-design-vue"
import {
departmentsApi,
type Department,
type CreateDepartmentForm,
type UpdateDepartmentForm,
} from "@/api/departments"
import { useListRequest } from "@/composables/useListRequest"
import { useAuthStore } from "@/stores/auth"
const authStore = useAuthStore()
const submitLoading = ref(false)
const modalVisible = ref(false)
const modalTitle = ref("新增部门")
const formRef = ref<FormInstance>()
const editingId = ref<number | null>(null)
const showTree = ref(false)
const treeLoading = ref(false)
const treeData = ref<Department[]>([])
const {
loading,
dataSource,
pagination,
handleTableChange,
refresh: refreshList,
} = useListRequest<Department>({
requestFn: departmentsApi.getList,
errorMessage: "获取部门列表失败",
})
const form = reactive<CreateDepartmentForm>({
name: "",
code: "",
parentId: undefined,
sort: 0,
description: "",
})
const rules = {
name: [{ required: true, message: "请输入部门名称", trigger: "blur" }],
code: [
{ required: true, message: "请输入部门编码", trigger: "blur" },
{
pattern: /^[a-zA-Z][a-zA-Z0-9_]*$/,
message: "编码只能包含字母、数字和下划线,且必须以字母开头",
trigger: "blur",
},
],
}
const columns: TableColumnsType = [
{ title: "ID", dataIndex: "id", key: "id", width: 80 },
{ title: "部门名称", dataIndex: "name", key: "name" },
{ title: "部门编码", dataIndex: "code", key: "code" },
{ title: "父部门", key: "parent", width: 150 },
{ title: "教师数量", key: "teacherCount", width: 100 },
{ title: "排序", dataIndex: "sort", key: "sort", width: 80 },
{ title: "状态", dataIndex: "validState", key: "validState", width: 80 },
{ title: "操作", key: "action", width: 150, fixed: "right" },
]
//
const departmentTreeOptions = computed(() => {
const buildOptions = (depts: Department[], excludeId?: number): any[] => {
return depts
.filter((dept) => dept.id !== excludeId)
.map((dept) => ({
title: dept.name,
value: dept.id,
children: dept.children
? buildOptions(dept.children, excludeId)
: undefined,
}))
}
return buildOptions(treeData.value, editingId.value || undefined)
})
const fetchTree = async () => {
treeLoading.value = true
try {
treeData.value = await departmentsApi.getTree()
} catch (error) {
message.error("获取部门树失败")
} finally {
treeLoading.value = false
}
}
const handleAdd = () => {
if (!authStore.hasPermission("department:create")) {
message.error("您没有创建部门的权限")
return
}
modalTitle.value = "新增部门"
editingId.value = null
modalVisible.value = true
nextTick(() => {
formRef.value?.resetFields()
form.name = ""
form.code = ""
form.parentId = undefined
form.sort = 0
form.description = ""
})
}
const handleEdit = async (record: Department) => {
modalTitle.value = "编辑部门"
editingId.value = record.id
modalVisible.value = true
try {
const detail = await departmentsApi.getDetail(record.id)
form.name = detail.name
form.code = detail.code
form.parentId = detail.parentId || undefined
form.sort = detail.sort
form.description = detail.description || ""
} catch (error) {
message.error("获取部门详情失败")
modalVisible.value = false
}
}
const handleDelete = (record: Department) => {
if (!authStore.hasPermission("department:delete")) {
message.error("您没有删除部门的权限")
return
}
Modal.confirm({
title: "确认删除",
content: `确定要删除部门 "${record.name}" 吗?删除前需先转移或删除该部门下的所有教师和子部门。`,
okText: "确定",
okType: "danger",
cancelText: "取消",
onOk: async () => {
try {
await departmentsApi.delete(record.id)
message.success("删除成功")
refreshList()
if (showTree.value) {
fetchTree()
}
} catch (error: any) {
const errorMessage = error?.response?.data?.message || "删除失败"
message.error(errorMessage)
}
},
})
}
const handleSubmit = async () => {
try {
await formRef.value?.validate()
submitLoading.value = true
const data: CreateDepartmentForm | UpdateDepartmentForm = {
name: form.name,
code: form.code,
parentId: form.parentId || undefined,
sort: form.sort,
description: form.description || undefined,
}
if (editingId.value) {
await departmentsApi.update(editingId.value, data as UpdateDepartmentForm)
message.success("编辑成功")
} else {
await departmentsApi.create(data)
message.success("新增成功")
}
modalVisible.value = false
refreshList()
if (showTree.value) {
fetchTree()
}
} catch (error: any) {
if (error?.errorFields) {
return
}
const errorMessage =
error?.response?.data?.message ||
(editingId.value ? "编辑失败" : "新增失败")
message.error(errorMessage)
} finally {
submitLoading.value = false
}
}
const handleCancel = () => {
modalVisible.value = false
formRef.value?.resetFields()
}
onMounted(() => {
fetchTree()
})
</script>
<style scoped></style>

View File

@ -1,280 +0,0 @@
<template>
<div class="grades-page">
<a-card class="mb-4">
<template #title>年级管理</template>
<template #extra>
<a-button
v-permission="'grade:create'"
type="primary"
@click="handleAdd"
>新增年级</a-button
>
</template>
</a-card>
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'level'">
<a-tag color="blue">{{ record.level }}</a-tag>
</template>
<template v-if="column.key === 'classCount'">
<span>{{ record._count?.classes || 0 }}个班级</span>
</template>
<template v-if="column.key === 'validState'">
<a-tag :color="record.validState === 1 ? 'green' : 'red'">
{{ record.validState === 1 ? "有效" : "失效" }}
</a-tag>
</template>
<template v-if="column.key === 'createTime'">
<span>{{ formatDate(record.createTime) }}</span>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button
v-permission="'grade:update'"
type="link"
size="small"
@click="handleEdit(record)"
>
编辑
</a-button>
<a-button
v-permission="'grade:delete'"
type="link"
size="small"
danger
@click="handleDelete(record)"
>
删除
</a-button>
</a-space>
</template>
</template>
</a-table>
<!-- 新增/编辑年级弹窗 -->
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
:confirm-loading="submitLoading"
@ok="handleSubmit"
@cancel="handleCancel"
width="600px"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 18 }"
>
<a-form-item label="年级名称" name="name">
<a-input
v-model:value="form.name"
placeholder="请输入年级名称,如:一年级"
:maxlength="50"
/>
</a-form-item>
<a-form-item label="年级编码" name="code">
<a-input
v-model:value="form.code"
placeholder="请输入年级编码grade_1"
:maxlength="50"
/>
</a-form-item>
<a-form-item label="年级级别" name="level">
<a-input-number
v-model:value="form.level"
placeholder="请输入年级级别,用于排序"
:min="1"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="年级描述" name="description">
<a-textarea
v-model:value="form.description"
placeholder="请输入年级描述"
:rows="3"
:maxlength="500"
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, nextTick } from "vue"
import { message, Modal } from "ant-design-vue"
import type { TableColumnsType, FormInstance } from "ant-design-vue"
import {
gradesApi,
type Grade,
type CreateGradeForm,
type UpdateGradeForm,
} from "@/api/grades"
import { useListRequest } from "@/composables/useListRequest"
import { useAuthStore } from "@/stores/auth"
const authStore = useAuthStore()
const submitLoading = ref(false)
const modalVisible = ref(false)
const modalTitle = ref("新增年级")
const formRef = ref<FormInstance>()
const editingId = ref<number | null>(null)
const {
loading,
dataSource,
pagination,
handleTableChange,
refresh: refreshList,
} = useListRequest<Grade>({
requestFn: gradesApi.getList,
errorMessage: "获取年级列表失败",
})
const form = reactive<CreateGradeForm>({
name: "",
code: "",
level: 1,
description: "",
})
const rules = {
name: [{ required: true, message: "请输入年级名称", trigger: "blur" }],
code: [
{ required: true, message: "请输入年级编码", trigger: "blur" },
{
pattern: /^[a-zA-Z][a-zA-Z0-9_]*$/,
message: "编码只能包含字母、数字和下划线,且必须以字母开头",
trigger: "blur",
},
],
level: [{ required: true, message: "请输入年级级别", trigger: "blur" }],
}
const columns: TableColumnsType = [
{ title: "ID", dataIndex: "id", key: "id", width: 80 },
{ title: "年级名称", dataIndex: "name", key: "name" },
{ title: "年级编码", dataIndex: "code", key: "code" },
{ title: "年级级别", dataIndex: "level", key: "level", width: 100 },
{ title: "班级数量", key: "classCount", width: 120 },
{ title: "状态", dataIndex: "validState", key: "validState", width: 80 },
{ title: "创建时间", dataIndex: "createTime", key: "createTime", width: 180 },
{ title: "操作", key: "action", width: 150, fixed: "right" },
]
const formatDate = (dateStr?: string) => {
if (!dateStr) return "-"
const date = new Date(dateStr)
return date.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
}
const handleAdd = () => {
if (!authStore.hasPermission("grade:create")) {
message.error("您没有创建年级的权限")
return
}
modalTitle.value = "新增年级"
editingId.value = null
modalVisible.value = true
nextTick(() => {
formRef.value?.resetFields()
form.name = ""
form.code = ""
form.level = 1
form.description = ""
})
}
const handleEdit = async (record: Grade) => {
modalTitle.value = "编辑年级"
editingId.value = record.id
modalVisible.value = true
try {
const detail = await gradesApi.getDetail(record.id)
form.name = detail.name
form.code = detail.code
form.level = detail.level
form.description = detail.description || ""
} catch (error) {
message.error("获取年级详情失败")
modalVisible.value = false
}
}
const handleDelete = (record: Grade) => {
if (!authStore.hasPermission("grade:delete")) {
message.error("您没有删除年级的权限")
return
}
Modal.confirm({
title: "确认删除",
content: `确定要删除年级 "${record.name}" 吗?删除前需先删除所有班级。`,
okText: "确定",
okType: "danger",
cancelText: "取消",
onOk: async () => {
try {
await gradesApi.delete(record.id)
message.success("删除成功")
refreshList()
} catch (error: any) {
const errorMessage = error?.response?.data?.message || "删除失败"
message.error(errorMessage)
}
},
})
}
const handleSubmit = async () => {
try {
await formRef.value?.validate()
submitLoading.value = true
if (editingId.value) {
await gradesApi.update(editingId.value, form as UpdateGradeForm)
message.success("编辑成功")
} else {
await gradesApi.create(form)
message.success("新增成功")
}
modalVisible.value = false
refreshList()
} catch (error: any) {
if (error?.errorFields) {
return
}
const errorMessage =
error?.response?.data?.message ||
(editingId.value ? "编辑失败" : "新增失败")
message.error(errorMessage)
} finally {
submitLoading.value = false
}
}
const handleCancel = () => {
modalVisible.value = false
formRef.value?.resetFields()
}
</script>
<style scoped></style>

View File

@ -1,307 +0,0 @@
<template>
<div class="schools-page">
<a-card>
<template #title>学校信息管理</template>
<template #extra>
<a-button
v-if="!schoolInfo"
v-permission="'school:create'"
type="primary"
@click="handleAdd"
>
新增学校信息
</a-button>
<a-button
v-else
v-permission="'school:update'"
type="primary"
@click="handleEdit"
>
编辑学校信息
</a-button>
</template>
<a-spin :spinning="loading">
<div v-if="schoolInfo" class="school-info">
<a-descriptions :column="2" bordered>
<a-descriptions-item label="学校名称">
{{ schoolInfo.tenant?.name || "-" }}
</a-descriptions-item>
<a-descriptions-item label="学校编码">
{{ schoolInfo.tenant?.code || "-" }}
</a-descriptions-item>
<a-descriptions-item label="地址">
{{ schoolInfo.address || "-" }}
</a-descriptions-item>
<a-descriptions-item label="联系电话">
{{ schoolInfo.phone || "-" }}
</a-descriptions-item>
<a-descriptions-item label="校长">
{{ schoolInfo.principal || "-" }}
</a-descriptions-item>
<a-descriptions-item label="建校时间">
{{ formatDate(schoolInfo.established) }}
</a-descriptions-item>
<a-descriptions-item label="学校网站" :span="2">
<a
v-if="schoolInfo.website"
:href="schoolInfo.website"
target="_blank"
>
{{ schoolInfo.website }}
</a>
<span v-else>-</span>
</a-descriptions-item>
<a-descriptions-item label="学校Logo" :span="2">
<img
v-if="schoolInfo.logo"
:src="schoolInfo.logo"
alt="学校Logo"
style="max-width: 200px; max-height: 100px"
/>
<span v-else>-</span>
</a-descriptions-item>
<a-descriptions-item label="学校描述" :span="2">
{{ schoolInfo.description || "-" }}
</a-descriptions-item>
</a-descriptions>
</div>
<a-empty v-else description="暂无学校信息" />
</a-spin>
</a-card>
<!-- 新增/编辑学校信息弹窗 -->
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
:confirm-loading="submitLoading"
@ok="handleSubmit"
@cancel="handleCancel"
width="700px"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 18 }"
>
<a-form-item label="地址" name="address">
<a-input
v-model:value="form.address"
placeholder="请输入学校地址"
:maxlength="200"
/>
</a-form-item>
<a-form-item label="联系电话" name="phone">
<a-input
v-model:value="form.phone"
placeholder="请输入联系电话"
:maxlength="20"
/>
</a-form-item>
<a-form-item label="校长" name="principal">
<a-input
v-model:value="form.principal"
placeholder="请输入校长姓名"
:maxlength="50"
/>
</a-form-item>
<a-form-item label="建校时间" name="established">
<a-date-picker
v-model:value="form.established"
placeholder="请选择建校时间"
style="width: 100%"
value-format="YYYY-MM-DD"
/>
</a-form-item>
<a-form-item label="学校网站" name="website">
<a-input
v-model:value="form.website"
placeholder="请输入学校网站URL"
:maxlength="500"
/>
</a-form-item>
<a-form-item label="学校Logo" name="logo">
<a-input
v-model:value="form.logo"
placeholder="请输入Logo URL"
:maxlength="500"
/>
</a-form-item>
<a-form-item label="学校描述" name="description">
<a-textarea
v-model:value="form.description"
placeholder="请输入学校描述"
:rows="4"
:maxlength="1000"
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from "vue"
import { message } from "ant-design-vue"
import type { FormInstance } from "ant-design-vue"
import {
schoolsApi,
type School,
type CreateSchoolForm,
type UpdateSchoolForm,
} from "@/api/schools"
import { useAuthStore } from "@/stores/auth"
import dayjs, { type Dayjs } from "dayjs"
const authStore = useAuthStore()
const loading = ref(false)
const submitLoading = ref(false)
const modalVisible = ref(false)
const modalTitle = ref("新增学校信息")
const formRef = ref<FormInstance>()
const schoolInfo = ref<School | null>(null)
const isEditing = ref(false)
const form = reactive<CreateSchoolForm & { established?: Dayjs }>({
address: "",
phone: "",
principal: "",
established: undefined,
description: "",
logo: "",
website: "",
})
const rules = {
website: [{ type: "url", message: "请输入正确的URL地址", trigger: "blur" }],
logo: [{ type: "url", message: "请输入正确的URL地址", trigger: "blur" }],
}
//
const formatDate = (dateStr?: string) => {
if (!dateStr) return "-"
return dayjs(dateStr).format("YYYY-MM-DD")
}
//
const fetchSchoolInfo = async () => {
loading.value = true
try {
const result = await schoolsApi.get()
// null
schoolInfo.value = result || null
} catch (error: any) {
// 404 null
if (error?.response?.status === 404) {
schoolInfo.value = null
} else {
message.error("获取学校信息失败")
}
} finally {
loading.value = false
}
}
//
const handleAdd = () => {
if (!authStore.hasPermission("school:create")) {
message.error("您没有创建学校信息的权限")
return
}
modalTitle.value = "新增学校信息"
isEditing.value = false
modalVisible.value = true
form.address = ""
form.phone = ""
form.principal = ""
form.established = undefined
form.description = ""
form.logo = ""
form.website = ""
formRef.value?.resetFields()
}
//
const handleEdit = () => {
if (!authStore.hasPermission("school:update")) {
message.error("您没有更新学校信息的权限")
return
}
if (!schoolInfo.value) return
modalTitle.value = "编辑学校信息"
isEditing.value = true
modalVisible.value = true
form.address = schoolInfo.value.address || ""
form.phone = schoolInfo.value.phone || ""
form.principal = schoolInfo.value.principal || ""
form.established = schoolInfo.value.established
? dayjs(schoolInfo.value.established)
: undefined
form.description = schoolInfo.value.description || ""
form.logo = schoolInfo.value.logo || ""
form.website = schoolInfo.value.website || ""
}
//
const handleSubmit = async () => {
try {
await formRef.value?.validate()
submitLoading.value = true
const data: CreateSchoolForm | UpdateSchoolForm = {
address: form.address || undefined,
phone: form.phone || undefined,
principal: form.principal || undefined,
established: form.established
? form.established.format("YYYY-MM-DD")
: undefined,
description: form.description || undefined,
logo: form.logo || undefined,
website: form.website || undefined,
}
if (isEditing.value) {
await schoolsApi.update(data as UpdateSchoolForm)
message.success("更新成功")
} else {
await schoolsApi.create(data as CreateSchoolForm)
message.success("创建成功")
}
modalVisible.value = false
fetchSchoolInfo()
} catch (error: any) {
if (error?.errorFields) {
return
}
const errorMessage =
error?.response?.data?.message ||
(isEditing.value ? "更新失败" : "创建失败")
message.error(errorMessage)
} finally {
submitLoading.value = false
}
}
//
const handleCancel = () => {
modalVisible.value = false
formRef.value?.resetFields()
}
onMounted(() => {
fetchSchoolInfo()
})
</script>
<style scoped>
.school-info {
margin-top: 16px;
}
</style>

View File

@ -1,552 +0,0 @@
<template>
<div class="students-page">
<a-card class="mb-4">
<template #title>学生管理</template>
<template #extra>
<a-space>
<a-select
v-model:value="searchParams.classId"
placeholder="选择班级"
allow-clear
style="width: 200px"
@change="handleSearch"
>
<a-select-option
v-for="cls in classOptions"
:key="cls.id"
:value="cls.id"
>
{{ cls.grade?.name }} - {{ cls.name }}
</a-select-option>
</a-select>
<a-button
v-permission="'student:create'"
type="primary"
@click="handleAdd"
>新增学生</a-button
>
</a-space>
</template>
</a-card>
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'user'">
<a-space>
<span>{{ record.user?.nickname || "-" }}</span>
<span style="color: #999">({{ record.user?.username }})</span>
</a-space>
</template>
<template v-if="column.key === 'class'">
<span
>{{ record.class?.grade?.name }} - {{ record.class?.name }}</span
>
</template>
<template v-if="column.key === 'gender'">
<a-tag v-if="record.gender === 1" color="blue"></a-tag>
<a-tag v-else-if="record.gender === 2" color="pink"></a-tag>
<span v-else>-</span>
</template>
<template v-if="column.key === 'interestClasses'">
<a-tag v-for="ic in record.interestClasses" :key="ic.id" class="mr-1">
{{ ic.class.name }}
</a-tag>
<span
v-if="
!record.interestClasses || record.interestClasses.length === 0
"
>-</span
>
</template>
<template v-if="column.key === 'validState'">
<a-tag :color="record.validState === 1 ? 'green' : 'red'">
{{ record.validState === 1 ? "有效" : "失效" }}
</a-tag>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button
v-permission="'student:update'"
type="link"
size="small"
@click="handleEdit(record)"
>
编辑
</a-button>
<a-button
v-permission="'student:delete'"
type="link"
size="small"
danger
@click="handleDelete(record)"
>
删除
</a-button>
</a-space>
</template>
</template>
</a-table>
<!-- 新增/编辑学生弹窗 -->
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
:confirm-loading="submitLoading"
@ok="handleSubmit"
@cancel="handleCancel"
width="700px"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 18 }"
>
<template v-if="!editingId">
<a-form-item label="用户名" name="username">
<a-input
v-model:value="form.username"
placeholder="请输入用户名"
:maxlength="50"
/>
</a-form-item>
<a-form-item label="密码" name="password">
<a-input-password
v-model:value="form.password"
placeholder="请输入密码"
:maxlength="100"
/>
</a-form-item>
<a-form-item label="确认密码" name="confirmPassword">
<a-input-password
v-model:value="form.confirmPassword"
placeholder="请再次输入密码"
:maxlength="100"
/>
</a-form-item>
</template>
<a-form-item label="昵称" name="nickname">
<a-input
v-model:value="form.nickname"
placeholder="请输入昵称"
:maxlength="50"
/>
</a-form-item>
<a-form-item label="邮箱" name="email">
<a-input
v-model:value="form.email"
placeholder="请输入邮箱"
:maxlength="100"
/>
</a-form-item>
<a-form-item label="行政班级" name="classId">
<a-select
v-model:value="form.classId"
placeholder="请选择行政班级"
:options="classOptions"
/>
</a-form-item>
<a-form-item label="学号" name="studentNo">
<a-input
v-model:value="form.studentNo"
placeholder="请输入学号"
:maxlength="50"
/>
</a-form-item>
<a-form-item label="联系电话" name="phone">
<a-input
v-model:value="form.phone"
placeholder="请输入联系电话"
:maxlength="20"
/>
</a-form-item>
<a-form-item label="性别" name="gender">
<a-radio-group v-model:value="form.gender">
<a-radio :value="1"></a-radio>
<a-radio :value="2"></a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="出生日期" name="birthDate">
<a-date-picker
v-model:value="form.birthDate"
placeholder="请选择出生日期"
style="width: 100%"
value-format="YYYY-MM-DD"
/>
</a-form-item>
<a-form-item label="入学日期" name="enrollmentDate">
<a-date-picker
v-model:value="form.enrollmentDate"
placeholder="请选择入学日期"
style="width: 100%"
value-format="YYYY-MM-DD"
/>
</a-form-item>
<a-form-item label="家长姓名" name="parentName">
<a-input
v-model:value="form.parentName"
placeholder="请输入家长姓名"
:maxlength="50"
/>
</a-form-item>
<a-form-item label="家长电话" name="parentPhone">
<a-input
v-model:value="form.parentPhone"
placeholder="请输入家长电话"
:maxlength="20"
/>
</a-form-item>
<a-form-item label="家庭地址" name="address">
<a-input
v-model:value="form.address"
placeholder="请输入家庭地址"
:maxlength="200"
/>
</a-form-item>
<a-form-item label="兴趣班" name="interestClassIds">
<a-select
v-model:value="form.interestClassIds"
mode="multiple"
placeholder="请选择兴趣班(可选)"
:options="interestClassOptions"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="描述" name="description">
<a-textarea
v-model:value="form.description"
placeholder="请输入描述"
:rows="3"
:maxlength="500"
/>
</a-form-item>
<a-form-item v-if="editingId" label="状态" name="validState">
<a-radio-group v-model:value="form.validState">
<a-radio :value="1">有效</a-radio>
<a-radio :value="2">失效</a-radio>
</a-radio-group>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, nextTick, onMounted } from "vue"
import { message, Modal } from "ant-design-vue"
import type { TableColumnsType, FormInstance } from "ant-design-vue"
import {
studentsApi,
type Student,
type CreateStudentForm,
type UpdateStudentForm,
} from "@/api/students"
import { classesApi, type Class } from "@/api/classes"
import { useListRequest } from "@/composables/useListRequest"
import { useAuthStore } from "@/stores/auth"
import dayjs, { type Dayjs } from "dayjs"
const authStore = useAuthStore()
const submitLoading = ref(false)
const modalVisible = ref(false)
const modalTitle = ref("新增学生")
const formRef = ref<FormInstance>()
const editingId = ref<number | null>(null)
const classesLoading = ref(false)
const classOptions = ref<Array<{ label: string; value: number }>>([])
const interestClassOptions = ref<Array<{ label: string; value: number }>>([])
const searchParams = reactive<{ classId?: number }>({})
const {
loading,
dataSource,
pagination,
handleTableChange,
refresh: refreshList,
search,
} = useListRequest<Student, { classId?: number }>({
requestFn: studentsApi.getList,
errorMessage: "获取学生列表失败",
defaultSearchParams: {},
})
const form = reactive<
CreateStudentForm & {
confirmPassword?: string
birthDate?: Dayjs
enrollmentDate?: Dayjs
validState?: number
}
>({
username: "",
password: "",
confirmPassword: "",
nickname: "",
email: "",
avatar: "",
classId: 0,
studentNo: "",
phone: "",
idCard: "",
gender: undefined,
birthDate: undefined,
enrollmentDate: undefined,
parentName: "",
parentPhone: "",
address: "",
description: "",
interestClassIds: [],
validState: 1,
})
const rules = {
username: [
{ required: true, message: "请输入用户名", trigger: "blur" },
{
pattern: /^[a-zA-Z][a-zA-Z0-9_]*$/,
message: "用户名只能包含字母、数字和下划线,且必须以字母开头",
trigger: "blur",
},
],
password: [
{ required: true, message: "请输入密码", trigger: "blur" },
{ min: 6, message: "密码长度不能少于6位", trigger: "blur" },
],
confirmPassword: [
{ required: true, message: "请再次输入密码", trigger: "blur" },
{
validator: (_rule: any, value: string) => {
if (value !== form.password) {
return Promise.reject("两次输入的密码不一致")
}
return Promise.resolve()
},
trigger: "blur",
},
],
nickname: [{ required: true, message: "请输入昵称", trigger: "blur" }],
email: [{ type: "email", message: "请输入正确的邮箱地址", trigger: "blur" }],
classId: [{ required: true, message: "请选择行政班级", trigger: "change" }],
}
const columns: TableColumnsType = [
{ title: "ID", dataIndex: "id", key: "id", width: 80 },
{ title: "学生", key: "user", width: 150 },
{ title: "学号", dataIndex: "studentNo", key: "studentNo", width: 120 },
{ title: "班级", key: "class", width: 200 },
{ title: "性别", dataIndex: "gender", key: "gender", width: 80 },
{ title: "联系电话", dataIndex: "phone", key: "phone", width: 120 },
{ title: "家长姓名", dataIndex: "parentName", key: "parentName", width: 120 },
{
title: "家长电话",
dataIndex: "parentPhone",
key: "parentPhone",
width: 120,
},
{ title: "兴趣班", key: "interestClasses", width: 200 },
{ title: "状态", dataIndex: "validState", key: "validState", width: 80 },
{ title: "操作", key: "action", width: 150, fixed: "right" },
]
const fetchClasses = async () => {
classesLoading.value = true
try {
//
const adminClasses = await classesApi.getList({
page: 1,
pageSize: 100,
type: 1,
})
classOptions.value = adminClasses.list.map((cls: Class) => ({
label: `${cls.grade?.name || ""} - ${cls.name}`,
value: cls.id,
}))
//
const interestClasses = await classesApi.getList({
page: 1,
pageSize: 100,
type: 2,
})
interestClassOptions.value = interestClasses.list.map((cls: Class) => ({
label: `${cls.grade?.name || ""} - ${cls.name}`,
value: cls.id,
}))
} catch (error) {
message.error("获取班级列表失败")
} finally {
classesLoading.value = false
}
}
const handleSearch = () => {
search(searchParams)
}
const handleAdd = () => {
if (!authStore.hasPermission("student:create")) {
message.error("您没有创建学生的权限")
return
}
modalTitle.value = "新增学生"
editingId.value = null
modalVisible.value = true
nextTick(() => {
formRef.value?.resetFields()
form.username = ""
form.password = ""
form.confirmPassword = ""
form.nickname = ""
form.email = ""
form.avatar = ""
form.classId = 0
form.studentNo = ""
form.phone = ""
form.idCard = ""
form.gender = undefined
form.birthDate = undefined
form.enrollmentDate = undefined
form.parentName = ""
form.parentPhone = ""
form.address = ""
form.description = ""
form.interestClassIds = []
form.validState = 1
})
}
const handleEdit = async (record: Student) => {
modalTitle.value = "编辑学生"
editingId.value = record.id
modalVisible.value = true
try {
const detail = await studentsApi.getDetail(record.id)
form.nickname = detail.user?.nickname || ""
form.email = detail.user?.email || ""
form.avatar = detail.user?.avatar || ""
form.classId = detail.classId
form.studentNo = detail.studentNo || ""
form.phone = detail.phone || ""
form.idCard = detail.idCard || ""
form.gender = detail.gender
form.birthDate = detail.birthDate ? dayjs(detail.birthDate) : undefined
form.enrollmentDate = detail.enrollmentDate
? dayjs(detail.enrollmentDate)
: undefined
form.parentName = detail.parentName || ""
form.parentPhone = detail.parentPhone || ""
form.address = detail.address || ""
form.description = detail.description || ""
form.interestClassIds =
detail.interestClasses?.map((ic) => ic.class.id) || []
form.validState = detail.validState
} catch (error) {
message.error("获取学生详情失败")
modalVisible.value = false
}
}
const handleDelete = (record: Student) => {
if (!authStore.hasPermission("student:delete")) {
message.error("您没有删除学生的权限")
return
}
Modal.confirm({
title: "确认删除",
content: `确定要删除学生 "${
record.user?.nickname || record.user?.username
}" 删除后无法恢复`,
okText: "确定",
okType: "danger",
cancelText: "取消",
onOk: async () => {
try {
await studentsApi.delete(record.id)
message.success("删除成功")
refreshList()
} catch (error: any) {
const errorMessage = error?.response?.data?.message || "删除失败"
message.error(errorMessage)
}
},
})
}
const handleSubmit = async () => {
try {
await formRef.value?.validate()
submitLoading.value = true
const data: CreateStudentForm | UpdateStudentForm = {
nickname: form.nickname,
email: form.email || undefined,
avatar: form.avatar || undefined,
classId: form.classId,
studentNo: form.studentNo || undefined,
phone: form.phone || undefined,
idCard: form.idCard || undefined,
gender: form.gender,
birthDate: form.birthDate
? form.birthDate.format("YYYY-MM-DD")
: undefined,
enrollmentDate: form.enrollmentDate
? form.enrollmentDate.format("YYYY-MM-DD")
: undefined,
parentName: form.parentName || undefined,
parentPhone: form.parentPhone || undefined,
address: form.address || undefined,
description: form.description || undefined,
interestClassIds: form.interestClassIds || [],
validState: form.validState,
}
if (editingId.value) {
await studentsApi.update(editingId.value, data as UpdateStudentForm)
message.success("编辑成功")
} else {
await studentsApi.create({
...data,
username: form.username,
password: form.password!,
} as CreateStudentForm)
message.success("新增成功")
}
modalVisible.value = false
refreshList()
} catch (error: any) {
if (error?.errorFields) {
return
}
const errorMessage =
error?.response?.data?.message ||
(editingId.value ? "编辑失败" : "新增失败")
message.error(errorMessage)
} finally {
submitLoading.value = false
}
}
const handleCancel = () => {
modalVisible.value = false
formRef.value?.resetFields()
}
onMounted(() => {
fetchClasses()
})
</script>
<style scoped></style>

View File

@ -1,498 +0,0 @@
<template>
<div class="teachers-page">
<a-card class="mb-4">
<template #title>教师管理</template>
<template #extra>
<a-space>
<a-select
v-model:value="searchParams.departmentId"
placeholder="选择部门"
allow-clear
style="width: 200px"
@change="handleSearch"
>
<a-select-option
v-for="dept in departmentOptions"
:key="dept.id"
:value="dept.id"
>
{{ dept.name }}
</a-select-option>
</a-select>
<a-button
v-permission="'teacher:create'"
type="primary"
@click="handleAdd"
>新增教师</a-button
>
</a-space>
</template>
</a-card>
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'user'">
<a-space>
<span>{{ record.user?.nickname || "-" }}</span>
<span style="color: #999">({{ record.user?.username }})</span>
</a-space>
</template>
<template v-if="column.key === 'department'">
<span>{{ record.department?.name || "-" }}</span>
</template>
<template v-if="column.key === 'gender'">
<a-tag v-if="record.gender === 1" color="blue"></a-tag>
<a-tag v-else-if="record.gender === 2" color="pink"></a-tag>
<span v-else>-</span>
</template>
<template v-if="column.key === 'validState'">
<a-tag :color="record.validState === 1 ? 'green' : 'red'">
{{ record.validState === 1 ? "有效" : "失效" }}
</a-tag>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button
v-permission="'teacher:update'"
type="link"
size="small"
@click="handleEdit(record)"
>
编辑
</a-button>
<a-button
v-permission="'teacher:delete'"
type="link"
size="small"
danger
@click="handleDelete(record)"
>
删除
</a-button>
</a-space>
</template>
</template>
</a-table>
<!-- 新增/编辑教师弹窗 -->
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
:confirm-loading="submitLoading"
@ok="handleSubmit"
@cancel="handleCancel"
width="700px"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 18 }"
>
<template v-if="!editingId">
<a-form-item label="用户名" name="username">
<a-input
v-model:value="form.username"
placeholder="请输入用户名"
:maxlength="50"
/>
</a-form-item>
<a-form-item label="密码" name="password">
<a-input-password
v-model:value="form.password"
placeholder="请输入密码"
:maxlength="100"
/>
</a-form-item>
<a-form-item label="确认密码" name="confirmPassword">
<a-input-password
v-model:value="form.confirmPassword"
placeholder="请再次输入密码"
:maxlength="100"
/>
</a-form-item>
</template>
<a-form-item label="昵称" name="nickname">
<a-input
v-model:value="form.nickname"
placeholder="请输入昵称"
:maxlength="50"
/>
</a-form-item>
<a-form-item label="邮箱" name="email">
<a-input
v-model:value="form.email"
placeholder="请输入邮箱"
:maxlength="100"
/>
</a-form-item>
<a-form-item label="部门" name="departmentId">
<a-select
v-model:value="form.departmentId"
placeholder="请选择部门"
:options="departmentOptions"
/>
</a-form-item>
<a-form-item label="工号" name="employeeNo">
<a-input
v-model:value="form.employeeNo"
placeholder="请输入工号"
:maxlength="50"
/>
</a-form-item>
<a-form-item label="联系电话" name="phone">
<a-input
v-model:value="form.phone"
placeholder="请输入联系电话"
:maxlength="20"
/>
</a-form-item>
<a-form-item label="性别" name="gender">
<a-radio-group v-model:value="form.gender">
<a-radio :value="1"></a-radio>
<a-radio :value="2"></a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="出生日期" name="birthDate">
<a-date-picker
v-model:value="form.birthDate"
placeholder="请选择出生日期"
style="width: 100%"
value-format="YYYY-MM-DD"
/>
</a-form-item>
<a-form-item label="入职日期" name="hireDate">
<a-date-picker
v-model:value="form.hireDate"
placeholder="请选择入职日期"
style="width: 100%"
value-format="YYYY-MM-DD"
/>
</a-form-item>
<a-form-item label="任教科目" name="subject">
<a-input
v-model:value="form.subject"
placeholder="请输入任教科目"
:maxlength="50"
/>
</a-form-item>
<a-form-item label="职称" name="title">
<a-input
v-model:value="form.title"
placeholder="请输入职称"
:maxlength="50"
/>
</a-form-item>
<a-form-item label="描述" name="description">
<a-textarea
v-model:value="form.description"
placeholder="请输入描述"
:rows="3"
:maxlength="500"
/>
</a-form-item>
<a-form-item v-if="editingId" label="状态" name="validState">
<a-radio-group v-model:value="form.validState">
<a-radio :value="1">有效</a-radio>
<a-radio :value="2">失效</a-radio>
</a-radio-group>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, nextTick, onMounted } from "vue"
import { message, Modal } from "ant-design-vue"
import type { TableColumnsType, FormInstance } from "ant-design-vue"
import {
teachersApi,
type Teacher,
type CreateTeacherForm,
type UpdateTeacherForm,
} from "@/api/teachers"
import { departmentsApi, type Department } from "@/api/departments"
import { useListRequest } from "@/composables/useListRequest"
import { useAuthStore } from "@/stores/auth"
import dayjs, { type Dayjs } from "dayjs"
const authStore = useAuthStore()
const submitLoading = ref(false)
const modalVisible = ref(false)
const modalTitle = ref("新增教师")
const formRef = ref<FormInstance>()
const editingId = ref<number | null>(null)
const departmentsLoading = ref(false)
const departmentOptions = ref<Array<{ label: string; value: number }>>([])
const searchParams = reactive<{ departmentId?: number }>({})
const {
loading,
dataSource,
pagination,
handleTableChange,
refresh: refreshList,
search,
} = useListRequest<Teacher, { departmentId?: number }>({
requestFn: teachersApi.getList,
errorMessage: "获取教师列表失败",
defaultSearchParams: {},
})
const form = reactive<
CreateTeacherForm & {
confirmPassword?: string
birthDate?: Dayjs
hireDate?: Dayjs
validState?: number
}
>({
username: "",
password: "",
confirmPassword: "",
nickname: "",
email: "",
avatar: "",
departmentId: 0,
employeeNo: "",
phone: "",
idCard: "",
gender: undefined,
birthDate: undefined,
hireDate: undefined,
subject: "",
title: "",
description: "",
validState: 1,
})
const rules = {
username: [
{ required: true, message: "请输入用户名", trigger: "blur" },
{
pattern: /^[a-zA-Z][a-zA-Z0-9_]*$/,
message: "用户名只能包含字母、数字和下划线,且必须以字母开头",
trigger: "blur",
},
],
password: [
{ required: true, message: "请输入密码", trigger: "blur" },
{ min: 6, message: "密码长度不能少于6位", trigger: "blur" },
],
confirmPassword: [
{ required: true, message: "请再次输入密码", trigger: "blur" },
{
validator: (_rule: any, value: string) => {
if (value !== form.password) {
return Promise.reject("两次输入的密码不一致")
}
return Promise.resolve()
},
trigger: "blur",
},
],
nickname: [{ required: true, message: "请输入昵称", trigger: "blur" }],
email: [{ type: "email", message: "请输入正确的邮箱地址", trigger: "blur" }],
departmentId: [{ required: true, message: "请选择部门", trigger: "change" }],
}
const columns: TableColumnsType = [
{ title: "ID", dataIndex: "id", key: "id", width: 80 },
{ title: "教师", key: "user", width: 150 },
{ title: "工号", dataIndex: "employeeNo", key: "employeeNo", width: 120 },
{ title: "部门", key: "department", width: 150 },
{ title: "性别", dataIndex: "gender", key: "gender", width: 80 },
{ title: "联系电话", dataIndex: "phone", key: "phone", width: 120 },
{ title: "任教科目", dataIndex: "subject", key: "subject", width: 120 },
{ title: "职称", dataIndex: "title", key: "title", width: 120 },
{ title: "状态", dataIndex: "validState", key: "validState", width: 80 },
{ title: "操作", key: "action", width: 150, fixed: "right" },
]
const fetchDepartments = async () => {
departmentsLoading.value = true
try {
const tree = await departmentsApi.getTree()
const flatten = (depts: Department[]): Department[] => {
const result: Department[] = []
depts.forEach((dept) => {
result.push(dept)
if (dept.children) {
result.push(...flatten(dept.children))
}
})
return result
}
const flatList = flatten(tree)
departmentOptions.value = flatList.map((dept: Department) => ({
label: dept.name,
value: dept.id,
}))
} catch (error) {
message.error("获取部门列表失败")
} finally {
departmentsLoading.value = false
}
}
const handleSearch = () => {
search(searchParams)
}
const handleAdd = () => {
if (!authStore.hasPermission("teacher:create")) {
message.error("您没有创建教师的权限")
return
}
modalTitle.value = "新增教师"
editingId.value = null
modalVisible.value = true
nextTick(() => {
formRef.value?.resetFields()
form.username = ""
form.password = ""
form.confirmPassword = ""
form.nickname = ""
form.email = ""
form.avatar = ""
form.departmentId = 0
form.employeeNo = ""
form.phone = ""
form.idCard = ""
form.gender = undefined
form.birthDate = undefined
form.hireDate = undefined
form.subject = ""
form.title = ""
form.description = ""
form.validState = 1
})
}
const handleEdit = async (record: Teacher) => {
modalTitle.value = "编辑教师"
editingId.value = record.id
modalVisible.value = true
try {
const detail = await teachersApi.getDetail(record.id)
form.nickname = detail.user?.nickname || ""
form.email = detail.user?.email || ""
form.avatar = detail.user?.avatar || ""
form.departmentId = detail.departmentId
form.employeeNo = detail.employeeNo || ""
form.phone = detail.phone || ""
form.idCard = detail.idCard || ""
form.gender = detail.gender
form.birthDate = detail.birthDate ? dayjs(detail.birthDate) : undefined
form.hireDate = detail.hireDate ? dayjs(detail.hireDate) : undefined
form.subject = detail.subject || ""
form.title = detail.title || ""
form.description = detail.description || ""
form.validState = detail.validState
} catch (error) {
message.error("获取教师详情失败")
modalVisible.value = false
}
}
const handleDelete = (record: Teacher) => {
if (!authStore.hasPermission("teacher:delete")) {
message.error("您没有删除教师的权限")
return
}
Modal.confirm({
title: "确认删除",
content: `确定要删除教师 "${
record.user?.nickname || record.user?.username
}" 删除后无法恢复`,
okText: "确定",
okType: "danger",
cancelText: "取消",
onOk: async () => {
try {
await teachersApi.delete(record.id)
message.success("删除成功")
refreshList()
} catch (error: any) {
const errorMessage = error?.response?.data?.message || "删除失败"
message.error(errorMessage)
}
},
})
}
const handleSubmit = async () => {
try {
await formRef.value?.validate()
submitLoading.value = true
const data: CreateTeacherForm | UpdateTeacherForm = {
nickname: form.nickname,
email: form.email || undefined,
avatar: form.avatar || undefined,
departmentId: form.departmentId,
employeeNo: form.employeeNo || undefined,
phone: form.phone || undefined,
idCard: form.idCard || undefined,
gender: form.gender,
birthDate: form.birthDate
? form.birthDate.format("YYYY-MM-DD")
: undefined,
hireDate: form.hireDate ? form.hireDate.format("YYYY-MM-DD") : undefined,
subject: form.subject || undefined,
title: form.title || undefined,
description: form.description || undefined,
validState: form.validState,
}
if (editingId.value) {
await teachersApi.update(editingId.value, data as UpdateTeacherForm)
message.success("编辑成功")
} else {
await teachersApi.create({
...data,
username: form.username,
password: form.password!,
} as CreateTeacherForm)
message.success("新增成功")
}
modalVisible.value = false
refreshList()
} catch (error: any) {
if (error?.errorFields) {
return
}
const errorMessage =
error?.response?.data?.message ||
(editingId.value ? "编辑失败" : "新增失败")
message.error(errorMessage)
} finally {
submitLoading.value = false
}
}
const handleCancel = () => {
modalVisible.value = false
formRef.value?.resetFields()
}
onMounted(() => {
fetchDepartments()
})
</script>
<style scoped></style>

File diff suppressed because it is too large Load Diff

View File

@ -1,961 +0,0 @@
<template>
<div class="history-page">
<!-- Animated Background -->
<div class="bg-animation">
<div class="bg-gradient bg-gradient-1"></div>
<div class="bg-gradient bg-gradient-2"></div>
<div class="bg-gradient bg-gradient-3"></div>
</div>
<!-- Header -->
<div class="page-header">
<div class="header-left">
<a-button type="text" class="back-btn" @click="handleBack">
<template #icon><ArrowLeftOutlined /></template>
</a-button>
<span class="title">创作历史</span>
<span class="count-badge">{{ total }} 个作品</span>
</div>
<div class="header-right">
<a-select
v-model:value="statusFilter"
placeholder="全部状态"
style="width: 120px"
allowClear
@change="handleFilterChange"
>
<a-select-option value="">全部</a-select-option>
<a-select-option value="completed">已完成</a-select-option>
<a-select-option value="processing">生成中</a-select-option>
<a-select-option value="pending">排队中</a-select-option>
<a-select-option value="failed">失败</a-select-option>
<a-select-option value="timeout">超时</a-select-option>
</a-select>
</div>
</div>
<!-- Content -->
<div class="page-content">
<!-- Loading -->
<div v-if="loading && list.length === 0" class="loading-state">
<div class="loader">
<div class="loader-ring"></div>
<div class="loader-ring"></div>
<div class="loader-ring"></div>
</div>
<p>加载中...</p>
</div>
<!-- Empty -->
<div v-else-if="list.length === 0" class="empty-state">
<div class="empty-icon">
<FileImageOutlined />
</div>
<p class="empty-title">暂无创作记录</p>
<p class="empty-text">开始你的第一次 3D 创作之旅吧</p>
<a-button type="primary" class="primary-btn" @click="goCreate">
<ThunderboltOutlined />
开始创作
</a-button>
</div>
<!-- Grid -->
<div v-else class="history-grid">
<div
v-for="task in list"
:key="task.id"
class="history-card"
@click="handleViewTask(task)"
>
<div class="card-preview">
<img
v-if="task.status === 'completed' && task.previewUrl"
:src="getPreviewUrl(task)"
alt="预览"
class="preview-image"
/>
<div
v-else-if="
task.status === 'processing' || task.status === 'pending'
"
class="preview-loading"
>
<div class="loading-dots">
<span></span>
<span></span>
<span></span>
</div>
<span class="loading-text">{{ task.status === 'pending' ? '排队中' : '生成中' }}</span>
</div>
<div
v-else-if="task.status === 'failed' || task.status === 'timeout'"
class="preview-failed"
>
<div class="failed-icon">
<CloseOutlined />
</div>
</div>
<div v-else class="preview-placeholder">
<FileImageOutlined />
</div>
<!-- Status Badge -->
<div class="status-badge" :class="`status-${task.status}`">
{{ getStatusText(task.status) }}
</div>
</div>
<!-- 悬停显示的底部信息 -->
<div class="card-hover-info" @click.stop>
<div class="hover-left">
<img
v-if="task.inputType === 'image'"
:src="task.inputContent"
alt=""
class="input-thumb"
/>
<span v-else class="input-desc">{{ task.inputContent }}</span>
</div>
<span class="hover-date">{{ formatDate(task.createTime) }}</span>
<div class="hover-actions">
<a-tooltip v-if="['failed', 'timeout'].includes(task.status)" title="重试" placement="top">
<button
class="action-btn"
:disabled="task.retryCount >= 3"
@click="handleRetry(task)"
>
<ReloadOutlined />
</button>
</a-tooltip>
<a-tooltip title="删除" placement="top">
<button
class="action-btn"
@click="handleDelete(task)"
>
<DeleteOutlined />
</button>
</a-tooltip>
</div>
</div>
</div>
</div>
<!-- Pagination -->
<div v-if="total > pageSize" class="pagination-wrapper">
<a-pagination
v-model:current="currentPage"
:total="total"
:page-size="pageSize"
:show-size-changer="false"
show-quick-jumper
@change="handlePageChange"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue"
import { useRouter, useRoute } from "vue-router"
import { message, Modal } from "ant-design-vue"
import {
ArrowLeftOutlined,
FileImageOutlined,
CloseOutlined,
ReloadOutlined,
DeleteOutlined,
ThunderboltOutlined,
} from "@ant-design/icons-vue"
import {
getAI3DTasks,
retryAI3DTask,
deleteAI3DTask,
type AI3DTask,
type AI3DTaskStatus,
} from "@/api/ai-3d"
import dayjs from "dayjs"
const router = useRouter()
const route = useRoute()
//
const loading = ref(false)
const list = ref<AI3DTask[]>([])
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(12)
const statusFilter = ref<AI3DTaskStatus | "">("")
//
let pollingTimer: number | null = null
//
const handleBack = () => {
router.back()
}
//
const goCreate = () => {
const tenantCode = route.params.tenantCode as string
router.push(`/${tenantCode}/workbench/3d-lab`)
}
//
const fetchList = async () => {
loading.value = true
try {
const params: any = {
page: currentPage.value,
pageSize: pageSize.value,
}
if (statusFilter.value) {
params.status = statusFilter.value
}
const res = await getAI3DTasks(params)
const data = res.data || res
list.value = data.list || []
total.value = data.total || 0
} catch (error) {
console.error("获取历史记录失败:", error)
message.error("获取历史记录失败")
} finally {
loading.value = false
}
}
//
const handleFilterChange = () => {
currentPage.value = 1
fetchList()
}
//
const handlePageChange = (page: number) => {
currentPage.value = page
fetchList()
}
// URL
const getPreviewUrl = (task: AI3DTask) => {
if (task.previewUrl) {
// COS访
if (task.previewUrl.includes("competition-ms-1325825530.cos")) {
return task.previewUrl
}
// 访CORS
if (
task.previewUrl.includes("tencentcos.cn") ||
task.previewUrl.includes("qcloud.com")
) {
return `/api/ai-3d/proxy-preview?url=${encodeURIComponent(task.previewUrl)}`
}
return task.previewUrl
}
return ""
}
//
const getStatusText = (status: string) => {
const texts: Record<string, string> = {
pending: "排队中",
processing: "生成中",
completed: "已完成",
failed: "失败",
timeout: "超时",
}
return texts[status] || status
}
//
const formatTime = (time: string) => {
return dayjs(time).format("YYYY-MM-DD HH:mm")
}
//
const formatDate = (time: string) => {
return dayjs(time).format("YYYY.MM.DD")
}
//
const handleViewTask = (task: AI3DTask) => {
router.push({
name: "AI3DGenerate",
params: { taskId: task.id },
})
}
// 3D
const handlePreview = (task: AI3DTask) => {
if (task.resultUrl || (task.resultUrls && task.resultUrls.length > 0)) {
const tenantCode = route.params.tenantCode as string
const urls = task.resultUrls || [task.resultUrl]
// sessionStorage
if (urls.length > 1) {
sessionStorage.setItem("model-viewer-urls", JSON.stringify(urls))
sessionStorage.setItem("model-viewer-index", "0")
sessionStorage.removeItem("model-viewer-url")
} else {
sessionStorage.setItem("model-viewer-url", urls[0] || "")
sessionStorage.removeItem("model-viewer-urls")
sessionStorage.removeItem("model-viewer-index")
}
router.push({
path: `/${tenantCode}/workbench/model-viewer`,
})
}
}
//
const handleRetry = async (task: AI3DTask) => {
if (task.retryCount >= 3) {
message.warning("已达到最大重试次数,请创建新任务")
return
}
try {
await retryAI3DTask(task.id)
message.success("重试已提交")
fetchList()
startPolling()
} catch (error: any) {
message.error(error.response?.data?.message || "重试失败")
}
}
//
const handleDelete = (task: AI3DTask) => {
Modal.confirm({
title: "确认删除",
content: "确定要删除这条创作记录吗?",
okText: "删除",
okType: "danger",
cancelText: "取消",
async onOk() {
try {
await deleteAI3DTask(task.id)
message.success("删除成功")
fetchList()
} catch (error) {
message.error("删除失败")
}
},
})
}
//
const startPolling = () => {
if (pollingTimer) return
pollingTimer = window.setInterval(async () => {
const hasProcessing = list.value.some(
(t) => t.status === "pending" || t.status === "processing"
)
if (!hasProcessing) {
stopPolling()
return
}
await fetchList()
}, 3000)
}
//
const stopPolling = () => {
if (pollingTimer) {
clearInterval(pollingTimer)
pollingTimer = null
}
}
onMounted(async () => {
await fetchList()
const hasProcessing = list.value.some(
(t) => t.status === "pending" || t.status === "processing"
)
if (hasProcessing) {
startPolling()
}
})
onUnmounted(() => {
stopPolling()
})
</script>
<style scoped lang="scss">
// ==========================================
// -
// ==========================================
$primary: #1890ff;
$primary-dark: #0958d9;
$primary-light: #40a9ff;
$secondary: #4096ff;
$success: #52c41a;
$warning: #faad14;
$error: #ff4d4f;
$background: #f5f5f5;
$surface: #ffffff;
$surface-light: #fafafa;
$text: rgba(0, 0, 0, 0.85);
$text-secondary: rgba(0, 0, 0, 0.65);
$text-muted: rgba(0, 0, 0, 0.45);
$border: #d9d9d9;
$border-light: #e8e8e8;
// -
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
.history-page {
min-height: 100vh;
background: $background;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
}
// ==========================================
// Animated Background
// ==========================================
.bg-animation {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
z-index: 0;
}
.bg-gradient {
position: absolute;
border-radius: 50%;
filter: blur(100px);
opacity: 0.15;
animation: float 30s ease-in-out infinite;
&.bg-gradient-1 {
width: 600px;
height: 600px;
background: $primary;
top: -200px;
left: -100px;
}
&.bg-gradient-2 {
width: 500px;
height: 500px;
background: $primary-light;
bottom: -150px;
right: -100px;
animation-delay: -10s;
}
&.bg-gradient-3 {
width: 400px;
height: 400px;
background: $secondary;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation-delay: -20s;
opacity: 0.1;
}
}
@keyframes float {
0%,
100% {
transform: translate(0, 0) scale(1);
}
33% {
transform: translate(30px, -30px) scale(1.05);
}
66% {
transform: translate(-20px, 20px) scale(0.95);
}
}
// ==========================================
// Header
// ==========================================
.page-header {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 64px;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
z-index: 10;
background: transparent;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
.back-btn {
color: $text !important;
width: 40px;
height: 40px;
border-radius: 10px !important;
border: 1px solid rgba($primary, 0.3) !important;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s !important;
flex-shrink: 0;
&:hover {
background: rgba($primary, 0.2) !important;
border-color: $primary !important;
transform: translateY(-1px);
}
}
.title {
font-size: 20px;
font-weight: 600;
background: $gradient-primary;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.count-badge {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: rgba($primary, 0.1);
border: 1px solid rgba($primary, 0.2);
border-radius: 20px;
color: $primary;
font-size: 11px;
font-weight: 600;
}
}
.header-right {
display: flex;
gap: 8px;
}
// ==========================================
// Content
// ==========================================
.page-content {
flex: 1;
padding: 24px;
padding-top: 88px;
overflow-y: auto;
position: relative;
z-index: 1;
}
// ==========================================
// Loading State
// ==========================================
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
gap: 24px;
color: $text-muted;
.loader {
position: relative;
width: 60px;
height: 60px;
}
.loader-ring {
position: absolute;
inset: 0;
border: 3px solid transparent;
border-radius: 50%;
animation: spin 1.5s linear infinite;
&:nth-child(1) {
border-top-color: $primary;
}
&:nth-child(2) {
width: 45px;
height: 45px;
top: 7.5px;
left: 7.5px;
border-right-color: $primary-light;
animation-direction: reverse;
}
&:nth-child(3) {
width: 30px;
height: 30px;
top: 15px;
left: 15px;
border-bottom-color: $secondary;
}
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
// ==========================================
// Empty State
// ==========================================
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
gap: 12px;
.empty-icon {
width: 80px;
height: 80px;
background: rgba($primary, 0.1);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
color: $primary-light;
margin-bottom: 8px;
}
.empty-title {
font-size: 18px;
font-weight: 600;
color: $text;
margin: 0;
}
.empty-text {
font-size: 14px;
color: $text-muted;
margin: 0 0 16px;
}
}
.primary-btn {
background: $gradient-primary !important;
border: none !important;
color: #fff !important;
font-weight: 500 !important;
height: 40px;
padding: 0 24px;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.3s ease !important;
&:hover {
filter: brightness(1.1);
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba($primary, 0.3);
}
}
// ==========================================
// History Grid
// ==========================================
.history-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 24px;
}
.history-card {
background: $surface;
border-radius: 16px;
overflow: hidden;
cursor: pointer;
position: relative;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06), 0 1px 4px rgba(0, 0, 0, 0.04);
transition: box-shadow 0.3s ease, transform 0.3s ease;
&:hover {
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.1), 0 4px 12px rgba(0, 0, 0, 0.06);
transform: translateY(-4px);
.card-preview .preview-image {
transform: scale(1.05);
}
.card-hover-info {
opacity: 1;
transform: translateY(0);
}
}
}
.card-preview {
height: 240px;
background: linear-gradient(
135deg,
rgba($surface-light, 0.9) 0%,
rgba($primary, 0.05) 100%
);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
.preview-image {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
.preview-loading,
.preview-failed,
.preview-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: $text-muted;
font-size: 32px;
.loading-text {
font-size: 13px;
}
}
.preview-failed {
.failed-icon {
width: 56px;
height: 56px;
background: linear-gradient(
135deg,
rgba($error, 0.15) 0%,
rgba($error, 0.25) 100%
);
border: 2px solid rgba($error, 0.3);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: $error;
animation: pulse-error 2s ease-in-out infinite;
}
}
}
@keyframes pulse-error {
0%,
100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba($error, 0.3);
}
50% {
transform: scale(1.05);
box-shadow: 0 0 20px 5px rgba($error, 0.15);
}
}
.loading-dots {
display: flex;
gap: 6px;
span {
width: 8px;
height: 8px;
background: $primary-light;
border-radius: 50%;
animation: dotPulse 1.4s ease-in-out infinite;
&:nth-child(1) {
animation-delay: 0s;
}
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
}
@keyframes dotPulse {
0%,
60%,
100% {
transform: scale(1);
opacity: 1;
}
30% {
transform: scale(1.5);
opacity: 0.7;
}
}
.status-badge {
position: absolute;
top: 12px;
right: 12px;
padding: 4px 12px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
&.status-completed {
background: rgba($success, 0.2);
color: $success;
border-color: rgba($success, 0.3);
}
&.status-processing,
&.status-pending {
background: rgba($primary-light, 0.2);
color: $primary-light;
border-color: rgba($primary-light, 0.3);
}
&.status-failed,
&.status-timeout {
background: rgba($error, 0.2);
color: $error;
border-color: rgba($error, 0.3);
}
}
//
.card-hover-info {
position: absolute;
bottom: 8px;
left: 8px;
right: 8px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(12px);
border-radius: 12px;
opacity: 0;
transform: translateY(10px);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
.hover-left {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
.input-thumb {
width: 36px;
height: 36px;
object-fit: cover;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.15);
flex-shrink: 0;
}
.input-desc {
font-size: 13px;
color: rgba(255, 255, 255, 0.95);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.hover-date {
font-size: 12px;
color: rgba(255, 255, 255, 0.6);
white-space: nowrap;
flex-shrink: 0;
}
.hover-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 8px;
background: rgba(255, 255, 255, 0.15);
color: rgba(255, 255, 255, 0.9);
cursor: pointer;
transition: all 0.2s;
font-size: 14px;
&:hover {
background: rgba(255, 255, 255, 0.3);
color: #fff;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
}
}
// ==========================================
// Pagination
// ==========================================
.pagination-wrapper {
display: flex;
justify-content: center;
margin-top: 32px;
padding-bottom: 24px;
}
// ==========================================
// Responsive
// ==========================================
@media (max-width: 768px) {
.page-header {
padding: 0 16px;
.header-left {
gap: 12px;
}
.title {
font-size: 16px;
}
.count-badge {
display: none;
}
}
.page-content {
padding: 16px;
padding-top: 80px;
}
.history-grid {
grid-template-columns: 1fr;
}
}
</style>

File diff suppressed because it is too large Load Diff