新增3D建模
This commit is contained in:
parent
59ba6b6904
commit
1dce34e76a
@ -1077,8 +1077,10 @@ model AI3DTask {
|
|||||||
inputType String @map("input_type") /// 输入类型:text | image
|
inputType String @map("input_type") /// 输入类型:text | image
|
||||||
inputContent String @map("input_content") @db.Text /// 输入内容:文字描述或图片URL
|
inputContent String @map("input_content") @db.Text /// 输入内容:文字描述或图片URL
|
||||||
status String @default("pending") /// 任务状态:pending | processing | completed | failed | timeout
|
status String @default("pending") /// 任务状态:pending | processing | completed | failed | timeout
|
||||||
resultUrl String? @map("result_url") /// 生成的3D模型URL
|
resultUrl String? @map("result_url") /// 生成的3D模型URL(单个结果,兼容旧数据)
|
||||||
previewUrl String? @map("preview_url") /// 预览图URL
|
previewUrl String? @map("preview_url") /// 预览图URL(单个结果,兼容旧数据)
|
||||||
|
resultUrls Json? @map("result_urls") /// 生成的3D模型URL数组(多个结果,文生3D生成4个)
|
||||||
|
previewUrls Json? @map("preview_urls") /// 预览图URL数组(多个结果)
|
||||||
errorMessage String? @map("error_message") @db.Text /// 失败时的错误信息
|
errorMessage String? @map("error_message") @db.Text /// 失败时的错误信息
|
||||||
externalTaskId String? @map("external_task_id") /// 外部AI服务的任务ID
|
externalTaskId String? @map("external_task_id") /// 外部AI服务的任务ID
|
||||||
retryCount Int @default(0) @map("retry_count") /// 已重试次数
|
retryCount Int @default(0) @map("retry_count") /// 已重试次数
|
||||||
|
|||||||
@ -38,6 +38,7 @@ const allPermissions = [
|
|||||||
{ code: 'user:read', resource: 'user', action: 'read', name: '查看用户', description: '允许查看用户列表和详情' },
|
{ code: 'user:read', resource: 'user', action: 'read', name: '查看用户', description: '允许查看用户列表和详情' },
|
||||||
{ code: 'user:update', resource: 'user', action: 'update', name: '更新用户', description: '允许更新用户信息' },
|
{ code: 'user:update', resource: 'user', action: 'update', name: '更新用户', description: '允许更新用户信息' },
|
||||||
{ code: 'user:delete', resource: 'user', action: 'delete', name: '删除用户', description: '允许删除用户' },
|
{ code: 'user:delete', resource: 'user', action: 'delete', name: '删除用户', description: '允许删除用户' },
|
||||||
|
{ code: 'user:password:update', resource: 'user', action: 'password:update', name: '重置密码', description: '允许重置用户密码' },
|
||||||
|
|
||||||
// 角色管理
|
// 角色管理
|
||||||
{ code: 'role:create', resource: 'role', action: 'create', name: '创建角色', description: '允许创建新角色' },
|
{ code: 'role:create', resource: 'role', action: 'create', name: '创建角色', description: '允许创建新角色' },
|
||||||
@ -47,10 +48,16 @@ const allPermissions = [
|
|||||||
{ code: 'role:assign', resource: 'role', action: 'assign', name: '分配角色', description: '允许给用户分配角色' },
|
{ code: 'role:assign', resource: 'role', action: 'assign', name: '分配角色', description: '允许给用户分配角色' },
|
||||||
|
|
||||||
// 权限管理
|
// 权限管理
|
||||||
|
{ code: 'permission:create', resource: 'permission', action: 'create', name: '创建权限', description: '允许创建新权限' },
|
||||||
{ code: 'permission:read', resource: 'permission', action: 'read', name: '查看权限', description: '允许查看权限列表' },
|
{ code: 'permission:read', resource: 'permission', action: 'read', name: '查看权限', description: '允许查看权限列表' },
|
||||||
|
{ code: 'permission:update', resource: 'permission', action: 'update', name: '更新权限', description: '允许更新权限信息' },
|
||||||
|
{ code: 'permission:delete', resource: 'permission', action: 'delete', name: '删除权限', description: '允许删除权限' },
|
||||||
|
|
||||||
// 菜单管理
|
// 菜单管理
|
||||||
|
{ code: 'menu:create', resource: 'menu', action: 'create', name: '创建菜单', description: '允许创建新菜单' },
|
||||||
{ code: 'menu:read', resource: 'menu', action: 'read', name: '查看菜单', description: '允许查看菜单列表' },
|
{ code: 'menu:read', resource: 'menu', action: 'read', name: '查看菜单', description: '允许查看菜单列表' },
|
||||||
|
{ code: 'menu:update', resource: 'menu', action: 'update', name: '更新菜单', description: '允许更新菜单信息' },
|
||||||
|
{ code: 'menu:delete', resource: 'menu', action: 'delete', name: '删除菜单', description: '允许删除菜单' },
|
||||||
|
|
||||||
// 租户管理(超级租户专属)
|
// 租户管理(超级租户专属)
|
||||||
{ code: 'tenant:create', resource: 'tenant', action: 'create', name: '创建租户', description: '允许创建租户' },
|
{ code: 'tenant:create', resource: 'tenant', action: 'create', name: '创建租户', description: '允许创建租户' },
|
||||||
@ -121,6 +128,7 @@ const allPermissions = [
|
|||||||
{ code: 'registration:update', resource: 'registration', action: 'update', name: '更新报名', description: '允许更新报名信息' },
|
{ code: 'registration:update', resource: 'registration', action: 'update', name: '更新报名', description: '允许更新报名信息' },
|
||||||
{ code: 'registration:delete', resource: 'registration', action: 'delete', name: '取消报名', description: '允许取消报名' },
|
{ code: 'registration:delete', resource: 'registration', action: 'delete', name: '取消报名', description: '允许取消报名' },
|
||||||
{ code: 'registration:approve', resource: 'registration', action: 'approve', name: '审核报名', description: '允许审核报名' },
|
{ code: 'registration:approve', resource: 'registration', action: 'approve', name: '审核报名', description: '允许审核报名' },
|
||||||
|
{ code: 'registration:audit', resource: 'registration', action: 'audit', name: '审核报名记录', description: '允许审核报名记录' },
|
||||||
|
|
||||||
// 参赛作品
|
// 参赛作品
|
||||||
{ code: 'work:create', resource: 'work', action: 'create', name: '上传作品', description: '允许上传参赛作品' },
|
{ code: 'work:create', resource: 'work', action: 'create', name: '上传作品', description: '允许上传参赛作品' },
|
||||||
@ -132,6 +140,12 @@ const allPermissions = [
|
|||||||
// 作品评审(评委端)
|
// 作品评审(评委端)
|
||||||
{ code: 'review:read', resource: 'review', action: 'read', name: '查看评审任务', description: '允许查看待评审作品' },
|
{ code: 'review:read', resource: 'review', action: 'read', name: '查看评审任务', description: '允许查看待评审作品' },
|
||||||
{ code: 'review:score', resource: 'review', action: 'score', name: '评审打分', description: '允许对作品打分' },
|
{ code: 'review:score', resource: 'review', action: 'score', name: '评审打分', description: '允许对作品打分' },
|
||||||
|
{ code: 'review:assign', resource: 'review', action: 'assign', name: '分配评审', description: '允许分配作品给评委' },
|
||||||
|
|
||||||
|
// 赛果管理
|
||||||
|
{ code: 'result:read', resource: 'result', action: 'read', name: '查看赛果', description: '允许查看赛事结果' },
|
||||||
|
{ code: 'result:publish', resource: 'result', action: 'publish', name: '发布赛果', description: '允许发布赛事结果' },
|
||||||
|
{ code: 'result:award', resource: 'result', action: 'award', name: '设置奖项', description: '允许设置奖项等级' },
|
||||||
|
|
||||||
// 赛事公告
|
// 赛事公告
|
||||||
{ code: 'notice:create', resource: 'notice', action: 'create', name: '创建公告', description: '允许创建赛事公告' },
|
{ code: 'notice:create', resource: 'notice', action: 'create', name: '创建公告', description: '允许创建赛事公告' },
|
||||||
@ -194,10 +208,10 @@ const superTenantRoles = [
|
|||||||
description: '系统超级管理员,管理赛事和系统配置',
|
description: '系统超级管理员,管理赛事和系统配置',
|
||||||
permissions: [
|
permissions: [
|
||||||
// 系统管理
|
// 系统管理
|
||||||
'user:create', 'user:read', 'user:update', 'user:delete',
|
'user:create', 'user:read', 'user:update', 'user:delete', 'user:password:update',
|
||||||
'role:create', 'role:read', 'role:update', 'role:delete', 'role:assign',
|
'role:create', 'role:read', 'role:update', 'role:delete', 'role:assign',
|
||||||
'permission:read',
|
'permission:create', 'permission:read', 'permission:update', 'permission:delete',
|
||||||
'menu:read',
|
'menu:create', 'menu:read', 'menu:update', 'menu:delete',
|
||||||
'tenant:create', 'tenant:read', 'tenant:update', 'tenant:delete',
|
'tenant:create', 'tenant:read', 'tenant:update', 'tenant:delete',
|
||||||
'dict:create', 'dict:read', 'dict:update', 'dict:delete',
|
'dict:create', 'dict:read', 'dict:update', 'dict:delete',
|
||||||
'config:create', 'config:read', 'config:update', 'config:delete',
|
'config:create', 'config:read', 'config:update', 'config:delete',
|
||||||
@ -206,8 +220,10 @@ const superTenantRoles = [
|
|||||||
'contest:create', 'contest:read', 'contest:update', 'contest:delete', 'contest:publish', 'contest:finish',
|
'contest:create', 'contest:read', 'contest:update', 'contest:delete', 'contest:publish', 'contest:finish',
|
||||||
'review-rule:create', 'review-rule:read', 'review-rule:update', 'review-rule:delete',
|
'review-rule:create', 'review-rule:read', 'review-rule:update', 'review-rule:delete',
|
||||||
'judge:create', 'judge:read', 'judge:update', 'judge:delete', 'judge:assign',
|
'judge:create', 'judge:read', 'judge:update', 'judge:delete', 'judge:assign',
|
||||||
'registration:read', 'registration:approve',
|
'registration:read', 'registration:approve', 'registration:audit',
|
||||||
'work:read',
|
'work:read', 'work:update',
|
||||||
|
'review:read', 'review:assign',
|
||||||
|
'result:read', 'result:publish', 'result:award',
|
||||||
'notice:create', 'notice:read', 'notice:update', 'notice:delete',
|
'notice:create', 'notice:read', 'notice:update', 'notice:delete',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@ -272,6 +272,8 @@ export class AI3DService {
|
|||||||
status: result.status,
|
status: result.status,
|
||||||
resultUrl: result.resultUrl,
|
resultUrl: result.resultUrl,
|
||||||
previewUrl: result.previewUrl,
|
previewUrl: result.previewUrl,
|
||||||
|
resultUrls: result.resultUrls || null,
|
||||||
|
previewUrls: result.previewUrls || null,
|
||||||
errorMessage: result.errorMessage,
|
errorMessage: result.errorMessage,
|
||||||
completeTime: new Date(),
|
completeTime: new Date(),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -4,8 +4,10 @@
|
|||||||
export interface AI3DGenerateResult {
|
export interface AI3DGenerateResult {
|
||||||
taskId: string; // 外部任务ID
|
taskId: string; // 外部任务ID
|
||||||
status: 'pending' | 'processing' | 'completed' | 'failed';
|
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||||
resultUrl?: string; // 3D模型URL
|
resultUrl?: string; // 3D模型URL(单个结果,兼容旧数据)
|
||||||
previewUrl?: string; // 预览图URL
|
previewUrl?: string; // 预览图URL(单个结果,兼容旧数据)
|
||||||
|
resultUrls?: string[]; // 3D模型URL数组(多个结果,文生3D生成4个)
|
||||||
|
previewUrls?: string[]; // 预览图URL数组(多个结果)
|
||||||
errorMessage?: string; // 错误信息
|
errorMessage?: string; // 错误信息
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,8 @@ interface MockTask {
|
|||||||
inputContent: string;
|
inputContent: string;
|
||||||
resultUrl?: string;
|
resultUrl?: string;
|
||||||
previewUrl?: string;
|
previewUrl?: string;
|
||||||
|
resultUrls?: string[];
|
||||||
|
previewUrls?: string[];
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -28,11 +30,21 @@ export class MockAI3DProvider implements AI3DProvider {
|
|||||||
// 模拟成功率
|
// 模拟成功率
|
||||||
private readonly SUCCESS_RATE = 0.9; // 90% 成功率
|
private readonly SUCCESS_RATE = 0.9; // 90% 成功率
|
||||||
|
|
||||||
// 示例 3D 模型 URL(使用公开的 GLB 文件)
|
// 示例 3D 模型 URL(使用公开可访问的 GLB 文件)
|
||||||
private readonly SAMPLE_MODELS = [
|
private readonly SAMPLE_MODELS = [
|
||||||
'/mock/models/sample-cube.glb',
|
// three.js 官方示例模型
|
||||||
'/mock/models/sample-sphere.glb',
|
'https://threejs.org/examples/models/gltf/DamagedHelmet/glTF/DamagedHelmet.gltf',
|
||||||
'/mock/models/sample-model.glb',
|
'https://threejs.org/examples/models/gltf/LittlestTokyo.glb',
|
||||||
|
'https://threejs.org/examples/models/gltf/Soldier.glb',
|
||||||
|
'https://threejs.org/examples/models/gltf/RobotExpressive/RobotExpressive.glb',
|
||||||
|
];
|
||||||
|
|
||||||
|
// 示例预览图(使用占位图服务)
|
||||||
|
private readonly SAMPLE_PREVIEWS = [
|
||||||
|
'https://picsum.photos/seed/model1/400/300',
|
||||||
|
'https://picsum.photos/seed/model2/400/300',
|
||||||
|
'https://picsum.photos/seed/model3/400/300',
|
||||||
|
'https://picsum.photos/seed/model4/400/300',
|
||||||
];
|
];
|
||||||
|
|
||||||
async submitTask(
|
async submitTask(
|
||||||
@ -82,6 +94,8 @@ export class MockAI3DProvider implements AI3DProvider {
|
|||||||
status: task.status,
|
status: task.status,
|
||||||
resultUrl: task.resultUrl,
|
resultUrl: task.resultUrl,
|
||||||
previewUrl: task.previewUrl,
|
previewUrl: task.previewUrl,
|
||||||
|
resultUrls: task.resultUrls,
|
||||||
|
previewUrls: task.previewUrls,
|
||||||
errorMessage: task.errorMessage,
|
errorMessage: task.errorMessage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -97,15 +111,45 @@ export class MockAI3DProvider implements AI3DProvider {
|
|||||||
const isSuccess = Math.random() < this.SUCCESS_RATE;
|
const isSuccess = Math.random() < this.SUCCESS_RATE;
|
||||||
|
|
||||||
if (isSuccess) {
|
if (isSuccess) {
|
||||||
// 随机选择一个示例模型
|
// 文生3D生成4个不同角度的模型
|
||||||
const modelIndex = Math.floor(Math.random() * this.SAMPLE_MODELS.length);
|
if (task.inputType === 'text') {
|
||||||
const modelUrl = this.SAMPLE_MODELS[modelIndex];
|
const resultUrls: string[] = [];
|
||||||
|
const previewUrls: string[] = [];
|
||||||
|
|
||||||
task.status = 'completed';
|
// 生成4个模型结果,使用不同的示例模型
|
||||||
task.resultUrl = modelUrl;
|
for (let i = 0; i < 4; i++) {
|
||||||
task.previewUrl = modelUrl.replace('.glb', '-preview.png');
|
const modelIndex = i % this.SAMPLE_MODELS.length;
|
||||||
|
const previewIndex = i % this.SAMPLE_PREVIEWS.length;
|
||||||
|
resultUrls.push(this.SAMPLE_MODELS[modelIndex]);
|
||||||
|
previewUrls.push(this.SAMPLE_PREVIEWS[previewIndex]);
|
||||||
|
}
|
||||||
|
|
||||||
this.logger.log(`Mock: 任务 ${taskId} 完成, 模型: ${modelUrl}`);
|
task.status = 'completed';
|
||||||
|
task.resultUrls = resultUrls;
|
||||||
|
task.previewUrls = previewUrls;
|
||||||
|
// 兼容旧字段,使用第一个结果
|
||||||
|
task.resultUrl = resultUrls[0];
|
||||||
|
task.previewUrl = previewUrls[0];
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Mock: 文生3D任务 ${taskId} 完成, 生成 ${resultUrls.length} 个模型`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 图生3D只生成1个模型
|
||||||
|
const modelIndex = Math.floor(
|
||||||
|
Math.random() * this.SAMPLE_MODELS.length,
|
||||||
|
);
|
||||||
|
const modelUrl = this.SAMPLE_MODELS[modelIndex];
|
||||||
|
const previewUrl = this.SAMPLE_PREVIEWS[modelIndex % this.SAMPLE_PREVIEWS.length];
|
||||||
|
|
||||||
|
task.status = 'completed';
|
||||||
|
task.resultUrl = modelUrl;
|
||||||
|
task.previewUrl = previewUrl;
|
||||||
|
task.resultUrls = [modelUrl];
|
||||||
|
task.previewUrls = [previewUrl];
|
||||||
|
|
||||||
|
this.logger.log(`Mock: 图生3D任务 ${taskId} 完成, 模型: ${modelUrl}`);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
task.status = 'failed';
|
task.status = 'failed';
|
||||||
task.errorMessage = '模拟生成失败:AI 服务暂时不可用';
|
task.errorMessage = '模拟生成失败:AI 服务暂时不可用';
|
||||||
|
|||||||
@ -59,83 +59,141 @@ export class TeamsService {
|
|||||||
throw new BadRequestException('队长不属于当前租户');
|
throw new BadRequestException('队长不属于当前租户');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建团队
|
// 使用事务创建团队和报名记录
|
||||||
const data: any = {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
tenantId,
|
// 创建团队
|
||||||
contestId: createTeamDto.contestId,
|
const data: any = {
|
||||||
teamName: createTeamDto.teamName,
|
|
||||||
leaderUserId: createTeamDto.leaderId,
|
|
||||||
maxMembers: createTeamDto.maxMembers,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (creatorId) {
|
|
||||||
data.creator = creatorId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const team = await this.prisma.contestTeam.create({
|
|
||||||
data,
|
|
||||||
include: {
|
|
||||||
contest: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
contestName: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
leader: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
username: true,
|
|
||||||
nickname: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 自动添加队长为成员
|
|
||||||
await this.prisma.contestTeamMember.create({
|
|
||||||
data: {
|
|
||||||
tenantId,
|
tenantId,
|
||||||
teamId: team.id,
|
contestId: createTeamDto.contestId,
|
||||||
userId: createTeamDto.leaderId,
|
teamName: createTeamDto.teamName,
|
||||||
role: 'leader',
|
leaderUserId: createTeamDto.leaderId,
|
||||||
creator: creatorId,
|
maxMembers: createTeamDto.maxMembers,
|
||||||
},
|
};
|
||||||
|
|
||||||
|
if (creatorId) {
|
||||||
|
data.creator = creatorId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const team = await tx.contestTeam.create({
|
||||||
|
data,
|
||||||
|
include: {
|
||||||
|
contest: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
contestName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
leader: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
nickname: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 自动添加队长为成员
|
||||||
|
await tx.contestTeamMember.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
teamId: team.id,
|
||||||
|
userId: createTeamDto.leaderId,
|
||||||
|
role: 'leader',
|
||||||
|
creator: creatorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加其他队员(排除队长)
|
||||||
|
if (createTeamDto.memberIds && createTeamDto.memberIds.length > 0) {
|
||||||
|
const memberIdsToAdd = createTeamDto.memberIds.filter(
|
||||||
|
(id) => id !== createTeamDto.leaderId,
|
||||||
|
);
|
||||||
|
for (const memberId of memberIdsToAdd) {
|
||||||
|
await tx.contestTeamMember.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
teamId: team.id,
|
||||||
|
userId: memberId,
|
||||||
|
role: 'member',
|
||||||
|
creator: creatorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加指导老师
|
||||||
|
if (createTeamDto.teacherIds && createTeamDto.teacherIds.length > 0) {
|
||||||
|
for (const teacherId of createTeamDto.teacherIds) {
|
||||||
|
await tx.contestTeamMember.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
teamId: team.id,
|
||||||
|
userId: teacherId,
|
||||||
|
role: 'mentor',
|
||||||
|
creator: creatorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动创建团队报名记录
|
||||||
|
const accountNo = leader.username;
|
||||||
|
const accountName = leader.nickname;
|
||||||
|
|
||||||
|
await tx.contestRegistration.create({
|
||||||
|
data: {
|
||||||
|
contestId: createTeamDto.contestId,
|
||||||
|
tenantId,
|
||||||
|
registrationType: 'team',
|
||||||
|
teamId: team.id,
|
||||||
|
teamName: createTeamDto.teamName,
|
||||||
|
userId: createTeamDto.leaderId,
|
||||||
|
accountNo,
|
||||||
|
accountName,
|
||||||
|
registrationState: 'pending',
|
||||||
|
registrationTime: new Date(),
|
||||||
|
registrant: creatorId || createTeamDto.leaderId,
|
||||||
|
creator: creatorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 返回完整的团队信息
|
||||||
|
return tx.contestTeam.findUnique({
|
||||||
|
where: { id: team.id },
|
||||||
|
include: {
|
||||||
|
contest: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
contestName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
leader: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
nickname: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
members: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
nickname: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
members: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 添加其他队员(排除队长)
|
|
||||||
if (createTeamDto.memberIds && createTeamDto.memberIds.length > 0) {
|
|
||||||
const memberIdsToAdd = createTeamDto.memberIds.filter(
|
|
||||||
(id) => id !== createTeamDto.leaderId,
|
|
||||||
);
|
|
||||||
for (const memberId of memberIdsToAdd) {
|
|
||||||
await this.prisma.contestTeamMember.create({
|
|
||||||
data: {
|
|
||||||
tenantId,
|
|
||||||
teamId: team.id,
|
|
||||||
userId: memberId,
|
|
||||||
role: 'member',
|
|
||||||
creator: creatorId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加指导老师
|
|
||||||
if (createTeamDto.teacherIds && createTeamDto.teacherIds.length > 0) {
|
|
||||||
for (const teacherId of createTeamDto.teacherIds) {
|
|
||||||
await this.prisma.contestTeamMember.create({
|
|
||||||
data: {
|
|
||||||
tenantId,
|
|
||||||
teamId: team.id,
|
|
||||||
userId: teacherId,
|
|
||||||
role: 'mentor',
|
|
||||||
creator: creatorId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.findOne(team.id, tenantId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAll(contestId: number, tenantId?: number) {
|
async findAll(contestId: number, tenantId?: number) {
|
||||||
|
|||||||
@ -30,6 +30,9 @@ export interface AI3DTask {
|
|||||||
status: AI3DTaskStatus;
|
status: AI3DTaskStatus;
|
||||||
resultUrl?: string;
|
resultUrl?: string;
|
||||||
previewUrl?: string;
|
previewUrl?: string;
|
||||||
|
// 多结果支持(文生3D会生成4个不同角度的模型)
|
||||||
|
resultUrls?: string[];
|
||||||
|
previewUrls?: string[];
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
externalTaskId?: string;
|
externalTaskId?: string;
|
||||||
retryCount: number;
|
retryCount: number;
|
||||||
|
|||||||
@ -96,6 +96,17 @@ const baseRoutes: RouteRecordRaw[] = [
|
|||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// 报名管理列表路由
|
||||||
|
{
|
||||||
|
path: "contests/registrations",
|
||||||
|
name: "ContestsRegistrations",
|
||||||
|
component: () => import("@/views/contests/registrations/Index.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "报名管理",
|
||||||
|
requiresAuth: true,
|
||||||
|
permissions: ["registration:read", "contest:read"],
|
||||||
|
},
|
||||||
|
},
|
||||||
// 报名记录路由
|
// 报名记录路由
|
||||||
{
|
{
|
||||||
path: "contests/registrations/:id/records",
|
path: "contests/registrations/:id/records",
|
||||||
@ -104,7 +115,7 @@ const baseRoutes: RouteRecordRaw[] = [
|
|||||||
meta: {
|
meta: {
|
||||||
title: "报名记录",
|
title: "报名记录",
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
permissions: ["contest:registration:read"],
|
permissions: ["registration:read", "contest:read"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// 评审进度详情路由
|
// 评审进度详情路由
|
||||||
@ -148,7 +159,7 @@ const baseRoutes: RouteRecordRaw[] = [
|
|||||||
meta: {
|
meta: {
|
||||||
title: "作业详情",
|
title: "作业详情",
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
permissions: ["homework:read", "homework:student:read"],
|
permissions: ["homework:read"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// 教师我的指导路由
|
// 教师我的指导路由
|
||||||
@ -172,6 +183,16 @@ const baseRoutes: RouteRecordRaw[] = [
|
|||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// 3D模型生成页面
|
||||||
|
{
|
||||||
|
path: "workbench/3d-lab/generate/:taskId",
|
||||||
|
name: "AI3DGenerate",
|
||||||
|
component: () => import("@/views/workbench/ai-3d/Generate.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "3D模型生成",
|
||||||
|
requiresAuth: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
// 动态路由将在这里添加
|
// 动态路由将在这里添加
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import * as Icons from "@ant-design/icons-vue"
|
|||||||
const EmptyLayout = () => import("@/layouts/EmptyLayout.vue")
|
const EmptyLayout = () => import("@/layouts/EmptyLayout.vue")
|
||||||
|
|
||||||
const componentMap: Record<string, () => Promise<any>> = {
|
const componentMap: Record<string, () => Promise<any>> = {
|
||||||
"workbench/Index": () => import("@/views/workbench/Index.vue"),
|
"workbench/ai-3d/Index": () => import("@/views/workbench/ai-3d/Index.vue"),
|
||||||
// 学校管理模块
|
// 学校管理模块
|
||||||
"school/schools/Index": () => import("@/views/school/schools/Index.vue"),
|
"school/schools/Index": () => import("@/views/school/schools/Index.vue"),
|
||||||
"school/departments/Index": () =>
|
"school/departments/Index": () =>
|
||||||
@ -251,7 +251,7 @@ export function convertMenusToRoutes(
|
|||||||
|
|
||||||
// 如果既没有组件也没有子路由,跳过这个菜单(无法渲染)
|
// 如果既没有组件也没有子路由,跳过这个菜单(无法渲染)
|
||||||
if (!componentLoader && (!childrenRoutes || childrenRoutes.length === 0)) {
|
if (!componentLoader && (!childrenRoutes || childrenRoutes.length === 0)) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const route: RouteRecordRaw = {
|
const route: RouteRecordRaw = {
|
||||||
|
|||||||
@ -319,12 +319,15 @@ const handleTableChange = (pag: any) => {
|
|||||||
|
|
||||||
// 查看比赛
|
// 查看比赛
|
||||||
const handleViewContest = (contestId: number) => {
|
const handleViewContest = (contestId: number) => {
|
||||||
router.push(`/${tenantCode}/contests/${contestId}`)
|
router.push({ name: "ContestsDetail", params: { tenantCode, id: contestId } })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查看报名记录
|
// 查看报名记录
|
||||||
const handleViewRecords = (record: Contest) => {
|
const handleViewRecords = (record: Contest) => {
|
||||||
router.push(`/${tenantCode}/contests/registrations/${record.id}/records`)
|
router.push({
|
||||||
|
name: "RegistrationRecords",
|
||||||
|
params: { tenantCode, id: record.id },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启动报名
|
// 启动报名
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<template #title>
|
<template #title>
|
||||||
<a-breadcrumb>
|
<a-breadcrumb>
|
||||||
<a-breadcrumb-item>
|
<a-breadcrumb-item>
|
||||||
<router-link :to="`/${tenantCode}/contests/registrations`">报名管理</router-link>
|
<router-link :to="{ name: 'ContestsRegistrations', params: { tenantCode } }">报名管理</router-link>
|
||||||
</a-breadcrumb-item>
|
</a-breadcrumb-item>
|
||||||
<a-breadcrumb-item>{{ contestName || '报名记录' }}</a-breadcrumb-item>
|
<a-breadcrumb-item>{{ contestName || '报名记录' }}</a-breadcrumb-item>
|
||||||
</a-breadcrumb>
|
</a-breadcrumb>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
885
frontend/src/views/workbench/ai-3d/Generate.vue
Normal file
885
frontend/src/views/workbench/ai-3d/Generate.vue
Normal file
@ -0,0 +1,885 @@
|
|||||||
|
<template>
|
||||||
|
<div class="generate-page">
|
||||||
|
<!-- Animated Background -->
|
||||||
|
<div class="bg-animation">
|
||||||
|
<div class="bg-gradient bg-gradient-1"></div>
|
||||||
|
<div class="bg-gradient bg-gradient-2"></div>
|
||||||
|
<div class="bg-gradient bg-gradient-3"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<a-button type="text" class="back-btn" @click="handleBack">
|
||||||
|
<template #icon><CloseOutlined /></template>
|
||||||
|
</a-button>
|
||||||
|
<span class="title">{{ pageTitle }}</span>
|
||||||
|
<span class="live-badge">
|
||||||
|
<span class="pulse-dot"></span>
|
||||||
|
LIVE
|
||||||
|
</span>
|
||||||
|
<a-tag class="pbr-tag">PBR</a-tag>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<span class="status-text" v-if="task?.status === 'processing' || task?.status === 'pending'">
|
||||||
|
<LoadingOutlined class="spin-icon" />
|
||||||
|
生成中...
|
||||||
|
</span>
|
||||||
|
<span class="status-text completed" v-else-if="task?.status === 'completed'">
|
||||||
|
<CheckCircleOutlined />
|
||||||
|
已完成
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="page-content">
|
||||||
|
<!-- Model Grid -->
|
||||||
|
<div class="model-grid">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in modelCards"
|
||||||
|
:key="index"
|
||||||
|
class="model-card"
|
||||||
|
:class="{
|
||||||
|
'is-ready': item.status === 'completed',
|
||||||
|
'is-selected': selectedIndex === index,
|
||||||
|
'is-loading': item.status === 'pending' || item.status === 'processing'
|
||||||
|
}"
|
||||||
|
@click="handleCardClick(index)"
|
||||||
|
>
|
||||||
|
<!-- Card Index Badge -->
|
||||||
|
<div class="card-index">{{ index + 1 }}</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<template v-if="item.status === 'pending' || item.status === 'processing'">
|
||||||
|
<div class="card-loading">
|
||||||
|
<div class="cube-container">
|
||||||
|
<div class="cube">
|
||||||
|
<div class="cube-face front"></div>
|
||||||
|
<div class="cube-face back"></div>
|
||||||
|
<div class="cube-face right"></div>
|
||||||
|
<div class="cube-face left"></div>
|
||||||
|
<div class="cube-face top"></div>
|
||||||
|
<div class="cube-face bottom"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="loading-info">
|
||||||
|
<div class="loading-title">AI 生成中</div>
|
||||||
|
<div class="loading-text">
|
||||||
|
<p>队列位置: <span class="highlight">{{ queueInfo.position }}</span></p>
|
||||||
|
<p>预计时间: <span class="highlight">{{ queueInfo.estimatedTime }}s</span></p>
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Completed State -->
|
||||||
|
<template v-else-if="item.status === 'completed'">
|
||||||
|
<div class="card-preview">
|
||||||
|
<img :src="item.previewUrl" :alt="`预览图${index + 1}`" />
|
||||||
|
<div class="preview-overlay">
|
||||||
|
<div class="preview-icon">
|
||||||
|
<EyeOutlined />
|
||||||
|
</div>
|
||||||
|
<span class="preview-text">查看 3D 模型</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-shine"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Failed State -->
|
||||||
|
<template v-else-if="item.status === 'failed'">
|
||||||
|
<div class="card-error">
|
||||||
|
<div class="error-icon">
|
||||||
|
<ExclamationCircleOutlined />
|
||||||
|
</div>
|
||||||
|
<p class="error-title">生成失败</p>
|
||||||
|
<p class="error-text">请重试或联系支持</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input Display -->
|
||||||
|
<div class="input-display">
|
||||||
|
<div class="input-card">
|
||||||
|
<div class="input-icon">
|
||||||
|
<ThunderboltOutlined />
|
||||||
|
</div>
|
||||||
|
<div class="input-content">
|
||||||
|
<span class="input-label">生成提示词</span>
|
||||||
|
<span class="input-text">{{ task?.inputContent }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tips -->
|
||||||
|
<div class="tips-section">
|
||||||
|
<div class="tip-item">
|
||||||
|
<span class="tip-icon">1</span>
|
||||||
|
<span>点击任意完成的卡片查看 3D 模型</span>
|
||||||
|
</div>
|
||||||
|
<div class="tip-item">
|
||||||
|
<span class="tip-icon">2</span>
|
||||||
|
<span>支持旋转、缩放、平移操作</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from "vue"
|
||||||
|
import { useRouter, useRoute } from "vue-router"
|
||||||
|
import { message } from "ant-design-vue"
|
||||||
|
import {
|
||||||
|
CloseOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
ExclamationCircleOutlined,
|
||||||
|
LoadingOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
ThunderboltOutlined,
|
||||||
|
} from "@ant-design/icons-vue"
|
||||||
|
import { getAI3DTask, type AI3DTask } from "@/api/ai-3d"
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
// Task data
|
||||||
|
const task = ref<AI3DTask | null>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const selectedIndex = ref<number | null>(null)
|
||||||
|
|
||||||
|
// Polling timer
|
||||||
|
let pollingTimer: number | null = null
|
||||||
|
|
||||||
|
// Queue info (simulated)
|
||||||
|
const queueInfo = ref({
|
||||||
|
position: 1,
|
||||||
|
estimatedTime: 190,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Page title
|
||||||
|
const pageTitle = computed(() => {
|
||||||
|
if (task.value?.inputType === "text") {
|
||||||
|
return "文生3D"
|
||||||
|
} else if (task.value?.inputType === "image") {
|
||||||
|
return "图生3D"
|
||||||
|
}
|
||||||
|
return "3D生成"
|
||||||
|
})
|
||||||
|
|
||||||
|
// 4 model cards state
|
||||||
|
const modelCards = computed(() => {
|
||||||
|
if (!task.value) {
|
||||||
|
return Array(4).fill({ status: "pending", previewUrl: "" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = task.value.status
|
||||||
|
const previewUrls = task.value.previewUrls || []
|
||||||
|
|
||||||
|
return Array(4).fill(null).map((_, index) => ({
|
||||||
|
status: status,
|
||||||
|
previewUrl: previewUrls[index] || "",
|
||||||
|
resultUrl: task.value?.resultUrls?.[index] || "",
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Back to previous page
|
||||||
|
const handleBack = () => {
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card click handler
|
||||||
|
const handleCardClick = (index: number) => {
|
||||||
|
const card = modelCards.value[index]
|
||||||
|
|
||||||
|
if (card.status === "pending" || card.status === "processing") {
|
||||||
|
message.info("模型生成中,请稍候...")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (card.status === "failed") {
|
||||||
|
message.error("该模型生成失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (card.status === "completed" && card.resultUrl) {
|
||||||
|
// Navigate to model viewer
|
||||||
|
const viewerUrl = `/model-viewer?url=${encodeURIComponent(card.resultUrl)}`
|
||||||
|
window.open(viewerUrl, "_blank")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch task details
|
||||||
|
const fetchTask = async () => {
|
||||||
|
const taskId = route.params.taskId as string
|
||||||
|
if (!taskId) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await getAI3DTask(Number(taskId))
|
||||||
|
task.value = res as AI3DTask
|
||||||
|
|
||||||
|
// Stop polling if task is complete or failed
|
||||||
|
if (res.status === "completed" || res.status === "failed" || res.status === "timeout") {
|
||||||
|
stopPolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update queue info (simulated)
|
||||||
|
if (res.status === "pending" || res.status === "processing") {
|
||||||
|
queueInfo.value.estimatedTime = Math.max(10, queueInfo.value.estimatedTime - 10)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取任务详情失败:", error)
|
||||||
|
message.error("获取任务详情失败")
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start polling
|
||||||
|
const startPolling = () => {
|
||||||
|
if (pollingTimer) return
|
||||||
|
|
||||||
|
pollingTimer = window.setInterval(() => {
|
||||||
|
fetchTask()
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop polling
|
||||||
|
const stopPolling = () => {
|
||||||
|
if (pollingTimer) {
|
||||||
|
clearInterval(pollingTimer)
|
||||||
|
pollingTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page mounted
|
||||||
|
onMounted(() => {
|
||||||
|
fetchTask()
|
||||||
|
startPolling()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Page unmounted
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopPolling()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
// ==========================================
|
||||||
|
// Energetic Modern Color Palette
|
||||||
|
// ==========================================
|
||||||
|
$primary: #7c3aed; // Violet
|
||||||
|
$primary-light: #a78bfa; // Light violet
|
||||||
|
$primary-dark: #5b21b6; // Dark violet
|
||||||
|
$secondary: #06b6d4; // Cyan
|
||||||
|
$accent: #f43f5e; // Rose/Pink
|
||||||
|
$success: #10b981; // Emerald
|
||||||
|
$background: #0f0f1a; // Dark
|
||||||
|
$surface: #1a1a2e; // Dark surface
|
||||||
|
$surface-light: #252542; // Lighter surface
|
||||||
|
$text: #e2e8f0; // Light gray
|
||||||
|
$text-muted: #94a3b8; // Muted gray
|
||||||
|
$border: #4c1d95; // Dark violet
|
||||||
|
|
||||||
|
// Gradients
|
||||||
|
$gradient-primary: linear-gradient(135deg, $primary 0%, #ec4899 100%);
|
||||||
|
$gradient-accent: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
||||||
|
$gradient-card: linear-gradient(145deg, rgba($primary, 0.1) 0%, rgba($secondary, 0.05) 100%);
|
||||||
|
|
||||||
|
.generate-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: $background;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Animated Background
|
||||||
|
// ==========================================
|
||||||
|
.bg-animation {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-gradient {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(80px);
|
||||||
|
opacity: 0.4;
|
||||||
|
animation: float 20s ease-in-out infinite;
|
||||||
|
|
||||||
|
&.bg-gradient-1 {
|
||||||
|
width: 600px;
|
||||||
|
height: 600px;
|
||||||
|
background: $primary;
|
||||||
|
top: -200px;
|
||||||
|
left: -100px;
|
||||||
|
animation-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bg-gradient-2 {
|
||||||
|
width: 500px;
|
||||||
|
height: 500px;
|
||||||
|
background: $secondary;
|
||||||
|
bottom: -150px;
|
||||||
|
right: -100px;
|
||||||
|
animation-delay: -7s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bg-gradient-3 {
|
||||||
|
width: 400px;
|
||||||
|
height: 400px;
|
||||||
|
background: $accent;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
animation-delay: -14s;
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translate(0, 0) scale(1);
|
||||||
|
}
|
||||||
|
33% {
|
||||||
|
transform: translate(30px, -30px) scale(1.05);
|
||||||
|
}
|
||||||
|
66% {
|
||||||
|
transform: translate(-20px, 20px) scale(0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Header
|
||||||
|
// ==========================================
|
||||||
|
.page-header {
|
||||||
|
height: 64px;
|
||||||
|
padding: 0 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: rgba($surface, 0.8);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border-bottom: 1px solid rgba($primary, 0.2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
color: $text !important;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 12px !important;
|
||||||
|
border: 1px solid rgba($primary, 0.3) !important;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.3s !important;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba($primary, 0.2) !important;
|
||||||
|
border-color: $primary !important;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: $gradient-primary;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: rgba($accent, 0.15);
|
||||||
|
border: 1px solid rgba($accent, 0.3);
|
||||||
|
border-radius: 20px;
|
||||||
|
color: $accent;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
|
||||||
|
.pulse-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
background: $accent;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pbr-tag {
|
||||||
|
background: rgba($secondary, 0.15) !important;
|
||||||
|
border: 1px solid rgba($secondary, 0.3) !important;
|
||||||
|
color: $secondary !important;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
.status-text {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: $primary-light;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
.spin-icon {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.completed {
|
||||||
|
color: $success;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.5; transform: scale(0.8); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Main Content
|
||||||
|
// ==========================================
|
||||||
|
.page-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px;
|
||||||
|
overflow: auto;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Model Grid
|
||||||
|
// ==========================================
|
||||||
|
.model-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 300px);
|
||||||
|
gap: 24px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Model Card
|
||||||
|
// ==========================================
|
||||||
|
.model-card {
|
||||||
|
width: 300px;
|
||||||
|
height: 220px;
|
||||||
|
background: rgba($surface, 0.6);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
border: 2px solid rgba($primary, 0.2);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba($primary, 0.5);
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 20px 40px rgba($primary, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-ready {
|
||||||
|
&:hover {
|
||||||
|
border-color: $secondary;
|
||||||
|
box-shadow: 0 20px 40px rgba($secondary, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-selected {
|
||||||
|
border-color: $secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-loading {
|
||||||
|
.card-index {
|
||||||
|
background: rgba($primary, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card Index Badge
|
||||||
|
.card-index {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
left: 12px;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
background: rgba($secondary, 0.3);
|
||||||
|
border: 1px solid rgba($secondary, 0.5);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: $text;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
z-index: 5;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Loading State
|
||||||
|
// ==========================================
|
||||||
|
.card-loading {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
background: $gradient-card;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3D Cube Animation
|
||||||
|
.cube-container {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
perspective: 200px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cube {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
animation: rotateCube 4s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cube-face {
|
||||||
|
position: absolute;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
margin-left: -20px;
|
||||||
|
margin-top: -20px;
|
||||||
|
border: 2px solid rgba($primary, 0.5);
|
||||||
|
background: rgba($primary, 0.1);
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
|
||||||
|
&.front { transform: translateZ(20px); border-color: $primary; }
|
||||||
|
&.back { transform: rotateY(180deg) translateZ(20px); border-color: $secondary; }
|
||||||
|
&.right { transform: rotateY(90deg) translateZ(20px); border-color: $accent; }
|
||||||
|
&.left { transform: rotateY(-90deg) translateZ(20px); border-color: $primary-light; }
|
||||||
|
&.top { transform: rotateX(90deg) translateZ(20px); border-color: $secondary; }
|
||||||
|
&.bottom { transform: rotateX(-90deg) translateZ(20px); border-color: $accent; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rotateCube {
|
||||||
|
0% { transform: rotateX(-20deg) rotateY(0deg); }
|
||||||
|
100% { transform: rotateX(-20deg) rotateY(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-info {
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.loading-title {
|
||||||
|
color: $text;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
background: $gradient-primary;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
color: $text-muted;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.8;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight {
|
||||||
|
color: $secondary;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
width: 120px;
|
||||||
|
height: 4px;
|
||||||
|
background: rgba($primary, 0.2);
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-top: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
width: 30%;
|
||||||
|
background: $gradient-primary;
|
||||||
|
border-radius: 2px;
|
||||||
|
animation: progressPulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes progressPulse {
|
||||||
|
0% { width: 20%; opacity: 0.5; }
|
||||||
|
50% { width: 80%; opacity: 1; }
|
||||||
|
100% { width: 20%; opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Preview State
|
||||||
|
// ==========================================
|
||||||
|
.card-preview {
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: transform 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover img {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
transparent 0%,
|
||||||
|
rgba($background, 0.8) 100%
|
||||||
|
);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
|
||||||
|
.preview-icon {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
background: $gradient-primary;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
transform: scale(0.8);
|
||||||
|
transition: transform 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-text {
|
||||||
|
color: $text;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .preview-overlay {
|
||||||
|
opacity: 1;
|
||||||
|
|
||||||
|
.preview-icon {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-shine {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -100%;
|
||||||
|
width: 50%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(255, 255, 255, 0.1),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
transition: left 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .card-shine {
|
||||||
|
left: 150%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Error State
|
||||||
|
// ==========================================
|
||||||
|
.card-error {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba($accent, 0.05);
|
||||||
|
|
||||||
|
.error-icon {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
background: rgba($accent, 0.15);
|
||||||
|
border: 2px solid rgba($accent, 0.3);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 28px;
|
||||||
|
color: $accent;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-title {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Input Display
|
||||||
|
// ==========================================
|
||||||
|
.input-display {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 24px;
|
||||||
|
background: rgba($surface, 0.6);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba($primary, 0.2);
|
||||||
|
border-radius: 16px;
|
||||||
|
max-width: 600px;
|
||||||
|
|
||||||
|
.input-icon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
background: $gradient-primary;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #fff;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
.input-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $text-muted;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-text {
|
||||||
|
font-size: 15px;
|
||||||
|
color: $text;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Tips Section
|
||||||
|
// ==========================================
|
||||||
|
.tips-section {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: $text-muted;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
.tip-icon {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
background: rgba($primary, 0.2);
|
||||||
|
border: 1px solid rgba($primary, 0.3);
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $primary-light;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Responsive
|
||||||
|
// ==========================================
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.model-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tips-section {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user