新增3D建模
This commit is contained in:
parent
59ba6b6904
commit
1dce34e76a
@ -1077,8 +1077,10 @@ model AI3DTask {
|
||||
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
|
||||
resultUrl String? @map("result_url") /// 生成的3D模型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 /// 失败时的错误信息
|
||||
externalTaskId String? @map("external_task_id") /// 外部AI服务的任务ID
|
||||
retryCount Int @default(0) @map("retry_count") /// 已重试次数
|
||||
|
||||
@ -38,6 +38,7 @@ const allPermissions = [
|
||||
{ code: 'user:read', resource: 'user', action: 'read', name: '查看用户', description: '允许查看用户列表和详情' },
|
||||
{ code: 'user:update', resource: 'user', action: 'update', 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: '允许创建新角色' },
|
||||
@ -47,10 +48,16 @@ const allPermissions = [
|
||||
{ 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: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: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: '允许创建租户' },
|
||||
@ -121,6 +128,7 @@ const allPermissions = [
|
||||
{ code: 'registration:update', resource: 'registration', action: 'update', name: '更新报名', description: '允许更新报名信息' },
|
||||
{ code: 'registration:delete', resource: 'registration', action: 'delete', 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: '允许上传参赛作品' },
|
||||
@ -132,6 +140,12 @@ const allPermissions = [
|
||||
// 作品评审(评委端)
|
||||
{ code: 'review:read', resource: 'review', action: 'read', 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: '允许创建赛事公告' },
|
||||
@ -194,10 +208,10 @@ const superTenantRoles = [
|
||||
description: '系统超级管理员,管理赛事和系统配置',
|
||||
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',
|
||||
'permission:read',
|
||||
'menu:read',
|
||||
'permission:create', 'permission:read', 'permission:update', 'permission:delete',
|
||||
'menu:create', 'menu:read', 'menu:update', 'menu:delete',
|
||||
'tenant:create', 'tenant:read', 'tenant:update', 'tenant:delete',
|
||||
'dict:create', 'dict:read', 'dict:update', 'dict: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',
|
||||
'review-rule:create', 'review-rule:read', 'review-rule:update', 'review-rule:delete',
|
||||
'judge:create', 'judge:read', 'judge:update', 'judge:delete', 'judge:assign',
|
||||
'registration:read', 'registration:approve',
|
||||
'work:read',
|
||||
'registration:read', 'registration:approve', 'registration:audit',
|
||||
'work:read', 'work:update',
|
||||
'review:read', 'review:assign',
|
||||
'result:read', 'result:publish', 'result:award',
|
||||
'notice:create', 'notice:read', 'notice:update', 'notice:delete',
|
||||
],
|
||||
},
|
||||
|
||||
@ -272,6 +272,8 @@ export class AI3DService {
|
||||
status: result.status,
|
||||
resultUrl: result.resultUrl,
|
||||
previewUrl: result.previewUrl,
|
||||
resultUrls: result.resultUrls || null,
|
||||
previewUrls: result.previewUrls || null,
|
||||
errorMessage: result.errorMessage,
|
||||
completeTime: new Date(),
|
||||
},
|
||||
|
||||
@ -4,8 +4,10 @@
|
||||
export interface AI3DGenerateResult {
|
||||
taskId: string; // 外部任务ID
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
resultUrl?: string; // 3D模型URL
|
||||
previewUrl?: string; // 预览图URL
|
||||
resultUrl?: string; // 3D模型URL(单个结果,兼容旧数据)
|
||||
previewUrl?: string; // 预览图URL(单个结果,兼容旧数据)
|
||||
resultUrls?: string[]; // 3D模型URL数组(多个结果,文生3D生成4个)
|
||||
previewUrls?: string[]; // 预览图URL数组(多个结果)
|
||||
errorMessage?: string; // 错误信息
|
||||
}
|
||||
|
||||
|
||||
@ -9,6 +9,8 @@ interface MockTask {
|
||||
inputContent: string;
|
||||
resultUrl?: string;
|
||||
previewUrl?: string;
|
||||
resultUrls?: string[];
|
||||
previewUrls?: string[];
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
@ -28,11 +30,21 @@ export class MockAI3DProvider implements AI3DProvider {
|
||||
// 模拟成功率
|
||||
private readonly SUCCESS_RATE = 0.9; // 90% 成功率
|
||||
|
||||
// 示例 3D 模型 URL(使用公开的 GLB 文件)
|
||||
// 示例 3D 模型 URL(使用公开可访问的 GLB 文件)
|
||||
private readonly SAMPLE_MODELS = [
|
||||
'/mock/models/sample-cube.glb',
|
||||
'/mock/models/sample-sphere.glb',
|
||||
'/mock/models/sample-model.glb',
|
||||
// three.js 官方示例模型
|
||||
'https://threejs.org/examples/models/gltf/DamagedHelmet/glTF/DamagedHelmet.gltf',
|
||||
'https://threejs.org/examples/models/gltf/LittlestTokyo.glb',
|
||||
'https://threejs.org/examples/models/gltf/Soldier.glb',
|
||||
'https://threejs.org/examples/models/gltf/RobotExpressive/RobotExpressive.glb',
|
||||
];
|
||||
|
||||
// 示例预览图(使用占位图服务)
|
||||
private readonly SAMPLE_PREVIEWS = [
|
||||
'https://picsum.photos/seed/model1/400/300',
|
||||
'https://picsum.photos/seed/model2/400/300',
|
||||
'https://picsum.photos/seed/model3/400/300',
|
||||
'https://picsum.photos/seed/model4/400/300',
|
||||
];
|
||||
|
||||
async submitTask(
|
||||
@ -82,6 +94,8 @@ export class MockAI3DProvider implements AI3DProvider {
|
||||
status: task.status,
|
||||
resultUrl: task.resultUrl,
|
||||
previewUrl: task.previewUrl,
|
||||
resultUrls: task.resultUrls,
|
||||
previewUrls: task.previewUrls,
|
||||
errorMessage: task.errorMessage,
|
||||
};
|
||||
}
|
||||
@ -97,15 +111,45 @@ export class MockAI3DProvider implements AI3DProvider {
|
||||
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];
|
||||
// 文生3D生成4个不同角度的模型
|
||||
if (task.inputType === 'text') {
|
||||
const resultUrls: string[] = [];
|
||||
const previewUrls: string[] = [];
|
||||
|
||||
task.status = 'completed';
|
||||
task.resultUrl = modelUrl;
|
||||
task.previewUrl = modelUrl.replace('.glb', '-preview.png');
|
||||
// 生成4个模型结果,使用不同的示例模型
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const modelIndex = i % this.SAMPLE_MODELS.length;
|
||||
const previewIndex = i % this.SAMPLE_PREVIEWS.length;
|
||||
resultUrls.push(this.SAMPLE_MODELS[modelIndex]);
|
||||
previewUrls.push(this.SAMPLE_PREVIEWS[previewIndex]);
|
||||
}
|
||||
|
||||
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 {
|
||||
task.status = 'failed';
|
||||
task.errorMessage = '模拟生成失败:AI 服务暂时不可用';
|
||||
|
||||
@ -59,83 +59,141 @@ export class TeamsService {
|
||||
throw new BadRequestException('队长不属于当前租户');
|
||||
}
|
||||
|
||||
// 创建团队
|
||||
const data: any = {
|
||||
tenantId,
|
||||
contestId: createTeamDto.contestId,
|
||||
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: {
|
||||
// 使用事务创建团队和报名记录
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// 创建团队
|
||||
const data: any = {
|
||||
tenantId,
|
||||
teamId: team.id,
|
||||
userId: createTeamDto.leaderId,
|
||||
role: 'leader',
|
||||
creator: creatorId,
|
||||
},
|
||||
contestId: createTeamDto.contestId,
|
||||
teamName: createTeamDto.teamName,
|
||||
leaderUserId: createTeamDto.leaderId,
|
||||
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) {
|
||||
|
||||
@ -30,6 +30,9 @@ export interface AI3DTask {
|
||||
status: AI3DTaskStatus;
|
||||
resultUrl?: string;
|
||||
previewUrl?: string;
|
||||
// 多结果支持(文生3D会生成4个不同角度的模型)
|
||||
resultUrls?: string[];
|
||||
previewUrls?: string[];
|
||||
errorMessage?: string;
|
||||
externalTaskId?: string;
|
||||
retryCount: number;
|
||||
|
||||
@ -96,6 +96,17 @@ const baseRoutes: RouteRecordRaw[] = [
|
||||
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",
|
||||
@ -104,7 +115,7 @@ const baseRoutes: RouteRecordRaw[] = [
|
||||
meta: {
|
||||
title: "报名记录",
|
||||
requiresAuth: true,
|
||||
permissions: ["contest:registration:read"],
|
||||
permissions: ["registration:read", "contest:read"],
|
||||
},
|
||||
},
|
||||
// 评审进度详情路由
|
||||
@ -148,7 +159,7 @@ const baseRoutes: RouteRecordRaw[] = [
|
||||
meta: {
|
||||
title: "作业详情",
|
||||
requiresAuth: true,
|
||||
permissions: ["homework:read", "homework:student:read"],
|
||||
permissions: ["homework:read"],
|
||||
},
|
||||
},
|
||||
// 教师我的指导路由
|
||||
@ -172,6 +183,16 @@ const baseRoutes: RouteRecordRaw[] = [
|
||||
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 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/departments/Index": () =>
|
||||
@ -251,7 +251,7 @@ export function convertMenusToRoutes(
|
||||
|
||||
// 如果既没有组件也没有子路由,跳过这个菜单(无法渲染)
|
||||
if (!componentLoader && (!childrenRoutes || childrenRoutes.length === 0)) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
const route: RouteRecordRaw = {
|
||||
|
||||
@ -319,12 +319,15 @@ const handleTableChange = (pag: any) => {
|
||||
|
||||
// 查看比赛
|
||||
const handleViewContest = (contestId: number) => {
|
||||
router.push(`/${tenantCode}/contests/${contestId}`)
|
||||
router.push({ name: "ContestsDetail", params: { tenantCode, id: contestId } })
|
||||
}
|
||||
|
||||
// 查看报名记录
|
||||
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>
|
||||
<a-breadcrumb>
|
||||
<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>{{ contestName || '报名记录' }}</a-breadcrumb-item>
|
||||
</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