新增3D建模页面

This commit is contained in:
zhangxiaohua 2026-01-13 14:01:17 +08:00
parent 9fc98a6fd5
commit 59ba6b6904
20 changed files with 2157 additions and 462 deletions

View File

@ -3,10 +3,20 @@
"name": "工作台",
"path": "/workbench",
"icon": "DashboardOutlined",
"component": "workbench/Index",
"component": null,
"parentId": null,
"sort": 1,
"permission": "workbench:read"
"permission": "ai-3d:read",
"children": [
{
"name": "3D建模实验室",
"path": "/workbench/3d-lab",
"icon": "ExperimentOutlined",
"component": "workbench/ai-3d/Index",
"sort": 1,
"permission": "ai-3d:read"
}
]
},
{
"name": "学校管理",

View File

@ -1,10 +1,17 @@
[
{
"code": "workbench:read",
"resource": "workbench",
"code": "ai-3d:read",
"resource": "ai-3d",
"action": "read",
"name": "查看工作台",
"description": "允许查看工作台"
"name": "使用3D建模实验室",
"description": "允许使用AI 3D建模实验室"
},
{
"code": "ai-3d:create",
"resource": "ai-3d",
"action": "create",
"name": "创建3D模型任务",
"description": "允许创建AI 3D模型生成任务"
},
{
"code": "user:create",

View File

@ -25,7 +25,8 @@
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"reflect-metadata": "^0.2.1",
"rxjs": "^7.8.1"
"rxjs": "^7.8.1",
"uuid": "^13.0.0"
},
"devDependencies": {
"@nestjs/cli": "^10.3.2",
@ -37,6 +38,7 @@
"@types/node": "^20.11.5",
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.36",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^6.19.1",
"@typescript-eslint/parser": "^6.19.1",
"dotenv": "^17.2.3",
@ -2513,6 +2515,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/validator": {
"version": "13.15.10",
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz",
@ -8927,6 +8936,16 @@
"node": ">=0.6"
}
},
"node_modules/request/node_modules/uuid": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==",
"deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.",
"license": "MIT",
"bin": {
"uuid": "bin/uuid"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@ -10454,13 +10473,16 @@
}
},
"node_modules/uuid": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==",
"deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.",
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "bin/uuid"
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/v8-compile-cache-lib": {

View File

@ -61,7 +61,8 @@
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"reflect-metadata": "^0.2.1",
"rxjs": "^7.8.1"
"rxjs": "^7.8.1",
"uuid": "^13.0.0"
},
"devDependencies": {
"@nestjs/cli": "^10.3.2",
@ -73,6 +74,7 @@
"@types/node": "^20.11.5",
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.36",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^6.19.1",
"@typescript-eslint/parser": "^6.19.1",
"dotenv": "^17.2.3",

View File

@ -49,6 +49,8 @@ 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)
@ -154,6 +156,8 @@ model User {
homeworkScoresAsReviewer HomeworkScore[] @relation("HomeworkScoreReviewer")
createdHomeworkScores HomeworkScore[] @relation("HomeworkScoreCreator")
modifiedHomeworkScores HomeworkScore[] @relation("HomeworkScoreModifier")
// AI 3D 生成关联
ai3dTasks AI3DTask[] @relation("AI3DTaskUser") /// 用户的 AI 3D 生成任务
@@unique([tenantId, username])
@@unique([tenantId, email])
@ -565,7 +569,7 @@ model Contest {
submitStartTime DateTime @map("submit_start_time") /// 作品提交开始时间
submitEndTime DateTime @map("submit_end_time") /// 作品提交结束时间
workType String? @map("work_type") /// 作品类型image/video/document/code
workRequirement String? @db.Text @map("work_requirement") /// 作品要求说明
workRequirement String? @map("work_requirement") @db.Text /// 作品要求说明
// 评审配置
reviewRuleId Int? @map("review_rule_id") /// 评审规则id
reviewStartTime DateTime @map("review_start_time") /// 评审开始时间
@ -626,7 +630,7 @@ model ContestReviewRule {
id Int @id @default(autoincrement())
tenantId Int @map("tenant_id") /// 租户ID
ruleName String @map("rule_name") /// 规则名称
ruleDescription String? @db.Text @map("rule_description") /// 规则说明
ruleDescription String? @map("rule_description") @db.Text /// 规则说明
judgeCount Int? @map("judge_count") /// 评委数量
dimensions Json /// 评分维度配置JSON
calculationRule String @default("average") @map("calculation_rule") /// 计算规则average/remove_max_min/remove_min
@ -985,7 +989,7 @@ model HomeworkSubmission {
studentId Int @map("student_id") /// 学生用户ID
workNo String? @map("work_no") /// 作品编号
workName String @map("work_name") /// 作品名称
workDescription String? @db.Text @map("work_description") /// 作品介绍
workDescription String? @map("work_description") @db.Text /// 作品介绍
files Json? /// 作品文件列表
attachments Json? /// 附件列表
submitTime DateTime @default(now()) @map("submit_time") /// 提交时间
@ -1060,3 +1064,33 @@ model HomeworkScore {
@@index([reviewerId])
@@map("t_homework_score")
}
// ============================================
// 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
status String @default("pending") /// 任务状态pending | processing | completed | failed | timeout
resultUrl String? @map("result_url") /// 生成的3D模型URL
previewUrl String? @map("preview_url") /// 预览图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")
}

View File

@ -44,8 +44,8 @@ if (!fs.existsSync(menusFilePath)) {
const menus = JSON.parse(fs.readFileSync(menusFilePath, 'utf-8'));
// 超级租户可见的菜单名称
const SUPER_TENANT_MENUS = ['工作台', '赛事活动', '赛事管理', '系统管理'];
// 超级租户可见的菜单名称(工作台只对普通租户可见)
const SUPER_TENANT_MENUS = ['赛事活动', '赛事管理', '系统管理'];
// 普通租户可见的菜单名称
const NORMAL_TENANT_MENUS = ['工作台', '学校管理', '赛事活动', '作业管理', '系统管理'];

View File

@ -29,8 +29,9 @@ const prisma = new PrismaClient();
// 基础权限(所有角色共享的权限池)
const allPermissions = [
// 工作台
{ code: 'workbench:read', resource: 'workbench', action: 'read', name: '查看工作台', description: '允许查看工作台' },
// AI 3D建模
{ code: 'ai-3d:read', resource: 'ai-3d', action: 'read', name: '使用3D建模实验室', description: '允许使用AI 3D建模实验室' },
{ code: 'ai-3d:create', resource: 'ai-3d', action: 'create', name: '创建3D模型任务', description: '允许创建AI 3D模型生成任务' },
// 用户管理
{ code: 'user:create', resource: 'user', action: 'create', name: '创建用户', description: '允许创建新用户' },
@ -192,8 +193,6 @@ const superTenantRoles = [
name: '超级管理员',
description: '系统超级管理员,管理赛事和系统配置',
permissions: [
// 工作台
'workbench:read',
// 系统管理
'user:create', 'user:read', 'user:update', 'user:delete',
'role:create', 'role:read', 'role:update', 'role:delete', 'role:assign',
@ -217,7 +216,6 @@ const superTenantRoles = [
name: '评委',
description: '赛事评委,可以评审作品',
permissions: [
'workbench:read',
'activity:read', // 查看赛事活动
'work:read', // 查看待评审作品
'review:read', // 查看评审任务
@ -234,7 +232,6 @@ const normalTenantRoles = [
name: '学校管理员',
description: '学校管理员,管理学校信息、教师、学生等',
permissions: [
'workbench:read',
'user:create', 'user:read', 'user:update', 'user:delete',
'role:create', 'role:read', 'role:update', 'role:delete', 'role:assign',
'permission:read',
@ -259,7 +256,8 @@ const normalTenantRoles = [
name: '教师',
description: '教师角色,可以报名赛事、指导学生、管理作业',
permissions: [
'workbench:read',
// AI 3D建模工作台入口
'ai-3d:read', 'ai-3d:create', // 使用AI 3D建模实验室
// 查看基础信息
'grade:read',
'class:read',
@ -282,7 +280,8 @@ const normalTenantRoles = [
name: '学生',
description: '学生角色,可以查看赛事、上传作品、提交作业',
permissions: [
'workbench:read',
// AI 3D建模工作台入口
'ai-3d:read', 'ai-3d:create', // 使用AI 3D建模实验室
// 赛事活动
'activity:read', // 查看赛事活动列表
'notice:read', // 查看赛事公告

View File

@ -1,78 +0,0 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
import * as dotenv from 'dotenv';
import * as path from 'path';
const nodeEnv = process.env.NODE_ENV || 'development';
const envFile = `.env.${nodeEnv}`;
const backendDir = path.resolve(__dirname, '..');
const envPath = path.resolve(backendDir, envFile);
dotenv.config({ path: envPath });
if (!process.env.DATABASE_URL) {
dotenv.config({ path: path.resolve(backendDir, '.env') });
}
if (!process.env.DATABASE_URL) {
console.error('DATABASE_URL not found');
process.exit(1);
}
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function updateTenantMenuPermission() {
try {
console.log('🚀 开始更新租户管理菜单权限...\n');
// 查找租户管理菜单
const tenantMenu = await prisma.menu.findFirst({
where: {
name: '租户管理',
path: '/system/tenants',
},
});
if (!tenantMenu) {
console.log('❌ 租户管理菜单不存在');
return;
}
console.log(`找到租户管理菜单: ID=${tenantMenu.id}, 当前权限=${tenantMenu.permission}`);
if (tenantMenu.permission === 'tenant:update') {
console.log('✅ 菜单权限已经是 tenant:update无需更新');
return;
}
// 更新菜单权限
await prisma.menu.update({
where: { id: tenantMenu.id },
data: {
permission: 'tenant:update',
},
});
console.log('✅ 菜单权限已更新为 tenant:update');
console.log('\n说明:');
console.log(' - 普通租户只有 tenant:read 权限,可以读取租户列表(用于发布赛事选择公开范围)');
console.log(' - 只有超级租户才有 tenant:update 权限,才能看到租户管理菜单');
} catch (error) {
console.error('❌ 更新失败:', error);
throw error;
} finally {
await prisma.$disconnect();
}
}
updateTenantMenuPermission()
.then(() => {
console.log('\n🎉 脚本执行完成!');
process.exit(0);
})
.catch((error) => {
console.error('\n💥 脚本执行失败:', error);
process.exit(1);
});

View File

@ -0,0 +1,77 @@
import {
Controller,
Get,
Post,
Delete,
Body,
Param,
Query,
UseGuards,
Request,
ParseIntPipe,
} from '@nestjs/common';
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';
@Controller('ai-3d')
@UseGuards(JwtAuthGuard)
export class AI3DController {
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);
}
}

View File

@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { AI3DController } from './ai-3d.controller';
import { AI3DService } from './ai-3d.service';
import { MockAI3DProvider } from './providers/mock.provider';
import { AI3D_PROVIDER } from './providers/ai-3d-provider.interface';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [AI3DController],
providers: [
AI3DService,
{
provide: AI3D_PROVIDER,
useClass: MockAI3DProvider,
},
],
exports: [AI3DService],
})
export class AI3DModule {}

View File

@ -0,0 +1,297 @@
import {
Injectable,
Inject,
NotFoundException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.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_CONCURRENT_TASKS = 3; // 每用户最大并行任务数
const TASK_TIMEOUT_MS = 10 * 60 * 1000; // 10分钟超时
const MAX_RETRY_COUNT = 3; // 最大重试次数
@Injectable()
export class AI3DService {
private readonly logger = new Logger(AI3DService.name);
constructor(
private prisma: PrismaService,
@Inject(AI3D_PROVIDER) private ai3dProvider: AI3DProvider,
) {}
/**
*
*/
async createTask(
userId: number,
tenantId: number,
dto: CreateTaskDto,
) {
// 1. 检查用户当前进行中的任务数量
const activeTaskCount = await this.prisma.aI3DTask.count({
where: {
userId,
status: { in: ['pending', 'processing'] },
},
});
if (activeTaskCount >= MAX_CONCURRENT_TASKS) {
throw new BadRequestException(
`您当前有 ${activeTaskCount} 个任务正在处理中,最多同时处理 ${MAX_CONCURRENT_TASKS} 个任务,请等待完成后再提交`,
);
}
// 2. 创建数据库记录
const task = await this.prisma.aI3DTask.create({
data: {
userId,
tenantId,
inputType: dto.inputType,
inputContent: dto.inputContent,
status: 'pending',
},
});
// 3. 提交到 AI 服务
try {
const externalTaskId = await this.ai3dProvider.submitTask(
dto.inputType,
dto.inputContent,
);
// 4. 更新状态为处理中
await this.prisma.aI3DTask.update({
where: { id: task.id },
data: {
status: 'processing',
externalTaskId,
},
});
// 5. 启动轮询检查任务状态
this.pollTaskStatus(task.id, externalTaskId, Date.now());
this.logger.log(`任务 ${task.id} 创建成功外部ID: ${externalTaskId}`);
return this.getTask(userId, task.id);
} 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;
}
}
/**
*
*/
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('任务不存在');
}
return task;
}
/**
*
*/
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 activeTaskCount = await this.prisma.aI3DTask.count({
where: {
userId,
status: { in: ['pending', 'processing'] },
},
});
if (activeTaskCount >= MAX_CONCURRENT_TASKS) {
throw new BadRequestException(
`您当前有 ${activeTaskCount} 个任务正在处理中,请等待完成后再重试`,
);
}
// 重置任务状态
await this.prisma.aI3DTask.update({
where: { id },
data: {
status: 'pending',
errorMessage: null,
completeTime: null,
retryCount: { increment: 1 },
},
});
// 重新提交任务
try {
const externalTaskId = await this.ai3dProvider.submitTask(
task.inputType as 'text' | 'image',
task.inputContent,
);
await this.prisma.aI3DTask.update({
where: { id },
data: {
status: 'processing',
externalTaskId,
},
});
this.pollTaskStatus(id, externalTaskId, Date.now());
this.logger.log(`任务 ${id} 重试成功外部ID: ${externalTaskId}`);
return this.getTask(userId, id);
} catch (error) {
await this.prisma.aI3DTask.update({
where: { id },
data: {
status: 'failed',
errorMessage: error.message || 'AI服务提交失败',
completeTime: new Date(),
},
});
this.logger.error(`任务 ${id} 重试失败: ${error.message}`);
throw error;
}
}
/**
*
*/
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') {
await this.prisma.aI3DTask.update({
where: { id: taskId },
data: {
status: result.status,
resultUrl: result.resultUrl,
previewUrl: result.previewUrl,
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

@ -0,0 +1,12 @@
import { IsString, IsIn, IsNotEmpty, MaxLength } from 'class-validator';
export class CreateTaskDto {
@IsString()
@IsIn(['text', 'image'], { message: '输入类型必须是 text 或 image' })
inputType: 'text' | 'image';
@IsString()
@IsNotEmpty({ message: '输入内容不能为空' })
@MaxLength(2000, { message: '输入内容最多2000个字符' })
inputContent: string;
}

View File

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

@ -0,0 +1,39 @@
/**
* AI 3D
*/
export interface AI3DGenerateResult {
taskId: string; // 外部任务ID
status: 'pending' | 'processing' | 'completed' | 'failed';
resultUrl?: string; // 3D模型URL
previewUrl?: string; // 预览图URL
errorMessage?: string; // 错误信息
}
/**
* AI 3D
* MockMeshy
*/
export interface AI3DProvider {
/**
*
* @param inputType text | image
* @param inputContent URL
* @returns ID
*/
submitTask(
inputType: 'text' | 'image',
inputContent: string,
): Promise<string>;
/**
*
* @param taskId ID
* @returns
*/
queryTask(taskId: string): Promise<AI3DGenerateResult>;
}
/**
* AI 3D Provider
*/
export const AI3D_PROVIDER = 'AI3D_PROVIDER';

View File

@ -0,0 +1,135 @@
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;
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 = [
'/mock/models/sample-cube.glb',
'/mock/models/sample-sphere.glb',
'/mock/models/sample-model.glb',
];
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,
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) {
// 随机选择一个示例模型
const modelIndex = Math.floor(Math.random() * this.SAMPLE_MODELS.length);
const modelUrl = this.SAMPLE_MODELS[modelIndex];
task.status = 'completed';
task.resultUrl = modelUrl;
task.previewUrl = modelUrl.replace('.glb', '-preview.png');
this.logger.log(`Mock: 任务 ${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

@ -17,6 +17,7 @@ import { JudgesManagementModule } from './judges-management/judges-management.mo
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 { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { RolesGuard } from './auth/guards/roles.guard';
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
@ -50,6 +51,7 @@ import { HttpExceptionFilter } from './common/filters/http-exception.filter';
UploadModule,
HomeworkModule,
OssModule,
AI3DModule,
],
providers: [
{

105
frontend/src/api/ai-3d.ts Normal file
View File

@ -0,0 +1,105 @@
import request from "@/utils/request";
import type { PaginationParams, PaginationResponse } 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;
errorMessage?: string;
externalTaskId?: string;
retryCount: number;
createTime: string;
completeTime?: string;
}
/**
*
*/
export interface CreateAI3DTaskParams {
inputType: AI3DInputType;
inputContent: string;
}
/**
*
*/
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}`);
}

View File

@ -22,3 +22,12 @@ export const uploadApi = {
return response;
},
};
/**
*
*/
export async function uploadFile(file: File): Promise<UploadResponse> {
const formData = new FormData();
formData.append("file", file);
return uploadApi.upload(formData);
}

View File

@ -29,10 +29,7 @@ const baseRoutes: RouteRecordRaw[] = [
path: "/:tenantCode",
name: "Main",
component: () => import("@/layouts/BasicLayout.vue"),
redirect: (to) => {
// 默认跳转到 workbench路由守卫会处理重定向到第一个菜单
return { path: `/${to.params.tenantCode}/workbench` }
},
// 不设置固定redirect由路由守卫根据用户菜单动态跳转到第一个可见菜单
meta: {},
children: [
// 创建比赛路由(不需要在菜单中显示)
@ -165,6 +162,16 @@ const baseRoutes: RouteRecordRaw[] = [
permissions: ["activity:read"],
},
},
// 3D建模实验室路由工作台模块下
{
path: "workbench/3d-lab",
name: "3DModelingLab",
component: () => import("@/views/workbench/ai-3d/Index.vue"),
meta: {
title: "3D建模实验室",
requiresAuth: true,
},
},
// 动态路由将在这里添加
],
},
@ -281,9 +288,9 @@ function buildPathWithTenantCode(tenantCode: string, path: string): string {
}
// 移除开头的斜杠(如果有)
const cleanPath = path.startsWith("/") ? path.slice(1) : path
// 如果路径是根路径,返回租户编码路径
// 如果路径是根路径,返回租户编码路径(路由守卫会处理跳转到第一个菜单)
if (cleanPath === "" || cleanPath === tenantCode) {
return `/${tenantCode}/workbench`
return `/${tenantCode}`
}
return `/${tenantCode}/${cleanPath}`
}
@ -469,8 +476,8 @@ router.beforeEach(async (to, _from, next) => {
// 路由已生效,重新解析目标路由
const resolved = router.resolve(targetPath)
// 如果访问的是主路由或 workbench且路由不存在,重定向到第一个菜单
const isMainRoute = to.name === "Main" || to.path.endsWith("/workbench")
// 如果访问的是主路由,重定向到第一个菜单
const isMainRoute = to.name === "Main"
// 如果解析后的路由不是404说明路由存在重新导航
if (resolved.name !== "NotFound" && !isMainRoute) {
@ -535,9 +542,10 @@ router.beforeEach(async (to, _from, next) => {
dynamicRoutesAdded
) {
const resolved = router.resolve(to.fullPath)
// 如果路由不存在,且不是登录页,尝试重定向到用户第一个菜单
// 如果访问的是 Main 路由(无具体子路径)或路由不存在,重定向到用户第一个菜单
const isMainRouteWithoutChild = to.name === "Main" || to.matched.length === 1 && to.matched[0].name === "Main"
if (
resolved.name === "NotFound" &&
(resolved.name === "NotFound" || isMainRouteWithoutChild) &&
to.name !== "Login" &&
to.name !== "LoginFallback"
) {
@ -622,9 +630,9 @@ router.beforeEach(async (to, _from, next) => {
if (!dynamicRoutesAdded && authStore.menus.length > 0) {
await addDynamicRoutes()
}
// 重定向到带租户编码的工作台
// 重定向到带租户编码的根路径(路由守卫会处理跳转到第一个菜单)
const userTenantCode = authStore.user?.tenantCode || "default"
next({ path: `/${userTenantCode}/workbench` })
next({ path: `/${userTenantCode}` })
return
}

View File

@ -0,0 +1,972 @@
<template>
<div class="ai-3d-container">
<!-- 左侧生成栏 -->
<div class="left-panel">
<div class="panel-header">
<a-segmented
v-model:value="inputType"
:options="inputTypeOptions"
block
/>
</div>
<div class="panel-content">
<!-- 文生3D输入 -->
<div v-if="inputType === 'text'" class="text-input-section">
<div class="input-hint">
请输入想要生成的内容建议以单体为主例如一只棕色的猫雕塑尾巴卷曲卡通风格
</div>
<a-textarea
v-model:value="textContent"
placeholder="请输入描述..."
:rows="6"
:maxlength="150"
show-count
class="text-input"
/>
<div class="sample-prompts">
<ReloadOutlined class="refresh-icon" @click="refreshSamples" />
<span
v-for="(sample, index) in currentSamples"
:key="index"
class="sample-tag"
@click="applySample(sample)"
>
{{ sample }}
</span>
</div>
</div>
<!-- 图生3D上传 -->
<div v-else class="image-input-section">
<div class="input-hint">
上传一张参考图片AI 将根据图片生成 3D 模型
</div>
<a-upload-dragger
v-model:file-list="imageFileList"
:before-upload="handleBeforeUpload"
:max-count="1"
accept="image/*"
class="image-upload"
>
<p class="ant-upload-drag-icon">
<PictureOutlined />
</p>
<p class="ant-upload-text">点击或拖拽图片到此处</p>
<p class="ant-upload-hint">支持 JPGPNG 格式</p>
</a-upload-dragger>
</div>
</div>
<div class="panel-footer">
<a-button
type="primary"
size="large"
block
:loading="generating"
:disabled="!canGenerate"
@click="handleGenerate"
>
<template #icon><ThunderboltOutlined /></template>
立即生成
</a-button>
</div>
</div>
<!-- 右侧内容区 -->
<div class="right-panel">
<!-- 介绍区 -->
<div class="intro-section">
<h1 class="intro-title">用一句话一张图生成你的 3D 世界</h1>
<div class="intro-features">
<div class="feature-item">
<span class="feature-icon"></span>
<span class="feature-text"
>AI 智能建模输入文字描述或上传参考图自动生成 3D 模型</span
>
</div>
<div class="feature-item">
<span class="feature-icon">👁</span>
<span class="feature-text"
>在线实时预览支持模型旋转缩放与查看细节</span
>
</div>
<div class="feature-item">
<span class="feature-icon">📁</span>
<span class="feature-text"
>作品统一管理所有建模成果自动保存至个人作品库</span
>
</div>
<div class="feature-item">
<span class="feature-icon">🔄</span>
<span class="feature-text"
>持续创作迭代支持基于已有作品再次生成与优化</span
>
</div>
</div>
<a class="intro-action" @click="focusInput"> 立即开始建模 </a>
<p class="intro-subtitle">从一个想法开始 3D 创作变得更简单</p>
</div>
<!-- 创作历史区 -->
<div class="history-section">
<div class="history-header">
<h2 class="history-title">创作历史</h2>
<a class="view-all" @click="showAllHistory = true">查看全部 ></a>
</div>
<div v-if="historyLoading" class="history-loading">
<a-spin />
</div>
<div v-else-if="historyList.length === 0" class="history-empty">
<a-empty description="暂无创作记录,开始你的第一次创作吧" />
</div>
<div v-else class="history-grid">
<div
v-for="task in historyList"
: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"
>
<LoadingOutlined spin />
<span>生成中...</span>
</div>
<div
v-else-if="
task.status === 'failed' || task.status === 'timeout'
"
class="preview-failed"
>
<ExclamationCircleOutlined />
<span>{{
task.status === "timeout" ? "已超时" : "生成失败"
}}</span>
</div>
<div v-else class="preview-placeholder">
<FileImageOutlined />
</div>
</div>
<div class="card-info">
<div class="card-desc" :title="task.inputContent">
{{ task.inputContent }}
</div>
<div class="card-meta">
<span class="card-time">{{ formatTime(task.createTime) }}</span>
<div class="card-actions" @click.stop>
<a-tooltip v-if="task.status === 'completed'" title="预览">
<EyeOutlined
class="action-icon"
@click="handlePreview(task)"
/>
</a-tooltip>
<a-tooltip
v-if="['failed', 'timeout'].includes(task.status)"
title="重试"
>
<ReloadOutlined
class="action-icon"
:class="{ disabled: task.retryCount >= 3 }"
@click="handleRetry(task)"
/>
</a-tooltip>
<a-tooltip title="删除">
<DeleteOutlined
class="action-icon danger"
@click="handleDelete(task)"
/>
</a-tooltip>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 全部历史记录抽屉 -->
<a-drawer
v-model:open="showAllHistory"
title="全部创作历史"
placement="right"
width="600px"
>
<a-list
:data-source="allHistoryList"
:loading="allHistoryLoading"
:pagination="pagination"
>
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #avatar>
<div class="list-preview">
<img
v-if="item.status === 'completed' && item.previewUrl"
:src="getPreviewUrl(item)"
alt="预览"
/>
<div v-else class="list-preview-placeholder">
<LoadingOutlined
v-if="['pending', 'processing'].includes(item.status)"
spin
/>
<ExclamationCircleOutlined
v-else-if="['failed', 'timeout'].includes(item.status)"
/>
<FileImageOutlined v-else />
</div>
</div>
</template>
<template #title>
<span class="list-desc">{{ item.inputContent }}</span>
</template>
<template #description>
<div class="list-meta">
<a-tag :color="getStatusColor(item.status)">{{
getStatusText(item.status)
}}</a-tag>
<span>{{ formatTime(item.createTime) }}</span>
</div>
</template>
</a-list-item-meta>
<template #actions>
<a v-if="item.status === 'completed'" @click="handlePreview(item)"
>预览</a
>
<a
v-if="
['failed', 'timeout'].includes(item.status) &&
item.retryCount < 3
"
@click="handleRetry(item)"
>重试</a
>
<a class="danger-link" @click="handleDelete(item)">删除</a>
</template>
</a-list-item>
</template>
</a-list>
</a-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue"
import { message, Modal } from "ant-design-vue"
import {
ReloadOutlined,
PictureOutlined,
ThunderboltOutlined,
LoadingOutlined,
ExclamationCircleOutlined,
FileImageOutlined,
EyeOutlined,
DeleteOutlined,
} from "@ant-design/icons-vue"
import type { UploadFile } from "ant-design-vue"
import {
createAI3DTask,
getAI3DTasks,
getAI3DTask,
retryAI3DTask,
deleteAI3DTask,
type AI3DTask,
} from "@/api/ai-3d"
import { uploadFile } from "@/api/upload"
import dayjs from "dayjs"
//
const inputTypeOptions = [
{ label: "文生3D", value: "text" },
{ label: "图生3D", value: "image" },
]
//
const samplePrompts = [
["啄木鸟", "尖锐的嘴", "金黄色"],
["可爱的猫咪", "卡通风格", "蓝色眼睛"],
["机器人", "金属质感", "未来风格"],
["中式花瓶", "青花瓷", "精致纹理"],
["小恐龙", "Q版造型", "绿色皮肤"],
["宇航员", "太空服", "写实风格"],
]
//
const inputType = ref<"text" | "image">("text")
const textContent = ref("")
const imageFileList = ref<UploadFile[]>()
const imageUrl = ref("")
const generating = ref(false)
const currentSampleIndex = ref(0)
//
const historyList = ref<AI3DTask[]>([])
const historyLoading = ref(false)
const showAllHistory = ref(false)
const allHistoryList = ref<AI3DTask[]>([])
const allHistoryLoading = ref(false)
const allHistoryTotal = ref(0)
const allHistoryPage = ref(1)
//
let pollingTimer: number | null = null
//
const currentSamples = computed(() => samplePrompts[currentSampleIndex.value])
const canGenerate = computed(() => {
if (inputType.value === "text") {
return textContent.value.trim().length > 0
} else {
return imageUrl.value.length > 0
}
})
const pagination = computed(() => ({
current: allHistoryPage.value,
pageSize: 10,
total: allHistoryTotal.value,
onChange: (page: number) => {
allHistoryPage.value = page
fetchAllHistory()
},
}))
//
const refreshSamples = () => {
currentSampleIndex.value =
(currentSampleIndex.value + 1) % samplePrompts.length
}
//
const applySample = (sample: string) => {
if (textContent.value) {
textContent.value += "" + sample
} else {
textContent.value = sample
}
}
//
const focusInput = () => {
//
}
//
const handleBeforeUpload = async (file: File) => {
const isImage = file.type.startsWith("image/")
if (!isImage) {
message.error("只能上传图片文件")
return false
}
const isLt10M = file.size / 1024 / 1024 < 10
if (!isLt10M) {
message.error("图片大小不能超过 10MB")
return false
}
//
try {
const result = await uploadFile(file)
imageUrl.value = result.url
message.success("图片上传成功")
} catch (error) {
message.error("图片上传失败")
return false
}
return false //
}
// 3D
const handleGenerate = async () => {
if (!canGenerate.value) return
generating.value = true
try {
const content =
inputType.value === "text" ? textContent.value.trim() : imageUrl.value
await createAI3DTask({
inputType: inputType.value,
inputContent: content,
})
message.success("任务已提交,请等待生成完成")
//
if (inputType.value === "text") {
textContent.value = ""
} else {
imageFileList.value = []
imageUrl.value = ""
}
//
fetchHistory()
//
startPolling()
} catch (error: any) {
message.error(error.response?.data?.message || "提交失败,请重试")
} finally {
generating.value = false
}
}
// 6
const fetchHistory = async () => {
historyLoading.value = true
try {
const res = await getAI3DTasks({ page: 1, pageSize: 6 })
historyList.value = res.list || []
} catch (error) {
console.error("获取历史记录失败:", error)
} finally {
historyLoading.value = false
}
}
//
const fetchAllHistory = async () => {
allHistoryLoading.value = true
try {
const res = await getAI3DTasks({ page: allHistoryPage.value, pageSize: 10 })
allHistoryList.value = res.list || []
allHistoryTotal.value = res.total || 0
} catch (error) {
console.error("获取全部历史记录失败:", error)
} finally {
allHistoryLoading.value = false
}
}
//
const startPolling = () => {
if (pollingTimer) return
pollingTimer = window.setInterval(async () => {
const processingTasks = historyList.value.filter(
(t) => t.status === "pending" || t.status === "processing"
)
if (processingTasks.length === 0) {
stopPolling()
return
}
//
await fetchHistory()
}, 3000)
}
//
const stopPolling = () => {
if (pollingTimer) {
clearInterval(pollingTimer)
pollingTimer = null
}
}
// 3D
const handlePreview = (task: AI3DTask) => {
if (task.resultUrl) {
const viewerUrl = `/model-viewer?url=${encodeURIComponent(task.resultUrl)}`
window.open(viewerUrl, "_blank")
}
}
//
const handleViewTask = (task: AI3DTask) => {
if (task.status === "completed") {
handlePreview(task)
}
}
//
const handleRetry = async (task: AI3DTask) => {
if (task.retryCount >= 3) {
message.warning("已达到最大重试次数,请创建新任务")
return
}
try {
await retryAI3DTask(task.id)
message.success("重试已提交")
fetchHistory()
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("删除成功")
fetchHistory()
if (showAllHistory.value) {
fetchAllHistory()
}
} catch (error) {
message.error("删除失败")
}
},
})
}
// URL
const getPreviewUrl = (task: AI3DTask) => {
if (task.previewUrl) {
return task.previewUrl.startsWith("http")
? task.previewUrl
: task.previewUrl
}
return ""
}
//
const getStatusColor = (status: string) => {
const colors: Record<string, string> = {
pending: "default",
processing: "processing",
completed: "success",
failed: "error",
timeout: "warning",
}
return colors[status] || "default"
}
//
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("MM-DD HH:mm")
}
//
onMounted(() => {
fetchHistory()
//
const hasProcessing = historyList.value.some(
(t) => t.status === "pending" || t.status === "processing"
)
if (hasProcessing) {
startPolling()
}
})
//
onUnmounted(() => {
stopPolling()
})
</script>
<style scoped lang="scss">
.ai-3d-container {
display: flex;
height: calc(100vh - 120px);
min-height: 600px;
background: #f5f5f5;
}
//
.left-panel {
width: 320px;
background: #1a1a2e;
color: #fff;
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.panel-header {
padding: 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
:deep(.ant-segmented) {
background: rgba(255, 255, 255, 0.1);
.ant-segmented-item {
color: rgba(255, 255, 255, 0.7);
&-selected {
background: #1890ff;
color: #fff;
}
}
}
}
.panel-content {
flex: 1;
padding: 20px;
overflow-y: auto;
}
.input-hint {
font-size: 13px;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 12px;
line-height: 1.6;
}
.text-input {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #fff;
resize: none;
&::placeholder {
color: rgba(255, 255, 255, 0.4);
}
&:focus {
border-color: #1890ff;
}
:deep(.ant-input-data-count) {
color: rgba(255, 255, 255, 0.5);
}
}
.sample-prompts {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
align-items: center;
.refresh-icon {
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
transition: color 0.3s;
&:hover {
color: #1890ff;
}
}
.sample-tag {
padding: 4px 12px;
background: rgba(255, 255, 255, 0.1);
border-radius: 16px;
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
transition: all 0.3s;
&:hover {
background: rgba(24, 144, 255, 0.3);
color: #fff;
}
}
}
.image-upload {
:deep(.ant-upload-drag) {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.2);
&:hover {
border-color: #1890ff;
}
.ant-upload-drag-icon {
color: rgba(255, 255, 255, 0.5);
font-size: 48px;
}
.ant-upload-text {
color: rgba(255, 255, 255, 0.8);
}
.ant-upload-hint {
color: rgba(255, 255, 255, 0.5);
}
}
}
.panel-footer {
padding: 20px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
//
.right-panel {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
//
.intro-section {
padding: 40px;
background: #fff;
border-bottom: 1px solid #e8e8e8;
}
.intro-title {
font-size: 28px;
font-weight: 600;
color: #1a1a2e;
margin-bottom: 24px;
}
.intro-features {
margin-bottom: 24px;
}
.feature-item {
display: flex;
align-items: center;
margin-bottom: 12px;
font-size: 14px;
color: #666;
.feature-icon {
margin-right: 12px;
font-size: 16px;
}
}
.intro-action {
display: inline-block;
color: #1890ff;
font-size: 16px;
cursor: pointer;
margin-bottom: 8px;
&:hover {
text-decoration: underline;
}
}
.intro-subtitle {
color: #999;
font-size: 13px;
margin: 0;
}
//
.history-section {
flex: 1;
padding: 24px 40px;
overflow-y: auto;
background: #fafafa;
}
.history-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.history-title {
font-size: 18px;
font-weight: 500;
color: #1a1a2e;
margin: 0;
}
.view-all {
color: #1890ff;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.history-loading,
.history-empty {
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
}
.history-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
}
.history-card {
background: #fff;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: all 0.3s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
}
.card-preview {
height: 140px;
background: #1a1a2e;
display: flex;
align-items: center;
justify-content: center;
.preview-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-loading,
.preview-failed,
.preview-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: rgba(255, 255, 255, 0.6);
font-size: 24px;
span {
font-size: 12px;
}
}
.preview-failed {
color: #ff4d4f;
}
}
.card-info {
padding: 12px;
}
.card-desc {
font-size: 13px;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 8px;
}
.card-meta {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-time {
font-size: 12px;
color: #999;
}
.card-actions {
display: flex;
gap: 8px;
.action-icon {
color: #666;
cursor: pointer;
transition: color 0.3s;
&:hover {
color: #1890ff;
}
&.danger:hover {
color: #ff4d4f;
}
&.disabled {
color: #ccc;
cursor: not-allowed;
}
}
}
//
.list-preview {
width: 60px;
height: 60px;
background: #1a1a2e;
border-radius: 4px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.list-preview-placeholder {
color: rgba(255, 255, 255, 0.5);
font-size: 20px;
}
}
.list-desc {
max-width: 300px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.list-meta {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
color: #999;
}
.danger-link {
color: #ff4d4f;
&:hover {
color: #ff7875;
}
}
</style>