修改样式
This commit is contained in:
parent
62cdebc388
commit
d9abd6939c
437
.claude/skills/design-system/SKILL.md
Normal file
437
.claude/skills/design-system/SKILL.md
Normal file
@ -0,0 +1,437 @@
|
||||
---
|
||||
name: design-system
|
||||
description: "比赛管理系统设计规范。当用户提出页面开发需求时自动应用。主色: #0958d9 蓝色主题。包含: 颜色系统、间距、圆角、阴影、字体、组件规范(按钮、卡片、表单、标签)、页面布局、动画效果。适用: 新增页面、修改样式、组件开发、UI调整。"
|
||||
---
|
||||
|
||||
# 比赛管理系统 - 设计规范 Skill
|
||||
|
||||
当用户提出页面开发、UI修改、组件创建等需求时,**必须遵循以下设计规范**。
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
每个新页面的 `<style scoped lang="scss">` 开头必须包含以下变量:
|
||||
|
||||
```scss
|
||||
// ==========================================
|
||||
// 项目统一设计变量 - 必须复制
|
||||
// ==========================================
|
||||
$primary: #0958d9;
|
||||
$primary-light: #1677ff;
|
||||
$primary-dark: #003eb3;
|
||||
$secondary: #4096ff;
|
||||
$success: #52c41a;
|
||||
$warning: #faad14;
|
||||
$error: #ff4d4f;
|
||||
|
||||
$background: #f5f5f5;
|
||||
$surface: #ffffff;
|
||||
|
||||
$text: rgba(0, 0, 0, 0.85);
|
||||
$text-secondary: rgba(0, 0, 0, 0.65);
|
||||
$text-muted: rgba(0, 0, 0, 0.45);
|
||||
|
||||
$border: #d9d9d9;
|
||||
$border-light: #e8e8e8;
|
||||
|
||||
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 色彩系统
|
||||
|
||||
### 主色(蓝色主题)
|
||||
|
||||
| 变量 | 色值 | 用途 |
|
||||
|------|------|------|
|
||||
| `$primary` | `#0958d9` | 主色、按钮、链接 |
|
||||
| `$primary-light` | `#1677ff` | 悬停态 |
|
||||
| `$primary-dark` | `#003eb3` | 激活态 |
|
||||
| `$secondary` | `#4096ff` | 辅助蓝 |
|
||||
|
||||
### 功能色
|
||||
|
||||
| 变量 | 色值 | 用途 |
|
||||
|------|------|------|
|
||||
| `$success` | `#52c41a` | 成功状态 |
|
||||
| `$warning` | `#faad14` | 警告状态 |
|
||||
| `$error` | `#ff4d4f` | 错误状态 |
|
||||
|
||||
### 中性色
|
||||
|
||||
| 变量 | 色值 | 用途 |
|
||||
|------|------|------|
|
||||
| `$background` | `#f5f5f5` | 页面背景 |
|
||||
| `$surface` | `#ffffff` | 卡片/容器背景 |
|
||||
| `$text` | `rgba(0,0,0,0.85)` | 主文本 |
|
||||
| `$text-secondary` | `rgba(0,0,0,0.65)` | 次要文本 |
|
||||
| `$text-muted` | `rgba(0,0,0,0.45)` | 弱化文本 |
|
||||
| `$border-light` | `#e8e8e8` | 边框 |
|
||||
|
||||
---
|
||||
|
||||
## 间距规范
|
||||
|
||||
基于 **8px** 基准:
|
||||
|
||||
| 尺寸 | 值 | 场景 |
|
||||
|------|------|------|
|
||||
| xs | 4px | 图标间距 |
|
||||
| sm | 8px | 元素内间距 |
|
||||
| md | 12px | 卡片内元素 |
|
||||
| base | 16px | 最常用 |
|
||||
| lg | 20px | 区块间距 |
|
||||
| xl | 24px | 大间距 |
|
||||
| 2xl | 32px | 模块分隔 |
|
||||
|
||||
---
|
||||
|
||||
## 圆角规范
|
||||
|
||||
| 尺寸 | 值 | 场景 |
|
||||
|------|------|------|
|
||||
| sm | 4px | 小元素 |
|
||||
| base | 6px | 默认 |
|
||||
| md | 8px | 卡片 |
|
||||
| lg | 10px | 返回按钮 |
|
||||
| xl | 12px | 大卡片 |
|
||||
| pill | 20px | 标签 |
|
||||
| full | 50% | 头像 |
|
||||
|
||||
---
|
||||
|
||||
## 阴影规范
|
||||
|
||||
| 等级 | 定义 | 场景 |
|
||||
|------|------|------|
|
||||
| sm | `0 1px 2px rgba(0,0,0,0.03)` | 轻微 |
|
||||
| base | `0 2px 8px rgba(0,0,0,0.06)` | 卡片默认 |
|
||||
| md | `0 4px 12px rgba(0,0,0,0.08)` | 悬停 |
|
||||
| lg | `0 8px 24px rgba(0,0,0,0.12)` | 弹窗 |
|
||||
|
||||
---
|
||||
|
||||
## 字体规范
|
||||
|
||||
| 层级 | 大小 | 权重 | 场景 |
|
||||
|------|------|------|------|
|
||||
| h1 | 36px | 700 | 大标题 |
|
||||
| h2 | 26px | 700 | 区块标题 |
|
||||
| h3 | 22px | 600 | 卡片标题 |
|
||||
| h4 | 18px | 600 | 小标题 |
|
||||
| body | 14px | 400 | 正文 |
|
||||
| small | 12px | 400 | 标签 |
|
||||
|
||||
---
|
||||
|
||||
## 组件规范
|
||||
|
||||
### 返回按钮(统一样式)
|
||||
|
||||
```scss
|
||||
.back-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px !important;
|
||||
border: 1px solid rgba($primary, 0.3) !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease !important;
|
||||
|
||||
&:hover {
|
||||
background: rgba($primary, 0.1) !important;
|
||||
border-color: $primary !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
模板代码:
|
||||
```vue
|
||||
<a-button type="text" class="back-btn" @click="handleBack">
|
||||
<template #icon><ArrowLeftOutlined /></template>
|
||||
</a-button>
|
||||
```
|
||||
|
||||
### 主按钮
|
||||
|
||||
```scss
|
||||
.primary-btn {
|
||||
background: $gradient-primary !important;
|
||||
border: none !important;
|
||||
color: #fff !important;
|
||||
font-weight: 500 !important;
|
||||
transition: all 0.3s ease !important;
|
||||
|
||||
&:hover {
|
||||
filter: brightness(1.1);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 24px rgba($primary, 0.2);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 卡片
|
||||
|
||||
```scss
|
||||
.card {
|
||||
background: $surface;
|
||||
border: 1px solid $border-light;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba($primary, 0.3);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 标签
|
||||
|
||||
```scss
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
|
||||
&.tag-success {
|
||||
background: rgba($success, 0.1);
|
||||
color: $success;
|
||||
border: 1px solid rgba($success, 0.3);
|
||||
}
|
||||
|
||||
&.tag-primary {
|
||||
background: rgba($primary, 0.1);
|
||||
color: $primary;
|
||||
border: 1px solid rgba($primary, 0.3);
|
||||
}
|
||||
|
||||
&.tag-error {
|
||||
background: rgba($error, 0.1);
|
||||
color: $error;
|
||||
border: 1px solid rgba($error, 0.3);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 面板/浮层
|
||||
|
||||
```scss
|
||||
.panel {
|
||||
background: rgba($surface, 0.85);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba($primary, 0.2);
|
||||
border-radius: 12px;
|
||||
|
||||
.panel-header {
|
||||
padding: 14px 16px;
|
||||
background: rgba($primary, 0.05);
|
||||
border-bottom: 1px solid rgba($primary, 0.1);
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 页面布局规范
|
||||
|
||||
### 页面头部(64px 高)
|
||||
|
||||
```scss
|
||||
.page-header {
|
||||
height: 64px;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: $surface;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
background: $gradient-primary;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 全屏页面容器
|
||||
|
||||
```scss
|
||||
.fullscreen-page {
|
||||
min-height: 100vh;
|
||||
background: $background;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
overflow: auto;
|
||||
}
|
||||
```
|
||||
|
||||
### 卡片网格
|
||||
|
||||
```scss
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 动画规范
|
||||
|
||||
### 过渡时间
|
||||
|
||||
- 标准过渡:`0.3s ease`
|
||||
- 所有交互元素必须添加过渡
|
||||
|
||||
### 悬停效果
|
||||
|
||||
```scss
|
||||
// 卡片悬停
|
||||
transform: translateY(-4px);
|
||||
|
||||
// 按钮悬停
|
||||
transform: translateY(-2px);
|
||||
|
||||
// 小元素悬停
|
||||
transform: translateY(-1px);
|
||||
```
|
||||
|
||||
### 常用动画
|
||||
|
||||
```scss
|
||||
// 脉冲
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(0.8); }
|
||||
}
|
||||
|
||||
// 旋转
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 加载/错误状态
|
||||
|
||||
### 加载遮罩
|
||||
|
||||
```scss
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: $background;
|
||||
z-index: 10;
|
||||
}
|
||||
```
|
||||
|
||||
### 错误卡片
|
||||
|
||||
```scss
|
||||
.error-card {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
background: $surface;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 16px;
|
||||
|
||||
.error-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
margin: 0 auto 20px;
|
||||
background: $error;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 滚动条
|
||||
|
||||
```scss
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: rgba($primary, 0.05);
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba($primary, 0.3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 响应式断点
|
||||
|
||||
```scss
|
||||
$breakpoint-sm: 640px;
|
||||
$breakpoint-md: 768px;
|
||||
$breakpoint-lg: 1024px;
|
||||
$breakpoint-xl: 1280px;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 开发检查清单
|
||||
|
||||
新增页面必须确认:
|
||||
|
||||
- [ ] 复制了颜色变量到 style 开头
|
||||
- [ ] 返回按钮使用 40x40px + 10px 圆角
|
||||
- [ ] 卡片悬停有 `translateY(-4px)` 效果
|
||||
- [ ] 页面头部高度 64px
|
||||
- [ ] 所有过渡动画 0.3s
|
||||
- [ ] 使用 `$gradient-primary` 作为主渐变
|
||||
- [ ] 加载状态使用蓝色系
|
||||
|
||||
---
|
||||
|
||||
## 参考页面
|
||||
|
||||
- `frontend/src/views/workbench/ai-3d/Index.vue` - 3D Lab 主页
|
||||
- `frontend/src/views/workbench/ai-3d/Generate.vue` - 生成页
|
||||
- `frontend/src/views/model/ModelViewer.vue` - 模型预览页
|
||||
@ -1,207 +0,0 @@
|
||||
需求确认
|
||||
|
||||
- AI服务:先用 Mock 数据开发,后期接入真实API(腾讯混元3D/Meshy)
|
||||
- 功能入口:独立页面
|
||||
- 生成历史:需要保存
|
||||
|
||||
---
|
||||
技术架构
|
||||
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 前端 Vue 3 │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ AI 3D生成页面 (/ai-3d) │ │
|
||||
│ │ - 文字输入 / 图片上传 │ │
|
||||
│ │ - 生成进度展示 │ │
|
||||
│ │ - 历史记录列表 │ │
|
||||
│ │ - 3D预览(复用 ModelViewer) │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
└───────────────────────────┬─────────────────────────────┘
|
||||
│
|
||||
┌───────────────────────────▼─────────────────────────────┐
|
||||
│ 后端 NestJS │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ AI 3D生成模块 │ │
|
||||
│ │ - 提交生成任务 │ │
|
||||
│ │ - 查询任务状态 │ │
|
||||
│ │ - 获取历史记录 │ │
|
||||
│ └──────────────────────┬──────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────▼──────────────────────────┐ │
|
||||
│ │ Mock Provider(开发阶段) │ │
|
||||
│ │ - 模拟生成延迟(5-10秒) │ │
|
||||
│ │ - 返回示例3D模型URL │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
|
||||
---
|
||||
实现步骤
|
||||
|
||||
第一步:后端 - 创建数据模型
|
||||
|
||||
文件: backend/prisma/schema.prisma
|
||||
|
||||
新增 AI3DTask 表:
|
||||
model AI3DTask {
|
||||
id Int @id @default(autoincrement())
|
||||
tenantId Int @map("tenant_id")
|
||||
userId Int @map("user_id")
|
||||
inputType String @map("input_type") // text | image
|
||||
inputContent String @db.Text // 文字描述或图片URL
|
||||
status String @default("pending") // pending|processing|completed|failed
|
||||
resultUrl String? @map("result_url") // 生成的3D模型URL
|
||||
errorMessage String? @map("error_message")
|
||||
createTime DateTime @default(now()) @map("create_time")
|
||||
completeTime DateTime? @map("complete_time")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@map("t_ai_3d_task")
|
||||
}
|
||||
|
||||
第二步:后端 - 创建 AI 3D 模块
|
||||
|
||||
新建文件结构:
|
||||
backend/src/ai-3d/
|
||||
├── ai-3d.module.ts
|
||||
├── ai-3d.controller.ts
|
||||
├── ai-3d.service.ts
|
||||
├── dto/
|
||||
│ ├── create-task.dto.ts
|
||||
│ └── query-task.dto.ts
|
||||
└── providers/
|
||||
└── mock.provider.ts # Mock实现
|
||||
|
||||
API 端点:
|
||||
POST /api/ai-3d/generate # 提交生成任务
|
||||
GET /api/ai-3d/tasks # 获取历史记录列表
|
||||
GET /api/ai-3d/tasks/:id # 获取任务详情/状态
|
||||
DELETE /api/ai-3d/tasks/:id # 删除任务
|
||||
|
||||
第三步:后端 - Mock Provider 实现
|
||||
|
||||
文件: backend/src/ai-3d/providers/mock.provider.ts
|
||||
|
||||
// Mock实现:模拟5-10秒生成延迟,返回示例模型
|
||||
async generate(input: { type: 'text' | 'image', content: string }) {
|
||||
// 模拟处理时间
|
||||
await sleep(random(5000, 10000));
|
||||
|
||||
// 返回示例模型URL(使用公开的GLB示例)
|
||||
return {
|
||||
status: 'completed',
|
||||
resultUrl: 'https://example.com/sample-model.glb'
|
||||
};
|
||||
}
|
||||
|
||||
第四步:前端 - 创建页面和API
|
||||
|
||||
新建文件结构:
|
||||
frontend/src/
|
||||
├── api/
|
||||
│ └── ai-3d.ts # API接口
|
||||
├── views/
|
||||
│ └── ai-3d/
|
||||
│ ├── index.vue # 主页面
|
||||
│ └── components/
|
||||
│ ├── GenerateForm.vue # 生成表单
|
||||
│ ├── TaskList.vue # 历史列表
|
||||
│ └── TaskCard.vue # 任务卡片
|
||||
|
||||
页面布局:
|
||||
┌────────────────────────────────────────────────┐
|
||||
│ AI 3D模型生成 │
|
||||
├────────────────────────────────────────────────┤
|
||||
│ ┌──────────────────┐ ┌────────────────────┐ │
|
||||
│ │ 生成方式 │ │ 历史记录 │ │
|
||||
│ │ ○ 文字描述 │ │ ┌────────────────┐ │ │
|
||||
│ │ ○ 上传图片 │ │ │ 任务1 完成 ✓ │ │ │
|
||||
│ │ │ │ └────────────────┘ │ │
|
||||
│ │ [输入区域] │ │ ┌────────────────┐ │ │
|
||||
│ │ │ │ │ 任务2 生成中...│ │ │
|
||||
│ │ [生成] 按钮 │ │ └────────────────┘ │ │
|
||||
│ └──────────────────┘ └────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────┐ │
|
||||
│ │ 3D预览区域(选中任务后显示) │ │
|
||||
│ │ │ │
|
||||
│ └──────────────────────────────────────────┘ │
|
||||
└────────────────────────────────────────────────┘
|
||||
|
||||
第五步:前端 - 添加路由和菜单
|
||||
|
||||
修改文件: frontend/src/router/index.ts
|
||||
|
||||
{
|
||||
path: "ai-3d",
|
||||
name: "AI3DGenerate",
|
||||
component: () => import("@/views/ai-3d/index.vue"),
|
||||
meta: {
|
||||
title: "AI 3D生成",
|
||||
requiresAuth: true,
|
||||
},
|
||||
}
|
||||
|
||||
---
|
||||
关键文件清单
|
||||
┌──────┬──────────────────────────────────────────────────────┐
|
||||
│ 操作 │ 文件路径 │
|
||||
├──────┼──────────────────────────────────────────────────────┤
|
||||
│ 修改 │ backend/prisma/schema.prisma - 添加 AI3DTask 表 │
|
||||
├──────┼──────────────────────────────────────────────────────┤
|
||||
│ 新建 │ backend/src/ai-3d/ai-3d.module.ts │
|
||||
├──────┼──────────────────────────────────────────────────────┤
|
||||
│ 新建 │ backend/src/ai-3d/ai-3d.controller.ts │
|
||||
├──────┼──────────────────────────────────────────────────────┤
|
||||
│ 新建 │ backend/src/ai-3d/ai-3d.service.ts │
|
||||
├──────┼──────────────────────────────────────────────────────┤
|
||||
│ 新建 │ backend/src/ai-3d/dto/create-task.dto.ts │
|
||||
├──────┼──────────────────────────────────────────────────────┤
|
||||
│ 新建 │ backend/src/ai-3d/dto/query-task.dto.ts │
|
||||
├──────┼──────────────────────────────────────────────────────┤
|
||||
│ 新建 │ backend/src/ai-3d/providers/mock.provider.ts │
|
||||
├──────┼──────────────────────────────────────────────────────┤
|
||||
│ 修改 │ backend/src/app.module.ts - 注册 AI3D 模块 │
|
||||
├──────┼──────────────────────────────────────────────────────┤
|
||||
│ 新建 │ frontend/src/api/ai-3d.ts │
|
||||
├──────┼──────────────────────────────────────────────────────┤
|
||||
│ 新建 │ frontend/src/views/ai-3d/index.vue │
|
||||
├──────┼──────────────────────────────────────────────────────┤
|
||||
│ 新建 │ frontend/src/views/ai-3d/components/GenerateForm.vue │
|
||||
├──────┼──────────────────────────────────────────────────────┤
|
||||
│ 新建 │ frontend/src/views/ai-3d/components/TaskList.vue │
|
||||
├──────┼──────────────────────────────────────────────────────┤
|
||||
│ 新建 │ frontend/src/views/ai-3d/components/TaskCard.vue │
|
||||
├──────┼──────────────────────────────────────────────────────┤
|
||||
│ 修改 │ frontend/src/router/index.ts - 添加路由 │
|
||||
└──────┴──────────────────────────────────────────────────────┘
|
||||
---
|
||||
验证方式
|
||||
|
||||
1. 数据库迁移
|
||||
cd backend
|
||||
npx prisma migrate dev --name add-ai-3d-task
|
||||
2. 后端测试
|
||||
cd backend && npm run start:dev
|
||||
# 测试API
|
||||
curl -X POST http://localhost:3001/api/ai-3d/generate \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"type":"text","content":"一个红色的椅子"}'
|
||||
3. 前端测试
|
||||
cd frontend && npm run dev
|
||||
# 访问 http://localhost:3000/<tenantCode>/ai-3d
|
||||
# 测试文字生成、图片上传、历史记录功能
|
||||
4. 完整流程测试
|
||||
- 输入文字描述 → 点击生成 → 等待完成 → 预览3D模型
|
||||
- 上传图片 → 点击生成 → 等待完成 → 预览3D模型
|
||||
- 查看历史记录 → 点击历史任务 → 预览3D模型
|
||||
|
||||
---
|
||||
后期扩展
|
||||
|
||||
Mock 开发完成后,接入真实 API 只需:
|
||||
1. 新建 hunyuan-3d.provider.ts 或 meshy.provider.ts
|
||||
2. 配置环境变量
|
||||
3. 在 Service 中切换 Provider
|
||||
@ -15,7 +15,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import axios from 'axios';
|
||||
import * as AdmZip from 'adm-zip';
|
||||
import AdmZip from 'adm-zip';
|
||||
import { AI3DService } from './ai-3d.service';
|
||||
import { CreateTaskDto } from './dto/create-task.dto';
|
||||
import { QueryTaskDto } from './dto/query-task.dto';
|
||||
@ -94,27 +94,15 @@ export class AI3DController {
|
||||
}
|
||||
|
||||
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') &&
|
||||
@ -134,7 +122,6 @@ export class AI3DController {
|
||||
}
|
||||
|
||||
// 从源URL获取文件
|
||||
console.log('开始下载文件...');
|
||||
const response = await axios.get(decodedUrl, {
|
||||
responseType: 'arraybuffer', // 使用arraybuffer以便处理ZIP
|
||||
timeout: 120000, // 120秒超时(ZIP文件可能较大)
|
||||
@ -151,10 +138,6 @@ export class AI3DController {
|
||||
response.headers['content-type'] || 'application/octet-stream';
|
||||
let contentLength = fileData.length;
|
||||
|
||||
console.log(
|
||||
`文件下载成功,大小: ${contentLength} bytes, 类型: ${contentType}`,
|
||||
);
|
||||
|
||||
// 如果是ZIP文件,解压并提取GLB文件
|
||||
if (
|
||||
decodedUrl.toLowerCase().includes('.zip') ||
|
||||
@ -165,46 +148,35 @@ export class AI3DController {
|
||||
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'),
|
||||
// 查找ZIP中的GLB文件
|
||||
const glbEntry = zipEntries.find(
|
||||
(entry) =>
|
||||
entry.entryName.toLowerCase().endsWith('.glb') ||
|
||||
entry.entryName.toLowerCase().endsWith('.gltf'),
|
||||
);
|
||||
|
||||
const targetEntry = glbEntry || gltfEntry;
|
||||
|
||||
if (targetEntry) {
|
||||
console.log(`找到模型文件: ${targetEntry.entryName}`);
|
||||
fileData = targetEntry.getData();
|
||||
contentType = targetEntry.entryName.toLowerCase().endsWith('.glb')
|
||||
if (glbEntry) {
|
||||
fileData = glbEntry.getData();
|
||||
contentType = glbEntry.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);
|
||||
const fileList = zipEntries.map((e) => e.entryName).join(', ');
|
||||
throw new HttpException(
|
||||
`ZIP文件中未找到GLB或GLTF文件。ZIP内容: ${fileList}`,
|
||||
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);
|
||||
throw new HttpException(
|
||||
`ZIP解压失败: ${zipError.message}`,
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -152,3 +152,4 @@ export const judgesManagementApi = {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<!-- 这个组件作为跳转器使用,实际查看器在 /model-viewer 页面 -->
|
||||
<!-- 这个组件作为跳转器使用,实际查看器在 model-viewer 页面 -->
|
||||
<span></span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watch } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
import { useRouter, useRoute } from "vue-router"
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
@ -19,6 +19,7 @@ interface Emits {
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
// 监听打开状态,跳转到全屏页面
|
||||
watch(
|
||||
@ -30,8 +31,9 @@ watch(
|
||||
// 先关闭状态
|
||||
emit("update:open", false)
|
||||
// 跳转到模型查看页面
|
||||
const tenantCode = route.params.tenantCode as string
|
||||
router.push({
|
||||
path: "/model-viewer",
|
||||
path: `/${tenantCode}/workbench/model-viewer`,
|
||||
query: { url: props.modelUrl }
|
||||
})
|
||||
} else if (newOpen && !props.modelUrl) {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<a-layout class="layout">
|
||||
<a-layout-sider
|
||||
v-if="!hideSidebar"
|
||||
v-model:collapsed="collapsed"
|
||||
:width="200"
|
||||
class="custom-sider"
|
||||
@ -51,7 +52,10 @@
|
||||
</div>
|
||||
</a-layout-sider>
|
||||
<a-layout class="main-layout">
|
||||
<a-layout-content class="content">
|
||||
<a-layout-content
|
||||
class="content"
|
||||
:class="{ 'content-fullscreen': hideSidebar }"
|
||||
>
|
||||
<router-view />
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
@ -82,6 +86,11 @@ const openKeys = ref<string[]>([])
|
||||
// 生成用户头像URL(使用DiceBear API自动生成默认头像)
|
||||
const userAvatar = computed(() => getUserAvatar(authStore.user))
|
||||
|
||||
// 根据路由 meta 判断是否隐藏侧边栏
|
||||
const hideSidebar = computed(() => {
|
||||
return route.meta?.hideSidebar === true
|
||||
})
|
||||
|
||||
// 使用动态菜单
|
||||
const menuItems = computed<MenuProps["items"]>(() => {
|
||||
if (authStore.menus && authStore.menus.length > 0) {
|
||||
@ -141,7 +150,8 @@ const handleMenuClick = ({ key }: { key: string }) => {
|
||||
const is3DLab =
|
||||
key.toLowerCase().includes("3dlab") ||
|
||||
key.toLowerCase().includes("3d-lab") ||
|
||||
(key.toLowerCase().includes("workbench") && key.toLowerCase().includes("3d"))
|
||||
(key.toLowerCase().includes("workbench") &&
|
||||
key.toLowerCase().includes("3d"))
|
||||
|
||||
// 方法2: 从菜单数据中查找对应的菜单项,检查path
|
||||
const findMenuByKey = (menus: any[], targetKey: string): any => {
|
||||
@ -158,15 +168,23 @@ const handleMenuClick = ({ key }: { key: string }) => {
|
||||
}
|
||||
|
||||
const menuItem = findMenuByKey(menuItems.value || [], key)
|
||||
const is3DLabByPath = menuItem?.label?.includes("3D建模") || menuItem?.title?.includes("3D建模")
|
||||
const is3DLabByPath =
|
||||
menuItem?.label?.includes("3D建模") || menuItem?.title?.includes("3D建模")
|
||||
|
||||
// 调试日志
|
||||
console.log("is3DLab:", is3DLab, "is3DLabByPath:", is3DLabByPath, "menuItem:", menuItem)
|
||||
console.log(
|
||||
"is3DLab:",
|
||||
is3DLab,
|
||||
"is3DLabByPath:",
|
||||
is3DLabByPath,
|
||||
"menuItem:",
|
||||
menuItem
|
||||
)
|
||||
|
||||
if (is3DLab || is3DLabByPath) {
|
||||
// 打开独立的全屏3D Lab页面(类似model-viewer)
|
||||
// 打开3D建模实验室页面(新窗口,路由配置了 hideSidebar 会自动隐藏侧边栏)
|
||||
console.log("检测到3D建模实验室,打开新窗口")
|
||||
const fullUrl = `${window.location.origin}/${tenantCode}/3d-lab-fullscreen`
|
||||
const fullUrl = `${window.location.origin}/${tenantCode}/workbench/3d-lab`
|
||||
window.open(fullUrl, "_blank")
|
||||
return
|
||||
}
|
||||
@ -414,4 +432,9 @@ const handleLogout = async () => {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.content-fullscreen {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -19,18 +19,6 @@ const baseRoutes: RouteRecordRaw[] = [
|
||||
component: () => import("@/views/auth/Login.vue"),
|
||||
meta: { requiresAuth: false },
|
||||
},
|
||||
{
|
||||
path: "/model-viewer",
|
||||
name: "ModelViewer",
|
||||
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",
|
||||
@ -187,6 +175,7 @@ const baseRoutes: RouteRecordRaw[] = [
|
||||
meta: {
|
||||
title: "3D建模实验室",
|
||||
requiresAuth: true,
|
||||
hideSidebar: true,
|
||||
},
|
||||
},
|
||||
// 3D模型生成页面
|
||||
@ -197,6 +186,29 @@ const baseRoutes: RouteRecordRaw[] = [
|
||||
meta: {
|
||||
title: "3D模型生成",
|
||||
requiresAuth: true,
|
||||
hideSidebar: true,
|
||||
},
|
||||
},
|
||||
// 3D创作历史页面
|
||||
{
|
||||
path: "workbench/3d-lab/history",
|
||||
name: "AI3DHistory",
|
||||
component: () => import("@/views/workbench/ai-3d/History.vue"),
|
||||
meta: {
|
||||
title: "创作历史",
|
||||
requiresAuth: true,
|
||||
hideSidebar: true,
|
||||
},
|
||||
},
|
||||
// 3D模型预览页面
|
||||
{
|
||||
path: "workbench/model-viewer",
|
||||
name: "ModelViewer",
|
||||
component: () => import("@/views/model/ModelViewer.vue"),
|
||||
meta: {
|
||||
title: "3D模型预览",
|
||||
requiresAuth: true,
|
||||
hideSidebar: true,
|
||||
},
|
||||
},
|
||||
// 动态路由将在这里添加
|
||||
@ -474,7 +486,7 @@ router.beforeEach(async (to, _from, next) => {
|
||||
return
|
||||
}
|
||||
// 如果URL中没有租户编码,添加租户编码(排除不需要认证的特殊路由)
|
||||
const skipTenantCodePaths = ["/login", "/model-viewer", "/403"]
|
||||
const skipTenantCodePaths = ["/login", "/403"]
|
||||
const shouldSkipTenantCode = skipTenantCodePaths.some(p => to.path.startsWith(p))
|
||||
if (!tenantCodeFromUrl && !shouldSkipTenantCode) {
|
||||
const correctedPath = buildPathWithTenantCode(userTenantCode, to.path)
|
||||
|
||||
@ -6,5 +6,6 @@ declare module "vue-router" {
|
||||
requiresAuth?: boolean;
|
||||
roles?: string[]; // 需要的角色列表
|
||||
permissions?: string[]; // 需要的权限列表
|
||||
hideSidebar?: boolean; // 是否隐藏侧边栏(全屏模式)
|
||||
}
|
||||
}
|
||||
|
||||
@ -409,9 +409,12 @@ const handlePreview3DModel = (fileUrl: string) => {
|
||||
}
|
||||
const fullUrl = getFileUrl(fileUrl)
|
||||
console.log("预览3D模型,原始URL:", fileUrl, "完整URL:", fullUrl)
|
||||
// 直接在新标签页打开模型查看器
|
||||
const viewerUrl = `/model-viewer?url=${encodeURIComponent(fullUrl)}`
|
||||
window.open(viewerUrl, "_blank")
|
||||
// 跳转到模型查看器页面
|
||||
const tenantCode = route.params.tenantCode as string
|
||||
router.push({
|
||||
path: `/${tenantCode}/workbench/model-viewer`,
|
||||
query: { url: fullUrl },
|
||||
})
|
||||
}
|
||||
|
||||
// 表格列定义
|
||||
|
||||
@ -159,6 +159,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from "vue"
|
||||
import { useRouter, useRoute } from "vue-router"
|
||||
import { message } from "ant-design-vue"
|
||||
import {
|
||||
FileOutlined,
|
||||
@ -181,6 +182,8 @@ interface Emits {
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
@ -462,9 +465,12 @@ const handlePreview3DModel = (fileUrl: string) => {
|
||||
}
|
||||
const fullUrl = getFileUrl(fileUrl)
|
||||
console.log("预览3D模型,原始URL:", fileUrl, "完整URL:", fullUrl)
|
||||
// 直接在新标签页打开模型查看器
|
||||
const viewerUrl = `/model-viewer?url=${encodeURIComponent(fullUrl)}`
|
||||
window.open(viewerUrl, "_blank")
|
||||
// 跳转到模型查看器页面
|
||||
const tenantCode = route.params.tenantCode as string
|
||||
router.push({
|
||||
path: `/${tenantCode}/workbench/model-viewer`,
|
||||
query: { url: fullUrl },
|
||||
})
|
||||
}
|
||||
|
||||
// 取消
|
||||
|
||||
@ -1,11 +1,17 @@
|
||||
<template>
|
||||
<div class="model-viewer-page">
|
||||
<!-- Animated Background -->
|
||||
<div class="bg-animation">
|
||||
<div class="bg-gradient bg-gradient-1"></div>
|
||||
<div class="bg-gradient bg-gradient-2"></div>
|
||||
<div class="bg-gradient bg-gradient-3"></div>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="viewer-header">
|
||||
<div class="header-left">
|
||||
<a-button type="text" class="back-btn" @click="handleBack">
|
||||
<template #icon><ArrowLeftOutlined /></template>
|
||||
返回
|
||||
</a-button>
|
||||
<span class="title">3D 模型预览</span>
|
||||
<span class="badge">LIVE</span>
|
||||
@ -373,8 +379,8 @@ const settingsPanelOpen = ref(true)
|
||||
|
||||
// 场景设置默认值
|
||||
const defaultSettings = {
|
||||
backgroundColor: "#0f0f1a",
|
||||
showGrid: true,
|
||||
backgroundColor: "#f5f5f5",
|
||||
showGrid: false,
|
||||
ambientLight: {
|
||||
intensity: 0.4,
|
||||
color: "#ffffff",
|
||||
@ -426,11 +432,7 @@ console.log("模型查看器 - URL:", modelUrl.value)
|
||||
|
||||
// 返回上一页
|
||||
const handleBack = () => {
|
||||
if (window.history.length <= 1) {
|
||||
window.close()
|
||||
} else {
|
||||
router.back()
|
||||
}
|
||||
router.back()
|
||||
}
|
||||
|
||||
// 重置相机视角
|
||||
@ -656,8 +658,8 @@ const initScene = () => {
|
||||
bottomLight.position.set(0, -10, 0)
|
||||
scene.add(bottomLight)
|
||||
|
||||
// 添加网格 - 使用紫色调
|
||||
gridHelper = new THREE.GridHelper(100, 100, 0x7c3aed, 0x3b1d70)
|
||||
// 添加网格 - 使用蓝色调
|
||||
gridHelper = new THREE.GridHelper(100, 100, 0x0958d9, 0x1677ff)
|
||||
gridHelper.visible = sceneSettings.showGrid
|
||||
scene.add(gridHelper)
|
||||
|
||||
@ -773,13 +775,13 @@ const loadModel = async () => {
|
||||
const distanceForWidth = size.x / 2 / Math.tan(fovRad / 2) / aspect
|
||||
const distanceForDepth = size.z / 2 / Math.tan(fovRad / 2)
|
||||
|
||||
// 取最大距离,并添加足够的边距(3倍)
|
||||
// 取最大距离,并添加适当的边距(1.8倍,让模型显示更大)
|
||||
const baseDistance = Math.max(
|
||||
distanceForHeight,
|
||||
distanceForWidth,
|
||||
distanceForDepth
|
||||
)
|
||||
const cameraDistance = Math.max(baseDistance * 3, maxDim * 3, 10)
|
||||
const cameraDistance = Math.max(baseDistance * 1.8, maxDim * 1.8, 5)
|
||||
|
||||
console.log("模型尺寸:", size)
|
||||
console.log("计算的相机距离:", cameraDistance)
|
||||
@ -953,26 +955,28 @@ onUnmounted(() => {
|
||||
|
||||
<style scoped lang="scss">
|
||||
// ==========================================
|
||||
// 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: #0958d9;
|
||||
$primary-light: #1677ff;
|
||||
$primary-dark: #003eb3;
|
||||
$secondary: #4096ff;
|
||||
$accent: #1677ff;
|
||||
$success: #52c41a;
|
||||
$warning: #faad14;
|
||||
$error: #ff4d4f;
|
||||
|
||||
// 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%
|
||||
);
|
||||
// 背景色 - 模型查看器保持深色以更好展示3D模型
|
||||
$background: #f5f5f5;
|
||||
$surface: #ffffff;
|
||||
$surface-dark: #1a1a2e;
|
||||
$text: rgba(0, 0, 0, 0.85);
|
||||
$text-secondary: rgba(0, 0, 0, 0.65);
|
||||
$text-muted: rgba(0, 0, 0, 0.45);
|
||||
$text-light: #ffffff;
|
||||
|
||||
// 渐变
|
||||
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
|
||||
|
||||
.model-viewer-page {
|
||||
position: fixed;
|
||||
@ -982,39 +986,65 @@ $gradient-dark: linear-gradient(
|
||||
background: $background;
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Animated background gradient
|
||||
&::before {
|
||||
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%
|
||||
);
|
||||
animation: backgroundPulse 15s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
// ==========================================
|
||||
// Animated Background
|
||||
// ==========================================
|
||||
.bg-animation {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.bg-gradient {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(100px);
|
||||
opacity: 0.15;
|
||||
animation: float 30s ease-in-out infinite;
|
||||
|
||||
&.bg-gradient-1 {
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
background: $primary;
|
||||
top: -200px;
|
||||
left: -100px;
|
||||
}
|
||||
|
||||
&.bg-gradient-2 {
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
background: $primary-light;
|
||||
bottom: -150px;
|
||||
right: -100px;
|
||||
animation-delay: -10s;
|
||||
}
|
||||
|
||||
&.bg-gradient-3 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: $secondary;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
animation-delay: -20s;
|
||||
opacity: 0.1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes backgroundPulse {
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0);
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: translate(-5%, -5%);
|
||||
33% {
|
||||
transform: translate(30px, -30px) scale(1.05);
|
||||
}
|
||||
66% {
|
||||
transform: translate(-20px, 20px) scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1026,9 +1056,10 @@ $gradient-dark: linear-gradient(
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 20px;
|
||||
background: rgba($surface, 0.8);
|
||||
background: rgba($surface, 0.7);
|
||||
backdrop-filter: blur(20px);
|
||||
border-bottom: 1px solid rgba($primary, 0.3);
|
||||
border-bottom: 1px solid rgba($primary, 0.1);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
|
||||
@ -1052,7 +1083,7 @@ $gradient-dark: linear-gradient(
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: $accent;
|
||||
background: $success;
|
||||
border-radius: 4px;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
@ -1074,16 +1105,34 @@ $gradient-dark: linear-gradient(
|
||||
}
|
||||
}
|
||||
|
||||
.back-btn,
|
||||
.back-btn {
|
||||
color: $text !important;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px !important;
|
||||
border: 1px solid rgba($primary, 0.3) !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease !important;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: rgba($primary, 0.2) !important;
|
||||
border-color: $primary !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
color: $text !important;
|
||||
border: 1px solid rgba($primary, 0.3) !important;
|
||||
border: 1px solid rgba($primary, 0.2) !important;
|
||||
border-radius: 8px !important;
|
||||
transition: all 0.3s ease !important;
|
||||
|
||||
&:hover {
|
||||
color: #fff !important;
|
||||
background: rgba($primary, 0.2) !important;
|
||||
color: $primary !important;
|
||||
background: rgba($primary, 0.1) !important;
|
||||
border-color: $primary !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
@ -1097,6 +1146,8 @@ $gradient-dark: linear-gradient(
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
background: rgba($surface, 0.3);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.model-canvas {
|
||||
@ -1119,7 +1170,7 @@ $gradient-dark: linear-gradient(
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba($background, 0.95);
|
||||
background: $background;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@ -1147,7 +1198,7 @@ $gradient-dark: linear-gradient(
|
||||
&:nth-child(2) {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-right-color: $secondary;
|
||||
border-right-color: $primary-light;
|
||||
animation-delay: 0.2s;
|
||||
animation-direction: reverse;
|
||||
}
|
||||
@ -1155,7 +1206,7 @@ $gradient-dark: linear-gradient(
|
||||
&:nth-child(3) {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-bottom-color: $accent;
|
||||
border-bottom-color: $secondary;
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
}
|
||||
@ -1181,17 +1232,17 @@ $gradient-dark: linear-gradient(
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba($background, 0.95);
|
||||
background: $background;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.error-card {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
background: rgba($surface, 0.8);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba($accent, 0.3);
|
||||
background: $surface;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
|
||||
.error-icon {
|
||||
width: 60px;
|
||||
@ -1203,7 +1254,7 @@ $gradient-dark: linear-gradient(
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
background: $accent;
|
||||
background: $error;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
@ -1240,7 +1291,7 @@ $gradient-dark: linear-gradient(
|
||||
|
||||
.outline-btn {
|
||||
background: transparent !important;
|
||||
border: 1px solid rgba($text, 0.3) !important;
|
||||
border: 1px solid #e8e8e8 !important;
|
||||
color: $text !important;
|
||||
|
||||
&:hover {
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<a-button type="text" class="back-btn" @click="handleBack">
|
||||
<template #icon><CloseOutlined /></template>
|
||||
<template #icon><ArrowLeftOutlined /></template>
|
||||
</a-button>
|
||||
<span class="title">{{ pageTitle }}</span>
|
||||
<span class="live-badge">
|
||||
@ -110,11 +110,9 @@
|
||||
<!-- Failed State -->
|
||||
<template v-else-if="item.status === 'failed'">
|
||||
<div class="card-error">
|
||||
<div class="error-icon">
|
||||
<ExclamationCircleOutlined />
|
||||
<div class="failed-icon">
|
||||
<CloseOutlined />
|
||||
</div>
|
||||
<p class="error-title">生成失败</p>
|
||||
<p class="error-text">请重试或联系支持</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@ -153,9 +151,9 @@ import { ref, computed, onMounted, onUnmounted } from "vue"
|
||||
import { useRouter, useRoute } from "vue-router"
|
||||
import { message } from "ant-design-vue"
|
||||
import {
|
||||
CloseOutlined,
|
||||
ArrowLeftOutlined,
|
||||
EyeOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
CloseOutlined,
|
||||
LoadingOutlined,
|
||||
CheckCircleOutlined,
|
||||
ThunderboltOutlined,
|
||||
@ -200,24 +198,39 @@ const getPreviewUrl = (url: string) => {
|
||||
return url
|
||||
}
|
||||
|
||||
// 4 model cards state
|
||||
// Model cards state - 根据 previewUrls 数量动态展示
|
||||
const modelCards = computed(() => {
|
||||
if (!task.value) {
|
||||
return Array(4).fill({ status: "pending", previewUrl: "" })
|
||||
// 加载中时显示1个加载卡片
|
||||
return [{ status: "pending", previewUrl: "" }]
|
||||
}
|
||||
|
||||
const status = task.value.status
|
||||
const previewUrls = task.value.previewUrls || []
|
||||
const resultUrls = task.value.resultUrls || []
|
||||
|
||||
return Array(4)
|
||||
// 如果是加载中状态且没有预览图,显示1个加载卡片
|
||||
if ((status === "pending" || status === "processing") && previewUrls.length === 0) {
|
||||
return [{ status: status, previewUrl: "" }]
|
||||
}
|
||||
|
||||
// 如果是失败状态,显示1个失败卡片
|
||||
if (status === "failed" || status === "timeout") {
|
||||
return [{ status: "failed", previewUrl: "" }]
|
||||
}
|
||||
|
||||
// 根据 previewUrls 数量生成卡片(至少1个)
|
||||
const cardCount = Math.max(previewUrls.length, resultUrls.length, 1)
|
||||
|
||||
return Array(cardCount)
|
||||
.fill(null)
|
||||
.map((_, index) => {
|
||||
const originalPreviewUrl = previewUrls[index] || ""
|
||||
return {
|
||||
status: status,
|
||||
previewUrl: getPreviewUrl(originalPreviewUrl),
|
||||
originalPreviewUrl: originalPreviewUrl, // 保留原始URL用于其他用途
|
||||
resultUrl: task.value?.resultUrls?.[index] || "",
|
||||
originalPreviewUrl: originalPreviewUrl,
|
||||
resultUrl: resultUrls[index] || "",
|
||||
}
|
||||
})
|
||||
})
|
||||
@ -242,9 +255,12 @@ const handleCardClick = (index: number) => {
|
||||
}
|
||||
|
||||
if (card.status === "completed" && card.resultUrl) {
|
||||
// Navigate to model viewer
|
||||
const viewerUrl = `/model-viewer?url=${encodeURIComponent(card.resultUrl)}`
|
||||
window.open(viewerUrl, "_blank")
|
||||
// Navigate to model viewer (使用路由导航,保持全屏模式)
|
||||
const tenantCode = route.params.tenantCode as string
|
||||
router.push({
|
||||
path: `/${tenantCode}/workbench/model-viewer`,
|
||||
query: { url: card.resultUrl },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -313,28 +329,33 @@ onUnmounted(() => {
|
||||
|
||||
<style scoped lang="scss">
|
||||
// ==========================================
|
||||
// Energetic Modern Color Palette
|
||||
// 蓝色主题色彩方案 - 与 Index.vue 统一
|
||||
// ==========================================
|
||||
$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: #0958d9;
|
||||
$primary-light: #1677ff;
|
||||
$primary-dark: #003eb3;
|
||||
$secondary: #4096ff;
|
||||
$accent: #1677ff;
|
||||
$success: #52c41a;
|
||||
$warning: #faad14;
|
||||
$error: #ff4d4f;
|
||||
|
||||
// Gradients
|
||||
$gradient-primary: linear-gradient(135deg, $primary 0%, #ec4899 100%);
|
||||
$gradient-accent: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
||||
// 背景色
|
||||
$background: #f5f5f5;
|
||||
$surface: #ffffff;
|
||||
$surface-light: #fafafa;
|
||||
|
||||
// 文字色
|
||||
$text: rgba(0, 0, 0, 0.85);
|
||||
$text-secondary: rgba(0, 0, 0, 0.65);
|
||||
$text-muted: rgba(0, 0, 0, 0.45);
|
||||
|
||||
// 渐变
|
||||
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
|
||||
$gradient-card: linear-gradient(
|
||||
145deg,
|
||||
rgba($primary, 0.1) 0%,
|
||||
rgba($secondary, 0.05) 100%
|
||||
rgba($primary, 0.05) 0%,
|
||||
rgba($secondary, 0.02) 100%
|
||||
);
|
||||
|
||||
.generate-page {
|
||||
@ -360,9 +381,9 @@ $gradient-card: linear-gradient(
|
||||
.bg-gradient {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
opacity: 0.4;
|
||||
animation: float 20s ease-in-out infinite;
|
||||
filter: blur(100px);
|
||||
opacity: 0.15;
|
||||
animation: float 30s ease-in-out infinite;
|
||||
|
||||
&.bg-gradient-1 {
|
||||
width: 600px;
|
||||
@ -370,27 +391,26 @@ $gradient-card: linear-gradient(
|
||||
background: $primary;
|
||||
top: -200px;
|
||||
left: -100px;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
&.bg-gradient-2 {
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
background: $secondary;
|
||||
background: $primary-light;
|
||||
bottom: -150px;
|
||||
right: -100px;
|
||||
animation-delay: -7s;
|
||||
animation-delay: -10s;
|
||||
}
|
||||
|
||||
&.bg-gradient-3 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: $accent;
|
||||
background: $secondary;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
animation-delay: -14s;
|
||||
opacity: 0.2;
|
||||
animation-delay: -20s;
|
||||
opacity: 0.1;
|
||||
}
|
||||
}
|
||||
|
||||
@ -416,9 +436,10 @@ $gradient-card: linear-gradient(
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: rgba($surface, 0.8);
|
||||
background: rgba($surface, 0.7);
|
||||
backdrop-filter: blur(20px);
|
||||
border-bottom: 1px solid rgba($primary, 0.2);
|
||||
border-bottom: 1px solid rgba($primary, 0.1);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
@ -433,12 +454,13 @@ $gradient-card: linear-gradient(
|
||||
color: $text !important;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px !important;
|
||||
border-radius: 10px !important;
|
||||
border: 1px solid rgba($primary, 0.3) !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s !important;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: rgba($primary, 0.2) !important;
|
||||
@ -461,10 +483,10 @@ $gradient-card: linear-gradient(
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
background: rgba($accent, 0.15);
|
||||
border: 1px solid rgba($accent, 0.3);
|
||||
background: rgba($success, 0.1);
|
||||
border: 1px solid rgba($success, 0.3);
|
||||
border-radius: 20px;
|
||||
color: $accent;
|
||||
color: $success;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
@ -472,16 +494,16 @@ $gradient-card: linear-gradient(
|
||||
.pulse-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: $accent;
|
||||
background: $success;
|
||||
border-radius: 50%;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.pbr-tag {
|
||||
background: rgba($secondary, 0.15) !important;
|
||||
border: 1px solid rgba($secondary, 0.3) !important;
|
||||
color: $secondary !important;
|
||||
background: rgba($primary, 0.1) !important;
|
||||
border: 1px solid rgba($primary, 0.2) !important;
|
||||
color: $primary !important;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
border-radius: 6px;
|
||||
@ -493,7 +515,7 @@ $gradient-card: linear-gradient(
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: $primary-light;
|
||||
color: $primary;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
|
||||
@ -545,9 +567,10 @@ $gradient-card: linear-gradient(
|
||||
// ==========================================
|
||||
.model-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 300px);
|
||||
grid-template-columns: repeat(auto-fit, 300px);
|
||||
gap: 24px;
|
||||
margin-bottom: 40px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
@ -556,35 +579,35 @@ $gradient-card: linear-gradient(
|
||||
.model-card {
|
||||
width: 300px;
|
||||
height: 220px;
|
||||
background: rgba($surface, 0.6);
|
||||
backdrop-filter: blur(10px);
|
||||
background: $surface;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border: 2px solid rgba($primary, 0.2);
|
||||
border: 1px solid #e8e8e8;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba($primary, 0.5);
|
||||
border-color: $primary;
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 20px 40px rgba($primary, 0.2);
|
||||
box-shadow: 0 12px 24px rgba($primary, 0.15);
|
||||
}
|
||||
|
||||
&.is-ready {
|
||||
&:hover {
|
||||
border-color: $secondary;
|
||||
box-shadow: 0 20px 40px rgba($secondary, 0.3);
|
||||
border-color: $primary;
|
||||
box-shadow: 0 12px 24px rgba($primary, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
border-color: $secondary;
|
||||
border-color: $primary;
|
||||
}
|
||||
|
||||
&.is-loading {
|
||||
.card-index {
|
||||
background: rgba($primary, 0.3);
|
||||
background: rgba($primary, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -596,17 +619,16 @@ $gradient-card: linear-gradient(
|
||||
left: 12px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: rgba($secondary, 0.3);
|
||||
border: 1px solid rgba($secondary, 0.5);
|
||||
background: rgba($primary, 0.1);
|
||||
border: 1px solid rgba($primary, 0.2);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: $text;
|
||||
color: $primary;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
z-index: 5;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
@ -646,9 +668,8 @@ $gradient-card: linear-gradient(
|
||||
top: 50%;
|
||||
margin-left: -20px;
|
||||
margin-top: -20px;
|
||||
border: 2px solid rgba($primary, 0.5);
|
||||
background: rgba($primary, 0.1);
|
||||
backdrop-filter: blur(5px);
|
||||
border: 2px solid rgba($primary, 0.3);
|
||||
background: rgba($primary, 0.05);
|
||||
|
||||
&.front {
|
||||
transform: translateZ(20px);
|
||||
@ -656,11 +677,11 @@ $gradient-card: linear-gradient(
|
||||
}
|
||||
&.back {
|
||||
transform: rotateY(180deg) translateZ(20px);
|
||||
border-color: $secondary;
|
||||
border-color: $primary-light;
|
||||
}
|
||||
&.right {
|
||||
transform: rotateY(90deg) translateZ(20px);
|
||||
border-color: $accent;
|
||||
border-color: $secondary;
|
||||
}
|
||||
&.left {
|
||||
transform: rotateY(-90deg) translateZ(20px);
|
||||
@ -668,11 +689,11 @@ $gradient-card: linear-gradient(
|
||||
}
|
||||
&.top {
|
||||
transform: rotateX(90deg) translateZ(20px);
|
||||
border-color: $secondary;
|
||||
border-color: $primary;
|
||||
}
|
||||
&.bottom {
|
||||
transform: rotateX(-90deg) translateZ(20px);
|
||||
border-color: $accent;
|
||||
border-color: $secondary;
|
||||
}
|
||||
}
|
||||
|
||||
@ -709,7 +730,7 @@ $gradient-card: linear-gradient(
|
||||
}
|
||||
|
||||
.highlight {
|
||||
color: $secondary;
|
||||
color: $primary;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
@ -772,7 +793,7 @@ $gradient-card: linear-gradient(
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
transparent 0%,
|
||||
rgba($background, 0.8) 100%
|
||||
rgba(0, 0, 0, 0.6) 100%
|
||||
);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -797,7 +818,7 @@ $gradient-card: linear-gradient(
|
||||
}
|
||||
|
||||
.preview-text {
|
||||
color: $text;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
@ -840,33 +861,31 @@ $gradient-card: linear-gradient(
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba($accent, 0.05);
|
||||
background: rgba($error, 0.02);
|
||||
|
||||
.error-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: rgba($accent, 0.15);
|
||||
border: 2px solid rgba($accent, 0.3);
|
||||
.failed-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: linear-gradient(135deg, rgba($error, 0.15) 0%, rgba($error, 0.25) 100%);
|
||||
border: 2px solid rgba($error, 0.3);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
color: $accent;
|
||||
margin-bottom: 16px;
|
||||
color: $error;
|
||||
animation: pulse-error 2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.error-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: $accent;
|
||||
@keyframes pulse-error {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 0 rgba($error, 0.3);
|
||||
}
|
||||
|
||||
.error-text {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: $text-muted;
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 0 20px 5px rgba($error, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
@ -882,11 +901,11 @@ $gradient-card: linear-gradient(
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px 24px;
|
||||
background: rgba($surface, 0.6);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba($primary, 0.2);
|
||||
background: $surface;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 16px;
|
||||
max-width: 600px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
|
||||
.input-icon {
|
||||
width: 44px;
|
||||
@ -933,21 +952,21 @@ $gradient-card: linear-gradient(
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: $text-muted;
|
||||
color: $text-secondary;
|
||||
font-size: 13px;
|
||||
|
||||
.tip-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background: rgba($primary, 0.2);
|
||||
border: 1px solid rgba($primary, 0.3);
|
||||
background: rgba($primary, 0.1);
|
||||
border: 1px solid rgba($primary, 0.2);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: $primary-light;
|
||||
color: $primary;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
927
frontend/src/views/workbench/ai-3d/History.vue
Normal file
927
frontend/src/views/workbench/ai-3d/History.vue
Normal file
@ -0,0 +1,927 @@
|
||||
<template>
|
||||
<div class="history-page">
|
||||
<!-- Animated Background -->
|
||||
<div class="bg-animation">
|
||||
<div class="bg-gradient bg-gradient-1"></div>
|
||||
<div class="bg-gradient bg-gradient-2"></div>
|
||||
<div class="bg-gradient bg-gradient-3"></div>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<a-button type="text" class="back-btn" @click="handleBack">
|
||||
<template #icon><ArrowLeftOutlined /></template>
|
||||
</a-button>
|
||||
<span class="title">创作历史</span>
|
||||
<span class="count-badge">{{ total }} 个作品</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<a-select
|
||||
v-model:value="statusFilter"
|
||||
placeholder="全部状态"
|
||||
style="width: 120px"
|
||||
allowClear
|
||||
@change="handleFilterChange"
|
||||
>
|
||||
<a-select-option value="">全部</a-select-option>
|
||||
<a-select-option value="completed">已完成</a-select-option>
|
||||
<a-select-option value="processing">生成中</a-select-option>
|
||||
<a-select-option value="pending">等待中</a-select-option>
|
||||
<a-select-option value="failed">失败</a-select-option>
|
||||
<a-select-option value="timeout">超时</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="page-content">
|
||||
<!-- Loading -->
|
||||
<div v-if="loading && list.length === 0" class="loading-state">
|
||||
<div class="loader">
|
||||
<div class="loader-ring"></div>
|
||||
<div class="loader-ring"></div>
|
||||
<div class="loader-ring"></div>
|
||||
</div>
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-else-if="list.length === 0" class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<FileImageOutlined />
|
||||
</div>
|
||||
<p class="empty-title">暂无创作记录</p>
|
||||
<p class="empty-text">开始你的第一次 3D 创作之旅吧</p>
|
||||
<a-button type="primary" class="primary-btn" @click="goCreate">
|
||||
<ThunderboltOutlined />
|
||||
开始创作
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- Grid -->
|
||||
<div v-else class="history-grid">
|
||||
<div
|
||||
v-for="task in list"
|
||||
:key="task.id"
|
||||
class="history-card"
|
||||
@click="handleViewTask(task)"
|
||||
>
|
||||
<div class="card-glow"></div>
|
||||
<div class="card-preview">
|
||||
<img
|
||||
v-if="task.status === 'completed' && task.previewUrl"
|
||||
:src="getPreviewUrl(task)"
|
||||
alt="预览"
|
||||
class="preview-image"
|
||||
/>
|
||||
<div
|
||||
v-else-if="task.status === 'processing' || task.status === 'pending'"
|
||||
class="preview-loading"
|
||||
>
|
||||
<div class="loading-dots">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
<span class="loading-text">生成中</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="task.status === 'failed' || task.status === 'timeout'"
|
||||
class="preview-failed"
|
||||
>
|
||||
<div class="failed-icon">
|
||||
<CloseOutlined />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="preview-placeholder">
|
||||
<FileImageOutlined />
|
||||
</div>
|
||||
|
||||
<!-- Status Badge -->
|
||||
<div class="status-badge" :class="`status-${task.status}`">
|
||||
{{ getStatusText(task.status) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-info">
|
||||
<div class="card-type">
|
||||
<a-tag :color="task.inputType === 'text' ? 'blue' : 'green'">
|
||||
{{ task.inputType === 'text' ? '文生3D' : '图生3D' }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="card-desc" :title="task.inputContent">
|
||||
{{ task.inputContent }}
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<span class="card-time">
|
||||
<ClockCircleOutlined />
|
||||
{{ formatTime(task.createTime) }}
|
||||
</span>
|
||||
<div class="card-actions" @click.stop>
|
||||
<a-tooltip v-if="task.status === 'completed'" title="预览模型">
|
||||
<div class="action-btn" @click="handlePreview(task)">
|
||||
<EyeOutlined />
|
||||
</div>
|
||||
</a-tooltip>
|
||||
<a-tooltip
|
||||
v-if="['failed', 'timeout'].includes(task.status)"
|
||||
title="重试"
|
||||
>
|
||||
<div
|
||||
class="action-btn"
|
||||
:class="{ disabled: task.retryCount >= 3 }"
|
||||
@click="handleRetry(task)"
|
||||
>
|
||||
<ReloadOutlined />
|
||||
</div>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="删除">
|
||||
<div class="action-btn danger" @click="handleDelete(task)">
|
||||
<DeleteOutlined />
|
||||
</div>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="total > pageSize" class="pagination-wrapper">
|
||||
<a-pagination
|
||||
v-model:current="currentPage"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
:show-size-changer="false"
|
||||
show-quick-jumper
|
||||
@change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
FileImageOutlined,
|
||||
CloseOutlined,
|
||||
ClockCircleOutlined,
|
||||
EyeOutlined,
|
||||
ReloadOutlined,
|
||||
DeleteOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import {
|
||||
getAI3DTasks,
|
||||
retryAI3DTask,
|
||||
deleteAI3DTask,
|
||||
type AI3DTask,
|
||||
type AI3DTaskStatus,
|
||||
} from '@/api/ai-3d'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const list = ref<AI3DTask[]>([])
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(12)
|
||||
const statusFilter = ref<AI3DTaskStatus | ''>('')
|
||||
|
||||
// 轮询定时器
|
||||
let pollingTimer: number | null = null
|
||||
|
||||
// 返回上一页
|
||||
const handleBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
// 去创建页
|
||||
const goCreate = () => {
|
||||
const tenantCode = route.params.tenantCode as string
|
||||
router.push(`/${tenantCode}/workbench/3d-lab`)
|
||||
}
|
||||
|
||||
// 获取列表
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = {
|
||||
page: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
}
|
||||
if (statusFilter.value) {
|
||||
params.status = statusFilter.value
|
||||
}
|
||||
const res = await getAI3DTasks(params)
|
||||
const data = res.data || res
|
||||
list.value = data.list || []
|
||||
total.value = data.total || 0
|
||||
} catch (error) {
|
||||
console.error('获取历史记录失败:', error)
|
||||
message.error('获取历史记录失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 筛选变化
|
||||
const handleFilterChange = () => {
|
||||
currentPage.value = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 分页变化
|
||||
const handlePageChange = (page: number) => {
|
||||
currentPage.value = page
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 获取预览图URL
|
||||
const getPreviewUrl = (task: AI3DTask) => {
|
||||
if (task.previewUrl) {
|
||||
if (
|
||||
task.previewUrl.includes('tencentcos.cn') ||
|
||||
task.previewUrl.includes('qcloud.com')
|
||||
) {
|
||||
return `/api/ai-3d/proxy-preview?url=${encodeURIComponent(task.previewUrl)}`
|
||||
}
|
||||
return task.previewUrl
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status: string) => {
|
||||
const texts: Record<string, string> = {
|
||||
pending: '等待中',
|
||||
processing: '生成中',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
timeout: '超时',
|
||||
}
|
||||
return texts[status] || status
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (time: string) => {
|
||||
return dayjs(time).format('YYYY-MM-DD HH:mm')
|
||||
}
|
||||
|
||||
// 查看任务详情
|
||||
const handleViewTask = (task: AI3DTask) => {
|
||||
router.push({
|
||||
name: 'AI3DGenerate',
|
||||
params: { taskId: task.id },
|
||||
})
|
||||
}
|
||||
|
||||
// 预览3D模型
|
||||
const handlePreview = (task: AI3DTask) => {
|
||||
if (task.resultUrl) {
|
||||
const tenantCode = route.params.tenantCode as string
|
||||
router.push({
|
||||
path: `/${tenantCode}/workbench/model-viewer`,
|
||||
query: { url: task.resultUrl },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 重试任务
|
||||
const handleRetry = async (task: AI3DTask) => {
|
||||
if (task.retryCount >= 3) {
|
||||
message.warning('已达到最大重试次数,请创建新任务')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await retryAI3DTask(task.id)
|
||||
message.success('重试已提交')
|
||||
fetchList()
|
||||
startPolling()
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || '重试失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 删除任务
|
||||
const handleDelete = (task: AI3DTask) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这条创作记录吗?',
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
try {
|
||||
await deleteAI3DTask(task.id)
|
||||
message.success('删除成功')
|
||||
fetchList()
|
||||
} catch (error) {
|
||||
message.error('删除失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 开始轮询
|
||||
const startPolling = () => {
|
||||
if (pollingTimer) return
|
||||
pollingTimer = window.setInterval(async () => {
|
||||
const hasProcessing = list.value.some(
|
||||
(t) => t.status === 'pending' || t.status === 'processing'
|
||||
)
|
||||
if (!hasProcessing) {
|
||||
stopPolling()
|
||||
return
|
||||
}
|
||||
await fetchList()
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
// 停止轮询
|
||||
const stopPolling = () => {
|
||||
if (pollingTimer) {
|
||||
clearInterval(pollingTimer)
|
||||
pollingTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchList()
|
||||
const hasProcessing = list.value.some(
|
||||
(t) => t.status === 'pending' || t.status === 'processing'
|
||||
)
|
||||
if (hasProcessing) {
|
||||
startPolling()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// ==========================================
|
||||
// 项目统一设计变量
|
||||
// ==========================================
|
||||
$primary: #0958d9;
|
||||
$primary-light: #1677ff;
|
||||
$primary-dark: #003eb3;
|
||||
$secondary: #4096ff;
|
||||
$success: #52c41a;
|
||||
$warning: #faad14;
|
||||
$error: #ff4d4f;
|
||||
|
||||
$background: #f5f5f5;
|
||||
$surface: #ffffff;
|
||||
$surface-light: #fafafa;
|
||||
|
||||
$text: rgba(0, 0, 0, 0.85);
|
||||
$text-secondary: rgba(0, 0, 0, 0.65);
|
||||
$text-muted: rgba(0, 0, 0, 0.45);
|
||||
|
||||
$border: #d9d9d9;
|
||||
$border-light: #e8e8e8;
|
||||
|
||||
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
|
||||
|
||||
.history-page {
|
||||
min-height: 100vh;
|
||||
background: $background;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Animated Background
|
||||
// ==========================================
|
||||
.bg-animation {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.bg-gradient {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(100px);
|
||||
opacity: 0.15;
|
||||
animation: float 30s ease-in-out infinite;
|
||||
|
||||
&.bg-gradient-1 {
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
background: $primary;
|
||||
top: -200px;
|
||||
left: -100px;
|
||||
}
|
||||
|
||||
&.bg-gradient-2 {
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
background: $primary-light;
|
||||
bottom: -150px;
|
||||
right: -100px;
|
||||
animation-delay: -10s;
|
||||
}
|
||||
|
||||
&.bg-gradient-3 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: $secondary;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
animation-delay: -20s;
|
||||
opacity: 0.1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
33% {
|
||||
transform: translate(30px, -30px) scale(1.05);
|
||||
}
|
||||
66% {
|
||||
transform: translate(-20px, 20px) scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Header
|
||||
// ==========================================
|
||||
.page-header {
|
||||
height: 64px;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: rgba($surface, 0.7);
|
||||
backdrop-filter: blur(20px);
|
||||
border-bottom: 1px solid rgba($primary, 0.1);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
flex-shrink: 0;
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
background: $gradient-primary;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.count-badge {
|
||||
padding: 4px 12px;
|
||||
background: rgba($primary, 0.1);
|
||||
border: 1px solid rgba($primary, 0.2);
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
color: $primary;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
color: $text !important;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px !important;
|
||||
border: 1px solid rgba($primary, 0.3) !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease !important;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: rgba($primary, 0.1) !important;
|
||||
border-color: $primary !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Content
|
||||
// ==========================================
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: rgba($surface, 0.3);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Loading State
|
||||
// ==========================================
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
gap: 24px;
|
||||
color: $text-muted;
|
||||
|
||||
.loader {
|
||||
position: relative;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.loader-ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 3px solid transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 1.5s linear infinite;
|
||||
|
||||
&:nth-child(1) {
|
||||
border-top-color: $primary;
|
||||
}
|
||||
&:nth-child(2) {
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
top: 7.5px;
|
||||
left: 7.5px;
|
||||
border-right-color: $primary-light;
|
||||
animation-direction: reverse;
|
||||
}
|
||||
&:nth-child(3) {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
top: 15px;
|
||||
left: 15px;
|
||||
border-bottom-color: $secondary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Empty State
|
||||
// ==========================================
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
gap: 12px;
|
||||
|
||||
.empty-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: rgba($primary, 0.1);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32px;
|
||||
color: $primary-light;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: $text;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
color: $text-muted;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
background: $gradient-primary !important;
|
||||
border: none !important;
|
||||
color: #fff !important;
|
||||
font-weight: 500 !important;
|
||||
height: 40px;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.3s ease !important;
|
||||
|
||||
&:hover {
|
||||
filter: brightness(1.1);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba($primary, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// History Grid
|
||||
// ==========================================
|
||||
.history-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.history-card {
|
||||
background: $surface;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
|
||||
.card-glow {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-glow {
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
background: $primary;
|
||||
border-radius: 10px;
|
||||
z-index: -1;
|
||||
opacity: 0;
|
||||
filter: blur(8px);
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.card-preview {
|
||||
height: 160px;
|
||||
background: rgba($surface-light, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
.preview-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.5s ease;
|
||||
}
|
||||
|
||||
.preview-loading,
|
||||
.preview-failed,
|
||||
.preview-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: $text-muted;
|
||||
font-size: 32px;
|
||||
|
||||
.loading-text {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-failed {
|
||||
.failed-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: linear-gradient(135deg, rgba($error, 0.15) 0%, rgba($error, 0.25) 100%);
|
||||
border: 2px solid rgba($error, 0.3);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: $error;
|
||||
animation: pulse-error 2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-error {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 0 rgba($error, 0.3);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 0 20px 5px rgba($error, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-dots {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
|
||||
span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: $primary-light;
|
||||
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; }
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dotPulse {
|
||||
0%, 60%, 100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
30% {
|
||||
transform: scale(1.5);
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
|
||||
&.status-completed {
|
||||
background: rgba($success, 0.2);
|
||||
color: $success;
|
||||
border-color: rgba($success, 0.3);
|
||||
}
|
||||
|
||||
&.status-processing,
|
||||
&.status-pending {
|
||||
background: rgba($primary-light, 0.2);
|
||||
color: $primary-light;
|
||||
border-color: rgba($primary-light, 0.3);
|
||||
}
|
||||
|
||||
&.status-failed,
|
||||
&.status-timeout {
|
||||
background: rgba($error, 0.2);
|
||||
color: $error;
|
||||
border-color: rgba($error, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.card-info {
|
||||
padding: 16px;
|
||||
background: rgba($primary, 0.15);
|
||||
}
|
||||
|
||||
.card-type {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 14px;
|
||||
color: $text;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: rgba($primary, 0.1);
|
||||
border: 1px solid rgba($primary, 0.2);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: $primary-light;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba($primary-light, 0.2);
|
||||
border-color: $primary-light;
|
||||
color: $primary-light;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&.danger:hover {
|
||||
background: rgba($error, 0.15);
|
||||
border-color: $error;
|
||||
color: $error;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
transform: none;
|
||||
background: rgba($primary, 0.08);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Pagination
|
||||
// ==========================================
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 32px;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Responsive
|
||||
// ==========================================
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
padding: 0 16px;
|
||||
|
||||
.header-left {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.count-badge {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.history-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -5,13 +5,15 @@
|
||||
<div class="bg-gradient bg-gradient-1"></div>
|
||||
<div class="bg-gradient bg-gradient-2"></div>
|
||||
<div class="bg-gradient bg-gradient-3"></div>
|
||||
<div class="bg-grid"></div>
|
||||
</div>
|
||||
|
||||
<!-- 左侧生成栏 -->
|
||||
<div class="left-panel">
|
||||
<!-- Header with Logo -->
|
||||
<div class="panel-logo">
|
||||
<a-button type="text" class="back-btn" @click="handleBack">
|
||||
<template #icon><ArrowLeftOutlined /></template>
|
||||
</a-button>
|
||||
<div class="logo-ring">
|
||||
<div class="logo-icon">
|
||||
<ThunderboltOutlined />
|
||||
@ -170,9 +172,11 @@
|
||||
<div class="history-header">
|
||||
<div class="header-left">
|
||||
<h2 class="history-title">创作历史</h2>
|
||||
<span class="history-count">{{ historyList.length }} 个作品</span>
|
||||
<span class="history-count"
|
||||
>{{ allHistoryTotal || historyList.length }} 个作品</span
|
||||
>
|
||||
</div>
|
||||
<a class="view-all" @click="showAllHistory = true">
|
||||
<a class="view-all" @click="goToHistory">
|
||||
查看全部
|
||||
<ArrowRightOutlined />
|
||||
</a>
|
||||
@ -195,9 +199,9 @@
|
||||
<p class="empty-text">开始你的第一次 3D 创作之旅吧</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="history-grid">
|
||||
<div v-else ref="historyGridRef" class="history-grid">
|
||||
<div
|
||||
v-for="task in historyList"
|
||||
v-for="task in displayedHistoryList"
|
||||
:key="task.id"
|
||||
class="history-card"
|
||||
@click="handleViewTask(task)"
|
||||
@ -231,10 +235,9 @@
|
||||
"
|
||||
class="preview-failed"
|
||||
>
|
||||
<ExclamationCircleOutlined />
|
||||
<span>{{
|
||||
task.status === "timeout" ? "已超时" : "生成失败"
|
||||
}}</span>
|
||||
<div class="failed-icon">
|
||||
<CloseOutlined />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="preview-placeholder">
|
||||
<FileImageOutlined />
|
||||
@ -286,88 +289,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 全部历史记录抽屉 -->
|
||||
<a-drawer
|
||||
v-model:open="showAllHistory"
|
||||
title="全部创作历史"
|
||||
placement="right"
|
||||
width="600px"
|
||||
class="history-drawer"
|
||||
>
|
||||
<a-list
|
||||
:data-source="allHistoryList"
|
||||
:loading="allHistoryLoading"
|
||||
:pagination="pagination"
|
||||
>
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<a-list-item-meta>
|
||||
<template #avatar>
|
||||
<div class="list-preview">
|
||||
<img
|
||||
v-if="item.status === 'completed' && item.previewUrl"
|
||||
:src="getPreviewUrl(item)"
|
||||
alt="预览"
|
||||
/>
|
||||
<div v-else class="list-preview-placeholder">
|
||||
<LoadingOutlined
|
||||
v-if="['pending', 'processing'].includes(item.status)"
|
||||
spin
|
||||
/>
|
||||
<ExclamationCircleOutlined
|
||||
v-else-if="['failed', 'timeout'].includes(item.status)"
|
||||
/>
|
||||
<FileImageOutlined v-else />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #title>
|
||||
<span class="list-desc">{{ item.inputContent }}</span>
|
||||
</template>
|
||||
<template #description>
|
||||
<div class="list-meta">
|
||||
<a-tag :color="getStatusColor(item.status)">{{
|
||||
getStatusText(item.status)
|
||||
}}</a-tag>
|
||||
<span>{{ formatTime(item.createTime) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
<template #actions>
|
||||
<a v-if="item.status === 'completed'" @click="handlePreview(item)"
|
||||
>预览</a
|
||||
>
|
||||
<a
|
||||
v-if="
|
||||
['failed', 'timeout'].includes(item.status) &&
|
||||
item.retryCount < 3
|
||||
"
|
||||
@click="handleRetry(item)"
|
||||
>重试</a
|
||||
>
|
||||
<a class="danger-link" @click="handleDelete(item)">删除</a>
|
||||
</template>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from "vue"
|
||||
import { useRouter, useRoute } from "vue-router"
|
||||
import { message, Modal } from "ant-design-vue"
|
||||
import {
|
||||
ReloadOutlined,
|
||||
PictureOutlined,
|
||||
ThunderboltOutlined,
|
||||
LoadingOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
CloseOutlined,
|
||||
FileImageOutlined,
|
||||
EyeOutlined,
|
||||
DeleteOutlined,
|
||||
ArrowRightOutlined,
|
||||
ArrowLeftOutlined,
|
||||
ClockCircleOutlined,
|
||||
} from "@ant-design/icons-vue"
|
||||
import type { UploadFile } from "ant-design-vue"
|
||||
@ -386,6 +325,19 @@ import dayjs from "dayjs"
|
||||
// 获取认证状态
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
// 返回首页
|
||||
const handleBack = () => {
|
||||
const tenantCode = route.params.tenantCode as string
|
||||
router.push(`/${tenantCode}`)
|
||||
}
|
||||
|
||||
// 跳转到历史记录页面
|
||||
const goToHistory = () => {
|
||||
const tenantCode = route.params.tenantCode as string
|
||||
router.push(`/${tenantCode}/workbench/3d-lab/history`)
|
||||
}
|
||||
|
||||
// 输入类型选项
|
||||
const inputTypeOptions = [
|
||||
@ -414,14 +366,16 @@ const currentSampleIndex = ref(0)
|
||||
// 历史记录
|
||||
const historyList = ref<AI3DTask[]>([])
|
||||
const historyLoading = ref(false)
|
||||
const showAllHistory = ref(false)
|
||||
const allHistoryList = ref<AI3DTask[]>([])
|
||||
const allHistoryLoading = ref(false)
|
||||
const allHistoryTotal = ref(0)
|
||||
const allHistoryPage = ref(1)
|
||||
|
||||
// 历史网格容器引用和宽度
|
||||
const historyGridRef = ref<HTMLElement | null>(null)
|
||||
const historyGridWidth = ref(0)
|
||||
|
||||
// 轮询定时器
|
||||
let pollingTimer: number | null = null
|
||||
// ResizeObserver 用于监听容器宽度变化
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
// 计算属性
|
||||
const currentSamples = computed(() => samplePrompts[currentSampleIndex.value])
|
||||
@ -440,15 +394,23 @@ const canGenerate = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const pagination = computed(() => ({
|
||||
current: allHistoryPage.value,
|
||||
pageSize: 10,
|
||||
total: allHistoryTotal.value,
|
||||
onChange: (page: number) => {
|
||||
allHistoryPage.value = page
|
||||
fetchAllHistory()
|
||||
},
|
||||
}))
|
||||
// 计算一行最多能展示的卡片数量
|
||||
// 卡片宽度 240px + 间距 20px = 260px
|
||||
const maxCardsPerRow = computed(() => {
|
||||
if (historyGridWidth.value === 0) return 6 // 默认值
|
||||
const cardWidth = 240
|
||||
const gap = 20
|
||||
// 可用宽度 = 容器宽度 - 左右 padding (48px * 2)
|
||||
const availableWidth = historyGridWidth.value - 96
|
||||
// 计算能放多少个:可用宽度 / (卡片宽度 + 间距)
|
||||
const count = Math.floor(availableWidth / (cardWidth + gap))
|
||||
return Math.max(1, count) // 至少显示1个
|
||||
})
|
||||
|
||||
// 限制显示的历史记录数量
|
||||
const displayedHistoryList = computed(() => {
|
||||
return historyList.value.slice(0, maxCardsPerRow.value)
|
||||
})
|
||||
|
||||
// 刷新示例
|
||||
const refreshSamples = () => {
|
||||
@ -539,6 +501,10 @@ const fetchHistory = async () => {
|
||||
const res = await getAI3DTasks({ page: 1, pageSize: 6 })
|
||||
const data = res.data || res // 兼容不同的响应格式
|
||||
historyList.value = data.list || []
|
||||
// 如果还没有获取过总数,则保存总数
|
||||
if (!allHistoryTotal.value && data.total) {
|
||||
allHistoryTotal.value = data.total
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取历史记录失败:", error)
|
||||
} finally {
|
||||
@ -546,21 +512,6 @@ const fetchHistory = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取全部历史记录
|
||||
const fetchAllHistory = async () => {
|
||||
allHistoryLoading.value = true
|
||||
try {
|
||||
const res = await getAI3DTasks({ page: allHistoryPage.value, pageSize: 10 })
|
||||
const data = res.data || res // 兼容不同的响应格式
|
||||
allHistoryList.value = data.list || []
|
||||
allHistoryTotal.value = data.total || 0
|
||||
} catch (error) {
|
||||
console.error("获取全部历史记录失败:", error)
|
||||
} finally {
|
||||
allHistoryLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 开始轮询(检查处理中的任务)
|
||||
const startPolling = () => {
|
||||
if (pollingTimer) return
|
||||
@ -591,8 +542,11 @@ const stopPolling = () => {
|
||||
// 预览3D模型
|
||||
const handlePreview = (task: AI3DTask) => {
|
||||
if (task.resultUrl) {
|
||||
const viewerUrl = `/model-viewer?url=${encodeURIComponent(task.resultUrl)}`
|
||||
window.open(viewerUrl, "_blank")
|
||||
const tenantCode = route.params.tenantCode as string
|
||||
router.push({
|
||||
path: `/${tenantCode}/workbench/model-viewer`,
|
||||
query: { url: task.resultUrl },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -635,9 +589,6 @@ const handleDelete = (task: AI3DTask) => {
|
||||
await deleteAI3DTask(task.id)
|
||||
message.success("删除成功")
|
||||
fetchHistory()
|
||||
if (showAllHistory.value) {
|
||||
fetchAllHistory()
|
||||
}
|
||||
} catch (error) {
|
||||
message.error("删除失败")
|
||||
}
|
||||
@ -663,18 +614,6 @@ const getPreviewUrl = (task: AI3DTask) => {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
pending: "default",
|
||||
processing: "processing",
|
||||
completed: "success",
|
||||
failed: "error",
|
||||
timeout: "warning",
|
||||
}
|
||||
return colors[status] || "default"
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status: string) => {
|
||||
const texts: Record<string, string> = {
|
||||
@ -705,9 +644,28 @@ const formatTime = (time: string) => {
|
||||
return dayjs(time).format("MM-DD HH:mm")
|
||||
}
|
||||
|
||||
// 更新容器宽度
|
||||
const updateGridWidth = () => {
|
||||
if (historyGridRef.value) {
|
||||
historyGridWidth.value = historyGridRef.value.offsetWidth
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载
|
||||
onMounted(() => {
|
||||
fetchHistory()
|
||||
onMounted(async () => {
|
||||
await fetchHistory()
|
||||
|
||||
// 等待 DOM 更新后计算容器宽度
|
||||
await nextTick()
|
||||
updateGridWidth()
|
||||
|
||||
// 监听容器宽度变化
|
||||
if (historyGridRef.value) {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
updateGridWidth()
|
||||
})
|
||||
resizeObserver.observe(historyGridRef.value)
|
||||
}
|
||||
|
||||
// 检查是否有处理中的任务,有则开启轮询
|
||||
const hasProcessing = historyList.value.some(
|
||||
@ -721,39 +679,54 @@ onMounted(() => {
|
||||
// 页面卸载
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
resizeObserver = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// ==========================================
|
||||
// Energetic Modern Color Palette
|
||||
// 蓝色主题色彩方案 - 简洁统一
|
||||
// ==========================================
|
||||
$primary: #7c3aed;
|
||||
$primary-light: #a78bfa;
|
||||
$primary-dark: #5b21b6;
|
||||
$secondary: #06b6d4;
|
||||
$accent: #f43f5e;
|
||||
$success: #10b981;
|
||||
$background: #0a0a12;
|
||||
$surface: #12121e;
|
||||
$surface-light: #1a1a2e;
|
||||
$text: #e2e8f0;
|
||||
$text-muted: #94a3b8;
|
||||
// 主色调 - 蓝色系
|
||||
$primary: #0958d9; // 主题蓝色(与系统统一)
|
||||
$primary-light: #1677ff;
|
||||
$primary-dark: #003eb3;
|
||||
$secondary: #4096ff; // 浅蓝色作为辅助色
|
||||
$accent: #1677ff; // 蓝色强调
|
||||
$success: #52c41a;
|
||||
$warning: #faad14;
|
||||
$error: #ff4d4f;
|
||||
|
||||
// Gradients
|
||||
$gradient-primary: linear-gradient(135deg, $primary 0%, #ec4899 100%);
|
||||
$gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
||||
// 背景色
|
||||
$background: #f5f5f5; // 浅灰背景(与系统统一)
|
||||
$surface: #ffffff; // 白色卡片
|
||||
$surface-light: #fafafa;
|
||||
|
||||
// 文字色
|
||||
$text: rgba(0, 0, 0, 0.85); // 深色文字
|
||||
$text-secondary: rgba(0, 0, 0, 0.65);
|
||||
$text-muted: rgba(0, 0, 0, 0.45);
|
||||
$text-light: #ffffff; // 深色区域的白色文字
|
||||
|
||||
// 渐变 - 蓝色渐变
|
||||
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
|
||||
$gradient-secondary: linear-gradient(135deg, $accent 0%, $primary 100%);
|
||||
|
||||
.ai-3d-container {
|
||||
display: flex;
|
||||
min-height: calc(100vh - 64px);
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
background: $background;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Animated Background
|
||||
// 简化背景动画 - 保留微妙效果
|
||||
// ==========================================
|
||||
.bg-animation {
|
||||
position: absolute;
|
||||
@ -766,88 +739,67 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
||||
.bg-gradient {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(120px);
|
||||
opacity: 0.4;
|
||||
animation: float 25s ease-in-out infinite;
|
||||
filter: blur(100px);
|
||||
opacity: 0.15;
|
||||
animation: float 30s ease-in-out infinite;
|
||||
|
||||
&.bg-gradient-1 {
|
||||
width: 800px;
|
||||
height: 800px;
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
background: $primary;
|
||||
top: -300px;
|
||||
left: -200px;
|
||||
animation-delay: 0s;
|
||||
top: -200px;
|
||||
left: -100px;
|
||||
}
|
||||
|
||||
&.bg-gradient-2 {
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
background: $secondary;
|
||||
bottom: -200px;
|
||||
right: -150px;
|
||||
animation-delay: -8s;
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
background: $primary-light;
|
||||
bottom: -150px;
|
||||
right: -100px;
|
||||
animation-delay: -10s;
|
||||
}
|
||||
|
||||
&.bg-gradient-3 {
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
background: $accent;
|
||||
top: 40%;
|
||||
right: 30%;
|
||||
animation-delay: -16s;
|
||||
opacity: 0.3;
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: $secondary;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
animation-delay: -20s;
|
||||
opacity: 0.1;
|
||||
}
|
||||
}
|
||||
|
||||
.bg-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba($primary, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba($primary, 0.03) 1px, transparent 1px);
|
||||
background-size: 50px 50px;
|
||||
animation: gridMove 20s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
25% {
|
||||
transform: translate(50px, -50px) scale(1.1);
|
||||
33% {
|
||||
transform: translate(30px, -30px) scale(1.05);
|
||||
}
|
||||
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);
|
||||
66% {
|
||||
transform: translate(-20px, 20px) scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Left Panel
|
||||
// Left Panel - 浅色变体
|
||||
// ==========================================
|
||||
.left-panel {
|
||||
width: 380px;
|
||||
height: 100%;
|
||||
background: rgba($surface, 0.8);
|
||||
backdrop-filter: blur(40px);
|
||||
border-right: 1px solid rgba($primary, 0.1);
|
||||
min-height: 100vh;
|
||||
background: rgba($surface, 0.5);
|
||||
backdrop-filter: blur(20px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
align-self: stretch;
|
||||
border-right: 1px solid rgba($primary, 0.1);
|
||||
box-shadow: 2px 0 12px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.panel-logo {
|
||||
@ -856,6 +808,26 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
border-bottom: 1px solid rgba($primary, 0.1);
|
||||
flex-shrink: 0;
|
||||
|
||||
.back-btn {
|
||||
color: $text !important;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px !important;
|
||||
border: 1px solid rgba($primary, 0.3) !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s !important;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: rgba($primary, 0.2) !important;
|
||||
border-color: $primary !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.logo-ring {
|
||||
position: relative;
|
||||
@ -909,6 +881,7 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
||||
.panel-header {
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid rgba($primary, 0.1);
|
||||
flex-shrink: 0;
|
||||
|
||||
:deep(.ant-segmented) {
|
||||
background: rgba($surface-light, 0.6);
|
||||
@ -1020,7 +993,7 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
color: $secondary;
|
||||
color: $primary-light;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
@ -1042,8 +1015,8 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: rgba($secondary, 0.2);
|
||||
border-color: $secondary;
|
||||
background: rgba($primary-light, 0.2);
|
||||
border-color: $primary-light;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
@ -1057,7 +1030,7 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: $secondary !important;
|
||||
border-color: $primary-light !important;
|
||||
background: rgba($surface-light, 0.8) !important;
|
||||
}
|
||||
|
||||
@ -1170,13 +1143,14 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
height: 100vh;
|
||||
background: rgba($surface, 0.3);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.intro-section {
|
||||
padding: 48px;
|
||||
background: rgba($surface, 0.4);
|
||||
backdrop-filter: blur(20px);
|
||||
border-bottom: 1px solid rgba($primary, 0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.intro-badge {
|
||||
@ -1245,17 +1219,11 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
background: rgba($surface-light, 0.4);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba($primary, 0.1);
|
||||
border-radius: 16px;
|
||||
background: $surface-light;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: rgba($surface-light, 0.6);
|
||||
border-color: rgba($primary, 0.3);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
@ -1269,16 +1237,16 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
||||
flex-shrink: 0;
|
||||
|
||||
&.gradient-1 {
|
||||
background: linear-gradient(135deg, $primary 0%, #ec4899 100%);
|
||||
background: linear-gradient(135deg, $primary 0%, $primary-light 100%);
|
||||
}
|
||||
&.gradient-2 {
|
||||
background: linear-gradient(135deg, $secondary 0%, #10b981 100%);
|
||||
background: linear-gradient(135deg, $primary-light 0%, $accent 100%);
|
||||
}
|
||||
&.gradient-3 {
|
||||
background: linear-gradient(135deg, $accent 0%, #f59e0b 100%);
|
||||
background: linear-gradient(135deg, $accent 0%, $primary 100%);
|
||||
}
|
||||
&.gradient-4 {
|
||||
background: linear-gradient(135deg, #8b5cf6 0%, $secondary 100%);
|
||||
background: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1305,9 +1273,9 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
||||
// ==========================================
|
||||
.history-section {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 32px 48px;
|
||||
overflow-y: auto;
|
||||
background: rgba($background, 0.5);
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
@ -1356,14 +1324,14 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: $secondary;
|
||||
color: $primary-light;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
color: $primary-light;
|
||||
color: $primary;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
@ -1396,7 +1364,7 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
border-right-color: $secondary;
|
||||
border-right-color: $primary-light;
|
||||
animation-duration: 2s;
|
||||
}
|
||||
|
||||
@ -1443,27 +1411,31 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
||||
}
|
||||
|
||||
.history-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 20px;
|
||||
overflow: hidden; // 移除滚动,只展示一行
|
||||
flex-wrap: nowrap; // 不换行
|
||||
}
|
||||
|
||||
.history-card {
|
||||
background: rgba($surface, 0.6);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 16px;
|
||||
flex-shrink: 0;
|
||||
width: 240px;
|
||||
background: $surface;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.4s;
|
||||
border: 1px solid rgba($primary, 0.1);
|
||||
transition: all 0.3s;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-8px);
|
||||
border-color: rgba($primary, 0.3);
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
|
||||
.card-glow {
|
||||
opacity: 0.6;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1471,12 +1443,12 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
||||
.card-glow {
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
background: $gradient-primary;
|
||||
border-radius: 18px;
|
||||
background: $primary;
|
||||
border-radius: 10px;
|
||||
z-index: -1;
|
||||
opacity: 0;
|
||||
filter: blur(20px);
|
||||
transition: opacity 0.4s;
|
||||
filter: blur(8px);
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.card-preview {
|
||||
@ -1515,7 +1487,30 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
||||
}
|
||||
|
||||
.preview-failed {
|
||||
color: $accent;
|
||||
.failed-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: linear-gradient(135deg, rgba($error, 0.15) 0%, rgba($error, 0.25) 100%);
|
||||
border: 2px solid rgba($error, 0.3);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: $error;
|
||||
animation: pulse-error 2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-error {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 0 rgba($error, 0.3);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 0 20px 5px rgba($error, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1526,7 +1521,7 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
||||
span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: $secondary;
|
||||
background: $primary-light;
|
||||
border-radius: 50%;
|
||||
animation: dotPulse 1.4s ease-in-out infinite;
|
||||
|
||||
@ -1574,21 +1569,22 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
||||
|
||||
&.status-processing,
|
||||
&.status-pending {
|
||||
background: rgba($secondary, 0.2);
|
||||
color: $secondary;
|
||||
border-color: rgba($secondary, 0.3);
|
||||
background: rgba($primary-light, 0.2);
|
||||
color: $primary-light;
|
||||
border-color: rgba($primary-light, 0.3);
|
||||
}
|
||||
|
||||
&.status-failed,
|
||||
&.status-timeout {
|
||||
background: rgba($accent, 0.2);
|
||||
color: $accent;
|
||||
border-color: rgba($accent, 0.3);
|
||||
background: rgba($error, 0.2);
|
||||
color: $error;
|
||||
border-color: rgba($error, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.card-info {
|
||||
padding: 16px;
|
||||
background: rgba(9, 88, 217, 0.15);
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
@ -1634,9 +1630,9 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: rgba($secondary, 0.2);
|
||||
border-color: $secondary;
|
||||
color: $secondary;
|
||||
background: rgba($primary-light, 0.2);
|
||||
border-color: $primary-light;
|
||||
color: $primary-light;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
@ -1661,80 +1657,20 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Drawer
|
||||
// ==========================================
|
||||
.history-drawer {
|
||||
:deep(.ant-drawer-header) {
|
||||
background: $surface;
|
||||
border-bottom-color: rgba($primary, 0.1);
|
||||
}
|
||||
|
||||
:deep(.ant-drawer-body) {
|
||||
background: $background;
|
||||
}
|
||||
}
|
||||
|
||||
.list-preview {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
background: $surface-light;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.list-preview-placeholder {
|
||||
color: $text-muted;
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.list-desc {
|
||||
max-width: 340px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
.list-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.danger-link {
|
||||
color: $accent;
|
||||
|
||||
&:hover {
|
||||
color: #ff7875;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Responsive
|
||||
// ==========================================
|
||||
@media (max-width: 1024px) {
|
||||
.left-panel {
|
||||
width: 320px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.intro-features {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.history-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
.history-card {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1745,8 +1681,6 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
||||
|
||||
.left-panel {
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid rgba($primary, 0.1);
|
||||
}
|
||||
|
||||
.intro-section {
|
||||
@ -1757,8 +1691,8 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.history-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
.history-card {
|
||||
width: 180px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user