diff --git a/.claude/settings.local.json b/.claude/settings.local.json index cfbd402..04147cd 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,5 +1,14 @@ { "permissions": { - "allow": ["Bash", "ReadFiles", "WriteFiles", "EditFiles"] + "allow": [ + "Bash", + "ReadFiles", + "WriteFiles", + "EditFiles", + "WebFetch(domain:3d.hunyuan.tencent.com)", + "WebSearch", + "WebFetch(domain:cloud.tencent.com)", + "WebFetch(domain:cloud.tencent.com.cn)" + ] } } diff --git a/.claude/skills/my-coding-standard/SKLII.md b/.claude/skills/my-coding-standard/SKLII.md new file mode 100644 index 0000000..9fbdcb8 --- /dev/null +++ b/.claude/skills/my-coding-standard/SKLII.md @@ -0,0 +1,207 @@ +需求确认 + + - AI服务:先用 Mock 数据开发,后期接入真实API(腾讯混元3D/Meshy) + - 功能入口:独立页面 + - 生成历史:需要保存 + + --- + 技术架构 + + ┌─────────────────────────────────────────────────────────┐ + │ 前端 Vue 3 │ + │ ┌─────────────────────────────────────────────────┐ │ + │ │ AI 3D生成页面 (/ai-3d) │ │ + │ │ - 文字输入 / 图片上传 │ │ + │ │ - 生成进度展示 │ │ + │ │ - 历史记录列表 │ │ + │ │ - 3D预览(复用 ModelViewer) │ │ + │ └─────────────────────────────────────────────────┘ │ + └───────────────────────────┬─────────────────────────────┘ + │ + ┌───────────────────────────▼─────────────────────────────┐ + │ 后端 NestJS │ + │ ┌─────────────────────────────────────────────────┐ │ + │ │ AI 3D生成模块 │ │ + │ │ - 提交生成任务 │ │ + │ │ - 查询任务状态 │ │ + │ │ - 获取历史记录 │ │ + │ └──────────────────────┬──────────────────────────┘ │ + │ │ │ + │ ┌──────────────────────▼──────────────────────────┐ │ + │ │ Mock Provider(开发阶段) │ │ + │ │ - 模拟生成延迟(5-10秒) │ │ + │ │ - 返回示例3D模型URL │ │ + │ └─────────────────────────────────────────────────┘ │ + └─────────────────────────────────────────────────────────┘ + + --- + 实现步骤 + + 第一步:后端 - 创建数据模型 + + 文件: backend/prisma/schema.prisma + + 新增 AI3DTask 表: + model AI3DTask { + id Int @id @default(autoincrement()) + tenantId Int @map("tenant_id") + userId Int @map("user_id") + inputType String @map("input_type") // text | image + inputContent String @db.Text // 文字描述或图片URL + status String @default("pending") // pending|processing|completed|failed + resultUrl String? @map("result_url") // 生成的3D模型URL + errorMessage String? @map("error_message") + createTime DateTime @default(now()) @map("create_time") + completeTime DateTime? @map("complete_time") + + tenant Tenant @relation(fields: [tenantId], references: [id]) + user User @relation(fields: [userId], references: [id]) + + @@map("t_ai_3d_task") + } + + 第二步:后端 - 创建 AI 3D 模块 + + 新建文件结构: + backend/src/ai-3d/ + ├── ai-3d.module.ts + ├── ai-3d.controller.ts + ├── ai-3d.service.ts + ├── dto/ + │ ├── create-task.dto.ts + │ └── query-task.dto.ts + └── providers/ + └── mock.provider.ts # Mock实现 + + API 端点: + POST /api/ai-3d/generate # 提交生成任务 + GET /api/ai-3d/tasks # 获取历史记录列表 + GET /api/ai-3d/tasks/:id # 获取任务详情/状态 + DELETE /api/ai-3d/tasks/:id # 删除任务 + + 第三步:后端 - Mock Provider 实现 + + 文件: backend/src/ai-3d/providers/mock.provider.ts + + // Mock实现:模拟5-10秒生成延迟,返回示例模型 + async generate(input: { type: 'text' | 'image', content: string }) { + // 模拟处理时间 + await sleep(random(5000, 10000)); + + // 返回示例模型URL(使用公开的GLB示例) + return { + status: 'completed', + resultUrl: 'https://example.com/sample-model.glb' + }; + } + + 第四步:前端 - 创建页面和API + + 新建文件结构: + frontend/src/ + ├── api/ + │ └── ai-3d.ts # API接口 + ├── views/ + │ └── ai-3d/ + │ ├── index.vue # 主页面 + │ └── components/ + │ ├── GenerateForm.vue # 生成表单 + │ ├── TaskList.vue # 历史列表 + │ └── TaskCard.vue # 任务卡片 + + 页面布局: + ┌────────────────────────────────────────────────┐ + │ AI 3D模型生成 │ + ├────────────────────────────────────────────────┤ + │ ┌──────────────────┐ ┌────────────────────┐ │ + │ │ 生成方式 │ │ 历史记录 │ │ + │ │ ○ 文字描述 │ │ ┌────────────────┐ │ │ + │ │ ○ 上传图片 │ │ │ 任务1 完成 ✓ │ │ │ + │ │ │ │ └────────────────┘ │ │ + │ │ [输入区域] │ │ ┌────────────────┐ │ │ + │ │ │ │ │ 任务2 生成中...│ │ │ + │ │ [生成] 按钮 │ │ └────────────────┘ │ │ + │ └──────────────────┘ └────────────────────┘ │ + │ │ + │ ┌──────────────────────────────────────────┐ │ + │ │ 3D预览区域(选中任务后显示) │ │ + │ │ │ │ + │ └──────────────────────────────────────────┘ │ + └────────────────────────────────────────────────┘ + + 第五步:前端 - 添加路由和菜单 + + 修改文件: frontend/src/router/index.ts + + { + path: "ai-3d", + name: "AI3DGenerate", + component: () => import("@/views/ai-3d/index.vue"), + meta: { + title: "AI 3D生成", + requiresAuth: true, + }, + } + + --- + 关键文件清单 + ┌──────┬──────────────────────────────────────────────────────┐ + │ 操作 │ 文件路径 │ + ├──────┼──────────────────────────────────────────────────────┤ + │ 修改 │ backend/prisma/schema.prisma - 添加 AI3DTask 表 │ + ├──────┼──────────────────────────────────────────────────────┤ + │ 新建 │ backend/src/ai-3d/ai-3d.module.ts │ + ├──────┼──────────────────────────────────────────────────────┤ + │ 新建 │ backend/src/ai-3d/ai-3d.controller.ts │ + ├──────┼──────────────────────────────────────────────────────┤ + │ 新建 │ backend/src/ai-3d/ai-3d.service.ts │ + ├──────┼──────────────────────────────────────────────────────┤ + │ 新建 │ backend/src/ai-3d/dto/create-task.dto.ts │ + ├──────┼──────────────────────────────────────────────────────┤ + │ 新建 │ backend/src/ai-3d/dto/query-task.dto.ts │ + ├──────┼──────────────────────────────────────────────────────┤ + │ 新建 │ backend/src/ai-3d/providers/mock.provider.ts │ + ├──────┼──────────────────────────────────────────────────────┤ + │ 修改 │ backend/src/app.module.ts - 注册 AI3D 模块 │ + ├──────┼──────────────────────────────────────────────────────┤ + │ 新建 │ frontend/src/api/ai-3d.ts │ + ├──────┼──────────────────────────────────────────────────────┤ + │ 新建 │ frontend/src/views/ai-3d/index.vue │ + ├──────┼──────────────────────────────────────────────────────┤ + │ 新建 │ frontend/src/views/ai-3d/components/GenerateForm.vue │ + ├──────┼──────────────────────────────────────────────────────┤ + │ 新建 │ frontend/src/views/ai-3d/components/TaskList.vue │ + ├──────┼──────────────────────────────────────────────────────┤ + │ 新建 │ frontend/src/views/ai-3d/components/TaskCard.vue │ + ├──────┼──────────────────────────────────────────────────────┤ + │ 修改 │ frontend/src/router/index.ts - 添加路由 │ + └──────┴──────────────────────────────────────────────────────┘ + --- + 验证方式 + + 1. 数据库迁移 + cd backend + npx prisma migrate dev --name add-ai-3d-task + 2. 后端测试 + cd backend && npm run start:dev + # 测试API + curl -X POST http://localhost:3001/api/ai-3d/generate \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"type":"text","content":"一个红色的椅子"}' + 3. 前端测试 + cd frontend && npm run dev + # 访问 http://localhost:3000//ai-3d + # 测试文字生成、图片上传、历史记录功能 + 4. 完整流程测试 + - 输入文字描述 → 点击生成 → 等待完成 → 预览3D模型 + - 上传图片 → 点击生成 → 等待完成 → 预览3D模型 + - 查看历史记录 → 点击历史任务 → 预览3D模型 + + --- + 后期扩展 + + Mock 开发完成后,接入真实 API 只需: + 1. 新建 hunyuan-3d.provider.ts 或 meshy.provider.ts + 2. 配置环境变量 + 3. 在 Service 中切换 Provider diff --git a/backend/package-lock.json b/backend/package-lock.json index 88c621d..9422d3a 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -20,6 +20,7 @@ "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "cos-nodejs-sdk-v5": "^2.15.4", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", @@ -3160,6 +3161,54 @@ "node": ">=8" } }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/atomically": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/atomically/-/atomically-1.7.0.tgz", + "integrity": "sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==", + "license": "MIT", + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "license": "MIT" + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -3338,6 +3387,15 @@ "node": ">= 18" } }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -3700,6 +3758,12 @@ ], "license": "CC-BY-4.0" }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "license": "Apache-2.0" + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3959,6 +4023,18 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -4008,6 +4084,88 @@ "typedarray": "^0.0.6" } }, + "node_modules/conf": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/conf/-/conf-9.0.2.tgz", + "integrity": "sha512-rLSiilO85qHgaTBIIHQpsv8z+NnVfZq3cKuYNCXN1AOqPzced0GWZEe/A517VldRLyQYXUMyV+vszavE2jSAqw==", + "license": "MIT", + "dependencies": { + "ajv": "^7.0.3", + "ajv-formats": "^1.5.1", + "atomically": "^1.7.0", + "debounce-fn": "^4.0.0", + "dot-prop": "^6.0.1", + "env-paths": "^2.2.0", + "json-schema-typed": "^7.0.3", + "make-dir": "^3.1.0", + "onetime": "^5.1.2", + "pkg-up": "^3.1.0", + "semver": "^7.3.4" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/conf/node_modules/ajv": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-7.2.4.tgz", + "integrity": "sha512-nBeQgg/ZZA3u3SYxyaDvpvDtgZ/EZPF547ARgZBrG9Bhu1vKDwAIjtIf+sDtJUKa2zOcEbmRLBRSyMraS/Oy1A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/conf/node_modules/ajv-formats": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-1.6.1.tgz", + "integrity": "sha512-4CjkH20If1lhR5CGtqkrVg3bbOtFEG80X9v6jDOIUhbzzbB+UzPBGy8GQhUNVZ0yvMHdMpawCOcy5ydGMsagGQ==", + "license": "MIT", + "dependencies": { + "ajv": "^7.0.0" + }, + "peerDependencies": { + "ajv": "^7.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/conf/node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/conf/node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/confbox": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", @@ -4084,6 +4242,21 @@ "node": ">= 0.10" } }, + "node_modules/cos-nodejs-sdk-v5": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/cos-nodejs-sdk-v5/-/cos-nodejs-sdk-v5-2.15.4.tgz", + "integrity": "sha512-TP/iYTvKKKhRK89on9SRfSMGEw/9SFAAU8EC1kdT5Fmpx7dAwaCNM2+R2H1TSYoQt+03rwOs8QEfNkX8GOHjHQ==", + "license": "ISC", + "dependencies": { + "conf": "^9.0.0", + "fast-xml-parser": "4.2.5", + "mime-types": "^2.1.24", + "request": "^2.88.2" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/cosmiconfig": { "version": "8.3.6", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", @@ -4155,6 +4328,42 @@ "node": ">= 8" } }, + "node_modules/dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/debounce-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-4.0.0.tgz", + "integrity": "sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/debounce-fn/node_modules/mimic-fn": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-3.1.0.tgz", + "integrity": "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -4252,6 +4461,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -4334,6 +4552,21 @@ "node": ">=6.0.0" } }, + "node_modules/dot-prop": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz", + "integrity": "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==", + "license": "MIT", + "dependencies": { + "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/dotenv": { "version": "17.2.3", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", @@ -4422,6 +4655,16 @@ "dev": true, "license": "MIT" }, + "node_modules/ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "license": "MIT", + "dependencies": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -4508,6 +4751,15 @@ "node": ">=10.13.0" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -5005,6 +5257,12 @@ "devOptional": true, "license": "MIT" }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/external-editor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", @@ -5020,6 +5278,15 @@ "node": ">=4" } }, + "node_modules/extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT" + }, "node_modules/fast-check": { "version": "3.23.2", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", @@ -5047,7 +5314,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-diff": { @@ -5078,7 +5344,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, "license": "MIT" }, "node_modules/fast-levenshtein": { @@ -5094,6 +5359,28 @@ "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "license": "MIT" }, + "node_modules/fast-xml-parser": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.2.5.tgz", + "integrity": "sha512-B9/wizE4WngqQftFPmdaMYlXoJlJOYxGQOanC77fq9k8+Z0v5dDSVh+3glErdIROP//s/jgb7ZuxKfB8nVyo0g==", + "funding": [ + { + "type": "paypal", + "url": "https://paypal.me/naturalintelligence" + }, + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^1.0.5" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -5279,6 +5566,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, "node_modules/fork-ts-checker-webpack-plugin": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-9.0.2.tgz", @@ -5332,6 +5628,20 @@ "node": "*" } }, + "node_modules/form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -5483,6 +5793,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + } + }, "node_modules/giget": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", @@ -5663,6 +5982,51 @@ "node": ">=0.10.0" } }, + "node_modules/har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==", + "license": "ISC", + "engines": { + "node": ">=4" + } + }, + "node_modules/har-validator": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", + "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", + "deprecated": "this library is no longer supported", + "license": "MIT", + "dependencies": { + "ajv": "^6.12.3", + "har-schema": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/har-validator/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/har-validator/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -5742,6 +6106,21 @@ "node": ">= 0.8" } }, + "node_modules/http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + }, + "engines": { + "node": ">=0.8", + "npm": ">=1.3.7" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -5994,6 +6373,15 @@ "node": ">=0.12.0" } }, + "node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-path-inside": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", @@ -6017,6 +6405,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "license": "MIT" + }, "node_modules/is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", @@ -6037,6 +6431,12 @@ "dev": true, "license": "ISC" }, + "node_modules/isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", + "license": "MIT" + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -6882,6 +7282,12 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", + "license": "MIT" + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -6909,13 +7315,24 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-7.0.3.tgz", + "integrity": "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A==", + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -6923,6 +7340,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -6978,6 +7401,21 @@ "npm": ">=6" } }, + "node_modules/jsprim": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", + "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", + "license": "MIT", + "dependencies": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.4.0", + "verror": "1.10.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/jwa": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", @@ -7351,7 +7789,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -7589,6 +8026,15 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -7643,7 +8089,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, "license": "MIT", "dependencies": { "mimic-fn": "^2.1.0" @@ -7743,7 +8188,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -7940,6 +8384,12 @@ "devOptional": true, "license": "MIT" }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8051,6 +8501,79 @@ "pathe": "^2.0.3" } }, + "node_modules/pkg-up": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", + "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", + "license": "MIT", + "dependencies": { + "find-up": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-up/node_modules/find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "license": "MIT", + "dependencies": { + "locate-path": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-up/node_modules/locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "license": "MIT", + "dependencies": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-up/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-up/node_modules/p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-up/node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", @@ -8181,11 +8704,22 @@ "node": ">= 0.10" } }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -8352,6 +8886,47 @@ "node": ">=0.10" } }, + "node_modules/request": { + "version": "2.88.2", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", + "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", + "deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142", + "license": "Apache-2.0", + "dependencies": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.3", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.5.0", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/request/node_modules/qs": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", + "integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.6" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -8366,7 +8941,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8925,6 +9499,31 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/sshpk": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", + "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", + "license": "MIT", + "dependencies": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "bin": { + "sshpk-conv": "bin/sshpk-conv", + "sshpk-sign": "bin/sshpk-sign", + "sshpk-verify": "bin/sshpk-verify" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -9079,6 +9678,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz", + "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/strtok3": { "version": "10.3.4", "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz", @@ -9417,6 +10028,19 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/tough-cookie": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", + "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.28", + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -9624,6 +10248,24 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -9791,7 +10433,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" @@ -9812,6 +10453,16 @@ "node": ">= 0.4.0" } }, + "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/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -9852,6 +10503,26 @@ "node": ">= 0.8" } }, + "node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/verror/node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "license": "MIT" + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", diff --git a/backend/package.json b/backend/package.json index c8ed635..f6795a6 100644 --- a/backend/package.json +++ b/backend/package.json @@ -56,6 +56,7 @@ "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "cos-nodejs-sdk-v5": "^2.15.4", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 3e18cbf..fb1d364 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -16,6 +16,7 @@ import { ContestsModule } from './contests/contests.module'; import { JudgesManagementModule } from './judges-management/judges-management.module'; import { UploadModule } from './upload/upload.module'; import { HomeworkModule } from './homework/homework.module'; +import { OssModule } from './oss/oss.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { RolesGuard } from './auth/guards/roles.guard'; import { TransformInterceptor } from './common/interceptors/transform.interceptor'; @@ -48,6 +49,7 @@ import { HttpExceptionFilter } from './common/filters/http-exception.filter'; JudgesManagementModule, UploadModule, HomeworkModule, + OssModule, ], providers: [ { diff --git a/backend/src/main.ts b/backend/src/main.ts index 7163c17..e00ceb8 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -2,8 +2,6 @@ import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@nestjs/common'; import { AppModule } from './app.module'; import { ConfigService } from '@nestjs/config'; -import { NestExpressApplication } from '@nestjs/platform-express'; -import { join } from 'path'; async function bootstrap() { const app = await NestFactory.create(AppModule); @@ -14,12 +12,8 @@ async function bootstrap() { credentials: true, }); - // 配置静态文件服务 - const expressApp = app as NestExpressApplication; - const uploadsPath = join(process.cwd(), 'uploads'); - expressApp.useStaticAssets(uploadsPath, { - prefix: '/api/uploads', - }); + // Global prefix + app.setGlobalPrefix('api'); // Global validation pipe app.useGlobalPipes( @@ -30,9 +24,6 @@ async function bootstrap() { }), ); - // Global prefix - app.setGlobalPrefix('api'); - // 验证环境配置加载 const configService = app.get(ConfigService); diff --git a/backend/src/oss/oss.module.ts b/backend/src/oss/oss.module.ts new file mode 100644 index 0000000..496e519 --- /dev/null +++ b/backend/src/oss/oss.module.ts @@ -0,0 +1,9 @@ +import { Module, Global } from '@nestjs/common'; +import { OssService } from './oss.service'; + +@Global() // 全局模块,其他模块无需导入即可使用 +@Module({ + providers: [OssService], + exports: [OssService], +}) +export class OssModule {} diff --git a/backend/src/oss/oss.service.ts b/backend/src/oss/oss.service.ts new file mode 100644 index 0000000..83df726 --- /dev/null +++ b/backend/src/oss/oss.service.ts @@ -0,0 +1,202 @@ +import { Injectable, BadRequestException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as COS from 'cos-nodejs-sdk-v5'; +import * as path from 'path'; +import { randomBytes } from 'crypto'; + +@Injectable() +export class OssService { + private client: COS | null = null; + private readonly bucket: string; + private readonly region: string; + private readonly enabled: boolean; + + constructor(private configService: ConfigService) { + const secretId = this.configService.get('COS_SECRET_ID'); + const secretKey = this.configService.get('COS_SECRET_KEY'); + this.bucket = this.configService.get('COS_BUCKET') || ''; + this.region = this.configService.get('COS_REGION') || 'ap-guangzhou'; + + // 检查是否配置了 COS + this.enabled = !!(secretId && secretKey && this.bucket); + + if (this.enabled) { + this.client = new COS({ + SecretId: secretId, + SecretKey: secretKey, + }); + console.log('腾讯云 COS 已启用,Bucket:', this.bucket, 'Region:', this.region); + } else { + console.log('腾讯云 COS 未配置,将使用本地存储'); + } + } + + /** + * 检查 COS 是否启用 + */ + isEnabled(): boolean { + return this.enabled; + } + + /** + * 上传文件到 COS + * @param file 文件 Buffer 或 Stream + * @param originalName 原始文件名 + * @param tenantId 租户ID(可选,用于目录隔离) + * @param userId 用户ID(可选,用于目录隔离) + * @returns 文件访问URL + */ + async uploadFile( + file: Buffer | NodeJS.ReadableStream, + originalName: string, + tenantId?: number, + userId?: number, + ): Promise<{ url: string; fileName: string; ossPath: string }> { + if (!this.enabled || !this.client) { + throw new BadRequestException('COS 服务未启用'); + } + + // 生成唯一文件名 + const fileExt = path.extname(originalName); + const uniqueId = randomBytes(16).toString('hex'); + const fileName = `${uniqueId}${fileExt}`; + + // 构建 COS 存储路径:uploads/tenant_X/user_Y/filename + let cosPath = 'uploads'; + if (tenantId) { + cosPath += `/tenant_${tenantId}`; + if (userId) { + cosPath += `/user_${userId}`; + } + } + cosPath += `/${fileName}`; + + try { + // 上传到 COS + await new Promise((resolve, reject) => { + this.client!.putObject( + { + Bucket: this.bucket, + Region: this.region, + Key: cosPath, + Body: file as Buffer, + }, + (err, data) => { + if (err) { + reject(err); + } else { + resolve(data); + } + }, + ); + }); + + // 构建访问URL + const url = `https://${this.bucket}.cos.${this.region}.myqcloud.com/${cosPath}`; + + // 返回文件信息 + return { + url, + fileName: originalName, + ossPath: cosPath, + }; + } catch (error: any) { + console.error('COS 上传失败:', error); + throw new BadRequestException(`文件上传失败: ${error.message}`); + } + } + + /** + * 删除 COS 文件 + * @param ossPath COS 文件路径 + */ + async deleteFile(ossPath: string): Promise { + if (!this.enabled || !this.client) { + throw new BadRequestException('COS 服务未启用'); + } + + try { + await new Promise((resolve, reject) => { + this.client!.deleteObject( + { + Bucket: this.bucket, + Region: this.region, + Key: ossPath, + }, + (err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }, + ); + }); + } catch (error: any) { + console.error('COS 删除失败:', error); + throw new BadRequestException(`文件删除失败: ${error.message}`); + } + } + + /** + * 获取文件的签名 URL(用于私有 Bucket) + * @param ossPath COS 文件路径 + * @param expires 过期时间(秒),默认 3600 + */ + async getSignedUrl(ossPath: string, expires: number = 3600): Promise { + if (!this.enabled || !this.client) { + throw new BadRequestException('COS 服务未启用'); + } + + try { + const url = this.client.getObjectUrl( + { + Bucket: this.bucket, + Region: this.region, + Key: ossPath, + Sign: true, + Expires: expires, + }, + ); + return url; + } catch (error: any) { + console.error('获取签名 URL 失败:', error); + throw new BadRequestException(`获取文件链接失败: ${error.message}`); + } + } + + /** + * 检查文件是否存在 + * @param ossPath COS 文件路径 + */ + async exists(ossPath: string): Promise { + if (!this.enabled || !this.client) { + return false; + } + + try { + await new Promise((resolve, reject) => { + this.client!.headObject( + { + Bucket: this.bucket, + Region: this.region, + Key: ossPath, + }, + (err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }, + ); + }); + return true; + } catch (error: any) { + if (error.statusCode === 404) { + return false; + } + throw error; + } + } +} diff --git a/backend/src/upload/upload.controller.ts b/backend/src/upload/upload.controller.ts index 1b955b4..9ad4e23 100644 --- a/backend/src/upload/upload.controller.ts +++ b/backend/src/upload/upload.controller.ts @@ -1,22 +1,29 @@ import { Controller, Post, + Get, + Param, + Res, UseInterceptors, UploadedFile, UseGuards, Request, BadRequestException, + NotFoundException, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; +import { Response } from 'express'; import { UploadService } from './upload.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import * as path from 'path'; +import * as fs from 'fs'; @Controller('upload') -@UseGuards(JwtAuthGuard) export class UploadController { constructor(private readonly uploadService: UploadService) {} @Post() + @UseGuards(JwtAuthGuard) @UseInterceptors(FileInterceptor('file')) async uploadFile( @UploadedFile() file: Express.Multer.File, @@ -39,3 +46,61 @@ export class UploadController { } } +/** + * 专门用于静态文件服务的控制器 + * 处理 /api/uploads/* 路径的文件请求 + */ +@Controller('uploads') +export class UploadsController { + private readonly uploadDir: string; + + constructor() { + this.uploadDir = path.join(process.cwd(), 'uploads'); + } + + @Get('*') + async serveFile(@Param() params: any, @Res() res: Response) { + // 获取文件路径(从通配符参数中) + const filePath = params[0] || ''; + const fullPath = path.join(this.uploadDir, filePath); + + // 安全检查:防止路径遍历攻击 + const normalizedPath = path.normalize(fullPath); + if (!normalizedPath.startsWith(this.uploadDir)) { + throw new BadRequestException('无效的文件路径'); + } + + // 检查文件是否存在 + if (!fs.existsSync(normalizedPath)) { + console.error('文件不存在:', normalizedPath); + throw new NotFoundException('文件不存在'); + } + + // 获取文件扩展名以设置正确的 Content-Type + const ext = path.extname(normalizedPath).toLowerCase(); + const mimeTypes: Record = { + '.glb': 'model/gltf-binary', + '.gltf': 'model/gltf+json', + '.obj': 'text/plain', + '.fbx': 'application/octet-stream', + '.stl': 'model/stl', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.pdf': 'application/pdf', + '.mp4': 'video/mp4', + '.webm': 'video/webm', + }; + + const contentType = mimeTypes[ext] || 'application/octet-stream'; + + res.setHeader('Content-Type', contentType); + res.setHeader('Access-Control-Allow-Origin', '*'); + + // 发送文件 + res.sendFile(normalizedPath); + } +} + diff --git a/backend/src/upload/upload.module.ts b/backend/src/upload/upload.module.ts index fd13579..b6e37cf 100644 --- a/backend/src/upload/upload.module.ts +++ b/backend/src/upload/upload.module.ts @@ -1,9 +1,9 @@ import { Module } from '@nestjs/common'; -import { UploadController } from './upload.controller'; +import { UploadController, UploadsController } from './upload.controller'; import { UploadService } from './upload.service'; @Module({ - controllers: [UploadController], + controllers: [UploadController, UploadsController], providers: [UploadService], exports: [UploadService], }) diff --git a/backend/src/upload/upload.service.ts b/backend/src/upload/upload.service.ts index 8f8c444..bb35082 100644 --- a/backend/src/upload/upload.service.ts +++ b/backend/src/upload/upload.service.ts @@ -1,5 +1,6 @@ import { Injectable, BadRequestException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { OssService } from '../oss/oss.service'; import * as fs from 'fs'; import * as path from 'path'; import { randomBytes } from 'crypto'; @@ -7,15 +8,28 @@ import { randomBytes } from 'crypto'; @Injectable() export class UploadService { private readonly uploadDir: string; + private readonly useOss: boolean; - constructor(private configService: ConfigService) { - // 上传文件存储目录 + constructor( + private configService: ConfigService, + private ossService: OssService, + ) { + // 本地上传文件存储目录(作为 COS 的备用方案) this.uploadDir = path.join(process.cwd(), 'uploads'); - - // 确保上传目录存在 + + // 确保本地上传目录存在 if (!fs.existsSync(this.uploadDir)) { fs.mkdirSync(this.uploadDir, { recursive: true }); } + + // 检查是否使用 COS + this.useOss = this.ossService.isEnabled(); + + if (this.useOss) { + console.log('文件上传将使用腾讯云 COS'); + } else { + console.log('文件上传将使用本地存储,目录:', this.uploadDir); + } } async uploadFile( @@ -27,11 +41,56 @@ export class UploadService { throw new BadRequestException('文件不存在'); } + // 优先使用 COS + if (this.useOss) { + return this.uploadToOss(file, tenantId, userId); + } + + // 备用方案:本地存储 + return this.uploadToLocal(file, tenantId, userId); + } + + /** + * 上传到腾讯云 COS + */ + private async uploadToOss( + file: Express.Multer.File, + tenantId?: number, + userId?: number, + ): Promise<{ url: string; fileName: string; size: number }> { + try { + const result = await this.ossService.uploadFile( + file.buffer, + file.originalname, + tenantId, + userId, + ); + + return { + url: result.url, + fileName: result.fileName, + size: file.size, + }; + } catch (error: any) { + console.error('COS 上传失败,尝试本地存储:', error.message); + // COS 失败时回退到本地存储 + return this.uploadToLocal(file, tenantId, userId); + } + } + + /** + * 上传到本地存储 + */ + private async uploadToLocal( + file: Express.Multer.File, + tenantId?: number, + userId?: number, + ): Promise<{ url: string; fileName: string; size: number }> { // 生成唯一文件名 const fileExt = path.extname(file.originalname); const uniqueId = randomBytes(16).toString('hex'); const fileName = `${uniqueId}${fileExt}`; - + // 根据租户ID和用户ID创建目录结构:uploads/tenantId/userId/ let targetDir = this.uploadDir; if (tenantId) { @@ -39,7 +98,7 @@ export class UploadService { if (!fs.existsSync(targetDir)) { fs.mkdirSync(targetDir, { recursive: true }); } - + if (userId) { targetDir = path.join(targetDir, `user_${userId}`); if (!fs.existsSync(targetDir)) { @@ -72,4 +131,3 @@ export class UploadService { }; } } - diff --git a/docs/ai-3d-generation.md b/docs/ai-3d-generation.md new file mode 100644 index 0000000..5694a95 --- /dev/null +++ b/docs/ai-3d-generation.md @@ -0,0 +1,1044 @@ +# AI 3D 模型生成功能开发文档 + +## 1. 功能概述 + +### 1.1 功能描述 +用户可以通过文字描述或上传图片,利用 AI 技术生成 3D 模型(GLB/GLTF 格式)。类似腾讯混元 3D (https://3d.hunyuan.tencent.com/) 的功能。 + +### 1.2 核心特性 +- **文字生成 3D**:用户输入一段文字描述,AI 生成对应的 3D 模型 +- **图片生成 3D**:用户上传一张图片,AI 根据图片生成 3D 模型 +- **任务历史**:保存用户的生成历史记录,支持查看和删除 +- **3D 预览**:生成完成后可在线预览 3D 模型 + +### 1.3 技术选型 +- **开发阶段**:使用 Mock 数据模拟 AI 生成过程 +- **生产阶段**:可对接腾讯混元 3D API 或 Meshy AI API +- **入口方式**:独立页面 `/ai-3d` + +--- + +## 2. 数据库设计 + +### 2.1 AI3DTask 表结构 + +```prisma +model AI3DTask { + id Int @id @default(autoincrement()) + tenantId Int @map("tenant_id") + userId Int @map("user_id") + inputType String @map("input_type") // text | image + inputContent String @db.Text @map("input_content") // 文字描述或图片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") // 失败时的错误信息 + 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]) + user User @relation(fields: [userId], references: [id]) + + @@index([userId]) + @@index([tenantId]) + @@index([status]) + @@index([createTime]) + @@map("t_ai_3d_task") +} +``` + +### 2.2 字段说明 + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | Int | 主键,自增 | +| tenantId | Int | 租户ID,用于多租户隔离 | +| userId | Int | 用户ID,任务归属用户 | +| inputType | String | 输入类型:`text`(文字) 或 `image`(图片) | +| inputContent | Text | 输入内容:文字描述或图片URL | +| status | String | 任务状态:pending/processing/completed/failed/timeout | +| resultUrl | String | 生成的3D模型文件URL | +| previewUrl | String | 模型预览图URL | +| errorMessage | String | 失败时的错误信息 | +| externalTaskId | String | 外部AI服务的任务ID,用于查询状态 | +| retryCount | Int | 已重试次数,默认0 | +| createTime | DateTime | 创建时间 | +| completeTime | DateTime | 完成时间 | + +### 2.3 状态流转 + +``` +pending (待处理) + ↓ +processing (处理中) + ↓ +completed (已完成) / failed (失败) / timeout (超时) + ↓ + [用户重试] → pending +``` + +### 2.4 配置常量 + +| 常量 | 默认值 | 说明 | +|------|--------|------| +| MAX_CONCURRENT_TASKS | 3 | 每用户最大并行任务数 | +| TASK_TIMEOUT_MINUTES | 10 | 任务超时时间(分钟) | +| MAX_RETRY_COUNT | 3 | 最大重试次数 | +| CLEANUP_INTERVAL_MINUTES | 5 | 超时清理任务执行间隔 | +| OLD_TASK_RETENTION_DAYS | 30 | 失败任务保留天数 | + +--- + +## 3. API 接口设计 + +### 3.1 创建生成任务 + +**POST** `/api/ai-3d/generate` + +**Request Body:** +```json +{ + "inputType": "text", // "text" | "image" + "inputContent": "一只可爱的小猫" // 文字描述或图片URL +} +``` + +**Response:** +```json +{ + "code": 0, + "message": "success", + "data": { + "id": 1, + "inputType": "text", + "inputContent": "一只可爱的小猫", + "status": "pending", + "createTime": "2024-01-13T10:00:00.000Z" + } +} +``` + +### 3.2 获取任务列表 + +**GET** `/api/ai-3d/tasks` + +**Query Parameters:** +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| page | number | 否 | 页码,默认1 | +| pageSize | number | 否 | 每页数量,默认10 | +| status | string | 否 | 状态筛选 | + +**Response:** +```json +{ + "code": 0, + "message": "success", + "data": { + "list": [ + { + "id": 1, + "inputType": "text", + "inputContent": "一只可爱的小猫", + "status": "completed", + "resultUrl": "/uploads/ai-3d/xxx.glb", + "previewUrl": "/uploads/ai-3d/xxx-preview.png", + "createTime": "2024-01-13T10:00:00.000Z", + "completeTime": "2024-01-13T10:01:30.000Z" + } + ], + "total": 1, + "page": 1, + "pageSize": 10 + } +} +``` + +### 3.3 获取任务详情 + +**GET** `/api/ai-3d/tasks/:id` + +**Response:** +```json +{ + "code": 0, + "message": "success", + "data": { + "id": 1, + "inputType": "text", + "inputContent": "一只可爱的小猫", + "status": "completed", + "resultUrl": "/uploads/ai-3d/xxx.glb", + "previewUrl": "/uploads/ai-3d/xxx-preview.png", + "errorMessage": null, + "createTime": "2024-01-13T10:00:00.000Z", + "completeTime": "2024-01-13T10:01:30.000Z" + } +} +``` + +### 3.4 删除任务 + +**DELETE** `/api/ai-3d/tasks/:id` + +**Response:** +```json +{ + "code": 0, + "message": "success", + "data": null +} +``` + +### 3.5 重试任务 + +**POST** `/api/ai-3d/tasks/:id/retry` + +**说明:** 仅支持状态为 `failed` 或 `timeout` 的任务,每个任务最多重试 3 次 + +**Response (成功):** +```json +{ + "code": 0, + "message": "success", + "data": { + "id": 1, + "inputType": "text", + "inputContent": "一只可爱的小猫", + "status": "processing", + "retryCount": 1, + "createTime": "2024-01-13T10:00:00.000Z" + } +} +``` + +**Response (失败 - 达到重试上限):** +```json +{ + "code": 400, + "message": "已达到最大重试次数 3 次,请创建新任务" +} +``` + +**Response (失败 - 并发限制):** +```json +{ + "code": 400, + "message": "您当前有 3 个任务正在处理中,请等待完成后再重试" +} +``` + +--- + +## 4. 后端模块设计 + +### 4.1 目录结构 + +``` +backend/src/ai-3d/ +├── ai-3d.module.ts # 模块定义 +├── ai-3d.controller.ts # 控制器 +├── ai-3d.service.ts # 业务服务 +├── ai-3d-cleanup.service.ts # 超时清理定时任务 +├── dto/ +│ ├── create-task.dto.ts # 创建任务DTO +│ └── query-task.dto.ts # 查询任务DTO +└── providers/ + ├── ai-3d-provider.interface.ts # AI服务接口 + ├── mock.provider.ts # Mock实现 + ├── hunyuan.provider.ts # 腾讯混元实现(预留) + └── meshy.provider.ts # Meshy AI实现(预留) +``` + +### 4.2 AI Provider 接口 + +```typescript +// ai-3d-provider.interface.ts +export interface AI3DGenerateResult { + taskId: string; // 外部任务ID + status: 'pending' | 'processing' | 'completed' | 'failed'; + resultUrl?: string; // 3D模型URL + previewUrl?: string; // 预览图URL + errorMessage?: string; // 错误信息 +} + +export interface AI3DProvider { + /** + * 提交生成任务 + */ + submitTask(inputType: 'text' | 'image', inputContent: string): Promise; + + /** + * 查询任务状态 + */ + queryTask(taskId: string): Promise; +} +``` + +### 4.3 Mock Provider 实现逻辑 + +```typescript +// mock.provider.ts +@Injectable() +export class MockAI3DProvider implements AI3DProvider { + private tasks = new Map(); + + async submitTask(inputType: string, inputContent: string): Promise { + const taskId = generateUUID(); + this.tasks.set(taskId, { + status: 'processing', + startTime: Date.now(), + }); + + // 模拟5-10秒后完成 + setTimeout(() => { + this.tasks.set(taskId, { + status: 'completed', + resultUrl: '/mock/sample-model.glb', + previewUrl: '/mock/sample-preview.png', + }); + }, 5000 + Math.random() * 5000); + + return taskId; + } + + async queryTask(taskId: string): Promise { + const task = this.tasks.get(taskId); + if (!task) { + throw new NotFoundException('Task not found'); + } + return task; + } +} +``` + +### 4.4 Service 核心逻辑 + +```typescript +// ai-3d.service.ts +@Injectable() +export class AI3DService { + constructor( + private prisma: PrismaService, + private ai3dProvider: AI3DProvider, + ) {} + + async createTask(userId: number, tenantId: number, dto: CreateTaskDto) { + // 1. 创建数据库记录 + const task = await this.prisma.aI3DTask.create({ + data: { + userId, + tenantId, + inputType: dto.inputType, + inputContent: dto.inputContent, + status: 'pending', + }, + }); + + // 2. 提交到AI服务 + const externalTaskId = await this.ai3dProvider.submitTask( + dto.inputType, + dto.inputContent, + ); + + // 3. 更新状态为处理中 + await this.prisma.aI3DTask.update({ + where: { id: task.id }, + data: { status: 'processing' }, + }); + + // 4. 启动轮询检查任务状态(或使用消息队列) + this.pollTaskStatus(task.id, externalTaskId); + + return task; + } + + async getTasks(userId: number, query: QueryTaskDto) { + const { page = 1, pageSize = 10, status } = query; + + const where = { + userId, + ...(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 }, + }); + + return null; + } + + private async pollTaskStatus(taskId: number, externalTaskId: string) { + // 轮询检查任务状态,完成后更新数据库 + const checkStatus = async () => { + 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(), + }, + }); + } else { + // 继续轮询 + setTimeout(checkStatus, 2000); + } + } catch (error) { + console.error('Poll task status error:', error); + setTimeout(checkStatus, 5000); + } + }; + + setTimeout(checkStatus, 2000); + } +} +``` + +--- + +## 5. 前端页面设计 + +### 5.1 目录结构 + +``` +frontend/src/ +├── views/ +│ └── ai-3d/ +│ ├── Index.vue # 主页面 +│ └── components/ +│ ├── GenerateForm.vue # 生成表单 +│ ├── TaskList.vue # 任务列表 +│ └── TaskCard.vue # 任务卡片 +├── api/ +│ └── ai-3d.ts # API接口 +└── router/ + └── index.ts # 添加路由 +``` + +### 5.2 页面布局 + +``` +┌─────────────────────────────────────────────────────┐ +│ AI 3D 模型生成 │ +├─────────────────────────────────────────────────────┤ +│ ┌─────────────────────────────────────────────┐ │ +│ │ 生成方式: ○ 文字描述 ○ 图片上传 │ │ +│ │ │ │ +│ │ [输入框/上传区域] │ │ +│ │ │ │ +│ │ [开始生成] │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ +│ 生成历史 │ +│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ +│ │ 预览图 │ │ 预览图 │ │ 预览图 │ │ 预览图 │ │ +│ │ │ │ │ │ │ │ │ │ +│ │ 已完成 │ │ 处理中 │ │ 已完成 │ │ 失败 │ │ +│ │ [查看] │ │ [...] │ │ [查看] │ │ [重试] │ │ +│ └────────┘ └────────┘ └────────┘ └────────┘ │ +│ │ +│ [加载更多] │ +└─────────────────────────────────────────────────────┘ +``` + +### 5.3 API 接口封装 + +```typescript +// api/ai-3d.ts +import request from '@/utils/request' + +export interface CreateTaskParams { + inputType: 'text' | 'image' + inputContent: string +} + +export interface QueryTaskParams { + page?: number + pageSize?: number + status?: string +} + +export interface AI3DTask { + id: number + inputType: 'text' | 'image' + inputContent: string + status: 'pending' | 'processing' | 'completed' | 'failed' | 'timeout' + resultUrl?: string + previewUrl?: string + errorMessage?: string + retryCount: number + createTime: string + completeTime?: string +} + +// 创建生成任务 +export function createAI3DTask(data: CreateTaskParams) { + return request.post('/ai-3d/generate', data) +} + +// 获取任务列表 +export function getAI3DTasks(params: QueryTaskParams) { + return request.get<{ list: AI3DTask[]; total: number }>('/ai-3d/tasks', { params }) +} + +// 获取任务详情 +export function getAI3DTask(id: number) { + return request.get(`/ai-3d/tasks/${id}`) +} + +// 删除任务 +export function deleteAI3DTask(id: number) { + return request.delete(`/ai-3d/tasks/${id}`) +} + +// 重试任务 +export function retryAI3DTask(id: number) { + return request.post(`/ai-3d/tasks/${id}/retry`) +} +``` + +### 5.4 路由配置 + +```typescript +// router/index.ts - 添加到 baseRoutes 的 Main children 中 +{ + path: "ai-3d", + name: "AI3DGenerate", + component: () => import("@/views/ai-3d/Index.vue"), + meta: { + title: "AI 3D生成", + requiresAuth: true, + }, +}, +``` + +--- + +## 6. 开发步骤 + +### 第一阶段:数据库与后端基础 + +1. **添加 Prisma Schema** + - 在 `schema.prisma` 中添加 `AI3DTask` 模型 + - 在 `Tenant` 和 `User` 模型中添加关联 + +2. **运行数据库迁移** + ```bash + cd backend + npx prisma migrate dev --name add_ai_3d_task + ``` + +3. **创建后端模块** + - 创建 `ai-3d` 目录结构 + - 实现 Mock Provider + - 实现 Service 和 Controller + - 注册模块到 AppModule + +### 第二阶段:前端页面 + +4. **创建 API 接口文件** + - 创建 `src/api/ai-3d.ts` + +5. **创建页面组件** + - 创建 `src/views/ai-3d/Index.vue` 主页面 + - 实现生成表单 + - 实现任务列表展示 + +6. **配置路由** + - 在 `router/index.ts` 中添加路由 + +### 第三阶段:功能完善 + +7. **状态轮询** + - 实现前端任务状态轮询 + - 任务完成时自动刷新 + +8. **3D 预览集成** + - 复用现有的 ModelViewer 组件 + +9. **测试与优化** + - 测试完整流程 + - 优化用户体验 + +--- + +## 7. 后续扩展 + +### 7.1 对接真实 AI 服务 + +当需要对接真实 AI 服务时,只需: + +1. 实现对应的 Provider(如 `HunyuanProvider`) +2. 通过环境变量切换 Provider +3. 无需修改 Service 层代码 + +```typescript +// 通过环境变量选择 Provider +const provider = process.env.AI_3D_PROVIDER || 'mock'; + +switch (provider) { + case 'hunyuan': + return new HunyuanProvider(config); + case 'meshy': + return new MeshyProvider(config); + default: + return new MockProvider(); +} +``` + +### 7.2 腾讯混元 3D API 参考 + +- 官网:https://3d.hunyuan.tencent.com/ +- 支持文字生成3D、图片生成3D +- 需要腾讯云账号和 API 密钥 + +### 7.3 Meshy AI API 参考 + +- 官网:https://www.meshy.ai/ +- 提供 REST API +- 支持 text-to-3d、image-to-3d +- 文档:https://docs.meshy.ai/ + +--- + +## 8. 并发控制与任务超时 + +### 8.1 并发控制 + +#### 8.1.1 设计目标 +- 限制每个用户同时进行的任务数量,避免资源滥用 +- 保证系统稳定性,防止单用户占用过多资源 +- 提供友好的错误提示 + +#### 8.1.2 实现方案 + +```typescript +// ai-3d.service.ts +import { BadRequestException } from '@nestjs/common'; + +// 配置常量 +const MAX_CONCURRENT_TASKS = 3; // 每用户最大并行任务数 + +@Injectable() +export class AI3DService { + 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, + ); + + await this.prisma.aI3DTask.update({ + where: { id: task.id }, + data: { + status: 'processing', + externalTaskId, + }, + }); + + // 4. 启动轮询检查任务状态 + this.pollTaskStatus(task.id, externalTaskId, Date.now()); + + return task; + } catch (error) { + // 提交失败,更新状态 + await this.prisma.aI3DTask.update({ + where: { id: task.id }, + data: { + status: 'failed', + errorMessage: error.message || 'AI服务提交失败', + completeTime: new Date(), + }, + }); + throw error; + } + } +} +``` + +#### 8.1.3 前端处理 + +```typescript +// 前端提交任务时处理并发限制错误 +const handleGenerate = async () => { + try { + loading.value = true; + await createAI3DTask({ + inputType: inputType.value, + inputContent: inputContent.value, + }); + message.success('任务已提交'); + fetchTasks(); // 刷新列表 + } catch (error: any) { + if (error.response?.status === 400) { + // 并发限制错误,显示友好提示 + message.warning(error.response.data.message); + } else { + message.error('提交失败,请重试'); + } + } finally { + loading.value = false; + } +}; +``` + +--- + +### 8.2 任务超时处理 + +#### 8.2.1 设计目标 +- 防止任务长时间卡在处理中状态 +- 自动标记超时任务,释放用户并发配额 +- 支持用户重试超时任务 + +#### 8.2.2 方案A:轮询时检查超时(适用于 Mock 开发阶段) + +```typescript +// ai-3d.service.ts +const TASK_TIMEOUT_MS = 10 * 60 * 1000; // 10分钟超时 + +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(), + }, + }); + console.log(`Task ${taskId} timeout after ${TASK_TIMEOUT_MS}ms`); + 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(), + }, + }); + } else { + // 继续轮询,每2秒检查一次 + setTimeout(checkStatus, 2000); + } + } catch (error) { + console.error(`Poll task ${taskId} error:`, error); + // 出错后延长轮询间隔,每5秒重试 + setTimeout(checkStatus, 5000); + } + }; + + // 首次检查延迟2秒 + setTimeout(checkStatus, 2000); +} +``` + +#### 8.2.3 方案B:定时任务批量清理(推荐生产环境) + +```typescript +// ai-3d-cleanup.service.ts +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class AI3DCleanupService { + private readonly logger = new Logger(AI3DCleanupService.name); + + constructor(private prisma: PrismaService) {} + + /** + * 每5分钟检查并处理超时任务 + */ + @Cron(CronExpression.EVERY_5_MINUTES) + async handleTimeoutTasks() { + const TIMEOUT_MINUTES = 10; + const timeoutThreshold = new Date(Date.now() - TIMEOUT_MINUTES * 60 * 1000); + + const result = await this.prisma.aI3DTask.updateMany({ + where: { + status: { in: ['pending', 'processing'] }, + createTime: { lt: timeoutThreshold }, + }, + data: { + status: 'timeout', + errorMessage: '任务处理超时,请重试', + completeTime: new Date(), + }, + }); + + if (result.count > 0) { + this.logger.log(`清理了 ${result.count} 个超时任务`); + } + } + + /** + * 每天凌晨2点清理30天前的失败/超时任务记录 + */ + @Cron('0 2 * * *') + async cleanupOldTasks() { + const RETENTION_DAYS = 30; + const retentionThreshold = new Date( + Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1000 + ); + + const result = await this.prisma.aI3DTask.deleteMany({ + where: { + status: { in: ['failed', 'timeout'] }, + createTime: { lt: retentionThreshold }, + }, + }); + + if (result.count > 0) { + this.logger.log(`清理了 ${result.count} 个过期失败任务`); + } + } +} +``` + +#### 8.2.4 模块注册 + +```typescript +// ai-3d.module.ts +import { Module } from '@nestjs/common'; +import { ScheduleModule } from '@nestjs/schedule'; +import { AI3DController } from './ai-3d.controller'; +import { AI3DService } from './ai-3d.service'; +import { AI3DCleanupService } from './ai-3d-cleanup.service'; +import { MockAI3DProvider } from './providers/mock.provider'; + +@Module({ + imports: [ScheduleModule.forRoot()], + controllers: [AI3DController], + providers: [ + AI3DService, + AI3DCleanupService, + { + provide: 'AI3D_PROVIDER', + useClass: MockAI3DProvider, + }, + ], +}) +export class AI3DModule {} +``` + +--- + +### 8.3 重试机制 + +#### 8.3.1 重试API + +**POST** `/api/ai-3d/tasks/:id/retry` + +```typescript +// ai-3d.controller.ts +@Post('tasks/:id/retry') +async retryTask(@Param('id') id: number, @Request() req) { + return this.ai3dService.retryTask(req.user.id, id); +} +``` + +#### 8.3.2 重试逻辑 + +```typescript +// ai-3d.service.ts +const MAX_RETRY_COUNT = 3; + +async retryTask(userId: number, taskId: number) { + const task = await this.prisma.aI3DTask.findFirst({ + where: { id: taskId, 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: taskId }, + data: { + status: 'pending', + errorMessage: null, + completeTime: null, + retryCount: { increment: 1 }, + }, + }); + + // 重新提交任务 + const externalTaskId = await this.ai3dProvider.submitTask( + task.inputType, + task.inputContent, + ); + + await this.prisma.aI3DTask.update({ + where: { id: taskId }, + data: { + status: 'processing', + externalTaskId, + }, + }); + + this.pollTaskStatus(taskId, externalTaskId, Date.now()); + + return this.getTask(userId, taskId); +} +``` + +#### 8.3.3 前端重试按钮 + +```vue + + + +``` + +--- + +### 8.4 方案对比与推荐 + +| 方案 | 优点 | 缺点 | 适用场景 | +|------|------|------|----------| +| 方案A(轮询检查) | 实现简单,无需额外依赖 | 服务重启后轮询丢失 | Mock 开发、小规模部署 | +| 方案B(定时任务) | 可靠性高,服务重启不影响 | 需要引入 @nestjs/schedule | 生产环境 | + +**推荐策略**: +- 开发阶段使用方案A,快速验证功能 +- 生产环境使用方案B + 方案A 结合,双重保障 + +--- + +## 9. 注意事项 + +1. **数据归属**:任务数据通过 `userId` 关联用户,确保用户只能访问自己的数据 +2. **多租户隔离**:保留 `tenantId` 字段,便于管理员按租户统计 +3. **文件存储**:生成的 3D 模型文件建议存储到 OSS,已实现 OSS 集成 +4. **并发控制**:每用户最多 3 个并行任务,可通过配置调整 +5. **任务超时**:10 分钟未完成自动标记为超时,支持重试 +6. **重试限制**:单任务最多重试 3 次,超过后需创建新任务 +7. **数据清理**:30 天前的失败/超时任务自动清理 diff --git a/frontend/src/views/contests/components/AddJudgeDrawer.vue b/frontend/src/views/contests/components/AddJudgeDrawer.vue index 645effe..c1cfe34 100644 --- a/frontend/src/views/contests/components/AddJudgeDrawer.vue +++ b/frontend/src/views/contests/components/AddJudgeDrawer.vue @@ -375,7 +375,10 @@ watch( watch( () => searchParams.organization, () => { - if (searchParams.organization === undefined || searchParams.organization === "") { + if ( + searchParams.organization === undefined || + searchParams.organization === "" + ) { handleSearch() } } diff --git a/frontend/src/views/model/ModelViewer.vue b/frontend/src/views/model/ModelViewer.vue index 852c906..39b4595 100644 --- a/frontend/src/views/model/ModelViewer.vue +++ b/frontend/src/views/model/ModelViewer.vue @@ -107,11 +107,18 @@ let dracoLoader: DRACOLoader | null = null let initialCameraPosition: THREE.Vector3 | null = null // 获取模型 URL +// Vue Router 会自动解码 query 参数,所以直接使用即可 const modelUrl = ref((route.query.url as string) || "") +console.log("模型查看器 - URL:", modelUrl.value) // 返回上一页 const handleBack = () => { - router.back() + // 如果是新标签页打开(没有历史记录),则关闭窗口 + if (window.history.length <= 1) { + window.close() + } else { + router.back() + } } // 重置相机视角 @@ -224,6 +231,18 @@ const loadModel = async () => { if (!scene || !modelUrl.value) { error.value = "模型 URL 不存在" loading.value = false + console.error("模型加载失败: URL为空", { scene: !!scene, url: modelUrl.value }) + return + } + + // 检查文件扩展名 + const supportedExtensions = ['.glb', '.gltf'] + const urlLower = modelUrl.value.toLowerCase() + const isSupported = supportedExtensions.some(ext => urlLower.includes(ext)) + if (!isSupported) { + error.value = `不支持的文件格式,目前仅支持 GLB/GLTF 格式` + loading.value = false + console.error("不支持的文件格式:", modelUrl.value) return } @@ -233,10 +252,16 @@ const loadModel = async () => { try { console.log("开始加载模型,URL:", modelUrl.value) - // 验证 URL - const response = await fetch(modelUrl.value, { method: "HEAD" }) - if (!response.ok) { - throw new Error(`文件不存在或无法访问 (${response.status})`) + // 验证 URL 是否可访问 + try { + const response = await fetch(modelUrl.value, { method: "HEAD" }) + if (!response.ok) { + throw new Error(`文件不存在或无法访问 (HTTP ${response.status})`) + } + console.log("文件验证通过,开始加载...") + } catch (fetchErr: any) { + console.error("文件访问验证失败:", fetchErr) + throw new Error(`无法访问文件: ${fetchErr.message}`) } const loader = new GLTFLoader()