diff --git a/.claude/ui-design-system.md b/.claude/ui-design-system.md new file mode 100644 index 0000000..0bee535 --- /dev/null +++ b/.claude/ui-design-system.md @@ -0,0 +1,607 @@ +# 比赛管理系统 - UI设计系统文档 + +> 版本: 1.0 +> 更新日期: 2026-01-14 +> 框架: Vue 3 + Ant Design Vue + Tailwind CSS + +--- + +## 📋 目录 + +1. [设计理念](#设计理念) +2. [色彩系统](#色彩系统) +3. [布局规范](#布局规范) +4. [组件规范](#组件规范) +5. [双主题架构](#双主题架构) +6. [风格统一建议](#风格统一建议) + +--- + +## 🎨 设计理念 + +### 整体定位 +- **企业级应用**: 专业、可靠、易用 +- **教育科技**: 现代、创新、智能 +- **双主题并存**: 传统业务管理 + 创新AI功能 + +### 设计原则 +1. **一致性优先**: 相同功能使用相同的交互模式 +2. **效率至上**: 减少用户操作步骤,提升工作效率 +3. **清晰明确**: 信息层级分明,状态反馈清晰 +4. **渐进增强**: 基础功能稳定,高级功能突出 + +--- + +## 🎨 色彩系统 + +### 主题A:标准业务主题(用于普通页面) + +#### 主色调 - 蓝色系 +```scss +--ant-color-primary: #0958d9 // 主色(Ant Design blue-6) +--ant-color-primary-hover: #1677ff // 悬停色(blue-5) +--ant-color-primary-active: #003eb3 // 激活色(blue-7) +--ant-color-primary-bg: #e6f7ff // 主色背景(blue-0) +--ant-color-primary-bg-hover: #bae7ff // 主色背景悬停(blue-1) +``` + +#### 功能色 +```scss +--ant-color-success: #52c41a // 成功(绿色) +--ant-color-error: #ff4d4f // 错误(红色) +--ant-color-warning: #faad14 // 警告(黄色) +--ant-color-info: #0958d9 // 信息(蓝色) +``` + +#### 中性色 +```scss +// 文字 +--ant-color-text: rgba(0, 0, 0, 0.85) // 主文字 +--ant-color-text-secondary: rgba(0, 0, 0, 0.45) // 次要文字 +--ant-color-text-tertiary: rgba(0, 0, 0, 0.25) // 辅助文字 + +// 边框 +--ant-color-border: #d9d9d9 // 主边框 +--ant-color-border-secondary: rgba(0, 0, 0, 0.06) // 次边框 + +// 背景 +--ant-color-bg-container: #ffffff // 容器背景 +--ant-color-bg-layout: #f5f5f5 // 布局背景 +``` + +#### 侧边栏主题 +```scss +--sidebar-bg: #f5f5f5 // 侧边栏背景 +--sidebar-menu-item-hover: #e6f7ff // 菜单项悬停 +--sidebar-menu-item-selected-bg: #e6f7ff // 菜单项选中背景 +--sidebar-menu-text: rgba(0, 0, 0, 0.85) // 菜单文字 +--sidebar-menu-text-selected: #0958d9 // 选中文字 +``` + +### 主题B:现代创意主题(用于3D建模页面) + +#### 主色调 - 深色科技风 +```scss +$primary: #7c3aed // 深紫色 +$primary-light: #a78bfa // 浅紫色 +$primary-dark: #5b21b6 // 深紫暗色 +$secondary: #06b6d4 // 青蓝色 +$accent: #f43f5e // 粉红强调色 +$success: #10b981 // 成功绿 +``` + +#### 背景与表面 +```scss +$background: #0a0a12 // 主背景(深黑) +$surface: #12121e // 卡片表面(深灰) +$surface-light: #1a1a2e // 浅表面 +``` + +#### 文字色 +```scss +$text: #e2e8f0 // 主文字(浅灰) +$text-muted: #94a3b8 // 次要文字 +``` + +#### 渐变 +```scss +$gradient-primary: linear-gradient(135deg, #7c3aed 0%, #ec4899 100%) +$gradient-secondary: linear-gradient(135deg, #06b6d4 0%, #7c3aed 100%) +``` + +--- + +## 📐 布局规范 + +### 间距系统(8px基础单位) + +```scss +$spacing-xs: 8px // 超小间距 +$spacing-sm: 16px // 小间距 +$spacing-md: 24px // 中间距 +$spacing-lg: 32px // 大间距 +$spacing-xl: 48px // 超大间距 +``` + +### 标准布局 + +#### 侧边栏布局(BasicLayout) +``` +- 侧边栏宽度: 200px +- 侧边栏背景: #f5f5f5 +- Logo高度: 64px +- 菜单项外边距: 4px 8px +- 菜单项圆角: 6px +- 内容区内边距: 20px +``` + +#### 卡片间距 +```scss +.card { + padding: 16px; + border-radius: 8px; + margin-bottom: 16px; + background: #fff; +} +``` + +#### 搜索表单 +```scss +.search-form { + padding: 16px; + margin-bottom: 16px; + border-radius: 8px; + background: #fafafa; +} +``` + +### 栅格系统 +- 使用Ant Design 24栅格系统 +- 常用布局: `` +- 响应式断点: xs(480), sm(576), md(768), lg(992), xl(1200), xxl(1600) + +--- + +## 🧩 组件规范 + +### 按钮 + +#### 类型与用途 +```vue + +创建 + + +取消 + + +查看详情 + + +删除 +``` + +#### 尺寸 +```vue +大按钮 +默认按钮 +小按钮 +``` + +#### 按钮组合间距 +```scss +.button-group { + .ant-btn + .ant-btn { + margin-left: 8px; + } +} +``` + +### 表格 + +#### 标准配置 +```typescript +{ + bordered: false, // 不显示边框(默认) + size: 'middle', // 默认尺寸 + pagination: { + defaultPageSize: 10, // 默认每页10条 + showSizeChanger: true, // 显示切换器 + showQuickJumper: true, // 显示快速跳转 + showTotal: (total) => `共 ${total} 条`, + }, + rowKey: 'id', // 行键 + loading: false, // 加载状态 +} +``` + +#### 列宽建议 +``` +- ID列: 80px +- 名称列: 120-200px +- 描述列: 200-300px +- 时间列: 180px +- 状态列: 100px +- 操作列: 120-200px (fixed: 'right') +``` + +### 表单 + +#### 布局 +```vue + + + +``` + +#### 输入框宽度 +``` +- 短文本(名称、编号): 200px +- 中文本(描述): 300-400px +- 长文本(详情): 100% +- 选择框(状态、类型): 120-200px +- 日期选择: 200px +- 日期范围: 280px +``` + +### 标签(Tags) + +#### 颜色语义 +```vue + +已发布 +进行中 +草稿 +已关闭 + + +个人赛 +团队赛 +``` + +### 模态框与抽屉 + +#### 模态框 +```vue + + + +``` + +#### 抽屉 +```vue + + + +``` + +### 卡片 + +#### 标准卡片 +```vue + + + +``` + +#### 无边框卡片 +```vue + + + +``` + +--- + +## 🌓 双主题架构 + +### 主题A:标准业务主题 + +**适用页面:** +- 赛事管理(contests) +- 学校管理(school) +- 作业管理(homework) +- 系统管理(system) +- 登录页面(auth) + +**设计特征:** +- ✅ 浅色背景(#ffffff, #f5f5f5) +- ✅ 蓝色主题(#0958d9) +- ✅ 标准Ant Design组件样式 +- ✅ 简洁的过渡动画(0.3s) +- ✅ 表格为主要数据展示方式 +- ✅ 圆角: 6-8px +- ✅ 轻微阴影效果 + +**代码示例:** +```vue + + + +``` + +### 主题B:现代创意主题 + +**适用页面:** +- 3D建模实验室(workbench/ai-3d) +- AI相关创新功能 + +**设计特征:** +- ✅ 深色背景(#0a0a12) +- ✅ 紫色+青蓝色主题(#7c3aed, #06b6d4) +- ✅ 玻璃态效果(backdrop-filter: blur()) +- ✅ 复杂动画(浮动、旋转、脉冲) +- ✅ 卡片网格布局 +- ✅ 圆角: 12-20px +- ✅ 发光效果(glow) +- ✅ 渐变背景 + +**代码示例:** +```vue + + + +``` + +--- + +## 🔄 风格统一建议 + +### 问题分析 +目前3D建模页面与其他业务页面风格差异较大,可能导致: +- 用户体验割裂感 +- 视觉不统一 +- 品牌识别度降低 + +### 统一方案 + +#### 方案A:保守统一(推荐用于企业应用) +**目标:** 将3D页面统一到标准业务主题 + +**调整内容:** +1. **色彩调整** + - 主色由紫色改为蓝色(#7c3aed → #0958d9) + - 背景改为浅色(#0a0a12 → #f5f5f5) + - 文字改为深色(#e2e8f0 → rgba(0,0,0,0.85)) + +2. **布局调整** + - 使用标准卡片布局 + - 保持双面板结构,但使用浅色背景 + - 移除动画背景 + +3. **组件调整** + - 圆角统一为8px + - 移除玻璃态效果 + - 简化动画效果 + +4. **保留特色** + - 保留双面板布局(体现AI功能特殊性) + - 保留历史记录网格展示 + - 保留进度动画反馈 + +**优点:** 风格统一,专业稳重 +**缺点:** 失去部分科技感和创新感 + +#### 方案B:渐进融合(推荐用于创新型应用) +**目标:** 在保留创新感的同时,与主题色对齐 + +**调整内容:** +1. **色彩微调** + - 主色保持紫色,但添加蓝色辅助色 + - 使用浅色变体(深蓝色背景 #0f172a 代替纯黑) + - 增加白色卡片区域(保留部分深色背景) + +2. **融合元素** + - 顶部添加面包屑/返回按钮(与其他页面一致) + - 左侧面板使用浅色变体 + - 右侧内容区使用白色背景 + +3. **动画优化** + - 保留关键动画(生成按钮、加载动画) + - 简化背景动画(减少动画数量) + - 统一过渡时间(0.3s) + +**优点:** 既保留创新感,又提升统一性 +**缺点:** 需要较多调整工作 + +#### 方案C:品牌双主题(适合大型平台) +**目标:** 建立明确的双主题设计系统 + +**调整内容:** +1. **主题切换** + - 在设置中提供主题切换选项 + - 标准业务使用浅色主题 + - AI创新功能使用深色主题 + - 统一过渡动画 + +2. **主题桥接** + - 在AI页面顶部添加主题标识 + - 使用渐变过渡效果 + - 统一图标和字体 + +3. **文档规范** + - 明确两套主题的使用场景 + - 制定切换规则 + - 统一组件库 + +**优点:** 灵活性高,可支持品牌升级 +**缺点:** 开发和维护成本高 + +### 快速优化建议(不改变整体风格) + +如果暂时不做大规模调整,可以先做以下小优化: + +1. **统一圆角** + - 将3D页面的圆角从12-20px调整为8-12px + +2. **添加面包屑** + - 在3D页面顶部添加返回路径 + +3. **统一过渡时间** + - 将所有动画过渡统一为0.3s + +4. **添加品牌标识** + - 在3D页面显著位置添加系统Logo + +5. **统一字体** + - 确保字体大小和行高与其他页面一致 + +--- + +## 📚 参考资源 + +### 设计规范 +- [Ant Design 设计价值观](https://ant.design/docs/spec/values-cn) +- [Material Design 色彩系统](https://material.io/design/color) +- [8点网格系统](https://spec.fm/specifics/8-pt-grid) + +### 颜色工具 +- [Coolors](https://coolors.co/) - 配色方案生成 +- [Adobe Color](https://color.adobe.com/) - 色彩搭配 +- [Contrast Checker](https://webaim.org/resources/contrastchecker/) - 对比度检查 + +### 组件库文档 +- [Ant Design Vue](https://antdv.com/components/overview-cn) +- [Tailwind CSS](https://tailwindcss.com/docs) + +--- + +## 📝 更新日志 + +### v1.0 (2026-01-14) +- ✅ 初始版本 +- ✅ 完成主题色彩系统梳理 +- ✅ 完成布局规范定义 +- ✅ 完成组件规范文档 +- ✅ 完成双主题对比分析 +- ✅ 提出风格统一方案 + +--- + +## 📞 联系与反馈 + +如有设计系统相关问题或建议,请通过以下方式反馈: +- 项目路径: `C:\Users\82788\Desktop\work\competition-management-system` +- 文档位置: `.claude/ui-design-system.md` + +--- + +**文档结束** diff --git a/backend/data/menus.json b/backend/data/menus.json index fe0368f..4126293 100644 --- a/backend/data/menus.json +++ b/backend/data/menus.json @@ -1,20 +1,36 @@ [ { - "name": "工作台", - "path": "/workbench", - "icon": "DashboardOutlined", + "name": "赛事活动", + "path": "/activities", + "icon": "FlagOutlined", "component": null, "parentId": null, "sort": 1, - "permission": "ai-3d:read", + "permission": "activity:read", "children": [ { - "name": "3D建模实验室", - "path": "/workbench/3d-lab", - "icon": "ExperimentOutlined", - "component": "workbench/ai-3d/Index", + "name": "活动列表", + "path": "/activities", + "icon": "UnorderedListOutlined", + "component": "contests/Activities", "sort": 1, - "permission": "ai-3d:read" + "permission": "activity:read" + }, + { + "name": "我的报名", + "path": "/activities/registrations", + "icon": "UserAddOutlined", + "component": "contests/registrations/Index", + "sort": 2, + "permission": "registration:create" + }, + { + "name": "我的作品", + "path": "/activities/works", + "icon": "FileTextOutlined", + "component": "contests/works/Index", + "sort": 3, + "permission": "work:create" } ] }, @@ -77,48 +93,13 @@ } ] }, - { - "name": "赛事活动", - "path": "/activities", - "icon": "FlagOutlined", - "component": null, - "parentId": null, - "sort": 3, - "permission": "activity:read", - "children": [ - { - "name": "活动列表", - "path": "/activities", - "icon": "UnorderedListOutlined", - "component": "contests/Activities", - "sort": 1, - "permission": "activity:read" - }, - { - "name": "我的报名", - "path": "/activities/registrations", - "icon": "UserAddOutlined", - "component": "contests/registrations/Index", - "sort": 2, - "permission": "registration:create" - }, - { - "name": "我的作品", - "path": "/activities/works", - "icon": "FileTextOutlined", - "component": "contests/works/Index", - "sort": 3, - "permission": "work:create" - } - ] - }, { "name": "赛事管理", "path": "/contests", "icon": "TrophyOutlined", "component": null, "parentId": null, - "sort": 4, + "sort": 3, "permission": "contest:create", "children": [ { @@ -193,7 +174,7 @@ "icon": "FormOutlined", "component": null, "parentId": null, - "sort": 5, + "sort": 4, "permission": "homework:read", "children": [ { @@ -228,7 +209,7 @@ "icon": "SettingOutlined", "component": null, "parentId": null, - "sort": 10, + "sort": 9, "permission": "user:read", "children": [ { @@ -296,5 +277,24 @@ "permission": "tenant:read" } ] + }, + { + "name": "工作台", + "path": "/workbench", + "icon": "DashboardOutlined", + "component": null, + "parentId": null, + "sort": 10, + "permission": "ai-3d:read", + "children": [ + { + "name": "3D建模实验室", + "path": "/workbench/3d-lab", + "icon": "ExperimentOutlined", + "component": "workbench/ai-3d/Index", + "sort": 1, + "permission": "ai-3d:read" + } + ] } ] diff --git a/backend/package-lock.json b/backend/package-lock.json index d9bf31c..723a8aa 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -17,6 +17,7 @@ "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.3.3", "@prisma/client": "^6.19.0", + "adm-zip": "^0.5.16", "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", @@ -2988,6 +2989,15 @@ "node": ">=0.4.0" } }, + "node_modules/adm-zip": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", + "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, "node_modules/ajv": { "version": "8.12.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", diff --git a/backend/package.json b/backend/package.json index 8bdb175..d05860f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -53,6 +53,7 @@ "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.3.3", "@prisma/client": "^6.19.0", + "adm-zip": "^0.5.16", "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 4854b38..27806b7 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -1077,8 +1077,8 @@ model AI3DTask { inputType String @map("input_type") /// 输入类型:text | image inputContent String @map("input_content") @db.Text /// 输入内容:文字描述或图片URL status String @default("pending") /// 任务状态:pending | processing | completed | failed | timeout - resultUrl String? @map("result_url") /// 生成的3D模型URL(单个结果,兼容旧数据) - previewUrl String? @map("preview_url") /// 预览图URL(单个结果,兼容旧数据) + resultUrl String? @map("result_url") @db.Text /// 生成的3D模型URL(单个结果,兼容旧数据) + previewUrl String? @map("preview_url") @db.Text /// 预览图URL(单个结果,兼容旧数据) resultUrls Json? @map("result_urls") /// 生成的3D模型URL数组(多个结果,文生3D生成4个) previewUrls Json? @map("preview_urls") /// 预览图URL数组(多个结果) errorMessage String? @map("error_message") @db.Text /// 失败时的错误信息 diff --git a/backend/src/ai-3d/ai-3d.controller.ts b/backend/src/ai-3d/ai-3d.controller.ts index 46211eb..17984ca 100644 --- a/backend/src/ai-3d/ai-3d.controller.ts +++ b/backend/src/ai-3d/ai-3d.controller.ts @@ -9,12 +9,19 @@ import { UseGuards, Request, ParseIntPipe, + Res, + HttpException, + HttpStatus, } from '@nestjs/common'; +import { Response } from 'express'; +import axios from 'axios'; +import * as AdmZip from 'adm-zip'; 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'; +import { Public } from '../auth/decorators/public.decorator'; @Controller('ai-3d') @UseGuards(JwtAuthGuard) @@ -74,4 +81,259 @@ export class AI3DController { const userId = req?.user?.userId; return this.ai3dService.deleteTask(userId, id); } + + /** + * 代理模型文件(解决CORS问题) + * GET /api/ai-3d/proxy-model + */ + @Get('proxy-model') + @Public() // 允许公开访问,因为模型URL已经包含签名 + async proxyModel(@Query('url') url: string, @Res() res: Response) { + if (!url) { + throw new HttpException('URL参数不能为空', HttpStatus.BAD_REQUEST); + } + + try { + console.log(`代理模型请求,原始URL: ${url.substring(0, 100)}...`); + + // URL解码(处理URL编码) + let decodedUrl: string; + try { + decodedUrl = decodeURIComponent(url); + // 如果解码后还包含编码字符,再解码一次 + if (decodedUrl.includes('%')) { + try { + decodedUrl = decodeURIComponent(decodedUrl); + } catch (e2) { + // 第二次解码失败,使用第一次解码的结果 + } + } + } catch (e) { + // 如果解码失败,使用原始URL + decodedUrl = url; + } + + console.log(`解码后URL: ${decodedUrl.substring(0, 100)}...`); + + // 验证URL是否为腾讯云COS链接(安全验证) + if ( + !decodedUrl.includes('tencentcos.cn') && + !decodedUrl.includes('qcloud.com') + ) { + throw new HttpException( + '不支持的URL来源,仅支持腾讯云COS链接', + HttpStatus.BAD_REQUEST, + ); + } + + // 验证URL格式 + try { + new URL(decodedUrl); + } catch (e) { + throw new HttpException('URL格式无效', HttpStatus.BAD_REQUEST); + } + + // 从源URL获取文件 + console.log('开始下载文件...'); + const response = await axios.get(decodedUrl, { + responseType: 'arraybuffer', // 使用arraybuffer以便处理ZIP + timeout: 120000, // 120秒超时(ZIP文件可能较大) + maxContentLength: 100 * 1024 * 1024, // 最大100MB + headers: { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + Accept: '*/*', + }, + }); + + let fileData: Buffer = Buffer.from(response.data); + let contentType = + response.headers['content-type'] || 'application/octet-stream'; + let contentLength = fileData.length; + + console.log( + `文件下载成功,大小: ${contentLength} bytes, 类型: ${contentType}`, + ); + + // 如果是ZIP文件,解压并提取GLB文件 + if ( + decodedUrl.toLowerCase().includes('.zip') || + contentType.includes('zip') || + contentType.includes('application/zip') + ) { + try { + const zip = new AdmZip(fileData); + const zipEntries = zip.getEntries(); + + // 记录ZIP文件信息(用于调试) + console.log(`ZIP文件包含 ${zipEntries.length} 个文件:`); + zipEntries.forEach((entry) => { + console.log(` - ${entry.entryName} (${entry.header.size} bytes)`); + }); + + // 查找ZIP中的GLB或GLTF文件(优先GLB) + const glbEntry = zipEntries.find((entry) => + entry.entryName.toLowerCase().endsWith('.glb'), + ); + const gltfEntry = zipEntries.find((entry) => + entry.entryName.toLowerCase().endsWith('.gltf'), + ); + + const targetEntry = glbEntry || gltfEntry; + + if (targetEntry) { + console.log(`找到模型文件: ${targetEntry.entryName}`); + fileData = targetEntry.getData(); + contentType = targetEntry.entryName.toLowerCase().endsWith('.glb') + ? 'model/gltf-binary' + : 'model/gltf+json'; + contentLength = fileData.length; + console.log(`模型文件大小: ${contentLength} bytes`); + } else { + // 列出ZIP中的所有文件,便于调试 + const fileList = zipEntries + .map((e) => `${e.entryName} (${e.header.size} bytes)`) + .join(', '); + const errorMsg = `ZIP文件中未找到GLB或GLTF文件。ZIP包含 ${zipEntries.length} 个文件: ${fileList}`; + console.error(errorMsg); + throw new HttpException(errorMsg, HttpStatus.BAD_REQUEST); + } + } catch (zipError: any) { + if (zipError instanceof HttpException) { + throw zipError; + } + const errorMsg = `ZIP解压失败: ${zipError.message}`; + console.error(errorMsg, zipError); + throw new HttpException(errorMsg, HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + // 设置响应头 + res.setHeader('Content-Type', contentType); + res.setHeader('Content-Length', contentLength); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + res.setHeader('Cache-Control', 'public, max-age=3600'); // 缓存1小时 + + // 发送文件数据 + res.send(fileData); + } catch (error: any) { + if (error instanceof HttpException) { + throw error; + } + if (error.response) { + res.status(error.response.status).json({ + message: `代理请求失败: ${error.response.statusText}`, + status: error.response.status, + }); + } else if (error.code === 'ECONNABORTED') { + res.status(HttpStatus.REQUEST_TIMEOUT).json({ + message: '请求超时,请稍后重试', + }); + } else if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') { + res.status(HttpStatus.BAD_GATEWAY).json({ + message: `无法连接到目标服务器: ${error.message}`, + }); + } else { + res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ + message: `代理请求失败: ${error.message}`, + error: + process.env.NODE_ENV === 'development' ? error.stack : undefined, + }); + } + } + } + + /** + * 代理预览图(解决CORS问题) + * GET /api/ai-3d/proxy-preview + */ + @Get('proxy-preview') + @Public() // 允许公开访问,因为预览图URL已经包含签名 + async proxyPreview(@Query('url') url: string, @Res() res: Response) { + if (!url) { + throw new HttpException('URL参数不能为空', HttpStatus.BAD_REQUEST); + } + + try { + // URL解码(处理URL编码) + let decodedUrl: string; + try { + decodedUrl = decodeURIComponent(url); + } catch (e) { + // 如果解码失败,使用原始URL + decodedUrl = url; + } + + // 验证URL是否为腾讯云COS链接(安全验证) + if ( + !decodedUrl.includes('tencentcos.cn') && + !decodedUrl.includes('qcloud.com') + ) { + throw new HttpException( + '不支持的URL来源,仅支持腾讯云COS链接', + HttpStatus.BAD_REQUEST, + ); + } + + // 验证URL格式 + try { + new URL(decodedUrl); + } catch (e) { + throw new HttpException('URL格式无效', HttpStatus.BAD_REQUEST); + } + + // 从源URL获取图片 + const response = await axios.get(decodedUrl, { + responseType: 'arraybuffer', + timeout: 30000, // 30秒超时 + maxContentLength: 10 * 1024 * 1024, // 最大10MB + headers: { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + Accept: 'image/*', + }, + }); + + const imageData = Buffer.from(response.data); + const contentType = response.headers['content-type'] || 'image/jpeg'; + const contentLength = imageData.length; + + // 设置响应头 + res.setHeader('Content-Type', contentType); + res.setHeader('Content-Length', contentLength); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + res.setHeader('Cache-Control', 'public, max-age=86400'); // 缓存24小时 + + // 发送图片数据 + res.send(imageData); + } catch (error: any) { + if (error instanceof HttpException) { + throw error; + } + if (error.response) { + res.status(error.response.status).json({ + message: `代理请求失败: ${error.response.statusText}`, + status: error.response.status, + }); + } else if (error.code === 'ECONNABORTED') { + res.status(HttpStatus.REQUEST_TIMEOUT).json({ + message: '请求超时,请稍后重试', + }); + } else if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') { + res.status(HttpStatus.BAD_GATEWAY).json({ + message: `无法连接到目标服务器: ${error.message}`, + }); + } else { + res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ + message: `代理请求失败: ${error.message}`, + error: + process.env.NODE_ENV === 'development' ? error.stack : undefined, + }); + } + } + } } diff --git a/backend/src/ai-3d/ai-3d.module.ts b/backend/src/ai-3d/ai-3d.module.ts index 918ca3c..a3c47de 100644 --- a/backend/src/ai-3d/ai-3d.module.ts +++ b/backend/src/ai-3d/ai-3d.module.ts @@ -1,18 +1,37 @@ import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; import { AI3DController } from './ai-3d.controller'; import { AI3DService } from './ai-3d.service'; import { MockAI3DProvider } from './providers/mock.provider'; +import { HunyuanAI3DProvider } from './providers/hunyuan.provider'; import { AI3D_PROVIDER } from './providers/ai-3d-provider.interface'; import { PrismaModule } from '../prisma/prisma.module'; @Module({ - imports: [PrismaModule], + imports: [PrismaModule, ConfigModule], controllers: [AI3DController], providers: [ AI3DService, + MockAI3DProvider, + HunyuanAI3DProvider, { provide: AI3D_PROVIDER, - useClass: MockAI3DProvider, + useFactory: ( + configService: ConfigService, + mockProvider: MockAI3DProvider, + hunyuanProvider: HunyuanAI3DProvider, + ) => { + const provider = configService.get('AI_3D_PROVIDER') || 'mock'; + + switch (provider.toLowerCase()) { + case 'hunyuan': + return hunyuanProvider; + case 'mock': + default: + return mockProvider; + } + }, + inject: [ConfigService, MockAI3DProvider, HunyuanAI3DProvider], }, ], exports: [AI3DService], diff --git a/backend/src/ai-3d/providers/hunyuan.provider.ts b/backend/src/ai-3d/providers/hunyuan.provider.ts new file mode 100644 index 0000000..e4f1746 --- /dev/null +++ b/backend/src/ai-3d/providers/hunyuan.provider.ts @@ -0,0 +1,258 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import axios from 'axios'; +import { AI3DProvider, AI3DGenerateResult } from './ai-3d-provider.interface'; +import { TencentCloudSigner } from '../utils/tencent-cloud-sign'; +import { ZipHandler } from '../utils/zip-handler'; + +/** + * 腾讯混元 3D Provider + * 文档:https://cloud.tencent.com/document/api/1804/123447 + * API概览:https://cloud.tencent.com/document/product/1804/120838 + * + * 重要说明: + * - 默认提供3个并发任务,最多同时处理3个任务 + * - 每个任务会消耗一定积分(根据资源包计费) + * - 如果遇到"资源不足"错误,可能是:并发数达到上限、积分不足、或服务暂时不可用 + */ +@Injectable() +export class HunyuanAI3DProvider implements AI3DProvider { + private readonly logger = new Logger(HunyuanAI3DProvider.name); + private readonly apiHost = 'ai3d.tencentcloudapi.com'; + private readonly apiVersion = '2025-05-13'; // 使用正确的API版本 + private readonly secretId: string; + private readonly secretKey: string; + private readonly region: string; + + constructor(private configService: ConfigService) { + this.secretId = this.configService.get('TENCENT_SECRET_ID'); + this.secretKey = this.configService.get('TENCENT_SECRET_KEY'); + this.region = + this.configService.get('TENCENT_REGION') || 'ap-guangzhou'; + + if (!this.secretId || !this.secretKey) { + this.logger.warn( + '未配置腾讯云密钥,请设置 TENCENT_SECRET_ID 和 TENCENT_SECRET_KEY 环境变量', + ); + } + } + + /** + * 提交生成任务 + */ + async submitTask( + inputType: 'text' | 'image', + inputContent: string, + ): Promise { + try { + // 构造请求参数 + const payload: any = {}; + + if (inputType === 'text') { + // 文生3D:使用 Prompt + payload.Prompt = inputContent; + this.logger.log(`提交文生3D任务: ${inputContent.substring(0, 50)}...`); + } else { + // 图生3D:使用 ImageUrl 或 ImageBase64 + if ( + inputContent.startsWith('http://') || + inputContent.startsWith('https://') + ) { + payload.ImageUrl = inputContent; + this.logger.log(`提交图生3D任务 (URL): ${inputContent}`); + } else { + // 假设是 Base64 编码的图片 + payload.ImageBase64 = inputContent; + this.logger.log( + `提交图生3D任务 (Base64): ${inputContent.substring(0, 30)}...`, + ); + } + } + + // 生成签名和请求头 + const headers = TencentCloudSigner.sign({ + secretId: this.secretId, + secretKey: this.secretKey, + service: 'ai3d', + host: this.apiHost, + region: this.region, + action: 'SubmitHunyuanTo3DProJob', + version: this.apiVersion, + payload, + }); + + // 发送请求 + const response = await axios.post(`https://${this.apiHost}`, payload, { + headers, + }); + + // 检查响应 + if (response.data.Response?.Error) { + const error = response.data.Response.Error; + this.logger.error(`混元3D API错误: ${error.Code} - ${error.Message}`); + + // 对特定错误提供更友好的提示 + if (error.Code === 'ResourceInsufficient') { + const friendlyMessage = + '资源不足。可能原因:1) 并发任务数已达到上限(默认3个),请等待其他任务完成;' + + '2) 积分余额不足,请检查腾讯云控制台的积分余额;' + + '3) 服务暂时不可用,请稍后重试。'; + throw new Error(`混元3D API错误: ${friendlyMessage}`); + } + + throw new Error(`混元3D API错误: ${error.Message}`); + } + + const jobId = response.data.Response?.JobId; + if (!jobId) { + this.logger.error('混元3D API未返回JobId'); + throw new Error('混元3D API未返回任务ID'); + } + + this.logger.log(`混元3D任务创建成功: ${jobId}`); + return jobId; + } catch (error) { + this.logger.error(`提交混元3D任务失败: ${error.message}`, error.stack); + throw error; + } + } + + /** + * 查询任务状态 + */ + async queryTask(taskId: string): Promise { + try { + // 构造请求参数 + const payload = { + JobId: taskId, + }; + + // 生成签名和请求头 + const headers = TencentCloudSigner.sign({ + secretId: this.secretId, + secretKey: this.secretKey, + service: 'ai3d', + host: this.apiHost, + region: this.region, + action: 'QueryHunyuanTo3DProJob', + version: this.apiVersion, + payload, + }); + + // 发送请求 + const response = await axios.post(`https://${this.apiHost}`, payload, { + headers, + }); + + // 检查响应 + if (response.data.Response?.Error) { + const error = response.data.Response.Error; + this.logger.error(`混元3D查询错误: ${error.Code} - ${error.Message}`); + return { + taskId, + status: 'failed', + errorMessage: error.Message, + }; + } + + const result = response.data.Response; + + // 映射任务状态 + // 混元状态: WAIT(等待中)| RUN(运行中)| FAIL(失败)| DONE(完成) + const statusMap: Record< + string, + 'pending' | 'processing' | 'completed' | 'failed' + > = { + WAIT: 'processing', + RUN: 'processing', + FAIL: 'failed', + DONE: 'completed', + }; + + const status = statusMap[result.Status] || 'processing'; + + // 构造返回结果 + const generateResult: AI3DGenerateResult = { + taskId, + status, + }; + + // 如果任务完成,提取模型URL + // 根据API文档,返回的是 ResultFile3Ds 数组 + if (status === 'completed' && result.ResultFile3Ds?.length > 0) { + const file3Ds = result.ResultFile3Ds; + // 提取所有模型URL和预览图URL + const urls = file3Ds.map((file: any) => file.Url).filter(Boolean); + const previewUrls = file3Ds + .map((file: any) => file.PreviewImageUrl) + .filter(Boolean); + + // 处理.zip文件:下载并解压 + if (urls.length > 0) { + const firstUrl = urls[0]; + + // 检查是否是.zip文件 + if (firstUrl.toLowerCase().endsWith('.zip')) { + this.logger.log(`检测到ZIP文件,开始下载并解压: ${firstUrl}`); + try { + const extracted = await ZipHandler.downloadAndExtract(firstUrl); + + // 使用解压后的文件URL + generateResult.resultUrl = extracted.modelUrl; + generateResult.resultUrls = [extracted.modelUrl]; + + if (extracted.previewUrl) { + generateResult.previewUrl = extracted.previewUrl; + generateResult.previewUrls = [extracted.previewUrl]; + } else if (previewUrls.length > 0) { + // 如果ZIP中没有预览图,使用API返回的预览图 + generateResult.previewUrl = previewUrls[0]; + generateResult.previewUrls = previewUrls; + } + + this.logger.log( + `ZIP文件处理完成,模型URL: ${extracted.modelUrl}`, + ); + } catch (error) { + this.logger.error(`处理ZIP文件失败: ${error.message}`); + // ZIP处理失败,尝试直接返回原始URL + generateResult.resultUrl = firstUrl; + generateResult.resultUrls = urls; + generateResult.previewUrl = previewUrls[0]; + generateResult.previewUrls = previewUrls; + } + } else { + // 不是ZIP文件,直接使用URL + generateResult.resultUrl = firstUrl; + generateResult.resultUrls = urls; + + if (previewUrls.length > 0) { + generateResult.previewUrl = previewUrls[0]; + generateResult.previewUrls = previewUrls; + } + } + } + + this.logger.log( + `混元3D任务 ${taskId} 完成: ${generateResult.resultUrls?.length || 0} 个模型文件`, + ); + } else if (status === 'failed') { + // 失败原因:根据文档,错误信息在 ErrorMessage 字段 + generateResult.errorMessage = + result.ErrorMessage || result.ErrorCode || '生成失败'; + this.logger.warn( + `混元3D任务 ${taskId} 失败: ${generateResult.errorMessage}`, + ); + } + + return generateResult; + } catch (error) { + this.logger.error(`查询混元3D任务失败: ${error.message}`, error.stack); + return { + taskId, + status: 'failed', + errorMessage: `查询任务失败: ${error.message}`, + }; + } + } +} diff --git a/backend/src/ai-3d/utils/tencent-cloud-sign.ts b/backend/src/ai-3d/utils/tencent-cloud-sign.ts new file mode 100644 index 0000000..708ca8f --- /dev/null +++ b/backend/src/ai-3d/utils/tencent-cloud-sign.ts @@ -0,0 +1,101 @@ +import * as crypto from 'crypto'; + +export interface TencentCloudSignOptions { + secretId: string; + secretKey: string; + service: string; + host: string; + region?: string; + action: string; + version: string; + payload?: any; + timestamp?: number; +} + +/** + * 腾讯云 API 签名 v3 + * 文档:https://cloud.tencent.com/document/api/213/30654 + */ +export class TencentCloudSigner { + private static readonly ALGORITHM = 'TC3-HMAC-SHA256'; + private static readonly SIGNED_HEADERS = 'content-type;host;x-tc-action'; + + /** + * 生成签名和请求头 + */ + static sign(options: TencentCloudSignOptions): Record { + const timestamp = options.timestamp || Math.floor(Date.now() / 1000); + const date = new Date(timestamp * 1000) + .toISOString() + .substr(0, 10); + + // 1. 拼接规范请求串 + const payload = options.payload ? JSON.stringify(options.payload) : ''; + const hashedRequestPayload = this.sha256Hex(payload); + const canonicalRequest = [ + 'POST', + '/', + '', + `content-type:application/json`, + `host:${options.host}`, + `x-tc-action:${options.action.toLowerCase()}`, + '', + this.SIGNED_HEADERS, + hashedRequestPayload, + ].join('\n'); + + // 2. 拼接待签名字符串 + const hashedCanonicalRequest = this.sha256Hex(canonicalRequest); + const credentialScope = `${date}/${options.service}/tc3_request`; + const stringToSign = [ + this.ALGORITHM, + timestamp.toString(), + credentialScope, + hashedCanonicalRequest, + ].join('\n'); + + // 3. 计算签名 + const secretDate = this.hmacSha256( + `TC3${options.secretKey}`, + date, + ); + const secretService = this.hmacSha256(secretDate, options.service); + const secretSigning = this.hmacSha256(secretService, 'tc3_request'); + const signature = this.hmacSha256Hex(secretSigning, stringToSign); + + // 4. 拼接 Authorization + const authorization = `${this.ALGORITHM} Credential=${options.secretId}/${credentialScope}, SignedHeaders=${this.SIGNED_HEADERS}, Signature=${signature}`; + + // 5. 返回请求头 + return { + 'Content-Type': 'application/json', + 'Host': options.host, + 'X-TC-Action': options.action, + 'X-TC-Version': options.version, + 'X-TC-Timestamp': timestamp.toString(), + 'X-TC-Region': options.region || 'ap-guangzhou', + 'Authorization': authorization, + }; + } + + /** + * SHA256 哈希(十六进制) + */ + private static sha256Hex(data: string): string { + return crypto.createHash('sha256').update(data, 'utf8').digest('hex'); + } + + /** + * HMAC-SHA256(Buffer) + */ + private static hmacSha256(key: string | Buffer, data: string): Buffer { + return crypto.createHmac('sha256', key).update(data, 'utf8').digest(); + } + + /** + * HMAC-SHA256(十六进制) + */ + private static hmacSha256Hex(key: Buffer, data: string): string { + return crypto.createHmac('sha256', key).update(data, 'utf8').digest('hex'); + } +} diff --git a/backend/src/ai-3d/utils/zip-handler.ts b/backend/src/ai-3d/utils/zip-handler.ts new file mode 100644 index 0000000..d3b542f --- /dev/null +++ b/backend/src/ai-3d/utils/zip-handler.ts @@ -0,0 +1,186 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as AdmZip from 'adm-zip'; +import axios from 'axios'; +import { Logger } from '@nestjs/common'; + +export class ZipHandler { + private static readonly logger = new Logger(ZipHandler.name); + + /** + * 下载并解压.zip文件,提取3D模型文件 + * @param zipUrl ZIP文件的URL + * @param outputDir 输出目录(默认为 backend/uploads/ai-3d) + * @returns 提取的3D模型文件路径和预览图路径 + */ + static async downloadAndExtract( + zipUrl: string, + outputDir?: string, + ): Promise<{ + modelPath: string; + previewPath?: string; + modelUrl: string; + previewUrl?: string; + }> { + try { + // 1. 设置输出目录 + const baseDir = + outputDir || + path.join(process.cwd(), 'uploads', 'ai-3d', Date.now().toString()); + if (!fs.existsSync(baseDir)) { + fs.mkdirSync(baseDir, { recursive: true }); + } + + // 2. 下载ZIP文件 + this.logger.log(`开始下载ZIP文件: ${zipUrl}`); + const zipPath = path.join(baseDir, 'model.zip'); + await this.downloadFile(zipUrl, zipPath); + this.logger.log(`ZIP文件下载完成: ${zipPath}`); + + // 3. 解压ZIP文件 + this.logger.log(`开始解压ZIP文件`); + const extractDir = path.join(baseDir, 'extracted'); + await this.extractZip(zipPath, extractDir); + this.logger.log(`ZIP文件解压完成: ${extractDir}`); + + // 4. 查找3D模型文件和预览图 + const files = this.getAllFiles(extractDir); + const modelFile = this.findModelFile(files); + const previewFile = this.findPreviewImage(files); + + if (!modelFile) { + throw new Error('在ZIP文件中未找到3D模型文件(.glb, .gltf)'); + } + + this.logger.log(`找到3D模型文件: ${modelFile}`); + if (previewFile) { + this.logger.log(`找到预览图: ${previewFile}`); + } + + // 5. 生成可访问的URL + // 假设模型文件在 uploads/ai-3d/timestamp/extracted/model.glb + // URL应该是 /api/uploads/ai-3d/timestamp/extracted/model.glb + const relativeModelPath = path.relative( + path.join(process.cwd(), 'uploads'), + modelFile, + ); + const modelUrl = `/api/uploads/${relativeModelPath.replace(/\\/g, '/')}`; + + let previewUrl: string | undefined; + if (previewFile) { + const relativePreviewPath = path.relative( + path.join(process.cwd(), 'uploads'), + previewFile, + ); + previewUrl = `/api/uploads/${relativePreviewPath.replace(/\\/g, '/')}`; + } + + // 6. 删除原始ZIP文件以节省空间 + try { + fs.unlinkSync(zipPath); + this.logger.log(`已删除原始ZIP文件: ${zipPath}`); + } catch (err) { + this.logger.warn(`删除ZIP文件失败: ${err.message}`); + } + + return { + modelPath: modelFile, + previewPath: previewFile, + modelUrl, + previewUrl, + }; + } catch (error) { + this.logger.error(`处理ZIP文件失败: ${error.message}`, error.stack); + throw error; + } + } + + /** + * 下载文件 + */ + private static async downloadFile( + url: string, + outputPath: string, + ): Promise { + const response = await axios.get(url, { + responseType: 'arraybuffer', + timeout: 60000, // 60秒超时 + }); + + fs.writeFileSync(outputPath, response.data); + } + + /** + * 解压ZIP文件 + */ + private static async extractZip( + zipPath: string, + outputDir: string, + ): Promise { + const zip = new AdmZip(zipPath); + zip.extractAllTo(outputDir, true); + } + + /** + * 递归获取目录下的所有文件 + */ + private static getAllFiles(dir: string): string[] { + const files: string[] = []; + + const traverse = (currentDir: string) => { + const items = fs.readdirSync(currentDir); + + for (const item of items) { + const fullPath = path.join(currentDir, item); + const stat = fs.statSync(fullPath); + + if (stat.isDirectory()) { + traverse(fullPath); + } else { + files.push(fullPath); + } + } + }; + + traverse(dir); + return files; + } + + /** + * 查找3D模型文件(.glb, .gltf) + */ + private static findModelFile(files: string[]): string | undefined { + // 优先查找.glb文件(二进制格式,更常用) + const glbFile = files.find((file) => file.toLowerCase().endsWith('.glb')); + if (glbFile) return glbFile; + + // 其次查找.gltf文件 + const gltfFile = files.find((file) => + file.toLowerCase().endsWith('.gltf'), + ); + if (gltfFile) return gltfFile; + + // 其他可能的3D格式 + const otherFormats = ['.obj', '.fbx', '.stl']; + for (const format of otherFormats) { + const file = files.find((f) => f.toLowerCase().endsWith(format)); + if (file) return file; + } + + return undefined; + } + + /** + * 查找预览图(.jpg, .jpeg, .png) + */ + private static findPreviewImage(files: string[]): string | undefined { + const imageExtensions = ['.jpg', '.jpeg', '.png', '.webp']; + + for (const ext of imageExtensions) { + const imageFile = files.find((file) => file.toLowerCase().endsWith(ext)); + if (imageFile) return imageFile; + } + + return undefined; + } +} diff --git a/frontend/src/api/judges-management.ts b/frontend/src/api/judges-management.ts index 91dd1ec..5a42fb6 100644 --- a/frontend/src/api/judges-management.ts +++ b/frontend/src/api/judges-management.ts @@ -151,3 +151,4 @@ export const judgesManagementApi = { + diff --git a/frontend/src/layouts/BasicLayout.vue b/frontend/src/layouts/BasicLayout.vue index 432d3ec..923d753 100644 --- a/frontend/src/layouts/BasicLayout.vue +++ b/frontend/src/layouts/BasicLayout.vue @@ -130,9 +130,48 @@ watch( ) const handleMenuClick = ({ key }: { key: string }) => { - // key 现在是路由名称,使用 name 进行跳转 - // 需要获取当前路由的租户编码,构建完整路径 const tenantCode = route.params.tenantCode as string + + // 调试日志 + console.log("点击菜单,key:", key) + + // 检查是否是3D建模实验室菜单 + // 方法1: 检查key是否包含3D相关字符(考虑到路由名称的生成规则) + // 路径 /workbench/3d-lab 会生成类似 Workbench3dLab 的key + const is3DLab = + key.toLowerCase().includes("3dlab") || + key.toLowerCase().includes("3d-lab") || + (key.toLowerCase().includes("workbench") && key.toLowerCase().includes("3d")) + + // 方法2: 从菜单数据中查找对应的菜单项,检查path + const findMenuByKey = (menus: any[], targetKey: string): any => { + for (const menu of menus) { + if (menu.key === targetKey) { + return menu + } + if (menu.children) { + const found = findMenuByKey(menu.children, targetKey) + if (found) return found + } + } + return null + } + + const menuItem = findMenuByKey(menuItems.value || [], key) + const is3DLabByPath = menuItem?.label?.includes("3D建模") || menuItem?.title?.includes("3D建模") + + // 调试日志 + console.log("is3DLab:", is3DLab, "is3DLabByPath:", is3DLabByPath, "menuItem:", menuItem) + + if (is3DLab || is3DLabByPath) { + // 打开独立的全屏3D Lab页面(类似model-viewer) + console.log("检测到3D建模实验室,打开新窗口") + const fullUrl = `${window.location.origin}/${tenantCode}/3d-lab-fullscreen` + window.open(fullUrl, "_blank") + return + } + + // 其他菜单项正常跳转 if (tenantCode) { router.push({ name: key, params: { tenantCode } }) } else { diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index dad018e..9864a94 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -25,6 +25,12 @@ const baseRoutes: RouteRecordRaw[] = [ component: () => import("@/views/model/ModelViewer.vue"), meta: { requiresAuth: false }, }, + { + path: "/:tenantCode/3d-lab-fullscreen", + name: "3DLabFullscreen", + component: () => import("@/views/workbench/ai-3d/Index.vue"), + meta: { requiresAuth: true }, + }, { path: "/:tenantCode", name: "Main", diff --git a/frontend/src/views/model/ModelViewer.vue b/frontend/src/views/model/ModelViewer.vue index 2ac8fbe..3d333ae 100644 --- a/frontend/src/views/model/ModelViewer.vue +++ b/frontend/src/views/model/ModelViewer.vue @@ -16,8 +16,11 @@ 重置视角 - - {{ isFullscreen ? '退出全屏' : '全屏' }} + + {{ isFullscreen ? "退出全屏" : "全屏" }} @@ -43,7 +46,9 @@

模型加载失败

{{ error }}

- 重试 + 重试 返回
@@ -73,10 +78,11 @@
-
- - 场景设置 - +
+ 场景设置 @@ -113,10 +119,14 @@
- {{ sceneSettings.ambientLight.intensity.toFixed(1) }} + {{ + sceneSettings.ambientLight.intensity.toFixed(1) + }}
@@ -138,10 +148,14 @@
- {{ sceneSettings.mainLight.intensity.toFixed(1) }} + {{ + sceneSettings.mainLight.intensity.toFixed(1) + }}
@@ -158,10 +172,14 @@
- {{ sceneSettings.mainLight.horizontalAngle }}° + {{ sceneSettings.mainLight.horizontalAngle }}°
@@ -169,10 +187,14 @@
- {{ sceneSettings.mainLight.verticalAngle }}° + {{ sceneSettings.mainLight.verticalAngle }}°
@@ -185,10 +207,14 @@
- {{ sceneSettings.fillLight.intensity.toFixed(1) }} + {{ + sceneSettings.fillLight.intensity.toFixed(1) + }}
@@ -219,10 +245,14 @@
- {{ sceneSettings.spotLight.intensity.toFixed(1) }} + {{ + sceneSettings.spotLight.intensity.toFixed(1) + }}
@@ -239,10 +269,14 @@
- {{ sceneSettings.spotLight.angle }}° + {{ sceneSettings.spotLight.angle }}°
@@ -256,10 +290,14 @@
- {{ sceneSettings.render.exposure.toFixed(1) }} + {{ + sceneSettings.render.exposure.toFixed(1) + }}
@@ -281,7 +319,9 @@
@@ -307,7 +347,7 @@ import { FullscreenExitOutlined, SettingOutlined, RightOutlined, - AppstoreOutlined + AppstoreOutlined, } from "@ant-design/icons-vue" // @ts-ignore @@ -326,7 +366,9 @@ const containerRef = ref(null) const loading = ref(true) const error = ref(null) const isFullscreen = ref(false) -const modelInfo = ref<{ size: string; vertices: string; faces: string } | null>(null) +const modelInfo = ref<{ size: string; vertices: string; faces: string } | null>( + null +) const settingsPanelOpen = ref(true) // 场景设置默认值 @@ -335,28 +377,28 @@ const defaultSettings = { showGrid: true, ambientLight: { intensity: 0.4, - color: "#ffffff" + color: "#ffffff", }, mainLight: { intensity: 1.5, color: "#ffffff", horizontalAngle: 45, - verticalAngle: 60 + verticalAngle: 60, }, fillLight: { intensity: 0.8, - color: "#ffffff" + color: "#ffffff", }, spotLight: { enabled: false, intensity: 1.0, color: "#ffffff", - angle: 30 + angle: 30, }, render: { exposure: 1.2, - toneMapping: "ACES" - } + toneMapping: "ACES", + }, } // 场景设置 @@ -504,12 +546,14 @@ const updateRenderSettings = () => { renderer.toneMappingExposure = sceneSettings.render.exposure const toneMappingMap: Record = { - "ACES": THREE.ACESFilmicToneMapping, - "Linear": THREE.LinearToneMapping, - "Reinhard": THREE.ReinhardToneMapping, - "Cineon": THREE.CineonToneMapping + ACES: THREE.ACESFilmicToneMapping, + Linear: THREE.LinearToneMapping, + Reinhard: THREE.ReinhardToneMapping, + Cineon: THREE.CineonToneMapping, } - renderer.toneMapping = toneMappingMap[sceneSettings.render.toneMapping] || THREE.ACESFilmicToneMapping + renderer.toneMapping = + toneMappingMap[sceneSettings.render.toneMapping] || + THREE.ACESFilmicToneMapping } } @@ -547,7 +591,7 @@ const initScene = () => { antialias: true, alpha: true, powerPreference: "high-performance", - precision: "highp" + precision: "highp", }) renderer.setSize(width, height) renderer.setPixelRatio(window.devicePixelRatio) @@ -626,16 +670,22 @@ const loadModel = async () => { if (!scene || !modelUrl.value) { error.value = "模型 URL 不存在" loading.value = false - console.error("模型加载失败: URL为空", { scene: !!scene, url: modelUrl.value }) + 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)) + // 检查文件扩展名(支持GLB、GLTF和ZIP格式,ZIP会在后端解压) + // 从URL中提取文件扩展名(忽略查询参数) + const urlWithoutQuery = modelUrl.value.split("?")[0].toLowerCase() + const supportedExtensions = [".glb", ".gltf", ".zip"] + const isSupported = supportedExtensions.some((ext) => + urlWithoutQuery.endsWith(ext) + ) if (!isSupported) { - error.value = `不支持的文件格式,目前仅支持 GLB/GLTF 格式` + error.value = `不支持的文件格式,目前仅支持 GLB/GLTF/ZIP 格式` loading.value = false console.error("不支持的文件格式:", modelUrl.value) return @@ -647,28 +697,24 @@ const loadModel = async () => { try { console.log("开始加载模型,URL:", modelUrl.value) - // 验证 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}`) - } + // 使用后端代理解决CORS问题 + // 将原始URL编码后作为查询参数传递给后端代理 + const proxyUrl = `/api/ai-3d/proxy-model?url=${encodeURIComponent(modelUrl.value)}` + console.log("使用代理URL:", proxyUrl) const loader = new GLTFLoader() // 配置 DRACO 解码器 if (!dracoLoader) { dracoLoader = new DRACOLoader() - dracoLoader.setDecoderPath("https://www.gstatic.com/draco/versioned/decoders/1.5.6/") + dracoLoader.setDecoderPath( + "https://www.gstatic.com/draco/versioned/decoders/1.5.6/" + ) } loader.setDRACOLoader(dracoLoader) - const gltf = await loader.loadAsync(modelUrl.value) + // 使用代理URL加载模型 + const gltf = await loader.loadAsync(proxyUrl) // 移除旧模型 if (model && scene) { @@ -704,7 +750,7 @@ const loadModel = async () => { modelInfo.value = { size: `${size.x.toFixed(2)} x ${size.y.toFixed(2)} x ${size.z.toFixed(2)}`, vertices: vertexCount.toLocaleString(), - faces: Math.round(faceCount).toLocaleString() + faces: Math.round(faceCount).toLocaleString(), } // 居中模型 @@ -723,12 +769,16 @@ const loadModel = async () => { const fovRad = (camera.fov * Math.PI) / 180 // 计算需要的距离:确保模型在垂直和水平方向都能完整显示 - const distanceForHeight = (size.y / 2) / Math.tan(fovRad / 2) - const distanceForWidth = (size.x / 2) / Math.tan(fovRad / 2) / aspect - const distanceForDepth = (size.z / 2) / Math.tan(fovRad / 2) + const distanceForHeight = size.y / 2 / Math.tan(fovRad / 2) + const distanceForWidth = size.x / 2 / Math.tan(fovRad / 2) / aspect + const distanceForDepth = size.z / 2 / Math.tan(fovRad / 2) // 取最大距离,并添加足够的边距(3倍) - const baseDistance = Math.max(distanceForHeight, distanceForWidth, distanceForDepth) + const baseDistance = Math.max( + distanceForHeight, + distanceForWidth, + distanceForDepth + ) const cameraDistance = Math.max(baseDistance * 3, maxDim * 3, 10) console.log("模型尺寸:", size) @@ -758,7 +808,34 @@ const loadModel = async () => { console.log("模型加载成功") } catch (err: any) { console.error("模型加载失败:", err) - error.value = err.message || "无法加载模型文件" + + // 提取更详细的错误信息 + let errorMessage = "无法加载模型文件" + + if (err.message) { + errorMessage = err.message + } else if (err.response?.data?.message) { + errorMessage = err.response.data.message + } else if (typeof err === "string") { + errorMessage = err + } + + // 如果是网络错误或代理错误,提供更友好的提示 + if (errorMessage.includes("代理请求失败") || errorMessage.includes("ZIP")) { + errorMessage = `模型文件处理失败: ${errorMessage}` + } else if ( + errorMessage.includes("timeout") || + errorMessage.includes("超时") + ) { + errorMessage = "模型文件下载超时,请检查网络连接或稍后重试" + } else if ( + errorMessage.includes("404") || + errorMessage.includes("不存在") + ) { + errorMessage = "模型文件不存在或已被删除" + } + + error.value = errorMessage loading.value = false } } @@ -878,20 +955,24 @@ onUnmounted(() => { // ========================================== // Energetic Modern Color Palette // ========================================== -$primary: #7c3aed; // Violet -$primary-light: #a78bfa; // Light violet -$secondary: #06b6d4; // Cyan -$accent: #f43f5e; // Rose/Pink -$background: #0f0f1a; // Dark -$surface: #1a1a2e; // Dark surface -$text: #e2e8f0; // Light gray -$text-muted: #94a3b8; // Muted gray -$border: #4c1d95; // Dark violet +$primary: #7c3aed; // Violet +$primary-light: #a78bfa; // Light violet +$secondary: #06b6d4; // Cyan +$accent: #f43f5e; // Rose/Pink +$background: #0f0f1a; // Dark +$surface: #1a1a2e; // Dark surface +$text: #e2e8f0; // Light gray +$text-muted: #94a3b8; // Muted gray +$border: #4c1d95; // Dark violet // Gradients $gradient-primary: linear-gradient(135deg, $primary 0%, #ec4899 100%); $gradient-accent: linear-gradient(135deg, $secondary 0%, $primary 100%); -$gradient-dark: linear-gradient(180deg, rgba($primary, 0.1) 0%, transparent 100%); +$gradient-dark: linear-gradient( + 180deg, + rgba($primary, 0.1) 0%, + transparent 100% +); .model-viewer-page { position: fixed; @@ -904,14 +985,23 @@ $gradient-dark: linear-gradient(180deg, rgba($primary, 0.1) 0%, transparent 100% // Animated background gradient &::before { - content: ''; + content: ""; position: absolute; top: -50%; left: -50%; width: 200%; height: 200%; - background: radial-gradient(circle at 30% 30%, rgba($primary, 0.15) 0%, transparent 50%), - radial-gradient(circle at 70% 70%, rgba($secondary, 0.1) 0%, transparent 50%); + background: + radial-gradient( + circle at 30% 30%, + rgba($primary, 0.15) 0%, + transparent 50% + ), + radial-gradient( + circle at 70% 70%, + rgba($secondary, 0.1) 0%, + transparent 50% + ); animation: backgroundPulse 15s ease-in-out infinite; pointer-events: none; z-index: 0; @@ -919,8 +1009,13 @@ $gradient-dark: linear-gradient(180deg, rgba($primary, 0.1) 0%, transparent 100% } @keyframes backgroundPulse { - 0%, 100% { transform: translate(0, 0); } - 50% { transform: translate(-5%, -5%); } + 0%, + 100% { + transform: translate(0, 0); + } + 50% { + transform: translate(-5%, -5%); + } } // ========================================== @@ -970,8 +1065,13 @@ $gradient-dark: linear-gradient(180deg, rgba($primary, 0.1) 0%, transparent 100% } @keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.7; } + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.7; + } } .back-btn, @@ -1067,7 +1167,9 @@ $gradient-dark: linear-gradient(180deg, rgba($primary, 0.1) 0%, transparent 100% } @keyframes spin { - to { transform: rotate(360deg); } + to { + transform: rotate(360deg); + } } // ========================================== @@ -1346,7 +1448,8 @@ $gradient-dark: linear-gradient(180deg, rgba($primary, 0.1) 0%, transparent 100% border-color: $primary; background: $primary; - &:hover, &:focus { + &:hover, + &:focus { border-color: $primary-light; box-shadow: 0 0 0 4px rgba($primary, 0.2); } diff --git a/frontend/src/views/workbench/ai-3d/Generate.vue b/frontend/src/views/workbench/ai-3d/Generate.vue index 703a134..e8ff067 100644 --- a/frontend/src/views/workbench/ai-3d/Generate.vue +++ b/frontend/src/views/workbench/ai-3d/Generate.vue @@ -21,11 +21,17 @@ PBR
- + 生成中... - + 已完成 @@ -43,7 +49,8 @@ :class="{ 'is-ready': item.status === 'completed', 'is-selected': selectedIndex === index, - 'is-loading': item.status === 'pending' || item.status === 'processing' + 'is-loading': + item.status === 'pending' || item.status === 'processing', }" @click="handleCardClick(index)" > @@ -51,7 +58,9 @@
{{ index + 1 }}
-