修改bug
This commit is contained in:
parent
1dce34e76a
commit
62cdebc388
607
.claude/ui-design-system.md
Normal file
607
.claude/ui-design-system.md
Normal file
@ -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栅格系统
|
||||
- 常用布局: `<a-col :span="24" :md="12" :lg="8">`
|
||||
- 响应式断点: xs(480), sm(576), md(768), lg(992), xl(1200), xxl(1600)
|
||||
|
||||
---
|
||||
|
||||
## 🧩 组件规范
|
||||
|
||||
### 按钮
|
||||
|
||||
#### 类型与用途
|
||||
```vue
|
||||
<!-- 主要操作 -->
|
||||
<a-button type="primary">创建</a-button>
|
||||
|
||||
<!-- 次要操作 -->
|
||||
<a-button>取消</a-button>
|
||||
|
||||
<!-- 文字按钮 -->
|
||||
<a-button type="link">查看详情</a-button>
|
||||
|
||||
<!-- 危险操作 -->
|
||||
<a-button danger>删除</a-button>
|
||||
```
|
||||
|
||||
#### 尺寸
|
||||
```vue
|
||||
<a-button size="large">大按钮</a-button>
|
||||
<a-button>默认按钮</a-button>
|
||||
<a-button size="small">小按钮</a-button>
|
||||
```
|
||||
|
||||
#### 按钮组合间距
|
||||
```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
|
||||
<a-form
|
||||
:model="form"
|
||||
:label-col="{ span: 6 }"
|
||||
:wrapper-col="{ span: 16 }"
|
||||
>
|
||||
<!-- 表单项 -->
|
||||
</a-form>
|
||||
```
|
||||
|
||||
#### 输入框宽度
|
||||
```
|
||||
- 短文本(名称、编号): 200px
|
||||
- 中文本(描述): 300-400px
|
||||
- 长文本(详情): 100%
|
||||
- 选择框(状态、类型): 120-200px
|
||||
- 日期选择: 200px
|
||||
- 日期范围: 280px
|
||||
```
|
||||
|
||||
### 标签(Tags)
|
||||
|
||||
#### 颜色语义
|
||||
```vue
|
||||
<!-- 状态标签 -->
|
||||
<a-tag color="success">已发布</a-tag>
|
||||
<a-tag color="processing">进行中</a-tag>
|
||||
<a-tag color="default">草稿</a-tag>
|
||||
<a-tag color="error">已关闭</a-tag>
|
||||
|
||||
<!-- 类型标签 -->
|
||||
<a-tag color="blue">个人赛</a-tag>
|
||||
<a-tag color="purple">团队赛</a-tag>
|
||||
```
|
||||
|
||||
### 模态框与抽屉
|
||||
|
||||
#### 模态框
|
||||
```vue
|
||||
<a-modal
|
||||
v-model:open="visible"
|
||||
title="标题"
|
||||
width="600px"
|
||||
@ok="handleOk"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<!-- 内容 -->
|
||||
</a-modal>
|
||||
```
|
||||
|
||||
#### 抽屉
|
||||
```vue
|
||||
<a-drawer
|
||||
v-model:open="visible"
|
||||
title="标题"
|
||||
width="600px"
|
||||
placement="right"
|
||||
>
|
||||
<!-- 内容 -->
|
||||
</a-drawer>
|
||||
```
|
||||
|
||||
### 卡片
|
||||
|
||||
#### 标准卡片
|
||||
```vue
|
||||
<a-card title="标题" class="mb-4">
|
||||
<!-- 内容 -->
|
||||
</a-card>
|
||||
```
|
||||
|
||||
#### 无边框卡片
|
||||
```vue
|
||||
<a-card :bordered="false" class="mb-4">
|
||||
<!-- 内容 -->
|
||||
</a-card>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌓 双主题架构
|
||||
|
||||
### 主题A:标准业务主题
|
||||
|
||||
**适用页面:**
|
||||
- 赛事管理(contests)
|
||||
- 学校管理(school)
|
||||
- 作业管理(homework)
|
||||
- 系统管理(system)
|
||||
- 登录页面(auth)
|
||||
|
||||
**设计特征:**
|
||||
- ✅ 浅色背景(#ffffff, #f5f5f5)
|
||||
- ✅ 蓝色主题(#0958d9)
|
||||
- ✅ 标准Ant Design组件样式
|
||||
- ✅ 简洁的过渡动画(0.3s)
|
||||
- ✅ 表格为主要数据展示方式
|
||||
- ✅ 圆角: 6-8px
|
||||
- ✅ 轻微阴影效果
|
||||
|
||||
**代码示例:**
|
||||
```vue
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<!-- 搜索区 -->
|
||||
<a-card class="search-card mb-4">
|
||||
<a-form layout="inline">
|
||||
<a-form-item label="搜索">
|
||||
<a-input v-model:value="keyword" placeholder="请输入" />
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button type="primary">查询</a-button>
|
||||
<a-button class="ml-2">重置</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<a-card>
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="dataSource"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
/>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-container {
|
||||
padding: 20px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.search-card {
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ml-2 {
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### 主题B:现代创意主题
|
||||
|
||||
**适用页面:**
|
||||
- 3D建模实验室(workbench/ai-3d)
|
||||
- AI相关创新功能
|
||||
|
||||
**设计特征:**
|
||||
- ✅ 深色背景(#0a0a12)
|
||||
- ✅ 紫色+青蓝色主题(#7c3aed, #06b6d4)
|
||||
- ✅ 玻璃态效果(backdrop-filter: blur())
|
||||
- ✅ 复杂动画(浮动、旋转、脉冲)
|
||||
- ✅ 卡片网格布局
|
||||
- ✅ 圆角: 12-20px
|
||||
- ✅ 发光效果(glow)
|
||||
- ✅ 渐变背景
|
||||
|
||||
**代码示例:**
|
||||
```vue
|
||||
<template>
|
||||
<div class="ai-3d-container">
|
||||
<!-- 动画背景 -->
|
||||
<div class="bg-animation">
|
||||
<div class="bg-gradient bg-gradient-1"></div>
|
||||
<div class="bg-gradient bg-gradient-2"></div>
|
||||
<div class="bg-grid"></div>
|
||||
</div>
|
||||
|
||||
<!-- 左侧面板 -->
|
||||
<div class="left-panel">
|
||||
<!-- 内容 -->
|
||||
</div>
|
||||
|
||||
<!-- 右侧内容 -->
|
||||
<div class="right-panel">
|
||||
<!-- 内容 -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$primary: #7c3aed;
|
||||
$secondary: #06b6d4;
|
||||
$background: #0a0a12;
|
||||
|
||||
.ai-3d-container {
|
||||
min-height: 100vh;
|
||||
background: $background;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bg-animation {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bg-gradient {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(120px);
|
||||
opacity: 0.4;
|
||||
animation: float 25s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.left-panel {
|
||||
width: 380px;
|
||||
background: rgba(18, 18, 30, 0.8);
|
||||
backdrop-filter: blur(40px);
|
||||
border-right: 1px solid rgba($primary, 0.1);
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: translate(-30px, 30px) scale(0.9);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 风格统一建议
|
||||
|
||||
### 问题分析
|
||||
目前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`
|
||||
|
||||
---
|
||||
|
||||
**文档结束**
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
10
backend/package-lock.json
generated
10
backend/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 /// 失败时的错误信息
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<string>('AI_3D_PROVIDER') || 'mock';
|
||||
|
||||
switch (provider.toLowerCase()) {
|
||||
case 'hunyuan':
|
||||
return hunyuanProvider;
|
||||
case 'mock':
|
||||
default:
|
||||
return mockProvider;
|
||||
}
|
||||
},
|
||||
inject: [ConfigService, MockAI3DProvider, HunyuanAI3DProvider],
|
||||
},
|
||||
],
|
||||
exports: [AI3DService],
|
||||
|
||||
258
backend/src/ai-3d/providers/hunyuan.provider.ts
Normal file
258
backend/src/ai-3d/providers/hunyuan.provider.ts
Normal file
@ -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<string>('TENCENT_SECRET_ID');
|
||||
this.secretKey = this.configService.get<string>('TENCENT_SECRET_KEY');
|
||||
this.region =
|
||||
this.configService.get<string>('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<string> {
|
||||
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<AI3DGenerateResult> {
|
||||
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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
101
backend/src/ai-3d/utils/tencent-cloud-sign.ts
Normal file
101
backend/src/ai-3d/utils/tencent-cloud-sign.ts
Normal file
@ -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<string, string> {
|
||||
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');
|
||||
}
|
||||
}
|
||||
186
backend/src/ai-3d/utils/zip-handler.ts
Normal file
186
backend/src/ai-3d/utils/zip-handler.ts
Normal file
@ -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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -151,3 +151,4 @@ export const judgesManagementApi = {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -16,8 +16,11 @@
|
||||
重置视角
|
||||
</a-button>
|
||||
<a-button type="text" class="action-btn" @click="toggleFullscreen">
|
||||
<template #icon><FullscreenOutlined v-if="!isFullscreen" /><FullscreenExitOutlined v-else /></template>
|
||||
{{ isFullscreen ? '退出全屏' : '全屏' }}
|
||||
<template #icon
|
||||
><FullscreenOutlined v-if="!isFullscreen" /><FullscreenExitOutlined
|
||||
v-else
|
||||
/></template>
|
||||
{{ isFullscreen ? "退出全屏" : "全屏" }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
@ -43,7 +46,9 @@
|
||||
<h3>模型加载失败</h3>
|
||||
<p>{{ error }}</p>
|
||||
<div class="error-actions">
|
||||
<a-button type="primary" class="gradient-btn" @click="handleRetry">重试</a-button>
|
||||
<a-button type="primary" class="gradient-btn" @click="handleRetry"
|
||||
>重试</a-button
|
||||
>
|
||||
<a-button class="outline-btn" @click="handleBack">返回</a-button>
|
||||
</div>
|
||||
</div>
|
||||
@ -73,10 +78,11 @@
|
||||
|
||||
<!-- Scene Settings Panel (右侧) -->
|
||||
<div v-if="!loading && !error" class="scene-settings">
|
||||
<div class="settings-header" @click="settingsPanelOpen = !settingsPanelOpen">
|
||||
<span class="settings-title">
|
||||
<SettingOutlined /> 场景设置
|
||||
</span>
|
||||
<div
|
||||
class="settings-header"
|
||||
@click="settingsPanelOpen = !settingsPanelOpen"
|
||||
>
|
||||
<span class="settings-title"> <SettingOutlined /> 场景设置 </span>
|
||||
<span class="collapse-icon" :class="{ 'is-open': settingsPanelOpen }">
|
||||
<RightOutlined />
|
||||
</span>
|
||||
@ -113,10 +119,14 @@
|
||||
<div class="slider-wrapper">
|
||||
<a-slider
|
||||
v-model:value="sceneSettings.ambientLight.intensity"
|
||||
:min="0" :max="2" :step="0.1"
|
||||
:min="0"
|
||||
:max="2"
|
||||
:step="0.1"
|
||||
@change="updateAmbientLight"
|
||||
/>
|
||||
<span class="slider-value">{{ sceneSettings.ambientLight.intensity.toFixed(1) }}</span>
|
||||
<span class="slider-value">{{
|
||||
sceneSettings.ambientLight.intensity.toFixed(1)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
@ -138,10 +148,14 @@
|
||||
<div class="slider-wrapper">
|
||||
<a-slider
|
||||
v-model:value="sceneSettings.mainLight.intensity"
|
||||
:min="0" :max="3" :step="0.1"
|
||||
:min="0"
|
||||
:max="3"
|
||||
:step="0.1"
|
||||
@change="updateMainLight"
|
||||
/>
|
||||
<span class="slider-value">{{ sceneSettings.mainLight.intensity.toFixed(1) }}</span>
|
||||
<span class="slider-value">{{
|
||||
sceneSettings.mainLight.intensity.toFixed(1)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
@ -158,10 +172,14 @@
|
||||
<div class="slider-wrapper">
|
||||
<a-slider
|
||||
v-model:value="sceneSettings.mainLight.horizontalAngle"
|
||||
:min="0" :max="360" :step="5"
|
||||
:min="0"
|
||||
:max="360"
|
||||
:step="5"
|
||||
@change="updateMainLightPosition"
|
||||
/>
|
||||
<span class="slider-value">{{ sceneSettings.mainLight.horizontalAngle }}°</span>
|
||||
<span class="slider-value"
|
||||
>{{ sceneSettings.mainLight.horizontalAngle }}°</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
@ -169,10 +187,14 @@
|
||||
<div class="slider-wrapper">
|
||||
<a-slider
|
||||
v-model:value="sceneSettings.mainLight.verticalAngle"
|
||||
:min="0" :max="90" :step="5"
|
||||
:min="0"
|
||||
:max="90"
|
||||
:step="5"
|
||||
@change="updateMainLightPosition"
|
||||
/>
|
||||
<span class="slider-value">{{ sceneSettings.mainLight.verticalAngle }}°</span>
|
||||
<span class="slider-value"
|
||||
>{{ sceneSettings.mainLight.verticalAngle }}°</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -185,10 +207,14 @@
|
||||
<div class="slider-wrapper">
|
||||
<a-slider
|
||||
v-model:value="sceneSettings.fillLight.intensity"
|
||||
:min="0" :max="2" :step="0.1"
|
||||
:min="0"
|
||||
:max="2"
|
||||
:step="0.1"
|
||||
@change="updateFillLight"
|
||||
/>
|
||||
<span class="slider-value">{{ sceneSettings.fillLight.intensity.toFixed(1) }}</span>
|
||||
<span class="slider-value">{{
|
||||
sceneSettings.fillLight.intensity.toFixed(1)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
@ -219,10 +245,14 @@
|
||||
<div class="slider-wrapper">
|
||||
<a-slider
|
||||
v-model:value="sceneSettings.spotLight.intensity"
|
||||
:min="0" :max="3" :step="0.1"
|
||||
:min="0"
|
||||
:max="3"
|
||||
:step="0.1"
|
||||
@change="updateSpotLight"
|
||||
/>
|
||||
<span class="slider-value">{{ sceneSettings.spotLight.intensity.toFixed(1) }}</span>
|
||||
<span class="slider-value">{{
|
||||
sceneSettings.spotLight.intensity.toFixed(1)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
@ -239,10 +269,14 @@
|
||||
<div class="slider-wrapper">
|
||||
<a-slider
|
||||
v-model:value="sceneSettings.spotLight.angle"
|
||||
:min="10" :max="90" :step="5"
|
||||
:min="10"
|
||||
:max="90"
|
||||
:step="5"
|
||||
@change="updateSpotLight"
|
||||
/>
|
||||
<span class="slider-value">{{ sceneSettings.spotLight.angle }}°</span>
|
||||
<span class="slider-value"
|
||||
>{{ sceneSettings.spotLight.angle }}°</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -256,10 +290,14 @@
|
||||
<div class="slider-wrapper">
|
||||
<a-slider
|
||||
v-model:value="sceneSettings.render.exposure"
|
||||
:min="0.1" :max="3" :step="0.1"
|
||||
:min="0.1"
|
||||
:max="3"
|
||||
:step="0.1"
|
||||
@change="updateRenderSettings"
|
||||
/>
|
||||
<span class="slider-value">{{ sceneSettings.render.exposure.toFixed(1) }}</span>
|
||||
<span class="slider-value">{{
|
||||
sceneSettings.render.exposure.toFixed(1)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
@ -281,7 +319,9 @@
|
||||
|
||||
<!-- 重置按钮 -->
|
||||
<div v-show="settingsPanelOpen" class="settings-footer">
|
||||
<a-button size="small" class="reset-btn" @click="resetSettings">重置默认</a-button>
|
||||
<a-button size="small" class="reset-btn" @click="resetSettings"
|
||||
>重置默认</a-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -307,7 +347,7 @@ import {
|
||||
FullscreenExitOutlined,
|
||||
SettingOutlined,
|
||||
RightOutlined,
|
||||
AppstoreOutlined
|
||||
AppstoreOutlined,
|
||||
} from "@ant-design/icons-vue"
|
||||
|
||||
// @ts-ignore
|
||||
@ -326,7 +366,9 @@ const containerRef = ref<HTMLDivElement | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(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<string, THREE.ToneMapping> = {
|
||||
"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);
|
||||
}
|
||||
|
||||
@ -21,11 +21,17 @@
|
||||
<a-tag class="pbr-tag">PBR</a-tag>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span class="status-text" v-if="task?.status === 'processing' || task?.status === 'pending'">
|
||||
<span
|
||||
class="status-text"
|
||||
v-if="task?.status === 'processing' || task?.status === 'pending'"
|
||||
>
|
||||
<LoadingOutlined class="spin-icon" />
|
||||
生成中...
|
||||
</span>
|
||||
<span class="status-text completed" v-else-if="task?.status === 'completed'">
|
||||
<span
|
||||
class="status-text completed"
|
||||
v-else-if="task?.status === 'completed'"
|
||||
>
|
||||
<CheckCircleOutlined />
|
||||
已完成
|
||||
</span>
|
||||
@ -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 @@
|
||||
<div class="card-index">{{ index + 1 }}</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<template v-if="item.status === 'pending' || item.status === 'processing'">
|
||||
<template
|
||||
v-if="item.status === 'pending' || item.status === 'processing'"
|
||||
>
|
||||
<div class="card-loading">
|
||||
<div class="cube-container">
|
||||
<div class="cube">
|
||||
@ -66,8 +75,16 @@
|
||||
<div class="loading-info">
|
||||
<div class="loading-title">AI 生成中</div>
|
||||
<div class="loading-text">
|
||||
<p>队列位置: <span class="highlight">{{ queueInfo.position }}</span></p>
|
||||
<p>预计时间: <span class="highlight">{{ queueInfo.estimatedTime }}s</span></p>
|
||||
<p>
|
||||
队列位置:
|
||||
<span class="highlight">{{ queueInfo.position }}</span>
|
||||
</p>
|
||||
<p>
|
||||
预计时间:
|
||||
<span class="highlight"
|
||||
>{{ queueInfo.estimatedTime }}s</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill"></div>
|
||||
@ -172,6 +189,17 @@ const pageTitle = computed(() => {
|
||||
return "3D生成"
|
||||
})
|
||||
|
||||
// 获取预览图URL(通过代理访问,解决CORS问题)
|
||||
const getPreviewUrl = (url: string) => {
|
||||
if (!url) return ""
|
||||
// 如果是腾讯云COS链接,通过代理访问
|
||||
if (url.includes("tencentcos.cn") || url.includes("qcloud.com")) {
|
||||
return `/api/ai-3d/proxy-preview?url=${encodeURIComponent(url)}`
|
||||
}
|
||||
// 其他URL直接返回
|
||||
return url
|
||||
}
|
||||
|
||||
// 4 model cards state
|
||||
const modelCards = computed(() => {
|
||||
if (!task.value) {
|
||||
@ -181,11 +209,17 @@ const modelCards = computed(() => {
|
||||
const status = task.value.status
|
||||
const previewUrls = task.value.previewUrls || []
|
||||
|
||||
return Array(4).fill(null).map((_, index) => ({
|
||||
status: status,
|
||||
previewUrl: previewUrls[index] || "",
|
||||
resultUrl: task.value?.resultUrls?.[index] || "",
|
||||
}))
|
||||
return Array(4)
|
||||
.fill(null)
|
||||
.map((_, index) => {
|
||||
const originalPreviewUrl = previewUrls[index] || ""
|
||||
return {
|
||||
status: status,
|
||||
previewUrl: getPreviewUrl(originalPreviewUrl),
|
||||
originalPreviewUrl: originalPreviewUrl, // 保留原始URL用于其他用途
|
||||
resultUrl: task.value?.resultUrls?.[index] || "",
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Back to previous page
|
||||
@ -221,16 +255,24 @@ const fetchTask = async () => {
|
||||
|
||||
try {
|
||||
const res = await getAI3DTask(Number(taskId))
|
||||
task.value = res as AI3DTask
|
||||
const taskData = res.data || res // 兼容不同的响应格式
|
||||
task.value = taskData as AI3DTask
|
||||
|
||||
// Stop polling if task is complete or failed
|
||||
if (res.status === "completed" || res.status === "failed" || res.status === "timeout") {
|
||||
if (
|
||||
taskData.status === "completed" ||
|
||||
taskData.status === "failed" ||
|
||||
taskData.status === "timeout"
|
||||
) {
|
||||
stopPolling()
|
||||
}
|
||||
|
||||
// Update queue info (simulated)
|
||||
if (res.status === "pending" || res.status === "processing") {
|
||||
queueInfo.value.estimatedTime = Math.max(10, queueInfo.value.estimatedTime - 10)
|
||||
if (taskData.status === "pending" || taskData.status === "processing") {
|
||||
queueInfo.value.estimatedTime = Math.max(
|
||||
10,
|
||||
queueInfo.value.estimatedTime - 10
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取任务详情失败:", error)
|
||||
@ -273,23 +315,27 @@ onUnmounted(() => {
|
||||
// ==========================================
|
||||
// Energetic Modern Color Palette
|
||||
// ==========================================
|
||||
$primary: #7c3aed; // Violet
|
||||
$primary-light: #a78bfa; // Light violet
|
||||
$primary-dark: #5b21b6; // Dark violet
|
||||
$secondary: #06b6d4; // Cyan
|
||||
$accent: #f43f5e; // Rose/Pink
|
||||
$success: #10b981; // Emerald
|
||||
$background: #0f0f1a; // Dark
|
||||
$surface: #1a1a2e; // Dark surface
|
||||
$surface-light: #252542; // Lighter surface
|
||||
$text: #e2e8f0; // Light gray
|
||||
$text-muted: #94a3b8; // Muted gray
|
||||
$border: #4c1d95; // Dark violet
|
||||
$primary: #7c3aed; // Violet
|
||||
$primary-light: #a78bfa; // Light violet
|
||||
$primary-dark: #5b21b6; // Dark violet
|
||||
$secondary: #06b6d4; // Cyan
|
||||
$accent: #f43f5e; // Rose/Pink
|
||||
$success: #10b981; // Emerald
|
||||
$background: #0f0f1a; // Dark
|
||||
$surface: #1a1a2e; // Dark surface
|
||||
$surface-light: #252542; // Lighter surface
|
||||
$text: #e2e8f0; // Light gray
|
||||
$text-muted: #94a3b8; // Muted gray
|
||||
$border: #4c1d95; // Dark violet
|
||||
|
||||
// Gradients
|
||||
$gradient-primary: linear-gradient(135deg, $primary 0%, #ec4899 100%);
|
||||
$gradient-accent: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
||||
$gradient-card: linear-gradient(145deg, rgba($primary, 0.1) 0%, rgba($secondary, 0.05) 100%);
|
||||
$gradient-card: linear-gradient(
|
||||
145deg,
|
||||
rgba($primary, 0.1) 0%,
|
||||
rgba($secondary, 0.05) 100%
|
||||
);
|
||||
|
||||
.generate-page {
|
||||
min-height: 100vh;
|
||||
@ -349,7 +395,8 @@ $gradient-card: linear-gradient(145deg, rgba($primary, 0.1) 0%, rgba($secondary,
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
33% {
|
||||
@ -461,12 +508,21 @@ $gradient-card: linear-gradient(145deg, rgba($primary, 0.1) 0%, rgba($secondary,
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(0.8); }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
@ -594,17 +650,39 @@ $gradient-card: linear-gradient(145deg, rgba($primary, 0.1) 0%, rgba($secondary,
|
||||
background: rgba($primary, 0.1);
|
||||
backdrop-filter: blur(5px);
|
||||
|
||||
&.front { transform: translateZ(20px); border-color: $primary; }
|
||||
&.back { transform: rotateY(180deg) translateZ(20px); border-color: $secondary; }
|
||||
&.right { transform: rotateY(90deg) translateZ(20px); border-color: $accent; }
|
||||
&.left { transform: rotateY(-90deg) translateZ(20px); border-color: $primary-light; }
|
||||
&.top { transform: rotateX(90deg) translateZ(20px); border-color: $secondary; }
|
||||
&.bottom { transform: rotateX(-90deg) translateZ(20px); border-color: $accent; }
|
||||
&.front {
|
||||
transform: translateZ(20px);
|
||||
border-color: $primary;
|
||||
}
|
||||
&.back {
|
||||
transform: rotateY(180deg) translateZ(20px);
|
||||
border-color: $secondary;
|
||||
}
|
||||
&.right {
|
||||
transform: rotateY(90deg) translateZ(20px);
|
||||
border-color: $accent;
|
||||
}
|
||||
&.left {
|
||||
transform: rotateY(-90deg) translateZ(20px);
|
||||
border-color: $primary-light;
|
||||
}
|
||||
&.top {
|
||||
transform: rotateX(90deg) translateZ(20px);
|
||||
border-color: $secondary;
|
||||
}
|
||||
&.bottom {
|
||||
transform: rotateX(-90deg) translateZ(20px);
|
||||
border-color: $accent;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rotateCube {
|
||||
0% { transform: rotateX(-20deg) rotateY(0deg); }
|
||||
100% { transform: rotateX(-20deg) rotateY(360deg); }
|
||||
0% {
|
||||
transform: rotateX(-20deg) rotateY(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotateX(-20deg) rotateY(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-info {
|
||||
@ -655,9 +733,18 @@ $gradient-card: linear-gradient(145deg, rgba($primary, 0.1) 0%, rgba($secondary,
|
||||
}
|
||||
|
||||
@keyframes progressPulse {
|
||||
0% { width: 20%; opacity: 0.5; }
|
||||
50% { width: 80%; opacity: 1; }
|
||||
100% { width: 20%; opacity: 0.5; }
|
||||
0% {
|
||||
width: 20%;
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% {
|
||||
width: 80%;
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
width: 20%;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
|
||||
@ -103,7 +103,7 @@
|
||||
<span class="btn-content">
|
||||
<LoadingOutlined v-if="generating" class="spin-icon" />
|
||||
<ThunderboltOutlined v-else />
|
||||
<span>{{ generating ? '生成中...' : '立即生成' }}</span>
|
||||
<span>{{ generating ? "生成中..." : "立即生成" }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@ -117,7 +117,7 @@
|
||||
<span class="badge-dot"></span>
|
||||
<span>AI Powered</span>
|
||||
</div>
|
||||
<h1 class="intro-title">用一句话、一张图<br/>创造你的 3D 世界</h1>
|
||||
<h1 class="intro-title">用一句话、一张图<br />创造你的 3D 世界</h1>
|
||||
<p class="intro-desc">
|
||||
借助先进的 AI 技术,将文字描述或图片瞬间转化为专业级 3D 模型
|
||||
</p>
|
||||
@ -209,9 +209,13 @@
|
||||
:src="getPreviewUrl(task)"
|
||||
alt="预览"
|
||||
class="preview-image"
|
||||
@error="handleImageError"
|
||||
@load="handleImageLoad"
|
||||
/>
|
||||
<div
|
||||
v-else-if="task.status === 'processing' || task.status === 'pending'"
|
||||
v-else-if="
|
||||
task.status === 'processing' || task.status === 'pending'
|
||||
"
|
||||
class="preview-loading"
|
||||
>
|
||||
<div class="loading-dots">
|
||||
@ -222,11 +226,15 @@
|
||||
<span class="loading-text">生成中</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="task.status === 'failed' || task.status === 'timeout'"
|
||||
v-else-if="
|
||||
task.status === 'failed' || task.status === 'timeout'
|
||||
"
|
||||
class="preview-failed"
|
||||
>
|
||||
<ExclamationCircleOutlined />
|
||||
<span>{{ task.status === 'timeout' ? '已超时' : '生成失败' }}</span>
|
||||
<span>{{
|
||||
task.status === "timeout" ? "已超时" : "生成失败"
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-else class="preview-placeholder">
|
||||
<FileImageOutlined />
|
||||
@ -512,9 +520,10 @@ const handleGenerate = async () => {
|
||||
}
|
||||
|
||||
// 跳转到生成页面
|
||||
const taskData = task.data || task // 兼容不同的响应格式
|
||||
router.push({
|
||||
name: "AI3DGenerate",
|
||||
params: { taskId: task.id },
|
||||
params: { taskId: taskData.id },
|
||||
})
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || "提交失败,请重试")
|
||||
@ -528,7 +537,8 @@ const fetchHistory = async () => {
|
||||
historyLoading.value = true
|
||||
try {
|
||||
const res = await getAI3DTasks({ page: 1, pageSize: 6 })
|
||||
historyList.value = res.list || []
|
||||
const data = res.data || res // 兼容不同的响应格式
|
||||
historyList.value = data.list || []
|
||||
} catch (error) {
|
||||
console.error("获取历史记录失败:", error)
|
||||
} finally {
|
||||
@ -541,8 +551,9 @@ const fetchAllHistory = async () => {
|
||||
allHistoryLoading.value = true
|
||||
try {
|
||||
const res = await getAI3DTasks({ page: allHistoryPage.value, pageSize: 10 })
|
||||
allHistoryList.value = res.list || []
|
||||
allHistoryTotal.value = res.total || 0
|
||||
const data = res.data || res // 兼容不同的响应格式
|
||||
allHistoryList.value = data.list || []
|
||||
allHistoryTotal.value = data.total || 0
|
||||
} catch (error) {
|
||||
console.error("获取全部历史记录失败:", error)
|
||||
} finally {
|
||||
@ -634,12 +645,20 @@ const handleDelete = (task: AI3DTask) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 获取预览图URL
|
||||
// 获取预览图URL(通过代理访问,解决CORS问题)
|
||||
const getPreviewUrl = (task: AI3DTask) => {
|
||||
if (task.previewUrl) {
|
||||
return task.previewUrl.startsWith("http")
|
||||
? task.previewUrl
|
||||
: task.previewUrl
|
||||
// 如果是腾讯云COS链接,通过代理访问
|
||||
if (
|
||||
task.previewUrl.includes("tencentcos.cn") ||
|
||||
task.previewUrl.includes("qcloud.com")
|
||||
) {
|
||||
// 确保URL正确编码
|
||||
const encodedUrl = encodeURIComponent(task.previewUrl)
|
||||
return `/api/ai-3d/proxy-preview?url=${encodedUrl}`
|
||||
}
|
||||
// 其他URL直接返回
|
||||
return task.previewUrl
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@ -668,6 +687,19 @@ const getStatusText = (status: string) => {
|
||||
return texts[status] || status
|
||||
}
|
||||
|
||||
// 图片加载错误处理
|
||||
const handleImageError = (event: Event) => {
|
||||
const img = event.target as HTMLImageElement
|
||||
console.error("预览图加载失败:", img.src)
|
||||
// 可以在这里添加错误提示或显示占位图
|
||||
}
|
||||
|
||||
// 图片加载成功处理
|
||||
const handleImageLoad = () => {
|
||||
// 图片加载成功,可以在这里做一些处理
|
||||
console.log("预览图加载成功")
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (time: string) => {
|
||||
return dayjs(time).format("MM-DD HH:mm")
|
||||
@ -778,15 +810,28 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
25% { transform: translate(50px, -50px) scale(1.1); }
|
||||
50% { transform: translate(-30px, 30px) scale(0.9); }
|
||||
75% { transform: translate(-50px, -30px) scale(1.05); }
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
25% {
|
||||
transform: translate(50px, -50px) scale(1.1);
|
||||
}
|
||||
50% {
|
||||
transform: translate(-30px, 30px) scale(0.9);
|
||||
}
|
||||
75% {
|
||||
transform: translate(-50px, -30px) scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gridMove {
|
||||
0% { transform: translate(0, 0); }
|
||||
100% { transform: translate(50px, 50px); }
|
||||
0% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
100% {
|
||||
transform: translate(50px, 50px);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
@ -818,8 +863,9 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
||||
height: 56px;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient($surface, $surface) padding-box,
|
||||
$gradient-primary border-box;
|
||||
background:
|
||||
linear-gradient($surface, $surface) padding-box,
|
||||
$gradient-primary border-box;
|
||||
animation: rotateBorder 8s linear infinite;
|
||||
}
|
||||
|
||||
@ -855,7 +901,9 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
||||
}
|
||||
|
||||
@keyframes rotateBorder {
|
||||
to { transform: rotate(360deg); }
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
@ -1097,12 +1145,19 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gradientShift {
|
||||
0%, 100% { background: $gradient-primary; }
|
||||
50% { background: $gradient-secondary; }
|
||||
0%,
|
||||
100% {
|
||||
background: $gradient-primary;
|
||||
}
|
||||
50% {
|
||||
background: $gradient-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
@ -1149,8 +1204,15 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(0.8); }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
.intro-title {
|
||||
@ -1206,10 +1268,18 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.gradient-1 { background: linear-gradient(135deg, $primary 0%, #ec4899 100%); }
|
||||
&.gradient-2 { background: linear-gradient(135deg, $secondary 0%, #10b981 100%); }
|
||||
&.gradient-3 { background: linear-gradient(135deg, $accent 0%, #f59e0b 100%); }
|
||||
&.gradient-4 { background: linear-gradient(135deg, #8b5cf6 0%, $secondary 100%); }
|
||||
&.gradient-1 {
|
||||
background: linear-gradient(135deg, $primary 0%, #ec4899 100%);
|
||||
}
|
||||
&.gradient-2 {
|
||||
background: linear-gradient(135deg, $secondary 0%, #10b981 100%);
|
||||
}
|
||||
&.gradient-3 {
|
||||
background: linear-gradient(135deg, $accent 0%, #f59e0b 100%);
|
||||
}
|
||||
&.gradient-4 {
|
||||
background: linear-gradient(135deg, #8b5cf6 0%, $secondary 100%);
|
||||
}
|
||||
}
|
||||
|
||||
.feature-info {
|
||||
@ -1460,15 +1530,29 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
||||
border-radius: 50%;
|
||||
animation: dotPulse 1.4s ease-in-out infinite;
|
||||
|
||||
&:nth-child(1) { animation-delay: 0s; }
|
||||
&:nth-child(2) { animation-delay: 0.2s; }
|
||||
&:nth-child(3) { animation-delay: 0.4s; }
|
||||
&:nth-child(1) {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
&:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
&:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dotPulse {
|
||||
0%, 60%, 100% { transform: scale(1); opacity: 1; }
|
||||
30% { transform: scale(1.5); opacity: 0.7; }
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
30% {
|
||||
transform: scale(1.5);
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
|
||||
442
pnpm-lock.yaml
generated
442
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user