新增3D建模页面
This commit is contained in:
parent
9fc98a6fd5
commit
59ba6b6904
@ -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": "学校管理",
|
||||
|
||||
@ -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",
|
||||
|
||||
34
backend/package-lock.json
generated
34
backend/package-lock.json
generated
@ -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": {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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 = ['工作台', '学校管理', '赛事活动', '作业管理', '系统管理'];
|
||||
|
||||
@ -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', // 查看赛事公告
|
||||
|
||||
@ -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);
|
||||
});
|
||||
77
backend/src/ai-3d/ai-3d.controller.ts
Normal file
77
backend/src/ai-3d/ai-3d.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
20
backend/src/ai-3d/ai-3d.module.ts
Normal file
20
backend/src/ai-3d/ai-3d.module.ts
Normal 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 {}
|
||||
297
backend/src/ai-3d/ai-3d.service.ts
Normal file
297
backend/src/ai-3d/ai-3d.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
backend/src/ai-3d/dto/create-task.dto.ts
Normal file
12
backend/src/ai-3d/dto/create-task.dto.ts
Normal 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;
|
||||
}
|
||||
23
backend/src/ai-3d/dto/query-task.dto.ts
Normal file
23
backend/src/ai-3d/dto/query-task.dto.ts
Normal 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;
|
||||
}
|
||||
39
backend/src/ai-3d/providers/ai-3d-provider.interface.ts
Normal file
39
backend/src/ai-3d/providers/ai-3d-provider.interface.ts
Normal 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 服务提供者接口
|
||||
* 支持 Mock、腾讯混元、Meshy 等实现
|
||||
*/
|
||||
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';
|
||||
135
backend/src/ai-3d/providers/mock.provider.ts
Normal file
135
backend/src/ai-3d/providers/mock.provider.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
105
frontend/src/api/ai-3d.ts
Normal 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}`);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
972
frontend/src/views/workbench/ai-3d/Index.vue
Normal file
972
frontend/src/views/workbench/ai-3d/Index.vue
Normal 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">支持 JPG、PNG 格式</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>
|
||||
Loading…
Reference in New Issue
Block a user