diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index b880921..4854b38 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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") /// 已重试次数 diff --git a/backend/scripts/init-roles-permissions.ts b/backend/scripts/init-roles-permissions.ts index bbb8343..0cfd00b 100644 --- a/backend/scripts/init-roles-permissions.ts +++ b/backend/scripts/init-roles-permissions.ts @@ -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', ], }, diff --git a/backend/src/ai-3d/ai-3d.service.ts b/backend/src/ai-3d/ai-3d.service.ts index 4f3caeb..e2330f2 100644 --- a/backend/src/ai-3d/ai-3d.service.ts +++ b/backend/src/ai-3d/ai-3d.service.ts @@ -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(), }, diff --git a/backend/src/ai-3d/providers/ai-3d-provider.interface.ts b/backend/src/ai-3d/providers/ai-3d-provider.interface.ts index 2de8e7a..d72336d 100644 --- a/backend/src/ai-3d/providers/ai-3d-provider.interface.ts +++ b/backend/src/ai-3d/providers/ai-3d-provider.interface.ts @@ -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; // 错误信息 } diff --git a/backend/src/ai-3d/providers/mock.provider.ts b/backend/src/ai-3d/providers/mock.provider.ts index e945fa7..376f647 100644 --- a/backend/src/ai-3d/providers/mock.provider.ts +++ b/backend/src/ai-3d/providers/mock.provider.ts @@ -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 服务暂时不可用'; diff --git a/backend/src/contests/teams/teams.service.ts b/backend/src/contests/teams/teams.service.ts index 058b1e5..98f09b9 100644 --- a/backend/src/contests/teams/teams.service.ts +++ b/backend/src/contests/teams/teams.service.ts @@ -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) { diff --git a/frontend/src/api/ai-3d.ts b/frontend/src/api/ai-3d.ts index 64f80aa..5c072ce 100644 --- a/frontend/src/api/ai-3d.ts +++ b/frontend/src/api/ai-3d.ts @@ -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; diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index d32c91b..dad018e 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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, + }, + }, // 动态路由将在这里添加 ], }, diff --git a/frontend/src/utils/menu.ts b/frontend/src/utils/menu.ts index 32cf716..777b018 100644 --- a/frontend/src/utils/menu.ts +++ b/frontend/src/utils/menu.ts @@ -13,7 +13,7 @@ import * as Icons from "@ant-design/icons-vue" const EmptyLayout = () => import("@/layouts/EmptyLayout.vue") const componentMap: Record Promise> = { - "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 = { diff --git a/frontend/src/views/contests/registrations/Index.vue b/frontend/src/views/contests/registrations/Index.vue index 27f9442..871e58a 100644 --- a/frontend/src/views/contests/registrations/Index.vue +++ b/frontend/src/views/contests/registrations/Index.vue @@ -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 }, + }) } // 启动报名 diff --git a/frontend/src/views/contests/registrations/Records.vue b/frontend/src/views/contests/registrations/Records.vue index 801ccba..858f7ca 100644 --- a/frontend/src/views/contests/registrations/Records.vue +++ b/frontend/src/views/contests/registrations/Records.vue @@ -4,7 +4,7 @@ diff --git a/frontend/src/views/workbench/ai-3d/Index.vue b/frontend/src/views/workbench/ai-3d/Index.vue index aa12343..88ce9c8 100644 --- a/frontend/src/views/workbench/ai-3d/Index.vue +++ b/frontend/src/views/workbench/ai-3d/Index.vue @@ -1,7 +1,28 @@