新增3D建模页面
This commit is contained in:
parent
9fc98a6fd5
commit
59ba6b6904
@ -3,10 +3,20 @@
|
|||||||
"name": "工作台",
|
"name": "工作台",
|
||||||
"path": "/workbench",
|
"path": "/workbench",
|
||||||
"icon": "DashboardOutlined",
|
"icon": "DashboardOutlined",
|
||||||
"component": "workbench/Index",
|
"component": null,
|
||||||
"parentId": null,
|
"parentId": null,
|
||||||
"sort": 1,
|
"sort": 1,
|
||||||
"permission": "workbench:read"
|
"permission": "ai-3d:read",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"name": "3D建模实验室",
|
||||||
|
"path": "/workbench/3d-lab",
|
||||||
|
"icon": "ExperimentOutlined",
|
||||||
|
"component": "workbench/ai-3d/Index",
|
||||||
|
"sort": 1,
|
||||||
|
"permission": "ai-3d:read"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "学校管理",
|
"name": "学校管理",
|
||||||
|
|||||||
@ -1,10 +1,17 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"code": "workbench:read",
|
"code": "ai-3d:read",
|
||||||
"resource": "workbench",
|
"resource": "ai-3d",
|
||||||
"action": "read",
|
"action": "read",
|
||||||
"name": "查看工作台",
|
"name": "使用3D建模实验室",
|
||||||
"description": "允许查看工作台"
|
"description": "允许使用AI 3D建模实验室"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ai-3d:create",
|
||||||
|
"resource": "ai-3d",
|
||||||
|
"action": "create",
|
||||||
|
"name": "创建3D模型任务",
|
||||||
|
"description": "允许创建AI 3D模型生成任务"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "user:create",
|
"code": "user:create",
|
||||||
|
|||||||
34
backend/package-lock.json
generated
34
backend/package-lock.json
generated
@ -25,7 +25,8 @@
|
|||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
"reflect-metadata": "^0.2.1",
|
"reflect-metadata": "^0.2.1",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1",
|
||||||
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.3.2",
|
"@nestjs/cli": "^10.3.2",
|
||||||
@ -37,6 +38,7 @@
|
|||||||
"@types/node": "^20.11.5",
|
"@types/node": "^20.11.5",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/passport-local": "^1.0.36",
|
"@types/passport-local": "^1.0.36",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.19.1",
|
"@typescript-eslint/eslint-plugin": "^6.19.1",
|
||||||
"@typescript-eslint/parser": "^6.19.1",
|
"@typescript-eslint/parser": "^6.19.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
@ -2513,6 +2515,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/uuid": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/validator": {
|
"node_modules/@types/validator": {
|
||||||
"version": "13.15.10",
|
"version": "13.15.10",
|
||||||
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz",
|
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz",
|
||||||
@ -8927,6 +8936,16 @@
|
|||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/request/node_modules/uuid": {
|
||||||
|
"version": "3.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
|
||||||
|
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==",
|
||||||
|
"deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/require-directory": {
|
"node_modules/require-directory": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
@ -10454,13 +10473,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/uuid": {
|
"node_modules/uuid": {
|
||||||
"version": "3.4.0",
|
"version": "13.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||||
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==",
|
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||||
"deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.",
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa",
|
||||||
|
"https://github.com/sponsors/ctavan"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"uuid": "bin/uuid"
|
"uuid": "dist-node/bin/uuid"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/v8-compile-cache-lib": {
|
"node_modules/v8-compile-cache-lib": {
|
||||||
|
|||||||
@ -61,7 +61,8 @@
|
|||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
"reflect-metadata": "^0.2.1",
|
"reflect-metadata": "^0.2.1",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1",
|
||||||
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.3.2",
|
"@nestjs/cli": "^10.3.2",
|
||||||
@ -73,6 +74,7 @@
|
|||||||
"@types/node": "^20.11.5",
|
"@types/node": "^20.11.5",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/passport-local": "^1.0.36",
|
"@types/passport-local": "^1.0.36",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.19.1",
|
"@typescript-eslint/eslint-plugin": "^6.19.1",
|
||||||
"@typescript-eslint/parser": "^6.19.1",
|
"@typescript-eslint/parser": "^6.19.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
|
|||||||
@ -24,33 +24,35 @@ model Tenant {
|
|||||||
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
||||||
modifyTime DateTime @updatedAt @map("modify_time") /// 修改时间
|
modifyTime DateTime @updatedAt @map("modify_time") /// 修改时间
|
||||||
|
|
||||||
users User[]
|
users User[]
|
||||||
roles Role[]
|
roles Role[]
|
||||||
menus TenantMenu[]
|
menus TenantMenu[]
|
||||||
permissions Permission[]
|
permissions Permission[]
|
||||||
dicts Dict[]
|
dicts Dict[]
|
||||||
configs Config[]
|
configs Config[]
|
||||||
school School? /// 学校信息(一对一)
|
school School? /// 学校信息(一对一)
|
||||||
grades Grade[] /// 年级
|
grades Grade[] /// 年级
|
||||||
departments Department[] /// 部门
|
departments Department[] /// 部门
|
||||||
classes Class[] /// 班级
|
classes Class[] /// 班级
|
||||||
teachers Teacher[] /// 教师
|
teachers Teacher[] /// 教师
|
||||||
students Student[] /// 学生
|
students Student[] /// 学生
|
||||||
contestTeams ContestTeam[] /// 赛事团队
|
contestTeams ContestTeam[] /// 赛事团队
|
||||||
contestTeamMembers ContestTeamMember[] /// 团队成员
|
contestTeamMembers ContestTeamMember[] /// 团队成员
|
||||||
contestRegistrations ContestRegistration[] /// 赛事报名
|
contestRegistrations ContestRegistration[] /// 赛事报名
|
||||||
contestRegistrationTeachers ContestRegistrationTeacher[] /// 报名指导老师关联
|
contestRegistrationTeachers ContestRegistrationTeacher[] /// 报名指导老师关联
|
||||||
contestWorks ContestWork[] /// 参赛作品
|
contestWorks ContestWork[] /// 参赛作品
|
||||||
contestWorkAttachments ContestWorkAttachment[] /// 作品附件
|
contestWorkAttachments ContestWorkAttachment[] /// 作品附件
|
||||||
contestWorkScores ContestWorkScore[] /// 作品评分
|
contestWorkScores ContestWorkScore[] /// 作品评分
|
||||||
contestReviewRules ContestReviewRule[] /// 评审规则
|
contestReviewRules ContestReviewRule[] /// 评审规则
|
||||||
// 作业管理关联
|
// 作业管理关联
|
||||||
homeworks Homework[] /// 作业
|
homeworks Homework[] /// 作业
|
||||||
homeworkSubmissions HomeworkSubmission[] /// 作业提交记录
|
homeworkSubmissions HomeworkSubmission[] /// 作业提交记录
|
||||||
homeworkReviewRules HomeworkReviewRule[] /// 作业评审规则
|
homeworkReviewRules HomeworkReviewRule[] /// 作业评审规则
|
||||||
homeworkScores HomeworkScore[] /// 作业评分
|
homeworkScores HomeworkScore[] /// 作业评分
|
||||||
creatorUser User? @relation("TenantCreator", fields: [creator], references: [id], onDelete: SetNull)
|
// AI 3D 生成关联
|
||||||
modifierUser User? @relation("TenantModifier", fields: [modifier], references: [id], onDelete: SetNull)
|
ai3dTasks AI3DTask[] /// AI 3D 生成任务
|
||||||
|
creatorUser User? @relation("TenantCreator", fields: [creator], references: [id], onDelete: SetNull)
|
||||||
|
modifierUser User? @relation("TenantModifier", fields: [modifier], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@map("tenants")
|
@@map("tenants")
|
||||||
}
|
}
|
||||||
@ -69,91 +71,93 @@ model User {
|
|||||||
organization String? /// 所属单位(用于评委等独立用户)
|
organization String? /// 所属单位(用于评委等独立用户)
|
||||||
status String @default("enabled") /// 账号状态:enabled-启用,disabled-停用
|
status String @default("enabled") /// 账号状态:enabled-启用,disabled-停用
|
||||||
validState Int @default(1) @map("valid_state") /// 有效状态:1-有效,2-失效
|
validState Int @default(1) @map("valid_state") /// 有效状态:1-有效,2-失效
|
||||||
creator Int? @map("creator") /// 创建人ID
|
creator Int? @map("creator") /// 创建人ID
|
||||||
modifier Int? @map("modifier") /// 修改人ID
|
modifier Int? @map("modifier") /// 修改人ID
|
||||||
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
||||||
modifyTime DateTime @updatedAt @map("modify_time") /// 修改时间
|
modifyTime DateTime @updatedAt @map("modify_time") /// 修改时间
|
||||||
|
|
||||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
roles UserRole[]
|
roles UserRole[]
|
||||||
logs Log[]
|
logs Log[]
|
||||||
createdBy User? @relation("UserCreator", fields: [creator], references: [id], onDelete: SetNull)
|
createdBy User? @relation("UserCreator", fields: [creator], references: [id], onDelete: SetNull)
|
||||||
modifiedBy User? @relation("UserModifier", fields: [modifier], references: [id], onDelete: SetNull)
|
modifiedBy User? @relation("UserModifier", fields: [modifier], references: [id], onDelete: SetNull)
|
||||||
createdUsers User[] @relation("UserCreator")
|
createdUsers User[] @relation("UserCreator")
|
||||||
modifiedUsers User[] @relation("UserModifier")
|
modifiedUsers User[] @relation("UserModifier")
|
||||||
createdRoles Role[] @relation("RoleCreator")
|
createdRoles Role[] @relation("RoleCreator")
|
||||||
modifiedRoles Role[] @relation("RoleModifier")
|
modifiedRoles Role[] @relation("RoleModifier")
|
||||||
createdPermissions Permission[] @relation("PermissionCreator")
|
createdPermissions Permission[] @relation("PermissionCreator")
|
||||||
modifiedPermissions Permission[] @relation("PermissionModifier")
|
modifiedPermissions Permission[] @relation("PermissionModifier")
|
||||||
createdMenus Menu[] @relation("MenuCreator")
|
createdMenus Menu[] @relation("MenuCreator")
|
||||||
modifiedMenus Menu[] @relation("MenuModifier")
|
modifiedMenus Menu[] @relation("MenuModifier")
|
||||||
createdDicts Dict[] @relation("DictCreator")
|
createdDicts Dict[] @relation("DictCreator")
|
||||||
modifiedDicts Dict[] @relation("DictModifier")
|
modifiedDicts Dict[] @relation("DictModifier")
|
||||||
createdDictItems DictItem[] @relation("DictItemCreator")
|
createdDictItems DictItem[] @relation("DictItemCreator")
|
||||||
modifiedDictItems DictItem[] @relation("DictItemModifier")
|
modifiedDictItems DictItem[] @relation("DictItemModifier")
|
||||||
createdConfigs Config[] @relation("ConfigCreator")
|
createdConfigs Config[] @relation("ConfigCreator")
|
||||||
modifiedConfigs Config[] @relation("ConfigModifier")
|
modifiedConfigs Config[] @relation("ConfigModifier")
|
||||||
createdTenants Tenant[] @relation("TenantCreator")
|
createdTenants Tenant[] @relation("TenantCreator")
|
||||||
modifiedTenants Tenant[] @relation("TenantModifier")
|
modifiedTenants Tenant[] @relation("TenantModifier")
|
||||||
teacher Teacher? /// 教师信息(一对一)
|
teacher Teacher? /// 教师信息(一对一)
|
||||||
student Student? /// 学生信息(一对一)
|
student Student? /// 学生信息(一对一)
|
||||||
createdSchools School[] @relation("SchoolCreator")
|
createdSchools School[] @relation("SchoolCreator")
|
||||||
modifiedSchools School[] @relation("SchoolModifier")
|
modifiedSchools School[] @relation("SchoolModifier")
|
||||||
createdGrades Grade[] @relation("GradeCreator")
|
createdGrades Grade[] @relation("GradeCreator")
|
||||||
modifiedGrades Grade[] @relation("GradeModifier")
|
modifiedGrades Grade[] @relation("GradeModifier")
|
||||||
createdDepartments Department[] @relation("DepartmentCreator")
|
createdDepartments Department[] @relation("DepartmentCreator")
|
||||||
modifiedDepartments Department[] @relation("DepartmentModifier")
|
modifiedDepartments Department[] @relation("DepartmentModifier")
|
||||||
createdClasses Class[] @relation("ClassCreator")
|
createdClasses Class[] @relation("ClassCreator")
|
||||||
modifiedClasses Class[] @relation("ClassModifier")
|
modifiedClasses Class[] @relation("ClassModifier")
|
||||||
createdTeachers Teacher[] @relation("TeacherCreator")
|
createdTeachers Teacher[] @relation("TeacherCreator")
|
||||||
modifiedTeachers Teacher[] @relation("TeacherModifier")
|
modifiedTeachers Teacher[] @relation("TeacherModifier")
|
||||||
createdStudents Student[] @relation("StudentCreator")
|
createdStudents Student[] @relation("StudentCreator")
|
||||||
modifiedStudents Student[] @relation("StudentModifier")
|
modifiedStudents Student[] @relation("StudentModifier")
|
||||||
// 赛事相关关联
|
// 赛事相关关联
|
||||||
createdContests Contest[] @relation("ContestCreator")
|
createdContests Contest[] @relation("ContestCreator")
|
||||||
modifiedContests Contest[] @relation("ContestModifier")
|
modifiedContests Contest[] @relation("ContestModifier")
|
||||||
createdContestAttachments ContestAttachment[] @relation("ContestAttachmentCreator")
|
createdContestAttachments ContestAttachment[] @relation("ContestAttachmentCreator")
|
||||||
modifiedContestAttachments ContestAttachment[] @relation("ContestAttachmentModifier")
|
modifiedContestAttachments ContestAttachment[] @relation("ContestAttachmentModifier")
|
||||||
createdContestReviewRules ContestReviewRule[] @relation("ContestReviewRuleCreator")
|
createdContestReviewRules ContestReviewRule[] @relation("ContestReviewRuleCreator")
|
||||||
modifiedContestReviewRules ContestReviewRule[] @relation("ContestReviewRuleModifier")
|
modifiedContestReviewRules ContestReviewRule[] @relation("ContestReviewRuleModifier")
|
||||||
createdContestTeams ContestTeam[] @relation("ContestTeamCreator")
|
createdContestTeams ContestTeam[] @relation("ContestTeamCreator")
|
||||||
modifiedContestTeams ContestTeam[] @relation("ContestTeamModifier")
|
modifiedContestTeams ContestTeam[] @relation("ContestTeamModifier")
|
||||||
ledContestTeams ContestTeam[] @relation("ContestTeamLeader")
|
ledContestTeams ContestTeam[] @relation("ContestTeamLeader")
|
||||||
createdContestTeamMembers ContestTeamMember[] @relation("ContestTeamMemberCreator")
|
createdContestTeamMembers ContestTeamMember[] @relation("ContestTeamMemberCreator")
|
||||||
modifiedContestTeamMembers ContestTeamMember[] @relation("ContestTeamMemberModifier")
|
modifiedContestTeamMembers ContestTeamMember[] @relation("ContestTeamMemberModifier")
|
||||||
contestTeamMembers ContestTeamMember[] @relation("ContestTeamMemberUser")
|
contestTeamMembers ContestTeamMember[] @relation("ContestTeamMemberUser")
|
||||||
createdContestRegistrations ContestRegistration[] @relation("ContestRegistrationCreator")
|
createdContestRegistrations ContestRegistration[] @relation("ContestRegistrationCreator")
|
||||||
modifiedContestRegistrations ContestRegistration[] @relation("ContestRegistrationModifier")
|
modifiedContestRegistrations ContestRegistration[] @relation("ContestRegistrationModifier")
|
||||||
contestRegistrations ContestRegistration[] @relation("ContestRegistrationUser")
|
contestRegistrations ContestRegistration[] @relation("ContestRegistrationUser")
|
||||||
createdContestWorks ContestWork[] @relation("ContestWorkCreator")
|
createdContestWorks ContestWork[] @relation("ContestWorkCreator")
|
||||||
modifiedContestWorks ContestWork[] @relation("ContestWorkModifier")
|
modifiedContestWorks ContestWork[] @relation("ContestWorkModifier")
|
||||||
createdContestWorkAttachments ContestWorkAttachment[] @relation("ContestWorkAttachmentCreator")
|
createdContestWorkAttachments ContestWorkAttachment[] @relation("ContestWorkAttachmentCreator")
|
||||||
modifiedContestWorkAttachments ContestWorkAttachment[] @relation("ContestWorkAttachmentModifier")
|
modifiedContestWorkAttachments ContestWorkAttachment[] @relation("ContestWorkAttachmentModifier")
|
||||||
createdContestWorkJudgeAssignments ContestWorkJudgeAssignment[] @relation("ContestWorkJudgeAssignmentCreator")
|
createdContestWorkJudgeAssignments ContestWorkJudgeAssignment[] @relation("ContestWorkJudgeAssignmentCreator")
|
||||||
modifiedContestWorkJudgeAssignments ContestWorkJudgeAssignment[] @relation("ContestWorkJudgeAssignmentModifier")
|
modifiedContestWorkJudgeAssignments ContestWorkJudgeAssignment[] @relation("ContestWorkJudgeAssignmentModifier")
|
||||||
assignedContestWorks ContestWorkJudgeAssignment[] @relation("ContestWorkJudgeAssignmentJudge")
|
assignedContestWorks ContestWorkJudgeAssignment[] @relation("ContestWorkJudgeAssignmentJudge")
|
||||||
createdContestWorkScores ContestWorkScore[] @relation("ContestWorkScoreCreator")
|
createdContestWorkScores ContestWorkScore[] @relation("ContestWorkScoreCreator")
|
||||||
modifiedContestWorkScores ContestWorkScore[] @relation("ContestWorkScoreModifier")
|
modifiedContestWorkScores ContestWorkScore[] @relation("ContestWorkScoreModifier")
|
||||||
scoredContestWorks ContestWorkScore[] @relation("ContestWorkScoreJudge")
|
scoredContestWorks ContestWorkScore[] @relation("ContestWorkScoreJudge")
|
||||||
createdContestNotices ContestNotice[] @relation("ContestNoticeCreator")
|
createdContestNotices ContestNotice[] @relation("ContestNoticeCreator")
|
||||||
modifiedContestNotices ContestNotice[] @relation("ContestNoticeModifier")
|
modifiedContestNotices ContestNotice[] @relation("ContestNoticeModifier")
|
||||||
contestJudges ContestJudge[] @relation("ContestJudgeUser")
|
contestJudges ContestJudge[] @relation("ContestJudgeUser")
|
||||||
createdContestJudges ContestJudge[] @relation("ContestJudgeCreator")
|
createdContestJudges ContestJudge[] @relation("ContestJudgeCreator")
|
||||||
modifiedContestJudges ContestJudge[] @relation("ContestJudgeModifier")
|
modifiedContestJudges ContestJudge[] @relation("ContestJudgeModifier")
|
||||||
contestRegistrationTeachers ContestRegistrationTeacher[] @relation("ContestRegistrationTeacherUser")
|
contestRegistrationTeachers ContestRegistrationTeacher[] @relation("ContestRegistrationTeacherUser")
|
||||||
createdContestRegistrationTeachers ContestRegistrationTeacher[] @relation("ContestRegistrationTeacherCreator")
|
createdContestRegistrationTeachers ContestRegistrationTeacher[] @relation("ContestRegistrationTeacherCreator")
|
||||||
modifiedContestRegistrationTeachers ContestRegistrationTeacher[] @relation("ContestRegistrationTeacherModifier")
|
modifiedContestRegistrationTeachers ContestRegistrationTeacher[] @relation("ContestRegistrationTeacherModifier")
|
||||||
// 作业管理关联
|
// 作业管理关联
|
||||||
createdHomeworks Homework[] @relation("HomeworkCreator")
|
createdHomeworks Homework[] @relation("HomeworkCreator")
|
||||||
modifiedHomeworks Homework[] @relation("HomeworkModifier")
|
modifiedHomeworks Homework[] @relation("HomeworkModifier")
|
||||||
homeworkSubmissions HomeworkSubmission[] @relation("HomeworkSubmissionStudent")
|
homeworkSubmissions HomeworkSubmission[] @relation("HomeworkSubmissionStudent")
|
||||||
createdHomeworkSubmissions HomeworkSubmission[] @relation("HomeworkSubmissionCreator")
|
createdHomeworkSubmissions HomeworkSubmission[] @relation("HomeworkSubmissionCreator")
|
||||||
modifiedHomeworkSubmissions HomeworkSubmission[] @relation("HomeworkSubmissionModifier")
|
modifiedHomeworkSubmissions HomeworkSubmission[] @relation("HomeworkSubmissionModifier")
|
||||||
createdHomeworkReviewRules HomeworkReviewRule[] @relation("HomeworkReviewRuleCreator")
|
createdHomeworkReviewRules HomeworkReviewRule[] @relation("HomeworkReviewRuleCreator")
|
||||||
modifiedHomeworkReviewRules HomeworkReviewRule[] @relation("HomeworkReviewRuleModifier")
|
modifiedHomeworkReviewRules HomeworkReviewRule[] @relation("HomeworkReviewRuleModifier")
|
||||||
homeworkScoresAsReviewer HomeworkScore[] @relation("HomeworkScoreReviewer")
|
homeworkScoresAsReviewer HomeworkScore[] @relation("HomeworkScoreReviewer")
|
||||||
createdHomeworkScores HomeworkScore[] @relation("HomeworkScoreCreator")
|
createdHomeworkScores HomeworkScore[] @relation("HomeworkScoreCreator")
|
||||||
modifiedHomeworkScores HomeworkScore[] @relation("HomeworkScoreModifier")
|
modifiedHomeworkScores HomeworkScore[] @relation("HomeworkScoreModifier")
|
||||||
|
// AI 3D 生成关联
|
||||||
|
ai3dTasks AI3DTask[] @relation("AI3DTaskUser") /// 用户的 AI 3D 生成任务
|
||||||
|
|
||||||
@@unique([tenantId, username])
|
@@unique([tenantId, username])
|
||||||
@@unique([tenantId, email])
|
@@unique([tenantId, email])
|
||||||
@ -565,7 +569,7 @@ model Contest {
|
|||||||
submitStartTime DateTime @map("submit_start_time") /// 作品提交开始时间
|
submitStartTime DateTime @map("submit_start_time") /// 作品提交开始时间
|
||||||
submitEndTime DateTime @map("submit_end_time") /// 作品提交结束时间
|
submitEndTime DateTime @map("submit_end_time") /// 作品提交结束时间
|
||||||
workType String? @map("work_type") /// 作品类型(如:image/video/document/code)
|
workType String? @map("work_type") /// 作品类型(如:image/video/document/code)
|
||||||
workRequirement String? @db.Text @map("work_requirement") /// 作品要求说明
|
workRequirement String? @map("work_requirement") @db.Text /// 作品要求说明
|
||||||
// 评审配置
|
// 评审配置
|
||||||
reviewRuleId Int? @map("review_rule_id") /// 评审规则id
|
reviewRuleId Int? @map("review_rule_id") /// 评审规则id
|
||||||
reviewStartTime DateTime @map("review_start_time") /// 评审开始时间
|
reviewStartTime DateTime @map("review_start_time") /// 评审开始时间
|
||||||
@ -579,17 +583,17 @@ model Contest {
|
|||||||
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
|
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
|
||||||
validState Int @default(1) @map("valid_state") /// 有效状态:1-有效,2-失效
|
validState Int @default(1) @map("valid_state") /// 有效状态:1-有效,2-失效
|
||||||
|
|
||||||
attachments ContestAttachment[] /// 赛事附件
|
attachments ContestAttachment[] /// 赛事附件
|
||||||
reviewRule ContestReviewRule? @relation("ContestReviewRuleContest", fields: [reviewRuleId], references: [id], onDelete: SetNull)
|
reviewRule ContestReviewRule? @relation("ContestReviewRuleContest", fields: [reviewRuleId], references: [id], onDelete: SetNull)
|
||||||
teams ContestTeam[] /// 赛事团队
|
teams ContestTeam[] /// 赛事团队
|
||||||
registrations ContestRegistration[] /// 报名记录
|
registrations ContestRegistration[] /// 报名记录
|
||||||
works ContestWork[] /// 参赛作品
|
works ContestWork[] /// 参赛作品
|
||||||
judges ContestJudge[] /// 比赛评委
|
judges ContestJudge[] /// 比赛评委
|
||||||
workAssignments ContestWorkJudgeAssignment[] @relation("ContestWorkJudgeAssignmentContest") /// 作品分配
|
workAssignments ContestWorkJudgeAssignment[] @relation("ContestWorkJudgeAssignmentContest") /// 作品分配
|
||||||
workScores ContestWorkScore[] @relation("ContestWorkScoreContest") /// 作品评分
|
workScores ContestWorkScore[] @relation("ContestWorkScoreContest") /// 作品评分
|
||||||
notices ContestNotice[] /// 赛事公告
|
notices ContestNotice[] /// 赛事公告
|
||||||
creatorUser User? @relation("ContestCreator", fields: [creator], references: [id], onDelete: SetNull)
|
creatorUser User? @relation("ContestCreator", fields: [creator], references: [id], onDelete: SetNull)
|
||||||
modifierUser User? @relation("ContestModifier", fields: [modifier], references: [id], onDelete: SetNull)
|
modifierUser User? @relation("ContestModifier", fields: [modifier], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@unique([contestName])
|
@@unique([contestName])
|
||||||
@@index([contestState])
|
@@index([contestState])
|
||||||
@ -600,22 +604,22 @@ model Contest {
|
|||||||
|
|
||||||
/// 赛事附件表
|
/// 赛事附件表
|
||||||
model ContestAttachment {
|
model ContestAttachment {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
contestId Int @map("contest_id") /// 赛事id
|
contestId Int @map("contest_id") /// 赛事id
|
||||||
fileName String @map("file_name") /// 文件名
|
fileName String @map("file_name") /// 文件名
|
||||||
fileUrl String @map("file_url") /// 文件路径
|
fileUrl String @map("file_url") /// 文件路径
|
||||||
format String? /// 文件类型(png,mp4)
|
format String? /// 文件类型(png,mp4)
|
||||||
fileType String? @map("file_type") /// 素材类型(image,video)
|
fileType String? @map("file_type") /// 素材类型(image,video)
|
||||||
size String @default("0") /// 文件大小
|
size String @default("0") /// 文件大小
|
||||||
creator Int? /// 创建人ID
|
creator Int? /// 创建人ID
|
||||||
modifier Int? /// 修改人ID
|
modifier Int? /// 修改人ID
|
||||||
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
||||||
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
|
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
|
||||||
validState Int @default(1) @map("valid_state") /// 有效状态:1-有效,2-失效
|
validState Int @default(1) @map("valid_state") /// 有效状态:1-有效,2-失效
|
||||||
|
|
||||||
contest Contest @relation(fields: [contestId], references: [id], onDelete: Cascade)
|
contest Contest @relation(fields: [contestId], references: [id], onDelete: Cascade)
|
||||||
creatorUser User? @relation("ContestAttachmentCreator", fields: [creator], references: [id], onDelete: SetNull)
|
creatorUser User? @relation("ContestAttachmentCreator", fields: [creator], references: [id], onDelete: SetNull)
|
||||||
modifierUser User? @relation("ContestAttachmentModifier", fields: [modifier], references: [id], onDelete: SetNull)
|
modifierUser User? @relation("ContestAttachmentModifier", fields: [modifier], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@index([contestId])
|
@@index([contestId])
|
||||||
@@map("t_contest_attachment")
|
@@map("t_contest_attachment")
|
||||||
@ -623,18 +627,18 @@ model ContestAttachment {
|
|||||||
|
|
||||||
/// 评审规则表(独立存在,可被多个赛事使用)
|
/// 评审规则表(独立存在,可被多个赛事使用)
|
||||||
model ContestReviewRule {
|
model ContestReviewRule {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
tenantId Int @map("tenant_id") /// 租户ID
|
tenantId Int @map("tenant_id") /// 租户ID
|
||||||
ruleName String @map("rule_name") /// 规则名称
|
ruleName String @map("rule_name") /// 规则名称
|
||||||
ruleDescription String? @db.Text @map("rule_description") /// 规则说明
|
ruleDescription String? @map("rule_description") @db.Text /// 规则说明
|
||||||
judgeCount Int? @map("judge_count") /// 评委数量
|
judgeCount Int? @map("judge_count") /// 评委数量
|
||||||
dimensions Json /// 评分维度配置JSON
|
dimensions Json /// 评分维度配置JSON
|
||||||
calculationRule String @default("average") @map("calculation_rule") /// 计算规则:average/remove_max_min/remove_min
|
calculationRule String @default("average") @map("calculation_rule") /// 计算规则:average/remove_max_min/remove_min
|
||||||
creator Int? /// 创建人ID
|
creator Int? /// 创建人ID
|
||||||
modifier Int? /// 修改人ID
|
modifier Int? /// 修改人ID
|
||||||
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
||||||
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
|
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
|
||||||
validState Int @default(1) @map("valid_state") /// 有效状态:1-有效,2-失效
|
validState Int @default(1) @map("valid_state") /// 有效状态:1-有效,2-失效
|
||||||
|
|
||||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
contests Contest[] @relation("ContestReviewRuleContest") /// 使用此规则的赛事列表
|
contests Contest[] @relation("ContestReviewRuleContest") /// 使用此规则的赛事列表
|
||||||
@ -647,25 +651,25 @@ model ContestReviewRule {
|
|||||||
|
|
||||||
/// 赛事团队表
|
/// 赛事团队表
|
||||||
model ContestTeam {
|
model ContestTeam {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
tenantId Int @map("tenant_id") /// 团队所属租户ID
|
tenantId Int @map("tenant_id") /// 团队所属租户ID
|
||||||
contestId Int @map("contest_id") /// 赛事id
|
contestId Int @map("contest_id") /// 赛事id
|
||||||
teamName String @map("team_name") /// 团队名称(租户内唯一)
|
teamName String @map("team_name") /// 团队名称(租户内唯一)
|
||||||
leaderUserId Int @map("leader_user_id") /// 团队负责人用户id
|
leaderUserId Int @map("leader_user_id") /// 团队负责人用户id
|
||||||
maxMembers Int? @map("max_members") /// 团队最大成员数
|
maxMembers Int? @map("max_members") /// 团队最大成员数
|
||||||
creator Int? /// 创建人ID
|
creator Int? /// 创建人ID
|
||||||
modifier Int? /// 修改人ID
|
modifier Int? /// 修改人ID
|
||||||
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
||||||
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
|
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
|
||||||
validState Int @default(1) @map("valid_state") /// 有效状态:1-有效,2-失效
|
validState Int @default(1) @map("valid_state") /// 有效状态:1-有效,2-失效
|
||||||
|
|
||||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
contest Contest @relation(fields: [contestId], references: [id], onDelete: Cascade)
|
contest Contest @relation(fields: [contestId], references: [id], onDelete: Cascade)
|
||||||
leader User @relation("ContestTeamLeader", fields: [leaderUserId], references: [id], onDelete: Restrict)
|
leader User @relation("ContestTeamLeader", fields: [leaderUserId], references: [id], onDelete: Restrict)
|
||||||
members ContestTeamMember[] /// 团队成员
|
members ContestTeamMember[] /// 团队成员
|
||||||
registrations ContestRegistration[] /// 报名记录
|
registrations ContestRegistration[] /// 报名记录
|
||||||
creatorUser User? @relation("ContestTeamCreator", fields: [creator], references: [id], onDelete: SetNull)
|
creatorUser User? @relation("ContestTeamCreator", fields: [creator], references: [id], onDelete: SetNull)
|
||||||
modifierUser User? @relation("ContestTeamModifier", fields: [modifier], references: [id], onDelete: SetNull)
|
modifierUser User? @relation("ContestTeamModifier", fields: [modifier], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@unique([tenantId, contestId, teamName])
|
@@unique([tenantId, contestId, teamName])
|
||||||
@@index([contestId])
|
@@index([contestId])
|
||||||
@ -675,21 +679,21 @@ model ContestTeam {
|
|||||||
|
|
||||||
/// 团队成员表
|
/// 团队成员表
|
||||||
model ContestTeamMember {
|
model ContestTeamMember {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
tenantId Int @map("tenant_id") /// 成员所属租户ID
|
tenantId Int @map("tenant_id") /// 成员所属租户ID
|
||||||
teamId Int @map("team_id") /// 团队id
|
teamId Int @map("team_id") /// 团队id
|
||||||
userId Int @map("user_id") /// 成员用户id
|
userId Int @map("user_id") /// 成员用户id
|
||||||
role String @default("member") /// 成员角色:member/leader/mentor
|
role String @default("member") /// 成员角色:member/leader/mentor
|
||||||
creator Int? /// 创建人ID
|
creator Int? /// 创建人ID
|
||||||
modifier Int? /// 修改人ID
|
modifier Int? /// 修改人ID
|
||||||
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
||||||
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
|
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
|
||||||
|
|
||||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
team ContestTeam @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
team ContestTeam @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||||
user User @relation("ContestTeamMemberUser", fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation("ContestTeamMemberUser", fields: [userId], references: [id], onDelete: Cascade)
|
||||||
creatorUser User? @relation("ContestTeamMemberCreator", fields: [creator], references: [id], onDelete: SetNull)
|
creatorUser User? @relation("ContestTeamMemberCreator", fields: [creator], references: [id], onDelete: SetNull)
|
||||||
modifierUser User? @relation("ContestTeamMemberModifier", fields: [modifier], references: [id], onDelete: SetNull)
|
modifierUser User? @relation("ContestTeamMemberModifier", fields: [modifier], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@unique([tenantId, teamId, userId])
|
@@unique([tenantId, teamId, userId])
|
||||||
@@index([teamId])
|
@@index([teamId])
|
||||||
@ -699,34 +703,34 @@ model ContestTeamMember {
|
|||||||
|
|
||||||
/// 赛事报名表
|
/// 赛事报名表
|
||||||
model ContestRegistration {
|
model ContestRegistration {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
contestId Int @map("contest_id") /// 赛事id
|
contestId Int @map("contest_id") /// 赛事id
|
||||||
tenantId Int @map("tenant_id") /// 所属租户ID
|
tenantId Int @map("tenant_id") /// 所属租户ID
|
||||||
registrationType String? @map("registration_type") /// 报名类型:individual/team
|
registrationType String? @map("registration_type") /// 报名类型:individual/team
|
||||||
teamId Int? @map("team_id") /// 团队id
|
teamId Int? @map("team_id") /// 团队id
|
||||||
teamName String? @map("team_name") /// 团队名称快照(团队赛)
|
teamName String? @map("team_name") /// 团队名称快照(团队赛)
|
||||||
userId Int @map("user_id") /// 账号id
|
userId Int @map("user_id") /// 账号id
|
||||||
accountNo String @map("account_no") /// 报名账号(记录报名快照)
|
accountNo String @map("account_no") /// 报名账号(记录报名快照)
|
||||||
accountName String @map("account_name") /// 报名账号名称(记录报名快照)
|
accountName String @map("account_name") /// 报名账号名称(记录报名快照)
|
||||||
role String? /// 报名角色快照:leader/member/mentor
|
role String? /// 报名角色快照:leader/member/mentor
|
||||||
registrationState String @default("pending") @map("registration_state") /// 报名状态:pending/passed/rejected/withdrawn
|
registrationState String @default("pending") @map("registration_state") /// 报名状态:pending/passed/rejected/withdrawn
|
||||||
registrant Int? /// 实际报名人用户ID
|
registrant Int? /// 实际报名人用户ID
|
||||||
registrationTime DateTime @map("registration_time") /// 报名时间
|
registrationTime DateTime @map("registration_time") /// 报名时间
|
||||||
reason String? @db.VarChar(1023) /// 审核理由
|
reason String? @db.VarChar(1023) /// 审核理由
|
||||||
operator Int? /// 审核人用户ID
|
operator Int? /// 审核人用户ID
|
||||||
operationDate DateTime? @map("operation_date") /// 审核时间
|
operationDate DateTime? @map("operation_date") /// 审核时间
|
||||||
creator Int? /// 创建人ID
|
creator Int? /// 创建人ID
|
||||||
modifier Int? /// 修改人ID
|
modifier Int? /// 修改人ID
|
||||||
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
||||||
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
|
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
|
||||||
|
|
||||||
contest Contest @relation(fields: [contestId], references: [id], onDelete: Cascade)
|
contest Contest @relation(fields: [contestId], references: [id], onDelete: Cascade)
|
||||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
team ContestTeam? @relation(fields: [teamId], references: [id], onDelete: SetNull)
|
team ContestTeam? @relation(fields: [teamId], references: [id], onDelete: SetNull)
|
||||||
user User @relation("ContestRegistrationUser", fields: [userId], references: [id], onDelete: Restrict)
|
user User @relation("ContestRegistrationUser", fields: [userId], references: [id], onDelete: Restrict)
|
||||||
works ContestWork[] /// 参赛作品
|
works ContestWork[] /// 参赛作品
|
||||||
creatorUser User? @relation("ContestRegistrationCreator", fields: [creator], references: [id], onDelete: SetNull)
|
creatorUser User? @relation("ContestRegistrationCreator", fields: [creator], references: [id], onDelete: SetNull)
|
||||||
modifierUser User? @relation("ContestRegistrationModifier", fields: [modifier], references: [id], onDelete: SetNull)
|
modifierUser User? @relation("ContestRegistrationModifier", fields: [modifier], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
teachers ContestRegistrationTeacher[] /// 指导老师关联
|
teachers ContestRegistrationTeacher[] /// 指导老师关联
|
||||||
|
|
||||||
@ -739,15 +743,15 @@ model ContestRegistration {
|
|||||||
|
|
||||||
/// 报名指导老师关联表
|
/// 报名指导老师关联表
|
||||||
model ContestRegistrationTeacher {
|
model ContestRegistrationTeacher {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
registrationId Int @map("registration_id") /// 报名记录ID
|
registrationId Int @map("registration_id") /// 报名记录ID
|
||||||
tenantId Int @map("tenant_id") /// 租户ID
|
tenantId Int @map("tenant_id") /// 租户ID
|
||||||
userId Int @map("user_id") /// 指导老师用户ID
|
userId Int @map("user_id") /// 指导老师用户ID
|
||||||
isDefault Boolean @default(false) @map("is_default") /// 是否为默认指导老师(报名时自动添加的,不能移除)
|
isDefault Boolean @default(false) @map("is_default") /// 是否为默认指导老师(报名时自动添加的,不能移除)
|
||||||
creator Int? /// 创建人ID
|
creator Int? /// 创建人ID
|
||||||
modifier Int? /// 修改人ID
|
modifier Int? /// 修改人ID
|
||||||
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
||||||
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
|
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
|
||||||
|
|
||||||
registration ContestRegistration @relation(fields: [registrationId], references: [id], onDelete: Cascade)
|
registration ContestRegistration @relation(fields: [registrationId], references: [id], onDelete: Cascade)
|
||||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
@ -763,43 +767,43 @@ model ContestRegistrationTeacher {
|
|||||||
|
|
||||||
/// 参赛作品表
|
/// 参赛作品表
|
||||||
model ContestWork {
|
model ContestWork {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
tenantId Int @map("tenant_id") /// 作品所属租户ID
|
tenantId Int @map("tenant_id") /// 作品所属租户ID
|
||||||
contestId Int @map("contest_id") /// 赛事id
|
contestId Int @map("contest_id") /// 赛事id
|
||||||
registrationId Int @map("registration_id") /// 报名记录id
|
registrationId Int @map("registration_id") /// 报名记录id
|
||||||
workNo String? @unique @map("work_no") /// 作品编号(展示用唯一编号)
|
workNo String? @unique @map("work_no") /// 作品编号(展示用唯一编号)
|
||||||
title String /// 作品标题
|
title String /// 作品标题
|
||||||
description String? @db.Text /// 作品说明
|
description String? @db.Text /// 作品说明
|
||||||
files Json? /// 作品文件列表(简易场景)
|
files Json? /// 作品文件列表(简易场景)
|
||||||
version Int @default(1) /// 作品版本号(递增)
|
version Int @default(1) /// 作品版本号(递增)
|
||||||
isLatest Boolean @default(true) @map("is_latest") /// 是否最新版本
|
isLatest Boolean @default(true) @map("is_latest") /// 是否最新版本
|
||||||
status String @default("submitted") /// 作品状态:submitted/locked/reviewing/rejected/accepted
|
status String @default("submitted") /// 作品状态:submitted/locked/reviewing/rejected/accepted
|
||||||
submitTime DateTime @default(now()) @map("submit_time") /// 提交时间
|
submitTime DateTime @default(now()) @map("submit_time") /// 提交时间
|
||||||
submitterUserId Int? @map("submitter_user_id") /// 提交人用户id
|
submitterUserId Int? @map("submitter_user_id") /// 提交人用户id
|
||||||
submitterAccountNo String? @map("submitter_account_no") /// 提交人账号
|
submitterAccountNo String? @map("submitter_account_no") /// 提交人账号
|
||||||
submitSource String @default("teacher") @map("submit_source") /// 提交来源:teacher/student/team_leader
|
submitSource String @default("teacher") @map("submit_source") /// 提交来源:teacher/student/team_leader
|
||||||
previewUrl String? @map("preview_url") /// 作品预览URL
|
previewUrl String? @map("preview_url") /// 作品预览URL
|
||||||
aiModelMeta Json? @map("ai_model_meta") /// AI建模元数据
|
aiModelMeta Json? @map("ai_model_meta") /// AI建模元数据
|
||||||
// 赛果相关字段
|
// 赛果相关字段
|
||||||
finalScore Decimal? @map("final_score") @db.Decimal(10, 2) /// 最终得分(根据规则计算)
|
finalScore Decimal? @map("final_score") @db.Decimal(10, 2) /// 最终得分(根据规则计算)
|
||||||
rank Int? /// 排名
|
rank Int? /// 排名
|
||||||
awardLevel String? @map("award_level") /// 奖项等级:first/second/third/excellent/none
|
awardLevel String? @map("award_level") /// 奖项等级:first/second/third/excellent/none
|
||||||
awardName String? @map("award_name") /// 奖项名称(如:一等奖、金奖)
|
awardName String? @map("award_name") /// 奖项名称(如:一等奖、金奖)
|
||||||
certificateUrl String? @map("certificate_url") /// 证书URL
|
certificateUrl String? @map("certificate_url") /// 证书URL
|
||||||
creator Int? /// 创建人ID
|
creator Int? /// 创建人ID
|
||||||
modifier Int? /// 修改人ID
|
modifier Int? /// 修改人ID
|
||||||
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
||||||
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
|
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
|
||||||
validState Int @default(1) @map("valid_state") /// 有效状态:1-有效,2-失效
|
validState Int @default(1) @map("valid_state") /// 有效状态:1-有效,2-失效
|
||||||
|
|
||||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
contest Contest @relation(fields: [contestId], references: [id], onDelete: Cascade)
|
contest Contest @relation(fields: [contestId], references: [id], onDelete: Cascade)
|
||||||
registration ContestRegistration @relation(fields: [registrationId], references: [id], onDelete: Restrict)
|
registration ContestRegistration @relation(fields: [registrationId], references: [id], onDelete: Restrict)
|
||||||
attachments ContestWorkAttachment[] /// 作品附件
|
attachments ContestWorkAttachment[] /// 作品附件
|
||||||
assignments ContestWorkJudgeAssignment[] /// 作品分配
|
assignments ContestWorkJudgeAssignment[] /// 作品分配
|
||||||
scores ContestWorkScore[] /// 作品评分
|
scores ContestWorkScore[] /// 作品评分
|
||||||
creatorUser User? @relation("ContestWorkCreator", fields: [creator], references: [id], onDelete: SetNull)
|
creatorUser User? @relation("ContestWorkCreator", fields: [creator], references: [id], onDelete: SetNull)
|
||||||
modifierUser User? @relation("ContestWorkModifier", fields: [modifier], references: [id], onDelete: SetNull)
|
modifierUser User? @relation("ContestWorkModifier", fields: [modifier], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@index([tenantId, contestId, isLatest])
|
@@index([tenantId, contestId, isLatest])
|
||||||
@@index([registrationId])
|
@@index([registrationId])
|
||||||
@ -810,24 +814,24 @@ model ContestWork {
|
|||||||
|
|
||||||
/// 作品附件文件表
|
/// 作品附件文件表
|
||||||
model ContestWorkAttachment {
|
model ContestWorkAttachment {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
tenantId Int @map("tenant_id") /// 所属租户ID
|
tenantId Int @map("tenant_id") /// 所属租户ID
|
||||||
contestId Int @map("contest_id") /// 赛事id
|
contestId Int @map("contest_id") /// 赛事id
|
||||||
workId Int @map("work_id") /// 作品id
|
workId Int @map("work_id") /// 作品id
|
||||||
fileName String @map("file_name") /// 文件名
|
fileName String @map("file_name") /// 文件名
|
||||||
fileUrl String @map("file_url") /// 文件路径
|
fileUrl String @map("file_url") /// 文件路径
|
||||||
format String? /// 文件类型(png,mp4)
|
format String? /// 文件类型(png,mp4)
|
||||||
fileType String? @map("file_type") /// 素材类型(image,video)
|
fileType String? @map("file_type") /// 素材类型(image,video)
|
||||||
size String @default("0") /// 文件大小
|
size String @default("0") /// 文件大小
|
||||||
creator Int? /// 创建人ID
|
creator Int? /// 创建人ID
|
||||||
modifier Int? /// 修改人ID
|
modifier Int? /// 修改人ID
|
||||||
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
||||||
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
|
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
|
||||||
|
|
||||||
work ContestWork @relation(fields: [workId], references: [id], onDelete: Cascade)
|
work ContestWork @relation(fields: [workId], references: [id], onDelete: Cascade)
|
||||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
creatorUser User? @relation("ContestWorkAttachmentCreator", fields: [creator], references: [id], onDelete: SetNull)
|
creatorUser User? @relation("ContestWorkAttachmentCreator", fields: [creator], references: [id], onDelete: SetNull)
|
||||||
modifierUser User? @relation("ContestWorkAttachmentModifier", fields: [modifier], references: [id], onDelete: SetNull)
|
modifierUser User? @relation("ContestWorkAttachmentModifier", fields: [modifier], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@index([tenantId, contestId, workId])
|
@@index([tenantId, contestId, workId])
|
||||||
@@map("t_contest_work_attachment")
|
@@map("t_contest_work_attachment")
|
||||||
@ -835,22 +839,22 @@ model ContestWorkAttachment {
|
|||||||
|
|
||||||
/// 比赛评委关联表(比赛与评委的多对多关系)
|
/// 比赛评委关联表(比赛与评委的多对多关系)
|
||||||
model ContestJudge {
|
model ContestJudge {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
contestId Int @map("contest_id") /// 比赛id
|
contestId Int @map("contest_id") /// 比赛id
|
||||||
judgeId Int @map("judge_id") /// 评委用户id
|
judgeId Int @map("judge_id") /// 评委用户id
|
||||||
specialty String? /// 评审专业领域(可选)
|
specialty String? /// 评审专业领域(可选)
|
||||||
weight Decimal? @db.Decimal(3, 2) /// 评审权重(可选,用于加权平均计算)
|
weight Decimal? @db.Decimal(3, 2) /// 评审权重(可选,用于加权平均计算)
|
||||||
description String? @db.Text /// 评委在该比赛中的说明
|
description String? @db.Text /// 评委在该比赛中的说明
|
||||||
creator Int? /// 创建人ID
|
creator Int? /// 创建人ID
|
||||||
modifier Int? /// 修改人ID
|
modifier Int? /// 修改人ID
|
||||||
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
||||||
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
|
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
|
||||||
validState Int @default(1) @map("valid_state") /// 有效状态:1-有效,2-失效
|
validState Int @default(1) @map("valid_state") /// 有效状态:1-有效,2-失效
|
||||||
|
|
||||||
contest Contest @relation(fields: [contestId], references: [id], onDelete: Cascade)
|
contest Contest @relation(fields: [contestId], references: [id], onDelete: Cascade)
|
||||||
judge User @relation("ContestJudgeUser", fields: [judgeId], references: [id], onDelete: Cascade)
|
judge User @relation("ContestJudgeUser", fields: [judgeId], references: [id], onDelete: Cascade)
|
||||||
creatorUser User? @relation("ContestJudgeCreator", fields: [creator], references: [id], onDelete: SetNull)
|
creatorUser User? @relation("ContestJudgeCreator", fields: [creator], references: [id], onDelete: SetNull)
|
||||||
modifierUser User? @relation("ContestJudgeModifier", fields: [modifier], references: [id], onDelete: SetNull)
|
modifierUser User? @relation("ContestJudgeModifier", fields: [modifier], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@unique([contestId, judgeId])
|
@@unique([contestId, judgeId])
|
||||||
@@index([contestId])
|
@@index([contestId])
|
||||||
@ -860,23 +864,23 @@ model ContestJudge {
|
|||||||
|
|
||||||
/// 作品分配表(评委分配作品)
|
/// 作品分配表(评委分配作品)
|
||||||
model ContestWorkJudgeAssignment {
|
model ContestWorkJudgeAssignment {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
contestId Int @map("contest_id") /// 赛事id
|
contestId Int @map("contest_id") /// 赛事id
|
||||||
workId Int @map("work_id") /// 作品id
|
workId Int @map("work_id") /// 作品id
|
||||||
judgeId Int @map("judge_id") /// 评委用户id
|
judgeId Int @map("judge_id") /// 评委用户id
|
||||||
assignmentTime DateTime @default(now()) @map("assignment_time") /// 分配时间
|
assignmentTime DateTime @default(now()) @map("assignment_time") /// 分配时间
|
||||||
status String @default("assigned") /// 分配状态:assigned/reviewing/completed
|
status String @default("assigned") /// 分配状态:assigned/reviewing/completed
|
||||||
creator Int? /// 创建人ID
|
creator Int? /// 创建人ID
|
||||||
modifier Int? /// 修改人ID
|
modifier Int? /// 修改人ID
|
||||||
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
||||||
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
|
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
|
||||||
|
|
||||||
contest Contest @relation("ContestWorkJudgeAssignmentContest", fields: [contestId], references: [id], onDelete: Cascade)
|
contest Contest @relation("ContestWorkJudgeAssignmentContest", fields: [contestId], references: [id], onDelete: Cascade)
|
||||||
work ContestWork @relation(fields: [workId], references: [id], onDelete: Cascade)
|
work ContestWork @relation(fields: [workId], references: [id], onDelete: Cascade)
|
||||||
judge User @relation("ContestWorkJudgeAssignmentJudge", fields: [judgeId], references: [id], onDelete: Restrict)
|
judge User @relation("ContestWorkJudgeAssignmentJudge", fields: [judgeId], references: [id], onDelete: Restrict)
|
||||||
scores ContestWorkScore[] /// 评分记录
|
scores ContestWorkScore[] /// 评分记录
|
||||||
creatorUser User? @relation("ContestWorkJudgeAssignmentCreator", fields: [creator], references: [id], onDelete: SetNull)
|
creatorUser User? @relation("ContestWorkJudgeAssignmentCreator", fields: [creator], references: [id], onDelete: SetNull)
|
||||||
modifierUser User? @relation("ContestWorkJudgeAssignmentModifier", fields: [modifier], references: [id], onDelete: SetNull)
|
modifierUser User? @relation("ContestWorkJudgeAssignmentModifier", fields: [modifier], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@unique([workId, judgeId])
|
@@unique([workId, judgeId])
|
||||||
@@index([contestId, judgeId])
|
@@index([contestId, judgeId])
|
||||||
@ -887,30 +891,30 @@ model ContestWorkJudgeAssignment {
|
|||||||
|
|
||||||
/// 作品评分表
|
/// 作品评分表
|
||||||
model ContestWorkScore {
|
model ContestWorkScore {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
tenantId Int @map("tenant_id") /// 所属租户ID
|
tenantId Int @map("tenant_id") /// 所属租户ID
|
||||||
contestId Int @map("contest_id") /// 赛事id
|
contestId Int @map("contest_id") /// 赛事id
|
||||||
workId Int @map("work_id") /// 作品id
|
workId Int @map("work_id") /// 作品id
|
||||||
assignmentId Int @map("assignment_id") /// 分配记录id
|
assignmentId Int @map("assignment_id") /// 分配记录id
|
||||||
judgeId Int @map("judge_id") /// 评委用户id
|
judgeId Int @map("judge_id") /// 评委用户id
|
||||||
judgeName String @map("judge_name") /// 评委姓名
|
judgeName String @map("judge_name") /// 评委姓名
|
||||||
dimensionScores Json @map("dimension_scores") /// 各维度评分JSON
|
dimensionScores Json @map("dimension_scores") /// 各维度评分JSON
|
||||||
totalScore Decimal @map("total_score") @db.Decimal(10, 2) /// 总分
|
totalScore Decimal @map("total_score") @db.Decimal(10, 2) /// 总分
|
||||||
comments String? @db.Text /// 评语
|
comments String? @db.Text /// 评语
|
||||||
scoreTime DateTime @map("score_time") /// 评分时间
|
scoreTime DateTime @map("score_time") /// 评分时间
|
||||||
creator Int? /// 创建人ID
|
creator Int? /// 创建人ID
|
||||||
modifier Int? /// 修改人ID
|
modifier Int? /// 修改人ID
|
||||||
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
||||||
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
|
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
|
||||||
validState Int @default(1) @map("valid_state") /// 有效状态:1-有效,2-失效
|
validState Int @default(1) @map("valid_state") /// 有效状态:1-有效,2-失效
|
||||||
|
|
||||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
contest Contest @relation("ContestWorkScoreContest", fields: [contestId], references: [id], onDelete: Cascade)
|
contest Contest @relation("ContestWorkScoreContest", fields: [contestId], references: [id], onDelete: Cascade)
|
||||||
work ContestWork @relation(fields: [workId], references: [id], onDelete: Cascade)
|
work ContestWork @relation(fields: [workId], references: [id], onDelete: Cascade)
|
||||||
assignment ContestWorkJudgeAssignment @relation(fields: [assignmentId], references: [id], onDelete: Restrict)
|
assignment ContestWorkJudgeAssignment @relation(fields: [assignmentId], references: [id], onDelete: Restrict)
|
||||||
judge User @relation("ContestWorkScoreJudge", fields: [judgeId], references: [id], onDelete: Restrict)
|
judge User @relation("ContestWorkScoreJudge", fields: [judgeId], references: [id], onDelete: Restrict)
|
||||||
creatorUser User? @relation("ContestWorkScoreCreator", fields: [creator], references: [id], onDelete: SetNull)
|
creatorUser User? @relation("ContestWorkScoreCreator", fields: [creator], references: [id], onDelete: SetNull)
|
||||||
modifierUser User? @relation("ContestWorkScoreModifier", fields: [modifier], references: [id], onDelete: SetNull)
|
modifierUser User? @relation("ContestWorkScoreModifier", fields: [modifier], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@index([contestId, workId, judgeId])
|
@@index([contestId, workId, judgeId])
|
||||||
@@index([workId])
|
@@index([workId])
|
||||||
@ -933,8 +937,8 @@ model ContestNotice {
|
|||||||
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
|
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
|
||||||
validState Int @default(1) @map("valid_state") /// 有效状态:1-有效,2-失效
|
validState Int @default(1) @map("valid_state") /// 有效状态:1-有效,2-失效
|
||||||
|
|
||||||
contest Contest @relation(fields: [contestId], references: [id], onDelete: Cascade)
|
contest Contest @relation(fields: [contestId], references: [id], onDelete: Cascade)
|
||||||
creatorUser User? @relation("ContestNoticeCreator", fields: [creator], references: [id], onDelete: SetNull)
|
creatorUser User? @relation("ContestNoticeCreator", fields: [creator], references: [id], onDelete: SetNull)
|
||||||
modifierUser User? @relation("ContestNoticeModifier", fields: [modifier], references: [id], onDelete: SetNull)
|
modifierUser User? @relation("ContestNoticeModifier", fields: [modifier], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@index([contestId])
|
@@index([contestId])
|
||||||
@ -951,26 +955,26 @@ model ContestNotice {
|
|||||||
model Homework {
|
model Homework {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
tenantId Int @map("tenant_id") /// 租户ID
|
tenantId Int @map("tenant_id") /// 租户ID
|
||||||
name String /// 作业名称
|
name String /// 作业名称
|
||||||
content String? @db.Text /// 作业内容(富文本)
|
content String? @db.Text /// 作业内容(富文本)
|
||||||
status String @default("unpublished") /// 作业状态:unpublished/published
|
status String @default("unpublished") /// 作业状态:unpublished/published
|
||||||
publishTime DateTime? @map("publish_time") /// 发布时间
|
publishTime DateTime? @map("publish_time") /// 发布时间
|
||||||
submitStartTime DateTime @map("submit_start_time") /// 提交开始时间
|
submitStartTime DateTime @map("submit_start_time") /// 提交开始时间
|
||||||
submitEndTime DateTime @map("submit_end_time") /// 提交结束时间
|
submitEndTime DateTime @map("submit_end_time") /// 提交结束时间
|
||||||
attachments Json? /// 附件列表 [{fileName, fileUrl, size}]
|
attachments Json? /// 附件列表 [{fileName, fileUrl, size}]
|
||||||
publishScope Json? @map("publish_scope") /// 公开范围(班级ID数组)
|
publishScope Json? @map("publish_scope") /// 公开范围(班级ID数组)
|
||||||
reviewRuleId Int? @map("review_rule_id") /// 评审规则ID
|
reviewRuleId Int? @map("review_rule_id") /// 评审规则ID
|
||||||
creator Int? /// 创建人ID
|
creator Int? /// 创建人ID
|
||||||
modifier Int? /// 修改人ID
|
modifier Int? /// 修改人ID
|
||||||
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
||||||
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
|
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
|
||||||
validState Int @default(1) @map("valid_state") /// 有效状态:1-有效,2-失效
|
validState Int @default(1) @map("valid_state") /// 有效状态:1-有效,2-失效
|
||||||
|
|
||||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
reviewRule HomeworkReviewRule? @relation(fields: [reviewRuleId], references: [id], onDelete: SetNull)
|
reviewRule HomeworkReviewRule? @relation(fields: [reviewRuleId], references: [id], onDelete: SetNull)
|
||||||
submissions HomeworkSubmission[] /// 作业提交记录
|
submissions HomeworkSubmission[] /// 作业提交记录
|
||||||
creatorUser User? @relation("HomeworkCreator", fields: [creator], references: [id], onDelete: SetNull)
|
creatorUser User? @relation("HomeworkCreator", fields: [creator], references: [id], onDelete: SetNull)
|
||||||
modifierUser User? @relation("HomeworkModifier", fields: [modifier], references: [id], onDelete: SetNull)
|
modifierUser User? @relation("HomeworkModifier", fields: [modifier], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@index([tenantId, status])
|
@@index([tenantId, status])
|
||||||
@@index([tenantId, submitStartTime, submitEndTime])
|
@@index([tenantId, submitStartTime, submitEndTime])
|
||||||
@ -979,23 +983,23 @@ model Homework {
|
|||||||
|
|
||||||
/// 作业提交表
|
/// 作业提交表
|
||||||
model HomeworkSubmission {
|
model HomeworkSubmission {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
tenantId Int @map("tenant_id") /// 租户ID
|
tenantId Int @map("tenant_id") /// 租户ID
|
||||||
homeworkId Int @map("homework_id") /// 作业ID
|
homeworkId Int @map("homework_id") /// 作业ID
|
||||||
studentId Int @map("student_id") /// 学生用户ID
|
studentId Int @map("student_id") /// 学生用户ID
|
||||||
workNo String? @map("work_no") /// 作品编号
|
workNo String? @map("work_no") /// 作品编号
|
||||||
workName String @map("work_name") /// 作品名称
|
workName String @map("work_name") /// 作品名称
|
||||||
workDescription String? @db.Text @map("work_description") /// 作品介绍
|
workDescription String? @map("work_description") @db.Text /// 作品介绍
|
||||||
files Json? /// 作品文件列表
|
files Json? /// 作品文件列表
|
||||||
attachments Json? /// 附件列表
|
attachments Json? /// 附件列表
|
||||||
submitTime DateTime @default(now()) @map("submit_time") /// 提交时间
|
submitTime DateTime @default(now()) @map("submit_time") /// 提交时间
|
||||||
status String @default("pending") /// 状态:pending/reviewed/rejected
|
status String @default("pending") /// 状态:pending/reviewed/rejected
|
||||||
totalScore Decimal? @map("total_score") @db.Decimal(10, 2) /// 总分
|
totalScore Decimal? @map("total_score") @db.Decimal(10, 2) /// 总分
|
||||||
creator Int? /// 创建人ID
|
creator Int? /// 创建人ID
|
||||||
modifier Int? /// 修改人ID
|
modifier Int? /// 修改人ID
|
||||||
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
||||||
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
|
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
|
||||||
validState Int @default(1) @map("valid_state") /// 有效状态:1-有效,2-失效
|
validState Int @default(1) @map("valid_state") /// 有效状态:1-有效,2-失效
|
||||||
|
|
||||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
homework Homework @relation(fields: [homeworkId], references: [id], onDelete: Cascade)
|
homework Homework @relation(fields: [homeworkId], references: [id], onDelete: Cascade)
|
||||||
@ -1013,16 +1017,16 @@ model HomeworkSubmission {
|
|||||||
|
|
||||||
/// 作业评审规则表
|
/// 作业评审规则表
|
||||||
model HomeworkReviewRule {
|
model HomeworkReviewRule {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
tenantId Int @map("tenant_id") /// 租户ID
|
tenantId Int @map("tenant_id") /// 租户ID
|
||||||
name String /// 规则名称
|
name String /// 规则名称
|
||||||
description String? @db.Text /// 规则描述
|
description String? @db.Text /// 规则描述
|
||||||
criteria Json /// 评分标准 [{name, maxScore, description}]
|
criteria Json /// 评分标准 [{name, maxScore, description}]
|
||||||
creator Int? /// 创建人ID
|
creator Int? /// 创建人ID
|
||||||
modifier Int? /// 修改人ID
|
modifier Int? /// 修改人ID
|
||||||
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
||||||
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
|
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
|
||||||
validState Int @default(1) @map("valid_state") /// 有效状态:1-有效,2-失效
|
validState Int @default(1) @map("valid_state") /// 有效状态:1-有效,2-失效
|
||||||
|
|
||||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
homeworks Homework[] /// 关联的作业
|
homeworks Homework[] /// 关联的作业
|
||||||
@ -1035,19 +1039,19 @@ model HomeworkReviewRule {
|
|||||||
|
|
||||||
/// 作业评分表
|
/// 作业评分表
|
||||||
model HomeworkScore {
|
model HomeworkScore {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
tenantId Int @map("tenant_id") /// 租户ID
|
tenantId Int @map("tenant_id") /// 租户ID
|
||||||
submissionId Int @map("submission_id") /// 提交记录ID
|
submissionId Int @map("submission_id") /// 提交记录ID
|
||||||
reviewerId Int @map("reviewer_id") /// 评审人ID
|
reviewerId Int @map("reviewer_id") /// 评审人ID
|
||||||
dimensionScores Json @map("dimension_scores") /// 各维度评分
|
dimensionScores Json @map("dimension_scores") /// 各维度评分
|
||||||
totalScore Decimal @map("total_score") @db.Decimal(10, 2) /// 总分
|
totalScore Decimal @map("total_score") @db.Decimal(10, 2) /// 总分
|
||||||
comments String? @db.Text /// 评语
|
comments String? @db.Text /// 评语
|
||||||
scoreTime DateTime @default(now()) @map("score_time") /// 评分时间
|
scoreTime DateTime @default(now()) @map("score_time") /// 评分时间
|
||||||
creator Int? /// 创建人ID
|
creator Int? /// 创建人ID
|
||||||
modifier Int? /// 修改人ID
|
modifier Int? /// 修改人ID
|
||||||
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
||||||
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
|
modifyTime DateTime @updatedAt @map("modify_time") /// 更新时间
|
||||||
validState Int @default(1) @map("valid_state") /// 有效状态:1-有效,2-失效
|
validState Int @default(1) @map("valid_state") /// 有效状态:1-有效,2-失效
|
||||||
|
|
||||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
submission HomeworkSubmission @relation(fields: [submissionId], references: [id], onDelete: Cascade)
|
submission HomeworkSubmission @relation(fields: [submissionId], references: [id], onDelete: Cascade)
|
||||||
@ -1060,3 +1064,33 @@ model HomeworkScore {
|
|||||||
@@index([reviewerId])
|
@@index([reviewerId])
|
||||||
@@map("t_homework_score")
|
@@map("t_homework_score")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// AI 3D 模型生成模块
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/// AI 3D 生成任务表
|
||||||
|
model AI3DTask {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
tenantId Int @map("tenant_id") /// 租户ID
|
||||||
|
userId Int @map("user_id") /// 用户ID(任务归属用户)
|
||||||
|
inputType String @map("input_type") /// 输入类型:text | image
|
||||||
|
inputContent String @map("input_content") @db.Text /// 输入内容:文字描述或图片URL
|
||||||
|
status String @default("pending") /// 任务状态:pending | processing | completed | failed | timeout
|
||||||
|
resultUrl String? @map("result_url") /// 生成的3D模型URL
|
||||||
|
previewUrl String? @map("preview_url") /// 预览图URL
|
||||||
|
errorMessage String? @map("error_message") @db.Text /// 失败时的错误信息
|
||||||
|
externalTaskId String? @map("external_task_id") /// 外部AI服务的任务ID
|
||||||
|
retryCount Int @default(0) @map("retry_count") /// 已重试次数
|
||||||
|
createTime DateTime @default(now()) @map("create_time") /// 创建时间
|
||||||
|
completeTime DateTime? @map("complete_time") /// 完成时间
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation("AI3DTaskUser", fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([status])
|
||||||
|
@@index([createTime])
|
||||||
|
@@map("t_ai_3d_task")
|
||||||
|
}
|
||||||
|
|||||||
@ -44,8 +44,8 @@ if (!fs.existsSync(menusFilePath)) {
|
|||||||
|
|
||||||
const menus = JSON.parse(fs.readFileSync(menusFilePath, 'utf-8'));
|
const menus = JSON.parse(fs.readFileSync(menusFilePath, 'utf-8'));
|
||||||
|
|
||||||
// 超级租户可见的菜单名称
|
// 超级租户可见的菜单名称(工作台只对普通租户可见)
|
||||||
const SUPER_TENANT_MENUS = ['工作台', '赛事活动', '赛事管理', '系统管理'];
|
const SUPER_TENANT_MENUS = ['赛事活动', '赛事管理', '系统管理'];
|
||||||
|
|
||||||
// 普通租户可见的菜单名称
|
// 普通租户可见的菜单名称
|
||||||
const NORMAL_TENANT_MENUS = ['工作台', '学校管理', '赛事活动', '作业管理', '系统管理'];
|
const NORMAL_TENANT_MENUS = ['工作台', '学校管理', '赛事活动', '作业管理', '系统管理'];
|
||||||
|
|||||||
@ -29,8 +29,9 @@ const prisma = new PrismaClient();
|
|||||||
|
|
||||||
// 基础权限(所有角色共享的权限池)
|
// 基础权限(所有角色共享的权限池)
|
||||||
const allPermissions = [
|
const allPermissions = [
|
||||||
// 工作台
|
// AI 3D建模
|
||||||
{ code: 'workbench:read', resource: 'workbench', action: 'read', name: '查看工作台', description: '允许查看工作台' },
|
{ code: 'ai-3d:read', resource: 'ai-3d', action: 'read', name: '使用3D建模实验室', description: '允许使用AI 3D建模实验室' },
|
||||||
|
{ code: 'ai-3d:create', resource: 'ai-3d', action: 'create', name: '创建3D模型任务', description: '允许创建AI 3D模型生成任务' },
|
||||||
|
|
||||||
// 用户管理
|
// 用户管理
|
||||||
{ code: 'user:create', resource: 'user', action: 'create', name: '创建用户', description: '允许创建新用户' },
|
{ code: 'user:create', resource: 'user', action: 'create', name: '创建用户', description: '允许创建新用户' },
|
||||||
@ -192,8 +193,6 @@ const superTenantRoles = [
|
|||||||
name: '超级管理员',
|
name: '超级管理员',
|
||||||
description: '系统超级管理员,管理赛事和系统配置',
|
description: '系统超级管理员,管理赛事和系统配置',
|
||||||
permissions: [
|
permissions: [
|
||||||
// 工作台
|
|
||||||
'workbench:read',
|
|
||||||
// 系统管理
|
// 系统管理
|
||||||
'user:create', 'user:read', 'user:update', 'user:delete',
|
'user:create', 'user:read', 'user:update', 'user:delete',
|
||||||
'role:create', 'role:read', 'role:update', 'role:delete', 'role:assign',
|
'role:create', 'role:read', 'role:update', 'role:delete', 'role:assign',
|
||||||
@ -217,7 +216,6 @@ const superTenantRoles = [
|
|||||||
name: '评委',
|
name: '评委',
|
||||||
description: '赛事评委,可以评审作品',
|
description: '赛事评委,可以评审作品',
|
||||||
permissions: [
|
permissions: [
|
||||||
'workbench:read',
|
|
||||||
'activity:read', // 查看赛事活动
|
'activity:read', // 查看赛事活动
|
||||||
'work:read', // 查看待评审作品
|
'work:read', // 查看待评审作品
|
||||||
'review:read', // 查看评审任务
|
'review:read', // 查看评审任务
|
||||||
@ -234,7 +232,6 @@ const normalTenantRoles = [
|
|||||||
name: '学校管理员',
|
name: '学校管理员',
|
||||||
description: '学校管理员,管理学校信息、教师、学生等',
|
description: '学校管理员,管理学校信息、教师、学生等',
|
||||||
permissions: [
|
permissions: [
|
||||||
'workbench:read',
|
|
||||||
'user:create', 'user:read', 'user:update', 'user:delete',
|
'user:create', 'user:read', 'user:update', 'user:delete',
|
||||||
'role:create', 'role:read', 'role:update', 'role:delete', 'role:assign',
|
'role:create', 'role:read', 'role:update', 'role:delete', 'role:assign',
|
||||||
'permission:read',
|
'permission:read',
|
||||||
@ -259,7 +256,8 @@ const normalTenantRoles = [
|
|||||||
name: '教师',
|
name: '教师',
|
||||||
description: '教师角色,可以报名赛事、指导学生、管理作业',
|
description: '教师角色,可以报名赛事、指导学生、管理作业',
|
||||||
permissions: [
|
permissions: [
|
||||||
'workbench:read',
|
// AI 3D建模(工作台入口)
|
||||||
|
'ai-3d:read', 'ai-3d:create', // 使用AI 3D建模实验室
|
||||||
// 查看基础信息
|
// 查看基础信息
|
||||||
'grade:read',
|
'grade:read',
|
||||||
'class:read',
|
'class:read',
|
||||||
@ -282,7 +280,8 @@ const normalTenantRoles = [
|
|||||||
name: '学生',
|
name: '学生',
|
||||||
description: '学生角色,可以查看赛事、上传作品、提交作业',
|
description: '学生角色,可以查看赛事、上传作品、提交作业',
|
||||||
permissions: [
|
permissions: [
|
||||||
'workbench:read',
|
// AI 3D建模(工作台入口)
|
||||||
|
'ai-3d:read', 'ai-3d:create', // 使用AI 3D建模实验室
|
||||||
// 赛事活动
|
// 赛事活动
|
||||||
'activity:read', // 查看赛事活动列表
|
'activity:read', // 查看赛事活动列表
|
||||||
'notice:read', // 查看赛事公告
|
'notice:read', // 查看赛事公告
|
||||||
|
|||||||
@ -1,78 +0,0 @@
|
|||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-nocheck
|
|
||||||
import * as dotenv from 'dotenv';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
const nodeEnv = process.env.NODE_ENV || 'development';
|
|
||||||
const envFile = `.env.${nodeEnv}`;
|
|
||||||
const backendDir = path.resolve(__dirname, '..');
|
|
||||||
const envPath = path.resolve(backendDir, envFile);
|
|
||||||
|
|
||||||
dotenv.config({ path: envPath });
|
|
||||||
|
|
||||||
if (!process.env.DATABASE_URL) {
|
|
||||||
dotenv.config({ path: path.resolve(backendDir, '.env') });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!process.env.DATABASE_URL) {
|
|
||||||
console.error('DATABASE_URL not found');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
async function updateTenantMenuPermission() {
|
|
||||||
try {
|
|
||||||
console.log('🚀 开始更新租户管理菜单权限...\n');
|
|
||||||
|
|
||||||
// 查找租户管理菜单
|
|
||||||
const tenantMenu = await prisma.menu.findFirst({
|
|
||||||
where: {
|
|
||||||
name: '租户管理',
|
|
||||||
path: '/system/tenants',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!tenantMenu) {
|
|
||||||
console.log('❌ 租户管理菜单不存在');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`找到租户管理菜单: ID=${tenantMenu.id}, 当前权限=${tenantMenu.permission}`);
|
|
||||||
|
|
||||||
if (tenantMenu.permission === 'tenant:update') {
|
|
||||||
console.log('✅ 菜单权限已经是 tenant:update,无需更新');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新菜单权限
|
|
||||||
await prisma.menu.update({
|
|
||||||
where: { id: tenantMenu.id },
|
|
||||||
data: {
|
|
||||||
permission: 'tenant:update',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('✅ 菜单权限已更新为 tenant:update');
|
|
||||||
console.log('\n说明:');
|
|
||||||
console.log(' - 普通租户只有 tenant:read 权限,可以读取租户列表(用于发布赛事选择公开范围)');
|
|
||||||
console.log(' - 只有超级租户才有 tenant:update 权限,才能看到租户管理菜单');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ 更新失败:', error);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateTenantMenuPermission()
|
|
||||||
.then(() => {
|
|
||||||
console.log('\n🎉 脚本执行完成!');
|
|
||||||
process.exit(0);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('\n💥 脚本执行失败:', error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
77
backend/src/ai-3d/ai-3d.controller.ts
Normal file
77
backend/src/ai-3d/ai-3d.controller.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
Request,
|
||||||
|
ParseIntPipe,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AI3DService } from './ai-3d.service';
|
||||||
|
import { CreateTaskDto } from './dto/create-task.dto';
|
||||||
|
import { QueryTaskDto } from './dto/query-task.dto';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { CurrentTenantId } from '../auth/decorators/current-tenant-id.decorator';
|
||||||
|
|
||||||
|
@Controller('ai-3d')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class AI3DController {
|
||||||
|
constructor(private readonly ai3dService: AI3DService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建生成任务
|
||||||
|
* POST /api/ai-3d/generate
|
||||||
|
*/
|
||||||
|
@Post('generate')
|
||||||
|
createTask(
|
||||||
|
@Body() createTaskDto: CreateTaskDto,
|
||||||
|
@CurrentTenantId() tenantId: number,
|
||||||
|
@Request() req,
|
||||||
|
) {
|
||||||
|
const userId = req?.user?.userId;
|
||||||
|
return this.ai3dService.createTask(userId, tenantId, createTaskDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取任务列表
|
||||||
|
* GET /api/ai-3d/tasks
|
||||||
|
*/
|
||||||
|
@Get('tasks')
|
||||||
|
getTasks(@Query() queryDto: QueryTaskDto, @Request() req) {
|
||||||
|
const userId = req?.user?.userId;
|
||||||
|
return this.ai3dService.getTasks(userId, queryDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取任务详情
|
||||||
|
* GET /api/ai-3d/tasks/:id
|
||||||
|
*/
|
||||||
|
@Get('tasks/:id')
|
||||||
|
getTask(@Param('id', ParseIntPipe) id: number, @Request() req) {
|
||||||
|
const userId = req?.user?.userId;
|
||||||
|
return this.ai3dService.getTask(userId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重试任务
|
||||||
|
* POST /api/ai-3d/tasks/:id/retry
|
||||||
|
*/
|
||||||
|
@Post('tasks/:id/retry')
|
||||||
|
retryTask(@Param('id', ParseIntPipe) id: number, @Request() req) {
|
||||||
|
const userId = req?.user?.userId;
|
||||||
|
return this.ai3dService.retryTask(userId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除任务
|
||||||
|
* DELETE /api/ai-3d/tasks/:id
|
||||||
|
*/
|
||||||
|
@Delete('tasks/:id')
|
||||||
|
deleteTask(@Param('id', ParseIntPipe) id: number, @Request() req) {
|
||||||
|
const userId = req?.user?.userId;
|
||||||
|
return this.ai3dService.deleteTask(userId, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
backend/src/ai-3d/ai-3d.module.ts
Normal file
20
backend/src/ai-3d/ai-3d.module.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AI3DController } from './ai-3d.controller';
|
||||||
|
import { AI3DService } from './ai-3d.service';
|
||||||
|
import { MockAI3DProvider } from './providers/mock.provider';
|
||||||
|
import { AI3D_PROVIDER } from './providers/ai-3d-provider.interface';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
controllers: [AI3DController],
|
||||||
|
providers: [
|
||||||
|
AI3DService,
|
||||||
|
{
|
||||||
|
provide: AI3D_PROVIDER,
|
||||||
|
useClass: MockAI3DProvider,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [AI3DService],
|
||||||
|
})
|
||||||
|
export class AI3DModule {}
|
||||||
297
backend/src/ai-3d/ai-3d.service.ts
Normal file
297
backend/src/ai-3d/ai-3d.service.ts
Normal file
@ -0,0 +1,297 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
Inject,
|
||||||
|
NotFoundException,
|
||||||
|
BadRequestException,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { CreateTaskDto } from './dto/create-task.dto';
|
||||||
|
import { QueryTaskDto } from './dto/query-task.dto';
|
||||||
|
import { AI3DProvider, AI3D_PROVIDER } from './providers/ai-3d-provider.interface';
|
||||||
|
|
||||||
|
// 配置常量
|
||||||
|
const MAX_CONCURRENT_TASKS = 3; // 每用户最大并行任务数
|
||||||
|
const TASK_TIMEOUT_MS = 10 * 60 * 1000; // 10分钟超时
|
||||||
|
const MAX_RETRY_COUNT = 3; // 最大重试次数
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AI3DService {
|
||||||
|
private readonly logger = new Logger(AI3DService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private prisma: PrismaService,
|
||||||
|
@Inject(AI3D_PROVIDER) private ai3dProvider: AI3DProvider,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建生成任务
|
||||||
|
*/
|
||||||
|
async createTask(
|
||||||
|
userId: number,
|
||||||
|
tenantId: number,
|
||||||
|
dto: CreateTaskDto,
|
||||||
|
) {
|
||||||
|
// 1. 检查用户当前进行中的任务数量
|
||||||
|
const activeTaskCount = await this.prisma.aI3DTask.count({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
status: { in: ['pending', 'processing'] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (activeTaskCount >= MAX_CONCURRENT_TASKS) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`您当前有 ${activeTaskCount} 个任务正在处理中,最多同时处理 ${MAX_CONCURRENT_TASKS} 个任务,请等待完成后再提交`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 创建数据库记录
|
||||||
|
const task = await this.prisma.aI3DTask.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
tenantId,
|
||||||
|
inputType: dto.inputType,
|
||||||
|
inputContent: dto.inputContent,
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. 提交到 AI 服务
|
||||||
|
try {
|
||||||
|
const externalTaskId = await this.ai3dProvider.submitTask(
|
||||||
|
dto.inputType,
|
||||||
|
dto.inputContent,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. 更新状态为处理中
|
||||||
|
await this.prisma.aI3DTask.update({
|
||||||
|
where: { id: task.id },
|
||||||
|
data: {
|
||||||
|
status: 'processing',
|
||||||
|
externalTaskId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. 启动轮询检查任务状态
|
||||||
|
this.pollTaskStatus(task.id, externalTaskId, Date.now());
|
||||||
|
|
||||||
|
this.logger.log(`任务 ${task.id} 创建成功,外部ID: ${externalTaskId}`);
|
||||||
|
|
||||||
|
return this.getTask(userId, task.id);
|
||||||
|
} catch (error) {
|
||||||
|
// 提交失败,更新状态
|
||||||
|
await this.prisma.aI3DTask.update({
|
||||||
|
where: { id: task.id },
|
||||||
|
data: {
|
||||||
|
status: 'failed',
|
||||||
|
errorMessage: error.message || 'AI服务提交失败',
|
||||||
|
completeTime: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.error(`任务 ${task.id} 提交失败: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取任务列表
|
||||||
|
*/
|
||||||
|
async getTasks(userId: number, query: QueryTaskDto) {
|
||||||
|
const { page = 1, pageSize = 10, status } = query;
|
||||||
|
|
||||||
|
const where: any = { userId };
|
||||||
|
if (status) {
|
||||||
|
where.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [list, total] = await Promise.all([
|
||||||
|
this.prisma.aI3DTask.findMany({
|
||||||
|
where,
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
orderBy: { createTime: 'desc' },
|
||||||
|
}),
|
||||||
|
this.prisma.aI3DTask.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
list,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取任务详情
|
||||||
|
*/
|
||||||
|
async getTask(userId: number, id: number) {
|
||||||
|
const task = await this.prisma.aI3DTask.findFirst({
|
||||||
|
where: { id, userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
throw new NotFoundException('任务不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除任务
|
||||||
|
*/
|
||||||
|
async deleteTask(userId: number, id: number) {
|
||||||
|
const task = await this.getTask(userId, id);
|
||||||
|
|
||||||
|
await this.prisma.aI3DTask.delete({
|
||||||
|
where: { id: task.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`任务 ${id} 已删除`);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重试任务
|
||||||
|
*/
|
||||||
|
async retryTask(userId: number, id: number) {
|
||||||
|
const task = await this.prisma.aI3DTask.findFirst({
|
||||||
|
where: { id, userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
throw new NotFoundException('任务不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只有失败或超时的任务可以重试
|
||||||
|
if (!['failed', 'timeout'].includes(task.status)) {
|
||||||
|
throw new BadRequestException('只有失败或超时的任务可以重试');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查重试次数
|
||||||
|
if (task.retryCount >= MAX_RETRY_COUNT) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`已达到最大重试次数 ${MAX_RETRY_COUNT} 次,请创建新任务`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查并发限制
|
||||||
|
const activeTaskCount = await this.prisma.aI3DTask.count({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
status: { in: ['pending', 'processing'] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (activeTaskCount >= MAX_CONCURRENT_TASKS) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`您当前有 ${activeTaskCount} 个任务正在处理中,请等待完成后再重试`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置任务状态
|
||||||
|
await this.prisma.aI3DTask.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: 'pending',
|
||||||
|
errorMessage: null,
|
||||||
|
completeTime: null,
|
||||||
|
retryCount: { increment: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 重新提交任务
|
||||||
|
try {
|
||||||
|
const externalTaskId = await this.ai3dProvider.submitTask(
|
||||||
|
task.inputType as 'text' | 'image',
|
||||||
|
task.inputContent,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.prisma.aI3DTask.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: 'processing',
|
||||||
|
externalTaskId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.pollTaskStatus(id, externalTaskId, Date.now());
|
||||||
|
|
||||||
|
this.logger.log(`任务 ${id} 重试成功,外部ID: ${externalTaskId}`);
|
||||||
|
|
||||||
|
return this.getTask(userId, id);
|
||||||
|
} catch (error) {
|
||||||
|
await this.prisma.aI3DTask.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: 'failed',
|
||||||
|
errorMessage: error.message || 'AI服务提交失败',
|
||||||
|
completeTime: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.error(`任务 ${id} 重试失败: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轮询检查任务状态
|
||||||
|
*/
|
||||||
|
private async pollTaskStatus(
|
||||||
|
taskId: number,
|
||||||
|
externalTaskId: string,
|
||||||
|
startTime: number,
|
||||||
|
) {
|
||||||
|
const checkStatus = async () => {
|
||||||
|
// 1. 检查是否超时
|
||||||
|
if (Date.now() - startTime > TASK_TIMEOUT_MS) {
|
||||||
|
await this.prisma.aI3DTask.update({
|
||||||
|
where: { id: taskId },
|
||||||
|
data: {
|
||||||
|
status: 'timeout',
|
||||||
|
errorMessage: '任务处理超时,请重试',
|
||||||
|
completeTime: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.logger.warn(`任务 ${taskId} 超时`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 查询外部任务状态
|
||||||
|
try {
|
||||||
|
const result = await this.ai3dProvider.queryTask(externalTaskId);
|
||||||
|
|
||||||
|
if (result.status === 'completed' || result.status === 'failed') {
|
||||||
|
await this.prisma.aI3DTask.update({
|
||||||
|
where: { id: taskId },
|
||||||
|
data: {
|
||||||
|
status: result.status,
|
||||||
|
resultUrl: result.resultUrl,
|
||||||
|
previewUrl: result.previewUrl,
|
||||||
|
errorMessage: result.errorMessage,
|
||||||
|
completeTime: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`任务 ${taskId} ${result.status === 'completed' ? '完成' : '失败'}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 继续轮询,每2秒检查一次
|
||||||
|
setTimeout(checkStatus, 2000);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`轮询任务 ${taskId} 状态出错: ${error.message}`);
|
||||||
|
// 出错后延长轮询间隔,每5秒重试
|
||||||
|
setTimeout(checkStatus, 5000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 首次检查延迟2秒
|
||||||
|
setTimeout(checkStatus, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
backend/src/ai-3d/dto/create-task.dto.ts
Normal file
12
backend/src/ai-3d/dto/create-task.dto.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { IsString, IsIn, IsNotEmpty, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateTaskDto {
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['text', 'image'], { message: '输入类型必须是 text 或 image' })
|
||||||
|
inputType: 'text' | 'image';
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: '输入内容不能为空' })
|
||||||
|
@MaxLength(2000, { message: '输入内容最多2000个字符' })
|
||||||
|
inputContent: string;
|
||||||
|
}
|
||||||
23
backend/src/ai-3d/dto/query-task.dto.ts
Normal file
23
backend/src/ai-3d/dto/query-task.dto.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { IsOptional, IsString, IsInt, Min, IsIn } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class QueryTaskDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
pageSize?: number = 10;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['pending', 'processing', 'completed', 'failed', 'timeout'], {
|
||||||
|
message: '状态必须是 pending、processing、completed、failed 或 timeout',
|
||||||
|
})
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
39
backend/src/ai-3d/providers/ai-3d-provider.interface.ts
Normal file
39
backend/src/ai-3d/providers/ai-3d-provider.interface.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* AI 3D 生成结果
|
||||||
|
*/
|
||||||
|
export interface AI3DGenerateResult {
|
||||||
|
taskId: string; // 外部任务ID
|
||||||
|
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||||
|
resultUrl?: string; // 3D模型URL
|
||||||
|
previewUrl?: string; // 预览图URL
|
||||||
|
errorMessage?: string; // 错误信息
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 3D 服务提供者接口
|
||||||
|
* 支持 Mock、腾讯混元、Meshy 等实现
|
||||||
|
*/
|
||||||
|
export interface AI3DProvider {
|
||||||
|
/**
|
||||||
|
* 提交生成任务
|
||||||
|
* @param inputType 输入类型:text | image
|
||||||
|
* @param inputContent 输入内容:文字描述或图片URL
|
||||||
|
* @returns 外部任务ID
|
||||||
|
*/
|
||||||
|
submitTask(
|
||||||
|
inputType: 'text' | 'image',
|
||||||
|
inputContent: string,
|
||||||
|
): Promise<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询任务状态
|
||||||
|
* @param taskId 外部任务ID
|
||||||
|
* @returns 任务状态和结果
|
||||||
|
*/
|
||||||
|
queryTask(taskId: string): Promise<AI3DGenerateResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 3D Provider 注入令牌
|
||||||
|
*/
|
||||||
|
export const AI3D_PROVIDER = 'AI3D_PROVIDER';
|
||||||
135
backend/src/ai-3d/providers/mock.provider.ts
Normal file
135
backend/src/ai-3d/providers/mock.provider.ts
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { AI3DProvider, AI3DGenerateResult } from './ai-3d-provider.interface';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
interface MockTask {
|
||||||
|
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||||
|
startTime: number;
|
||||||
|
inputType: string;
|
||||||
|
inputContent: string;
|
||||||
|
resultUrl?: string;
|
||||||
|
previewUrl?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock AI 3D Provider
|
||||||
|
* 用于开发阶段模拟 AI 3D 生成服务
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class MockAI3DProvider implements AI3DProvider {
|
||||||
|
private readonly logger = new Logger(MockAI3DProvider.name);
|
||||||
|
private tasks = new Map<string, MockTask>();
|
||||||
|
|
||||||
|
// 模拟完成时间范围(毫秒)
|
||||||
|
private readonly MIN_COMPLETION_TIME = 5000; // 5秒
|
||||||
|
private readonly MAX_COMPLETION_TIME = 15000; // 15秒
|
||||||
|
|
||||||
|
// 模拟成功率
|
||||||
|
private readonly SUCCESS_RATE = 0.9; // 90% 成功率
|
||||||
|
|
||||||
|
// 示例 3D 模型 URL(使用公开的 GLB 文件)
|
||||||
|
private readonly SAMPLE_MODELS = [
|
||||||
|
'/mock/models/sample-cube.glb',
|
||||||
|
'/mock/models/sample-sphere.glb',
|
||||||
|
'/mock/models/sample-model.glb',
|
||||||
|
];
|
||||||
|
|
||||||
|
async submitTask(
|
||||||
|
inputType: 'text' | 'image',
|
||||||
|
inputContent: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const taskId = uuidv4();
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Mock: 创建任务 ${taskId}, 类型: ${inputType}, 内容: ${inputContent.substring(0, 50)}...`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 创建任务记录
|
||||||
|
this.tasks.set(taskId, {
|
||||||
|
status: 'processing',
|
||||||
|
startTime: Date.now(),
|
||||||
|
inputType,
|
||||||
|
inputContent,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 模拟异步完成
|
||||||
|
const completionTime =
|
||||||
|
this.MIN_COMPLETION_TIME +
|
||||||
|
Math.random() * (this.MAX_COMPLETION_TIME - this.MIN_COMPLETION_TIME);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.completeTask(taskId);
|
||||||
|
}, completionTime);
|
||||||
|
|
||||||
|
return taskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryTask(taskId: string): Promise<AI3DGenerateResult> {
|
||||||
|
const task = this.tasks.get(taskId);
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
this.logger.warn(`Mock: 任务 ${taskId} 不存在`);
|
||||||
|
return {
|
||||||
|
taskId,
|
||||||
|
status: 'failed',
|
||||||
|
errorMessage: '任务不存在',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
taskId,
|
||||||
|
status: task.status,
|
||||||
|
resultUrl: task.resultUrl,
|
||||||
|
previewUrl: task.previewUrl,
|
||||||
|
errorMessage: task.errorMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模拟任务完成
|
||||||
|
*/
|
||||||
|
private completeTask(taskId: string): void {
|
||||||
|
const task = this.tasks.get(taskId);
|
||||||
|
if (!task) return;
|
||||||
|
|
||||||
|
// 根据成功率决定是否成功
|
||||||
|
const isSuccess = Math.random() < this.SUCCESS_RATE;
|
||||||
|
|
||||||
|
if (isSuccess) {
|
||||||
|
// 随机选择一个示例模型
|
||||||
|
const modelIndex = Math.floor(Math.random() * this.SAMPLE_MODELS.length);
|
||||||
|
const modelUrl = this.SAMPLE_MODELS[modelIndex];
|
||||||
|
|
||||||
|
task.status = 'completed';
|
||||||
|
task.resultUrl = modelUrl;
|
||||||
|
task.previewUrl = modelUrl.replace('.glb', '-preview.png');
|
||||||
|
|
||||||
|
this.logger.log(`Mock: 任务 ${taskId} 完成, 模型: ${modelUrl}`);
|
||||||
|
} else {
|
||||||
|
task.status = 'failed';
|
||||||
|
task.errorMessage = '模拟生成失败:AI 服务暂时不可用';
|
||||||
|
|
||||||
|
this.logger.warn(`Mock: 任务 ${taskId} 失败`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.tasks.set(taskId, task);
|
||||||
|
|
||||||
|
// 清理过期任务(保留1小时)
|
||||||
|
this.cleanupOldTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理超过1小时的任务记录
|
||||||
|
*/
|
||||||
|
private cleanupOldTasks(): void {
|
||||||
|
const oneHourAgo = Date.now() - 60 * 60 * 1000;
|
||||||
|
|
||||||
|
for (const [taskId, task] of this.tasks.entries()) {
|
||||||
|
if (task.startTime < oneHourAgo) {
|
||||||
|
this.tasks.delete(taskId);
|
||||||
|
this.logger.debug(`Mock: 清理过期任务 ${taskId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,6 +17,7 @@ import { JudgesManagementModule } from './judges-management/judges-management.mo
|
|||||||
import { UploadModule } from './upload/upload.module';
|
import { UploadModule } from './upload/upload.module';
|
||||||
import { HomeworkModule } from './homework/homework.module';
|
import { HomeworkModule } from './homework/homework.module';
|
||||||
import { OssModule } from './oss/oss.module';
|
import { OssModule } from './oss/oss.module';
|
||||||
|
import { AI3DModule } from './ai-3d/ai-3d.module';
|
||||||
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from './auth/guards/roles.guard';
|
import { RolesGuard } from './auth/guards/roles.guard';
|
||||||
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
|
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
|
||||||
@ -50,6 +51,7 @@ import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
|||||||
UploadModule,
|
UploadModule,
|
||||||
HomeworkModule,
|
HomeworkModule,
|
||||||
OssModule,
|
OssModule,
|
||||||
|
AI3DModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
|
|||||||
105
frontend/src/api/ai-3d.ts
Normal file
105
frontend/src/api/ai-3d.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
import type { PaginationParams, PaginationResponse } from "@/types/api";
|
||||||
|
|
||||||
|
// ==================== AI 3D 任务相关类型 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 3D 任务状态
|
||||||
|
*/
|
||||||
|
export type AI3DTaskStatus =
|
||||||
|
| "pending"
|
||||||
|
| "processing"
|
||||||
|
| "completed"
|
||||||
|
| "failed"
|
||||||
|
| "timeout";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 3D 任务输入类型
|
||||||
|
*/
|
||||||
|
export type AI3DInputType = "text" | "image";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 3D 任务
|
||||||
|
*/
|
||||||
|
export interface AI3DTask {
|
||||||
|
id: number;
|
||||||
|
tenantId: number;
|
||||||
|
userId: number;
|
||||||
|
inputType: AI3DInputType;
|
||||||
|
inputContent: string;
|
||||||
|
status: AI3DTaskStatus;
|
||||||
|
resultUrl?: string;
|
||||||
|
previewUrl?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
externalTaskId?: string;
|
||||||
|
retryCount: number;
|
||||||
|
createTime: string;
|
||||||
|
completeTime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建任务参数
|
||||||
|
*/
|
||||||
|
export interface CreateAI3DTaskParams {
|
||||||
|
inputType: AI3DInputType;
|
||||||
|
inputContent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询任务参数
|
||||||
|
*/
|
||||||
|
export interface QueryAI3DTaskParams extends PaginationParams {
|
||||||
|
status?: AI3DTaskStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务列表响应
|
||||||
|
*/
|
||||||
|
export interface AI3DTaskListResponse {
|
||||||
|
list: AI3DTask[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== API 接口 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建生成任务
|
||||||
|
* POST /api/ai-3d/generate
|
||||||
|
*/
|
||||||
|
export function createAI3DTask(data: CreateAI3DTaskParams) {
|
||||||
|
return request.post<AI3DTask>("/ai-3d/generate", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取任务列表
|
||||||
|
* GET /api/ai-3d/tasks
|
||||||
|
*/
|
||||||
|
export function getAI3DTasks(params?: QueryAI3DTaskParams) {
|
||||||
|
return request.get<AI3DTaskListResponse>("/ai-3d/tasks", { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取任务详情
|
||||||
|
* GET /api/ai-3d/tasks/:id
|
||||||
|
*/
|
||||||
|
export function getAI3DTask(id: number) {
|
||||||
|
return request.get<AI3DTask>(`/ai-3d/tasks/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重试任务
|
||||||
|
* POST /api/ai-3d/tasks/:id/retry
|
||||||
|
*/
|
||||||
|
export function retryAI3DTask(id: number) {
|
||||||
|
return request.post<AI3DTask>(`/ai-3d/tasks/${id}/retry`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除任务
|
||||||
|
* DELETE /api/ai-3d/tasks/:id
|
||||||
|
*/
|
||||||
|
export function deleteAI3DTask(id: number) {
|
||||||
|
return request.delete(`/ai-3d/tasks/${id}`);
|
||||||
|
}
|
||||||
@ -22,3 +22,12 @@ export const uploadApi = {
|
|||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传单个文件
|
||||||
|
*/
|
||||||
|
export async function uploadFile(file: File): Promise<UploadResponse> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
return uploadApi.upload(formData);
|
||||||
|
}
|
||||||
|
|||||||
@ -29,10 +29,7 @@ const baseRoutes: RouteRecordRaw[] = [
|
|||||||
path: "/:tenantCode",
|
path: "/:tenantCode",
|
||||||
name: "Main",
|
name: "Main",
|
||||||
component: () => import("@/layouts/BasicLayout.vue"),
|
component: () => import("@/layouts/BasicLayout.vue"),
|
||||||
redirect: (to) => {
|
// 不设置固定redirect,由路由守卫根据用户菜单动态跳转到第一个可见菜单
|
||||||
// 默认跳转到 workbench,路由守卫会处理重定向到第一个菜单
|
|
||||||
return { path: `/${to.params.tenantCode}/workbench` }
|
|
||||||
},
|
|
||||||
meta: {},
|
meta: {},
|
||||||
children: [
|
children: [
|
||||||
// 创建比赛路由(不需要在菜单中显示)
|
// 创建比赛路由(不需要在菜单中显示)
|
||||||
@ -165,6 +162,16 @@ const baseRoutes: RouteRecordRaw[] = [
|
|||||||
permissions: ["activity:read"],
|
permissions: ["activity:read"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// 3D建模实验室路由(工作台模块下)
|
||||||
|
{
|
||||||
|
path: "workbench/3d-lab",
|
||||||
|
name: "3DModelingLab",
|
||||||
|
component: () => import("@/views/workbench/ai-3d/Index.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "3D建模实验室",
|
||||||
|
requiresAuth: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
// 动态路由将在这里添加
|
// 动态路由将在这里添加
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -281,9 +288,9 @@ function buildPathWithTenantCode(tenantCode: string, path: string): string {
|
|||||||
}
|
}
|
||||||
// 移除开头的斜杠(如果有)
|
// 移除开头的斜杠(如果有)
|
||||||
const cleanPath = path.startsWith("/") ? path.slice(1) : path
|
const cleanPath = path.startsWith("/") ? path.slice(1) : path
|
||||||
// 如果路径是根路径,返回租户编码路径
|
// 如果路径是根路径,返回租户编码根路径(路由守卫会处理跳转到第一个菜单)
|
||||||
if (cleanPath === "" || cleanPath === tenantCode) {
|
if (cleanPath === "" || cleanPath === tenantCode) {
|
||||||
return `/${tenantCode}/workbench`
|
return `/${tenantCode}`
|
||||||
}
|
}
|
||||||
return `/${tenantCode}/${cleanPath}`
|
return `/${tenantCode}/${cleanPath}`
|
||||||
}
|
}
|
||||||
@ -469,8 +476,8 @@ router.beforeEach(async (to, _from, next) => {
|
|||||||
// 路由已生效,重新解析目标路由
|
// 路由已生效,重新解析目标路由
|
||||||
const resolved = router.resolve(targetPath)
|
const resolved = router.resolve(targetPath)
|
||||||
|
|
||||||
// 如果访问的是主路由或 workbench,且路由不存在,重定向到第一个菜单
|
// 如果访问的是主路由,重定向到第一个菜单
|
||||||
const isMainRoute = to.name === "Main" || to.path.endsWith("/workbench")
|
const isMainRoute = to.name === "Main"
|
||||||
|
|
||||||
// 如果解析后的路由不是404,说明路由存在,重新导航
|
// 如果解析后的路由不是404,说明路由存在,重新导航
|
||||||
if (resolved.name !== "NotFound" && !isMainRoute) {
|
if (resolved.name !== "NotFound" && !isMainRoute) {
|
||||||
@ -535,9 +542,10 @@ router.beforeEach(async (to, _from, next) => {
|
|||||||
dynamicRoutesAdded
|
dynamicRoutesAdded
|
||||||
) {
|
) {
|
||||||
const resolved = router.resolve(to.fullPath)
|
const resolved = router.resolve(to.fullPath)
|
||||||
// 如果路由不存在,且不是登录页,尝试重定向到用户第一个菜单
|
// 如果访问的是 Main 路由(无具体子路径)或路由不存在,重定向到用户第一个菜单
|
||||||
|
const isMainRouteWithoutChild = to.name === "Main" || to.matched.length === 1 && to.matched[0].name === "Main"
|
||||||
if (
|
if (
|
||||||
resolved.name === "NotFound" &&
|
(resolved.name === "NotFound" || isMainRouteWithoutChild) &&
|
||||||
to.name !== "Login" &&
|
to.name !== "Login" &&
|
||||||
to.name !== "LoginFallback"
|
to.name !== "LoginFallback"
|
||||||
) {
|
) {
|
||||||
@ -622,9 +630,9 @@ router.beforeEach(async (to, _from, next) => {
|
|||||||
if (!dynamicRoutesAdded && authStore.menus.length > 0) {
|
if (!dynamicRoutesAdded && authStore.menus.length > 0) {
|
||||||
await addDynamicRoutes()
|
await addDynamicRoutes()
|
||||||
}
|
}
|
||||||
// 重定向到带租户编码的工作台
|
// 重定向到带租户编码的根路径(路由守卫会处理跳转到第一个菜单)
|
||||||
const userTenantCode = authStore.user?.tenantCode || "default"
|
const userTenantCode = authStore.user?.tenantCode || "default"
|
||||||
next({ path: `/${userTenantCode}/workbench` })
|
next({ path: `/${userTenantCode}` })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
972
frontend/src/views/workbench/ai-3d/Index.vue
Normal file
972
frontend/src/views/workbench/ai-3d/Index.vue
Normal file
@ -0,0 +1,972 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ai-3d-container">
|
||||||
|
<!-- 左侧生成栏 -->
|
||||||
|
<div class="left-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<a-segmented
|
||||||
|
v-model:value="inputType"
|
||||||
|
:options="inputTypeOptions"
|
||||||
|
block
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-content">
|
||||||
|
<!-- 文生3D输入 -->
|
||||||
|
<div v-if="inputType === 'text'" class="text-input-section">
|
||||||
|
<div class="input-hint">
|
||||||
|
请输入想要生成的内容,建议以单体为主。例如:一只棕色的猫雕塑,尾巴卷曲,卡通风格
|
||||||
|
</div>
|
||||||
|
<a-textarea
|
||||||
|
v-model:value="textContent"
|
||||||
|
placeholder="请输入描述..."
|
||||||
|
:rows="6"
|
||||||
|
:maxlength="150"
|
||||||
|
show-count
|
||||||
|
class="text-input"
|
||||||
|
/>
|
||||||
|
<div class="sample-prompts">
|
||||||
|
<ReloadOutlined class="refresh-icon" @click="refreshSamples" />
|
||||||
|
<span
|
||||||
|
v-for="(sample, index) in currentSamples"
|
||||||
|
:key="index"
|
||||||
|
class="sample-tag"
|
||||||
|
@click="applySample(sample)"
|
||||||
|
>
|
||||||
|
{{ sample }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 图生3D上传 -->
|
||||||
|
<div v-else class="image-input-section">
|
||||||
|
<div class="input-hint">
|
||||||
|
上传一张参考图片,AI 将根据图片生成 3D 模型
|
||||||
|
</div>
|
||||||
|
<a-upload-dragger
|
||||||
|
v-model:file-list="imageFileList"
|
||||||
|
:before-upload="handleBeforeUpload"
|
||||||
|
:max-count="1"
|
||||||
|
accept="image/*"
|
||||||
|
class="image-upload"
|
||||||
|
>
|
||||||
|
<p class="ant-upload-drag-icon">
|
||||||
|
<PictureOutlined />
|
||||||
|
</p>
|
||||||
|
<p class="ant-upload-text">点击或拖拽图片到此处</p>
|
||||||
|
<p class="ant-upload-hint">支持 JPG、PNG 格式</p>
|
||||||
|
</a-upload-dragger>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-footer">
|
||||||
|
<a-button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
:loading="generating"
|
||||||
|
:disabled="!canGenerate"
|
||||||
|
@click="handleGenerate"
|
||||||
|
>
|
||||||
|
<template #icon><ThunderboltOutlined /></template>
|
||||||
|
立即生成
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧内容区 -->
|
||||||
|
<div class="right-panel">
|
||||||
|
<!-- 介绍区 -->
|
||||||
|
<div class="intro-section">
|
||||||
|
<h1 class="intro-title">用一句话、一张图,生成你的 3D 世界</h1>
|
||||||
|
<div class="intro-features">
|
||||||
|
<div class="feature-item">
|
||||||
|
<span class="feature-icon">✨</span>
|
||||||
|
<span class="feature-text"
|
||||||
|
>AI 智能建模:输入文字描述或上传参考图,自动生成 3D 模型</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="feature-item">
|
||||||
|
<span class="feature-icon">👁</span>
|
||||||
|
<span class="feature-text"
|
||||||
|
>在线实时预览:支持模型旋转、缩放与查看细节</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="feature-item">
|
||||||
|
<span class="feature-icon">📁</span>
|
||||||
|
<span class="feature-text"
|
||||||
|
>作品统一管理:所有建模成果自动保存至个人作品库</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="feature-item">
|
||||||
|
<span class="feature-icon">🔄</span>
|
||||||
|
<span class="feature-text"
|
||||||
|
>持续创作迭代:支持基于已有作品再次生成与优化</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a class="intro-action" @click="focusInput"> 立即开始建模 → </a>
|
||||||
|
<p class="intro-subtitle">从一个想法开始,让 3D 创作变得更简单</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 创作历史区 -->
|
||||||
|
<div class="history-section">
|
||||||
|
<div class="history-header">
|
||||||
|
<h2 class="history-title">创作历史</h2>
|
||||||
|
<a class="view-all" @click="showAllHistory = true">查看全部 ></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="historyLoading" class="history-loading">
|
||||||
|
<a-spin />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="historyList.length === 0" class="history-empty">
|
||||||
|
<a-empty description="暂无创作记录,开始你的第一次创作吧" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="history-grid">
|
||||||
|
<div
|
||||||
|
v-for="task in historyList"
|
||||||
|
:key="task.id"
|
||||||
|
class="history-card"
|
||||||
|
@click="handleViewTask(task)"
|
||||||
|
>
|
||||||
|
<div class="card-preview">
|
||||||
|
<img
|
||||||
|
v-if="task.status === 'completed' && task.previewUrl"
|
||||||
|
:src="getPreviewUrl(task)"
|
||||||
|
alt="预览"
|
||||||
|
class="preview-image"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else-if="
|
||||||
|
task.status === 'processing' || task.status === 'pending'
|
||||||
|
"
|
||||||
|
class="preview-loading"
|
||||||
|
>
|
||||||
|
<LoadingOutlined spin />
|
||||||
|
<span>生成中...</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="
|
||||||
|
task.status === 'failed' || task.status === 'timeout'
|
||||||
|
"
|
||||||
|
class="preview-failed"
|
||||||
|
>
|
||||||
|
<ExclamationCircleOutlined />
|
||||||
|
<span>{{
|
||||||
|
task.status === "timeout" ? "已超时" : "生成失败"
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="preview-placeholder">
|
||||||
|
<FileImageOutlined />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-info">
|
||||||
|
<div class="card-desc" :title="task.inputContent">
|
||||||
|
{{ task.inputContent }}
|
||||||
|
</div>
|
||||||
|
<div class="card-meta">
|
||||||
|
<span class="card-time">{{ formatTime(task.createTime) }}</span>
|
||||||
|
<div class="card-actions" @click.stop>
|
||||||
|
<a-tooltip v-if="task.status === 'completed'" title="预览">
|
||||||
|
<EyeOutlined
|
||||||
|
class="action-icon"
|
||||||
|
@click="handlePreview(task)"
|
||||||
|
/>
|
||||||
|
</a-tooltip>
|
||||||
|
<a-tooltip
|
||||||
|
v-if="['failed', 'timeout'].includes(task.status)"
|
||||||
|
title="重试"
|
||||||
|
>
|
||||||
|
<ReloadOutlined
|
||||||
|
class="action-icon"
|
||||||
|
:class="{ disabled: task.retryCount >= 3 }"
|
||||||
|
@click="handleRetry(task)"
|
||||||
|
/>
|
||||||
|
</a-tooltip>
|
||||||
|
<a-tooltip title="删除">
|
||||||
|
<DeleteOutlined
|
||||||
|
class="action-icon danger"
|
||||||
|
@click="handleDelete(task)"
|
||||||
|
/>
|
||||||
|
</a-tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 全部历史记录抽屉 -->
|
||||||
|
<a-drawer
|
||||||
|
v-model:open="showAllHistory"
|
||||||
|
title="全部创作历史"
|
||||||
|
placement="right"
|
||||||
|
width="600px"
|
||||||
|
>
|
||||||
|
<a-list
|
||||||
|
:data-source="allHistoryList"
|
||||||
|
:loading="allHistoryLoading"
|
||||||
|
:pagination="pagination"
|
||||||
|
>
|
||||||
|
<template #renderItem="{ item }">
|
||||||
|
<a-list-item>
|
||||||
|
<a-list-item-meta>
|
||||||
|
<template #avatar>
|
||||||
|
<div class="list-preview">
|
||||||
|
<img
|
||||||
|
v-if="item.status === 'completed' && item.previewUrl"
|
||||||
|
:src="getPreviewUrl(item)"
|
||||||
|
alt="预览"
|
||||||
|
/>
|
||||||
|
<div v-else class="list-preview-placeholder">
|
||||||
|
<LoadingOutlined
|
||||||
|
v-if="['pending', 'processing'].includes(item.status)"
|
||||||
|
spin
|
||||||
|
/>
|
||||||
|
<ExclamationCircleOutlined
|
||||||
|
v-else-if="['failed', 'timeout'].includes(item.status)"
|
||||||
|
/>
|
||||||
|
<FileImageOutlined v-else />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #title>
|
||||||
|
<span class="list-desc">{{ item.inputContent }}</span>
|
||||||
|
</template>
|
||||||
|
<template #description>
|
||||||
|
<div class="list-meta">
|
||||||
|
<a-tag :color="getStatusColor(item.status)">{{
|
||||||
|
getStatusText(item.status)
|
||||||
|
}}</a-tag>
|
||||||
|
<span>{{ formatTime(item.createTime) }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</a-list-item-meta>
|
||||||
|
<template #actions>
|
||||||
|
<a v-if="item.status === 'completed'" @click="handlePreview(item)"
|
||||||
|
>预览</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
v-if="
|
||||||
|
['failed', 'timeout'].includes(item.status) &&
|
||||||
|
item.retryCount < 3
|
||||||
|
"
|
||||||
|
@click="handleRetry(item)"
|
||||||
|
>重试</a
|
||||||
|
>
|
||||||
|
<a class="danger-link" @click="handleDelete(item)">删除</a>
|
||||||
|
</template>
|
||||||
|
</a-list-item>
|
||||||
|
</template>
|
||||||
|
</a-list>
|
||||||
|
</a-drawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from "vue"
|
||||||
|
import { message, Modal } from "ant-design-vue"
|
||||||
|
import {
|
||||||
|
ReloadOutlined,
|
||||||
|
PictureOutlined,
|
||||||
|
ThunderboltOutlined,
|
||||||
|
LoadingOutlined,
|
||||||
|
ExclamationCircleOutlined,
|
||||||
|
FileImageOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
} from "@ant-design/icons-vue"
|
||||||
|
import type { UploadFile } from "ant-design-vue"
|
||||||
|
import {
|
||||||
|
createAI3DTask,
|
||||||
|
getAI3DTasks,
|
||||||
|
getAI3DTask,
|
||||||
|
retryAI3DTask,
|
||||||
|
deleteAI3DTask,
|
||||||
|
type AI3DTask,
|
||||||
|
} from "@/api/ai-3d"
|
||||||
|
import { uploadFile } from "@/api/upload"
|
||||||
|
import dayjs from "dayjs"
|
||||||
|
|
||||||
|
// 输入类型选项
|
||||||
|
const inputTypeOptions = [
|
||||||
|
{ label: "文生3D", value: "text" },
|
||||||
|
{ label: "图生3D", value: "image" },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 示例提示词
|
||||||
|
const samplePrompts = [
|
||||||
|
["啄木鸟", "尖锐的嘴", "金黄色"],
|
||||||
|
["可爱的猫咪", "卡通风格", "蓝色眼睛"],
|
||||||
|
["机器人", "金属质感", "未来风格"],
|
||||||
|
["中式花瓶", "青花瓷", "精致纹理"],
|
||||||
|
["小恐龙", "Q版造型", "绿色皮肤"],
|
||||||
|
["宇航员", "太空服", "写实风格"],
|
||||||
|
]
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
const inputType = ref<"text" | "image">("text")
|
||||||
|
const textContent = ref("")
|
||||||
|
const imageFileList = ref<UploadFile[]>()
|
||||||
|
const imageUrl = ref("")
|
||||||
|
const generating = ref(false)
|
||||||
|
const currentSampleIndex = ref(0)
|
||||||
|
|
||||||
|
// 历史记录
|
||||||
|
const historyList = ref<AI3DTask[]>([])
|
||||||
|
const historyLoading = ref(false)
|
||||||
|
const showAllHistory = ref(false)
|
||||||
|
const allHistoryList = ref<AI3DTask[]>([])
|
||||||
|
const allHistoryLoading = ref(false)
|
||||||
|
const allHistoryTotal = ref(0)
|
||||||
|
const allHistoryPage = ref(1)
|
||||||
|
|
||||||
|
// 轮询定时器
|
||||||
|
let pollingTimer: number | null = null
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const currentSamples = computed(() => samplePrompts[currentSampleIndex.value])
|
||||||
|
|
||||||
|
const canGenerate = computed(() => {
|
||||||
|
if (inputType.value === "text") {
|
||||||
|
return textContent.value.trim().length > 0
|
||||||
|
} else {
|
||||||
|
return imageUrl.value.length > 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const pagination = computed(() => ({
|
||||||
|
current: allHistoryPage.value,
|
||||||
|
pageSize: 10,
|
||||||
|
total: allHistoryTotal.value,
|
||||||
|
onChange: (page: number) => {
|
||||||
|
allHistoryPage.value = page
|
||||||
|
fetchAllHistory()
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 刷新示例
|
||||||
|
const refreshSamples = () => {
|
||||||
|
currentSampleIndex.value =
|
||||||
|
(currentSampleIndex.value + 1) % samplePrompts.length
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应用示例
|
||||||
|
const applySample = (sample: string) => {
|
||||||
|
if (textContent.value) {
|
||||||
|
textContent.value += "," + sample
|
||||||
|
} else {
|
||||||
|
textContent.value = sample
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 聚焦输入
|
||||||
|
const focusInput = () => {
|
||||||
|
// 滚动到左侧面板
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图片上传前处理
|
||||||
|
const handleBeforeUpload = async (file: File) => {
|
||||||
|
const isImage = file.type.startsWith("image/")
|
||||||
|
if (!isImage) {
|
||||||
|
message.error("只能上传图片文件")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLt10M = file.size / 1024 / 1024 < 10
|
||||||
|
if (!isLt10M) {
|
||||||
|
message.error("图片大小不能超过 10MB")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上传图片
|
||||||
|
try {
|
||||||
|
const result = await uploadFile(file)
|
||||||
|
imageUrl.value = result.url
|
||||||
|
message.success("图片上传成功")
|
||||||
|
} catch (error) {
|
||||||
|
message.error("图片上传失败")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return false // 阻止默认上传行为
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成3D模型
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
if (!canGenerate.value) return
|
||||||
|
|
||||||
|
generating.value = true
|
||||||
|
try {
|
||||||
|
const content =
|
||||||
|
inputType.value === "text" ? textContent.value.trim() : imageUrl.value
|
||||||
|
|
||||||
|
await createAI3DTask({
|
||||||
|
inputType: inputType.value,
|
||||||
|
inputContent: content,
|
||||||
|
})
|
||||||
|
|
||||||
|
message.success("任务已提交,请等待生成完成")
|
||||||
|
|
||||||
|
// 清空输入
|
||||||
|
if (inputType.value === "text") {
|
||||||
|
textContent.value = ""
|
||||||
|
} else {
|
||||||
|
imageFileList.value = []
|
||||||
|
imageUrl.value = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新历史记录
|
||||||
|
fetchHistory()
|
||||||
|
|
||||||
|
// 开始轮询
|
||||||
|
startPolling()
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.response?.data?.message || "提交失败,请重试")
|
||||||
|
} finally {
|
||||||
|
generating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取历史记录(最近6条)
|
||||||
|
const fetchHistory = async () => {
|
||||||
|
historyLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getAI3DTasks({ page: 1, pageSize: 6 })
|
||||||
|
historyList.value = res.list || []
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取历史记录失败:", error)
|
||||||
|
} finally {
|
||||||
|
historyLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取全部历史记录
|
||||||
|
const fetchAllHistory = async () => {
|
||||||
|
allHistoryLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getAI3DTasks({ page: allHistoryPage.value, pageSize: 10 })
|
||||||
|
allHistoryList.value = res.list || []
|
||||||
|
allHistoryTotal.value = res.total || 0
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取全部历史记录失败:", error)
|
||||||
|
} finally {
|
||||||
|
allHistoryLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始轮询(检查处理中的任务)
|
||||||
|
const startPolling = () => {
|
||||||
|
if (pollingTimer) return
|
||||||
|
|
||||||
|
pollingTimer = window.setInterval(async () => {
|
||||||
|
const processingTasks = historyList.value.filter(
|
||||||
|
(t) => t.status === "pending" || t.status === "processing"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (processingTasks.length === 0) {
|
||||||
|
stopPolling()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新历史记录
|
||||||
|
await fetchHistory()
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止轮询
|
||||||
|
const stopPolling = () => {
|
||||||
|
if (pollingTimer) {
|
||||||
|
clearInterval(pollingTimer)
|
||||||
|
pollingTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预览3D模型
|
||||||
|
const handlePreview = (task: AI3DTask) => {
|
||||||
|
if (task.resultUrl) {
|
||||||
|
const viewerUrl = `/model-viewer?url=${encodeURIComponent(task.resultUrl)}`
|
||||||
|
window.open(viewerUrl, "_blank")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查看任务详情
|
||||||
|
const handleViewTask = (task: AI3DTask) => {
|
||||||
|
if (task.status === "completed") {
|
||||||
|
handlePreview(task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重试任务
|
||||||
|
const handleRetry = async (task: AI3DTask) => {
|
||||||
|
if (task.retryCount >= 3) {
|
||||||
|
message.warning("已达到最大重试次数,请创建新任务")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await retryAI3DTask(task.id)
|
||||||
|
message.success("重试已提交")
|
||||||
|
fetchHistory()
|
||||||
|
startPolling()
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.response?.data?.message || "重试失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除任务
|
||||||
|
const handleDelete = (task: AI3DTask) => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: "确认删除",
|
||||||
|
content: "确定要删除这条创作记录吗?",
|
||||||
|
okText: "删除",
|
||||||
|
okType: "danger",
|
||||||
|
cancelText: "取消",
|
||||||
|
async onOk() {
|
||||||
|
try {
|
||||||
|
await deleteAI3DTask(task.id)
|
||||||
|
message.success("删除成功")
|
||||||
|
fetchHistory()
|
||||||
|
if (showAllHistory.value) {
|
||||||
|
fetchAllHistory()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error("删除失败")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取预览图URL
|
||||||
|
const getPreviewUrl = (task: AI3DTask) => {
|
||||||
|
if (task.previewUrl) {
|
||||||
|
return task.previewUrl.startsWith("http")
|
||||||
|
? task.previewUrl
|
||||||
|
: task.previewUrl
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取状态颜色
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
pending: "default",
|
||||||
|
processing: "processing",
|
||||||
|
completed: "success",
|
||||||
|
failed: "error",
|
||||||
|
timeout: "warning",
|
||||||
|
}
|
||||||
|
return colors[status] || "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取状态文本
|
||||||
|
const getStatusText = (status: string) => {
|
||||||
|
const texts: Record<string, string> = {
|
||||||
|
pending: "等待中",
|
||||||
|
processing: "生成中",
|
||||||
|
completed: "已完成",
|
||||||
|
failed: "失败",
|
||||||
|
timeout: "已超时",
|
||||||
|
}
|
||||||
|
return texts[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化时间
|
||||||
|
const formatTime = (time: string) => {
|
||||||
|
return dayjs(time).format("MM-DD HH:mm")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面加载
|
||||||
|
onMounted(() => {
|
||||||
|
fetchHistory()
|
||||||
|
|
||||||
|
// 检查是否有处理中的任务,有则开启轮询
|
||||||
|
const hasProcessing = historyList.value.some(
|
||||||
|
(t) => t.status === "pending" || t.status === "processing"
|
||||||
|
)
|
||||||
|
if (hasProcessing) {
|
||||||
|
startPolling()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 页面卸载
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopPolling()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.ai-3d-container {
|
||||||
|
display: flex;
|
||||||
|
height: calc(100vh - 120px);
|
||||||
|
min-height: 600px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 左侧面板
|
||||||
|
.left-panel {
|
||||||
|
width: 320px;
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
padding: 20px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
|
||||||
|
:deep(.ant-segmented) {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
|
||||||
|
.ant-segmented-item {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
|
||||||
|
&-selected {
|
||||||
|
background: #1890ff;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-hint {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-input {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
color: #fff;
|
||||||
|
resize: none;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-input-data-count) {
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sample-prompts {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.refresh-icon {
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sample-tag {
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(24, 144, 255, 0.3);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-upload {
|
||||||
|
:deep(.ant-upload-drag) {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-upload-drag-icon {
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-size: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-upload-text {
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-upload-hint {
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-footer {
|
||||||
|
padding: 20px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 右侧面板
|
||||||
|
.right-panel {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 介绍区
|
||||||
|
.intro-section {
|
||||||
|
padding: 40px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #e8e8e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a2e;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro-features {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
|
||||||
|
.feature-icon {
|
||||||
|
margin-right: 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro-action {
|
||||||
|
display: inline-block;
|
||||||
|
color: #1890ff;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro-subtitle {
|
||||||
|
color: #999;
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 历史记录区
|
||||||
|
.history-section {
|
||||||
|
flex: 1;
|
||||||
|
padding: 24px 40px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a1a2e;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-all {
|
||||||
|
color: #1890ff;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-loading,
|
||||||
|
.history-empty {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-preview {
|
||||||
|
height: 140px;
|
||||||
|
background: #1a1a2e;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.preview-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-loading,
|
||||||
|
.preview-failed,
|
||||||
|
.preview-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 24px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-failed {
|
||||||
|
color: #ff4d4f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-info {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #333;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.action-icon {
|
||||||
|
color: #666;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.danger:hover {
|
||||||
|
color: #ff4d4f;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
color: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 抽屉列表样式
|
||||||
|
.list-preview {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
background: #1a1a2e;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-preview-placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-desc {
|
||||||
|
max-width: 300px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-link {
|
||||||
|
color: #ff4d4f;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #ff7875;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Reference in New Issue
Block a user