修改bug

This commit is contained in:
zhangxiaohua 2026-01-14 10:06:08 +08:00
parent 1dce34e76a
commit 62cdebc388
17 changed files with 2420 additions and 214 deletions

607
.claude/ui-design-system.md Normal file
View 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`
---
**文档结束**

View File

@ -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"
}
]
}
]

View File

@ -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",

View File

@ -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",

View File

@ -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 /// 失败时的错误信息

View File

@ -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,
});
}
}
}
}

View File

@ -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],

View 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
*
*
* - 33
* -
* - "资源不足"
*/
@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}`,
};
}
}
}

View 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-SHA256Buffer
*/
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');
}
}

View 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;
}
}

View File

@ -151,3 +151,4 @@ export const judgesManagementApi = {

View File

@ -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: key3D
// /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 Labmodel-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 {

View File

@ -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",

View File

@ -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))
// GLBGLTFZIPZIP
// 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);
}

View File

@ -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;
}
}
// ==========================================

View File

@ -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

File diff suppressed because it is too large Load Diff