新增3D建模

This commit is contained in:
zhangxiaohua 2026-01-13 16:41:12 +08:00
parent 59ba6b6904
commit 1dce34e76a
14 changed files with 3040 additions and 429 deletions

View File

@ -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") /// 已重试次数

View File

@ -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',
], ],
}, },

View File

@ -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(),
}, },

View File

@ -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; // 错误信息
} }

View File

@ -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 服务暂时不可用';

View File

@ -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) {

View File

@ -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;

View File

@ -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,
},
},
// 动态路由将在这里添加 // 动态路由将在这里添加
], ],
}, },

View File

@ -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 = {

View File

@ -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 },
})
} }
// //

View File

@ -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

View 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