修改样式
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';
|
} from '@nestjs/common';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import * as AdmZip from 'adm-zip';
|
import AdmZip from 'adm-zip';
|
||||||
import { AI3DService } from './ai-3d.service';
|
import { AI3DService } from './ai-3d.service';
|
||||||
import { CreateTaskDto } from './dto/create-task.dto';
|
import { CreateTaskDto } from './dto/create-task.dto';
|
||||||
import { QueryTaskDto } from './dto/query-task.dto';
|
import { QueryTaskDto } from './dto/query-task.dto';
|
||||||
@ -94,27 +94,15 @@ export class AI3DController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(`代理模型请求,原始URL: ${url.substring(0, 100)}...`);
|
|
||||||
|
|
||||||
// URL解码(处理URL编码)
|
// URL解码(处理URL编码)
|
||||||
let decodedUrl: string;
|
let decodedUrl: string;
|
||||||
try {
|
try {
|
||||||
decodedUrl = decodeURIComponent(url);
|
decodedUrl = decodeURIComponent(url);
|
||||||
// 如果解码后还包含编码字符,再解码一次
|
|
||||||
if (decodedUrl.includes('%')) {
|
|
||||||
try {
|
|
||||||
decodedUrl = decodeURIComponent(decodedUrl);
|
|
||||||
} catch (e2) {
|
|
||||||
// 第二次解码失败,使用第一次解码的结果
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 如果解码失败,使用原始URL
|
// 如果解码失败,使用原始URL
|
||||||
decodedUrl = url;
|
decodedUrl = url;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`解码后URL: ${decodedUrl.substring(0, 100)}...`);
|
|
||||||
|
|
||||||
// 验证URL是否为腾讯云COS链接(安全验证)
|
// 验证URL是否为腾讯云COS链接(安全验证)
|
||||||
if (
|
if (
|
||||||
!decodedUrl.includes('tencentcos.cn') &&
|
!decodedUrl.includes('tencentcos.cn') &&
|
||||||
@ -134,7 +122,6 @@ export class AI3DController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 从源URL获取文件
|
// 从源URL获取文件
|
||||||
console.log('开始下载文件...');
|
|
||||||
const response = await axios.get(decodedUrl, {
|
const response = await axios.get(decodedUrl, {
|
||||||
responseType: 'arraybuffer', // 使用arraybuffer以便处理ZIP
|
responseType: 'arraybuffer', // 使用arraybuffer以便处理ZIP
|
||||||
timeout: 120000, // 120秒超时(ZIP文件可能较大)
|
timeout: 120000, // 120秒超时(ZIP文件可能较大)
|
||||||
@ -151,10 +138,6 @@ export class AI3DController {
|
|||||||
response.headers['content-type'] || 'application/octet-stream';
|
response.headers['content-type'] || 'application/octet-stream';
|
||||||
let contentLength = fileData.length;
|
let contentLength = fileData.length;
|
||||||
|
|
||||||
console.log(
|
|
||||||
`文件下载成功,大小: ${contentLength} bytes, 类型: ${contentType}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 如果是ZIP文件,解压并提取GLB文件
|
// 如果是ZIP文件,解压并提取GLB文件
|
||||||
if (
|
if (
|
||||||
decodedUrl.toLowerCase().includes('.zip') ||
|
decodedUrl.toLowerCase().includes('.zip') ||
|
||||||
@ -165,46 +148,35 @@ export class AI3DController {
|
|||||||
const zip = new AdmZip(fileData);
|
const zip = new AdmZip(fileData);
|
||||||
const zipEntries = zip.getEntries();
|
const zipEntries = zip.getEntries();
|
||||||
|
|
||||||
// 记录ZIP文件信息(用于调试)
|
// 查找ZIP中的GLB文件
|
||||||
console.log(`ZIP文件包含 ${zipEntries.length} 个文件:`);
|
const glbEntry = zipEntries.find(
|
||||||
zipEntries.forEach((entry) => {
|
(entry) =>
|
||||||
console.log(` - ${entry.entryName} (${entry.header.size} bytes)`);
|
entry.entryName.toLowerCase().endsWith('.glb') ||
|
||||||
});
|
|
||||||
|
|
||||||
// 查找ZIP中的GLB或GLTF文件(优先GLB)
|
|
||||||
const glbEntry = zipEntries.find((entry) =>
|
|
||||||
entry.entryName.toLowerCase().endsWith('.glb'),
|
|
||||||
);
|
|
||||||
const gltfEntry = zipEntries.find((entry) =>
|
|
||||||
entry.entryName.toLowerCase().endsWith('.gltf'),
|
entry.entryName.toLowerCase().endsWith('.gltf'),
|
||||||
);
|
);
|
||||||
|
|
||||||
const targetEntry = glbEntry || gltfEntry;
|
if (glbEntry) {
|
||||||
|
fileData = glbEntry.getData();
|
||||||
if (targetEntry) {
|
contentType = glbEntry.entryName.toLowerCase().endsWith('.glb')
|
||||||
console.log(`找到模型文件: ${targetEntry.entryName}`);
|
|
||||||
fileData = targetEntry.getData();
|
|
||||||
contentType = targetEntry.entryName.toLowerCase().endsWith('.glb')
|
|
||||||
? 'model/gltf-binary'
|
? 'model/gltf-binary'
|
||||||
: 'model/gltf+json';
|
: 'model/gltf+json';
|
||||||
contentLength = fileData.length;
|
contentLength = fileData.length;
|
||||||
console.log(`模型文件大小: ${contentLength} bytes`);
|
|
||||||
} else {
|
} else {
|
||||||
// 列出ZIP中的所有文件,便于调试
|
// 列出ZIP中的所有文件,便于调试
|
||||||
const fileList = zipEntries
|
const fileList = zipEntries.map((e) => e.entryName).join(', ');
|
||||||
.map((e) => `${e.entryName} (${e.header.size} bytes)`)
|
throw new HttpException(
|
||||||
.join(', ');
|
`ZIP文件中未找到GLB或GLTF文件。ZIP内容: ${fileList}`,
|
||||||
const errorMsg = `ZIP文件中未找到GLB或GLTF文件。ZIP包含 ${zipEntries.length} 个文件: ${fileList}`;
|
HttpStatus.BAD_REQUEST,
|
||||||
console.error(errorMsg);
|
);
|
||||||
throw new HttpException(errorMsg, HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
}
|
||||||
} catch (zipError: any) {
|
} catch (zipError: any) {
|
||||||
if (zipError instanceof HttpException) {
|
if (zipError instanceof HttpException) {
|
||||||
throw zipError;
|
throw zipError;
|
||||||
}
|
}
|
||||||
const errorMsg = `ZIP解压失败: ${zipError.message}`;
|
throw new HttpException(
|
||||||
console.error(errorMsg, zipError);
|
`ZIP解压失败: ${zipError.message}`,
|
||||||
throw new HttpException(errorMsg, HttpStatus.INTERNAL_SERVER_ERROR);
|
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -152,3 +152,4 @@ export const judgesManagementApi = {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- 这个组件作为跳转器使用,实际查看器在 /model-viewer 页面 -->
|
<!-- 这个组件作为跳转器使用,实际查看器在 model-viewer 页面 -->
|
||||||
<span></span>
|
<span></span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { watch } from "vue"
|
import { watch } from "vue"
|
||||||
import { useRouter } from "vue-router"
|
import { useRouter, useRoute } from "vue-router"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean
|
open: boolean
|
||||||
@ -19,6 +19,7 @@ interface Emits {
|
|||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
// 监听打开状态,跳转到全屏页面
|
// 监听打开状态,跳转到全屏页面
|
||||||
watch(
|
watch(
|
||||||
@ -30,8 +31,9 @@ watch(
|
|||||||
// 先关闭状态
|
// 先关闭状态
|
||||||
emit("update:open", false)
|
emit("update:open", false)
|
||||||
// 跳转到模型查看页面
|
// 跳转到模型查看页面
|
||||||
|
const tenantCode = route.params.tenantCode as string
|
||||||
router.push({
|
router.push({
|
||||||
path: "/model-viewer",
|
path: `/${tenantCode}/workbench/model-viewer`,
|
||||||
query: { url: props.modelUrl }
|
query: { url: props.modelUrl }
|
||||||
})
|
})
|
||||||
} else if (newOpen && !props.modelUrl) {
|
} else if (newOpen && !props.modelUrl) {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<a-layout class="layout">
|
<a-layout class="layout">
|
||||||
<a-layout-sider
|
<a-layout-sider
|
||||||
|
v-if="!hideSidebar"
|
||||||
v-model:collapsed="collapsed"
|
v-model:collapsed="collapsed"
|
||||||
:width="200"
|
:width="200"
|
||||||
class="custom-sider"
|
class="custom-sider"
|
||||||
@ -51,7 +52,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</a-layout-sider>
|
</a-layout-sider>
|
||||||
<a-layout class="main-layout">
|
<a-layout class="main-layout">
|
||||||
<a-layout-content class="content">
|
<a-layout-content
|
||||||
|
class="content"
|
||||||
|
:class="{ 'content-fullscreen': hideSidebar }"
|
||||||
|
>
|
||||||
<router-view />
|
<router-view />
|
||||||
</a-layout-content>
|
</a-layout-content>
|
||||||
</a-layout>
|
</a-layout>
|
||||||
@ -82,6 +86,11 @@ const openKeys = ref<string[]>([])
|
|||||||
// 生成用户头像URL(使用DiceBear API自动生成默认头像)
|
// 生成用户头像URL(使用DiceBear API自动生成默认头像)
|
||||||
const userAvatar = computed(() => getUserAvatar(authStore.user))
|
const userAvatar = computed(() => getUserAvatar(authStore.user))
|
||||||
|
|
||||||
|
// 根据路由 meta 判断是否隐藏侧边栏
|
||||||
|
const hideSidebar = computed(() => {
|
||||||
|
return route.meta?.hideSidebar === true
|
||||||
|
})
|
||||||
|
|
||||||
// 使用动态菜单
|
// 使用动态菜单
|
||||||
const menuItems = computed<MenuProps["items"]>(() => {
|
const menuItems = computed<MenuProps["items"]>(() => {
|
||||||
if (authStore.menus && authStore.menus.length > 0) {
|
if (authStore.menus && authStore.menus.length > 0) {
|
||||||
@ -141,7 +150,8 @@ const handleMenuClick = ({ key }: { key: string }) => {
|
|||||||
const is3DLab =
|
const is3DLab =
|
||||||
key.toLowerCase().includes("3dlab") ||
|
key.toLowerCase().includes("3dlab") ||
|
||||||
key.toLowerCase().includes("3d-lab") ||
|
key.toLowerCase().includes("3d-lab") ||
|
||||||
(key.toLowerCase().includes("workbench") && key.toLowerCase().includes("3d"))
|
(key.toLowerCase().includes("workbench") &&
|
||||||
|
key.toLowerCase().includes("3d"))
|
||||||
|
|
||||||
// 方法2: 从菜单数据中查找对应的菜单项,检查path
|
// 方法2: 从菜单数据中查找对应的菜单项,检查path
|
||||||
const findMenuByKey = (menus: any[], targetKey: string): any => {
|
const findMenuByKey = (menus: any[], targetKey: string): any => {
|
||||||
@ -158,15 +168,23 @@ const handleMenuClick = ({ key }: { key: string }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const menuItem = findMenuByKey(menuItems.value || [], key)
|
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) {
|
if (is3DLab || is3DLabByPath) {
|
||||||
// 打开独立的全屏3D Lab页面(类似model-viewer)
|
// 打开3D建模实验室页面(新窗口,路由配置了 hideSidebar 会自动隐藏侧边栏)
|
||||||
console.log("检测到3D建模实验室,打开新窗口")
|
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")
|
window.open(fullUrl, "_blank")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -414,4 +432,9 @@ const handleLogout = async () => {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content-fullscreen {
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -19,18 +19,6 @@ const baseRoutes: RouteRecordRaw[] = [
|
|||||||
component: () => import("@/views/auth/Login.vue"),
|
component: () => import("@/views/auth/Login.vue"),
|
||||||
meta: { requiresAuth: false },
|
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",
|
path: "/:tenantCode",
|
||||||
name: "Main",
|
name: "Main",
|
||||||
@ -187,6 +175,7 @@ const baseRoutes: RouteRecordRaw[] = [
|
|||||||
meta: {
|
meta: {
|
||||||
title: "3D建模实验室",
|
title: "3D建模实验室",
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
|
hideSidebar: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// 3D模型生成页面
|
// 3D模型生成页面
|
||||||
@ -197,6 +186,29 @@ const baseRoutes: RouteRecordRaw[] = [
|
|||||||
meta: {
|
meta: {
|
||||||
title: "3D模型生成",
|
title: "3D模型生成",
|
||||||
requiresAuth: true,
|
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
|
return
|
||||||
}
|
}
|
||||||
// 如果URL中没有租户编码,添加租户编码(排除不需要认证的特殊路由)
|
// 如果URL中没有租户编码,添加租户编码(排除不需要认证的特殊路由)
|
||||||
const skipTenantCodePaths = ["/login", "/model-viewer", "/403"]
|
const skipTenantCodePaths = ["/login", "/403"]
|
||||||
const shouldSkipTenantCode = skipTenantCodePaths.some(p => to.path.startsWith(p))
|
const shouldSkipTenantCode = skipTenantCodePaths.some(p => to.path.startsWith(p))
|
||||||
if (!tenantCodeFromUrl && !shouldSkipTenantCode) {
|
if (!tenantCodeFromUrl && !shouldSkipTenantCode) {
|
||||||
const correctedPath = buildPathWithTenantCode(userTenantCode, to.path)
|
const correctedPath = buildPathWithTenantCode(userTenantCode, to.path)
|
||||||
|
|||||||
@ -6,5 +6,6 @@ declare module "vue-router" {
|
|||||||
requiresAuth?: boolean;
|
requiresAuth?: boolean;
|
||||||
roles?: string[]; // 需要的角色列表
|
roles?: string[]; // 需要的角色列表
|
||||||
permissions?: string[]; // 需要的权限列表
|
permissions?: string[]; // 需要的权限列表
|
||||||
|
hideSidebar?: boolean; // 是否隐藏侧边栏(全屏模式)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -409,9 +409,12 @@ const handlePreview3DModel = (fileUrl: string) => {
|
|||||||
}
|
}
|
||||||
const fullUrl = getFileUrl(fileUrl)
|
const fullUrl = getFileUrl(fileUrl)
|
||||||
console.log("预览3D模型,原始URL:", fileUrl, "完整URL:", fullUrl)
|
console.log("预览3D模型,原始URL:", fileUrl, "完整URL:", fullUrl)
|
||||||
// 直接在新标签页打开模型查看器
|
// 跳转到模型查看器页面
|
||||||
const viewerUrl = `/model-viewer?url=${encodeURIComponent(fullUrl)}`
|
const tenantCode = route.params.tenantCode as string
|
||||||
window.open(viewerUrl, "_blank")
|
router.push({
|
||||||
|
path: `/${tenantCode}/workbench/model-viewer`,
|
||||||
|
query: { url: fullUrl },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 表格列定义
|
// 表格列定义
|
||||||
|
|||||||
@ -159,6 +159,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, computed } from "vue"
|
import { ref, watch, computed } from "vue"
|
||||||
|
import { useRouter, useRoute } from "vue-router"
|
||||||
import { message } from "ant-design-vue"
|
import { message } from "ant-design-vue"
|
||||||
import {
|
import {
|
||||||
FileOutlined,
|
FileOutlined,
|
||||||
@ -181,6 +182,8 @@ interface Emits {
|
|||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@ -462,9 +465,12 @@ const handlePreview3DModel = (fileUrl: string) => {
|
|||||||
}
|
}
|
||||||
const fullUrl = getFileUrl(fileUrl)
|
const fullUrl = getFileUrl(fileUrl)
|
||||||
console.log("预览3D模型,原始URL:", fileUrl, "完整URL:", fullUrl)
|
console.log("预览3D模型,原始URL:", fileUrl, "完整URL:", fullUrl)
|
||||||
// 直接在新标签页打开模型查看器
|
// 跳转到模型查看器页面
|
||||||
const viewerUrl = `/model-viewer?url=${encodeURIComponent(fullUrl)}`
|
const tenantCode = route.params.tenantCode as string
|
||||||
window.open(viewerUrl, "_blank")
|
router.push({
|
||||||
|
path: `/${tenantCode}/workbench/model-viewer`,
|
||||||
|
query: { url: fullUrl },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 取消
|
// 取消
|
||||||
|
|||||||
@ -1,11 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="model-viewer-page">
|
<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 -->
|
<!-- Header -->
|
||||||
<div class="viewer-header">
|
<div class="viewer-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<a-button type="text" class="back-btn" @click="handleBack">
|
<a-button type="text" class="back-btn" @click="handleBack">
|
||||||
<template #icon><ArrowLeftOutlined /></template>
|
<template #icon><ArrowLeftOutlined /></template>
|
||||||
返回
|
|
||||||
</a-button>
|
</a-button>
|
||||||
<span class="title">3D 模型预览</span>
|
<span class="title">3D 模型预览</span>
|
||||||
<span class="badge">LIVE</span>
|
<span class="badge">LIVE</span>
|
||||||
@ -373,8 +379,8 @@ const settingsPanelOpen = ref(true)
|
|||||||
|
|
||||||
// 场景设置默认值
|
// 场景设置默认值
|
||||||
const defaultSettings = {
|
const defaultSettings = {
|
||||||
backgroundColor: "#0f0f1a",
|
backgroundColor: "#f5f5f5",
|
||||||
showGrid: true,
|
showGrid: false,
|
||||||
ambientLight: {
|
ambientLight: {
|
||||||
intensity: 0.4,
|
intensity: 0.4,
|
||||||
color: "#ffffff",
|
color: "#ffffff",
|
||||||
@ -426,11 +432,7 @@ console.log("模型查看器 - URL:", modelUrl.value)
|
|||||||
|
|
||||||
// 返回上一页
|
// 返回上一页
|
||||||
const handleBack = () => {
|
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)
|
bottomLight.position.set(0, -10, 0)
|
||||||
scene.add(bottomLight)
|
scene.add(bottomLight)
|
||||||
|
|
||||||
// 添加网格 - 使用紫色调
|
// 添加网格 - 使用蓝色调
|
||||||
gridHelper = new THREE.GridHelper(100, 100, 0x7c3aed, 0x3b1d70)
|
gridHelper = new THREE.GridHelper(100, 100, 0x0958d9, 0x1677ff)
|
||||||
gridHelper.visible = sceneSettings.showGrid
|
gridHelper.visible = sceneSettings.showGrid
|
||||||
scene.add(gridHelper)
|
scene.add(gridHelper)
|
||||||
|
|
||||||
@ -773,13 +775,13 @@ const loadModel = async () => {
|
|||||||
const distanceForWidth = size.x / 2 / Math.tan(fovRad / 2) / aspect
|
const distanceForWidth = size.x / 2 / Math.tan(fovRad / 2) / aspect
|
||||||
const distanceForDepth = size.z / 2 / Math.tan(fovRad / 2)
|
const distanceForDepth = size.z / 2 / Math.tan(fovRad / 2)
|
||||||
|
|
||||||
// 取最大距离,并添加足够的边距(3倍)
|
// 取最大距离,并添加适当的边距(1.8倍,让模型显示更大)
|
||||||
const baseDistance = Math.max(
|
const baseDistance = Math.max(
|
||||||
distanceForHeight,
|
distanceForHeight,
|
||||||
distanceForWidth,
|
distanceForWidth,
|
||||||
distanceForDepth
|
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("模型尺寸:", size)
|
||||||
console.log("计算的相机距离:", cameraDistance)
|
console.log("计算的相机距离:", cameraDistance)
|
||||||
@ -953,26 +955,28 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Energetic Modern Color Palette
|
// 蓝色主题色彩方案 - 与系统统一
|
||||||
// ==========================================
|
// ==========================================
|
||||||
$primary: #7c3aed; // Violet
|
$primary: #0958d9;
|
||||||
$primary-light: #a78bfa; // Light violet
|
$primary-light: #1677ff;
|
||||||
$secondary: #06b6d4; // Cyan
|
$primary-dark: #003eb3;
|
||||||
$accent: #f43f5e; // Rose/Pink
|
$secondary: #4096ff;
|
||||||
$background: #0f0f1a; // Dark
|
$accent: #1677ff;
|
||||||
$surface: #1a1a2e; // Dark surface
|
$success: #52c41a;
|
||||||
$text: #e2e8f0; // Light gray
|
$warning: #faad14;
|
||||||
$text-muted: #94a3b8; // Muted gray
|
$error: #ff4d4f;
|
||||||
$border: #4c1d95; // Dark violet
|
|
||||||
|
|
||||||
// Gradients
|
// 背景色 - 模型查看器保持深色以更好展示3D模型
|
||||||
$gradient-primary: linear-gradient(135deg, $primary 0%, #ec4899 100%);
|
$background: #f5f5f5;
|
||||||
$gradient-accent: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
$surface: #ffffff;
|
||||||
$gradient-dark: linear-gradient(
|
$surface-dark: #1a1a2e;
|
||||||
180deg,
|
$text: rgba(0, 0, 0, 0.85);
|
||||||
rgba($primary, 0.1) 0%,
|
$text-secondary: rgba(0, 0, 0, 0.65);
|
||||||
transparent 100%
|
$text-muted: rgba(0, 0, 0, 0.45);
|
||||||
);
|
$text-light: #ffffff;
|
||||||
|
|
||||||
|
// 渐变
|
||||||
|
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
|
||||||
|
|
||||||
.model-viewer-page {
|
.model-viewer-page {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@ -982,39 +986,65 @@ $gradient-dark: linear-gradient(
|
|||||||
background: $background;
|
background: $background;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
// Animated background gradient
|
// ==========================================
|
||||||
&::before {
|
// Animated Background
|
||||||
content: "";
|
// ==========================================
|
||||||
|
.bg-animation {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -50%;
|
inset: 0;
|
||||||
left: -50%;
|
overflow: hidden;
|
||||||
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;
|
pointer-events: none;
|
||||||
z-index: 0;
|
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%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
transform: translate(0, 0);
|
transform: translate(0, 0) scale(1);
|
||||||
}
|
}
|
||||||
50% {
|
33% {
|
||||||
transform: translate(-5%, -5%);
|
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;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 12px 20px;
|
padding: 12px 20px;
|
||||||
background: rgba($surface, 0.8);
|
background: rgba($surface, 0.7);
|
||||||
backdrop-filter: blur(20px);
|
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;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
|
||||||
@ -1052,7 +1083,7 @@ $gradient-dark: linear-gradient(
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background: $accent;
|
background: $success;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
animation: pulse 2s ease-in-out infinite;
|
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 {
|
.action-btn {
|
||||||
color: $text !important;
|
color: $text !important;
|
||||||
border: 1px solid rgba($primary, 0.3) !important;
|
border: 1px solid rgba($primary, 0.2) !important;
|
||||||
border-radius: 8px !important;
|
border-radius: 8px !important;
|
||||||
transition: all 0.3s ease !important;
|
transition: all 0.3s ease !important;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: #fff !important;
|
color: $primary !important;
|
||||||
background: rgba($primary, 0.2) !important;
|
background: rgba($primary, 0.1) !important;
|
||||||
border-color: $primary !important;
|
border-color: $primary !important;
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
@ -1097,6 +1146,8 @@ $gradient-dark: linear-gradient(
|
|||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
background: rgba($surface, 0.3);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-canvas {
|
.model-canvas {
|
||||||
@ -1119,7 +1170,7 @@ $gradient-dark: linear-gradient(
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: rgba($background, 0.95);
|
background: $background;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1147,7 +1198,7 @@ $gradient-dark: linear-gradient(
|
|||||||
&:nth-child(2) {
|
&:nth-child(2) {
|
||||||
width: 60px;
|
width: 60px;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
border-right-color: $secondary;
|
border-right-color: $primary-light;
|
||||||
animation-delay: 0.2s;
|
animation-delay: 0.2s;
|
||||||
animation-direction: reverse;
|
animation-direction: reverse;
|
||||||
}
|
}
|
||||||
@ -1155,7 +1206,7 @@ $gradient-dark: linear-gradient(
|
|||||||
&:nth-child(3) {
|
&:nth-child(3) {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
border-bottom-color: $accent;
|
border-bottom-color: $secondary;
|
||||||
animation-delay: 0.4s;
|
animation-delay: 0.4s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1181,17 +1232,17 @@ $gradient-dark: linear-gradient(
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: rgba($background, 0.95);
|
background: $background;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-card {
|
.error-card {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
background: rgba($surface, 0.8);
|
background: $surface;
|
||||||
backdrop-filter: blur(20px);
|
border: 1px solid #e8e8e8;
|
||||||
border: 1px solid rgba($accent, 0.3);
|
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
|
||||||
.error-icon {
|
.error-icon {
|
||||||
width: 60px;
|
width: 60px;
|
||||||
@ -1203,7 +1254,7 @@ $gradient-dark: linear-gradient(
|
|||||||
font-size: 32px;
|
font-size: 32px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background: $accent;
|
background: $error;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1240,7 +1291,7 @@ $gradient-dark: linear-gradient(
|
|||||||
|
|
||||||
.outline-btn {
|
.outline-btn {
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
border: 1px solid rgba($text, 0.3) !important;
|
border: 1px solid #e8e8e8 !important;
|
||||||
color: $text !important;
|
color: $text !important;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<a-button type="text" class="back-btn" @click="handleBack">
|
<a-button type="text" class="back-btn" @click="handleBack">
|
||||||
<template #icon><CloseOutlined /></template>
|
<template #icon><ArrowLeftOutlined /></template>
|
||||||
</a-button>
|
</a-button>
|
||||||
<span class="title">{{ pageTitle }}</span>
|
<span class="title">{{ pageTitle }}</span>
|
||||||
<span class="live-badge">
|
<span class="live-badge">
|
||||||
@ -110,11 +110,9 @@
|
|||||||
<!-- Failed State -->
|
<!-- Failed State -->
|
||||||
<template v-else-if="item.status === 'failed'">
|
<template v-else-if="item.status === 'failed'">
|
||||||
<div class="card-error">
|
<div class="card-error">
|
||||||
<div class="error-icon">
|
<div class="failed-icon">
|
||||||
<ExclamationCircleOutlined />
|
<CloseOutlined />
|
||||||
</div>
|
</div>
|
||||||
<p class="error-title">生成失败</p>
|
|
||||||
<p class="error-text">请重试或联系支持</p>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@ -153,9 +151,9 @@ import { ref, computed, onMounted, onUnmounted } from "vue"
|
|||||||
import { useRouter, useRoute } from "vue-router"
|
import { useRouter, useRoute } from "vue-router"
|
||||||
import { message } from "ant-design-vue"
|
import { message } from "ant-design-vue"
|
||||||
import {
|
import {
|
||||||
CloseOutlined,
|
ArrowLeftOutlined,
|
||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
ExclamationCircleOutlined,
|
CloseOutlined,
|
||||||
LoadingOutlined,
|
LoadingOutlined,
|
||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
ThunderboltOutlined,
|
ThunderboltOutlined,
|
||||||
@ -200,24 +198,39 @@ const getPreviewUrl = (url: string) => {
|
|||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4 model cards state
|
// Model cards state - 根据 previewUrls 数量动态展示
|
||||||
const modelCards = computed(() => {
|
const modelCards = computed(() => {
|
||||||
if (!task.value) {
|
if (!task.value) {
|
||||||
return Array(4).fill({ status: "pending", previewUrl: "" })
|
// 加载中时显示1个加载卡片
|
||||||
|
return [{ status: "pending", previewUrl: "" }]
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = task.value.status
|
const status = task.value.status
|
||||||
const previewUrls = task.value.previewUrls || []
|
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)
|
.fill(null)
|
||||||
.map((_, index) => {
|
.map((_, index) => {
|
||||||
const originalPreviewUrl = previewUrls[index] || ""
|
const originalPreviewUrl = previewUrls[index] || ""
|
||||||
return {
|
return {
|
||||||
status: status,
|
status: status,
|
||||||
previewUrl: getPreviewUrl(originalPreviewUrl),
|
previewUrl: getPreviewUrl(originalPreviewUrl),
|
||||||
originalPreviewUrl: originalPreviewUrl, // 保留原始URL用于其他用途
|
originalPreviewUrl: originalPreviewUrl,
|
||||||
resultUrl: task.value?.resultUrls?.[index] || "",
|
resultUrl: resultUrls[index] || "",
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -242,9 +255,12 @@ const handleCardClick = (index: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (card.status === "completed" && card.resultUrl) {
|
if (card.status === "completed" && card.resultUrl) {
|
||||||
// Navigate to model viewer
|
// Navigate to model viewer (使用路由导航,保持全屏模式)
|
||||||
const viewerUrl = `/model-viewer?url=${encodeURIComponent(card.resultUrl)}`
|
const tenantCode = route.params.tenantCode as string
|
||||||
window.open(viewerUrl, "_blank")
|
router.push({
|
||||||
|
path: `/${tenantCode}/workbench/model-viewer`,
|
||||||
|
query: { url: card.resultUrl },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -313,28 +329,33 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Energetic Modern Color Palette
|
// 蓝色主题色彩方案 - 与 Index.vue 统一
|
||||||
// ==========================================
|
// ==========================================
|
||||||
$primary: #7c3aed; // Violet
|
$primary: #0958d9;
|
||||||
$primary-light: #a78bfa; // Light violet
|
$primary-light: #1677ff;
|
||||||
$primary-dark: #5b21b6; // Dark violet
|
$primary-dark: #003eb3;
|
||||||
$secondary: #06b6d4; // Cyan
|
$secondary: #4096ff;
|
||||||
$accent: #f43f5e; // Rose/Pink
|
$accent: #1677ff;
|
||||||
$success: #10b981; // Emerald
|
$success: #52c41a;
|
||||||
$background: #0f0f1a; // Dark
|
$warning: #faad14;
|
||||||
$surface: #1a1a2e; // Dark surface
|
$error: #ff4d4f;
|
||||||
$surface-light: #252542; // Lighter surface
|
|
||||||
$text: #e2e8f0; // Light gray
|
|
||||||
$text-muted: #94a3b8; // Muted gray
|
|
||||||
$border: #4c1d95; // Dark violet
|
|
||||||
|
|
||||||
// Gradients
|
// 背景色
|
||||||
$gradient-primary: linear-gradient(135deg, $primary 0%, #ec4899 100%);
|
$background: #f5f5f5;
|
||||||
$gradient-accent: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
$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(
|
$gradient-card: linear-gradient(
|
||||||
145deg,
|
145deg,
|
||||||
rgba($primary, 0.1) 0%,
|
rgba($primary, 0.05) 0%,
|
||||||
rgba($secondary, 0.05) 100%
|
rgba($secondary, 0.02) 100%
|
||||||
);
|
);
|
||||||
|
|
||||||
.generate-page {
|
.generate-page {
|
||||||
@ -360,9 +381,9 @@ $gradient-card: linear-gradient(
|
|||||||
.bg-gradient {
|
.bg-gradient {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
filter: blur(80px);
|
filter: blur(100px);
|
||||||
opacity: 0.4;
|
opacity: 0.15;
|
||||||
animation: float 20s ease-in-out infinite;
|
animation: float 30s ease-in-out infinite;
|
||||||
|
|
||||||
&.bg-gradient-1 {
|
&.bg-gradient-1 {
|
||||||
width: 600px;
|
width: 600px;
|
||||||
@ -370,27 +391,26 @@ $gradient-card: linear-gradient(
|
|||||||
background: $primary;
|
background: $primary;
|
||||||
top: -200px;
|
top: -200px;
|
||||||
left: -100px;
|
left: -100px;
|
||||||
animation-delay: 0s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.bg-gradient-2 {
|
&.bg-gradient-2 {
|
||||||
width: 500px;
|
width: 500px;
|
||||||
height: 500px;
|
height: 500px;
|
||||||
background: $secondary;
|
background: $primary-light;
|
||||||
bottom: -150px;
|
bottom: -150px;
|
||||||
right: -100px;
|
right: -100px;
|
||||||
animation-delay: -7s;
|
animation-delay: -10s;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.bg-gradient-3 {
|
&.bg-gradient-3 {
|
||||||
width: 400px;
|
width: 400px;
|
||||||
height: 400px;
|
height: 400px;
|
||||||
background: $accent;
|
background: $secondary;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
animation-delay: -14s;
|
animation-delay: -20s;
|
||||||
opacity: 0.2;
|
opacity: 0.1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -416,9 +436,10 @@ $gradient-card: linear-gradient(
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
background: rgba($surface, 0.8);
|
background: rgba($surface, 0.7);
|
||||||
backdrop-filter: blur(20px);
|
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;
|
flex-shrink: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
@ -433,12 +454,13 @@ $gradient-card: linear-gradient(
|
|||||||
color: $text !important;
|
color: $text !important;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
border-radius: 12px !important;
|
border-radius: 10px !important;
|
||||||
border: 1px solid rgba($primary, 0.3) !important;
|
border: 1px solid rgba($primary, 0.3) !important;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
transition: all 0.3s !important;
|
transition: all 0.3s !important;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba($primary, 0.2) !important;
|
background: rgba($primary, 0.2) !important;
|
||||||
@ -461,10 +483,10 @@ $gradient-card: linear-gradient(
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
background: rgba($accent, 0.15);
|
background: rgba($success, 0.1);
|
||||||
border: 1px solid rgba($accent, 0.3);
|
border: 1px solid rgba($success, 0.3);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
color: $accent;
|
color: $success;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
@ -472,16 +494,16 @@ $gradient-card: linear-gradient(
|
|||||||
.pulse-dot {
|
.pulse-dot {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
background: $accent;
|
background: $success;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: pulse 1.5s ease-in-out infinite;
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.pbr-tag {
|
.pbr-tag {
|
||||||
background: rgba($secondary, 0.15) !important;
|
background: rgba($primary, 0.1) !important;
|
||||||
border: 1px solid rgba($secondary, 0.3) !important;
|
border: 1px solid rgba($primary, 0.2) !important;
|
||||||
color: $secondary !important;
|
color: $primary !important;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@ -493,7 +515,7 @@ $gradient-card: linear-gradient(
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
color: $primary-light;
|
color: $primary;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
||||||
@ -545,9 +567,10 @@ $gradient-card: linear-gradient(
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
.model-grid {
|
.model-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 300px);
|
grid-template-columns: repeat(auto-fit, 300px);
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
margin-bottom: 40px;
|
margin-bottom: 40px;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@ -556,35 +579,35 @@ $gradient-card: linear-gradient(
|
|||||||
.model-card {
|
.model-card {
|
||||||
width: 300px;
|
width: 300px;
|
||||||
height: 220px;
|
height: 220px;
|
||||||
background: rgba($surface, 0.6);
|
background: $surface;
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
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;
|
position: relative;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: rgba($primary, 0.5);
|
border-color: $primary;
|
||||||
transform: translateY(-4px);
|
transform: translateY(-4px);
|
||||||
box-shadow: 0 20px 40px rgba($primary, 0.2);
|
box-shadow: 0 12px 24px rgba($primary, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-ready {
|
&.is-ready {
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: $secondary;
|
border-color: $primary;
|
||||||
box-shadow: 0 20px 40px rgba($secondary, 0.3);
|
box-shadow: 0 12px 24px rgba($primary, 0.2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-selected {
|
&.is-selected {
|
||||||
border-color: $secondary;
|
border-color: $primary;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-loading {
|
&.is-loading {
|
||||||
.card-index {
|
.card-index {
|
||||||
background: rgba($primary, 0.3);
|
background: rgba($primary, 0.1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -596,17 +619,16 @@ $gradient-card: linear-gradient(
|
|||||||
left: 12px;
|
left: 12px;
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
background: rgba($secondary, 0.3);
|
background: rgba($primary, 0.1);
|
||||||
border: 1px solid rgba($secondary, 0.5);
|
border: 1px solid rgba($primary, 0.2);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: $text;
|
color: $primary;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@ -646,9 +668,8 @@ $gradient-card: linear-gradient(
|
|||||||
top: 50%;
|
top: 50%;
|
||||||
margin-left: -20px;
|
margin-left: -20px;
|
||||||
margin-top: -20px;
|
margin-top: -20px;
|
||||||
border: 2px solid rgba($primary, 0.5);
|
border: 2px solid rgba($primary, 0.3);
|
||||||
background: rgba($primary, 0.1);
|
background: rgba($primary, 0.05);
|
||||||
backdrop-filter: blur(5px);
|
|
||||||
|
|
||||||
&.front {
|
&.front {
|
||||||
transform: translateZ(20px);
|
transform: translateZ(20px);
|
||||||
@ -656,11 +677,11 @@ $gradient-card: linear-gradient(
|
|||||||
}
|
}
|
||||||
&.back {
|
&.back {
|
||||||
transform: rotateY(180deg) translateZ(20px);
|
transform: rotateY(180deg) translateZ(20px);
|
||||||
border-color: $secondary;
|
border-color: $primary-light;
|
||||||
}
|
}
|
||||||
&.right {
|
&.right {
|
||||||
transform: rotateY(90deg) translateZ(20px);
|
transform: rotateY(90deg) translateZ(20px);
|
||||||
border-color: $accent;
|
border-color: $secondary;
|
||||||
}
|
}
|
||||||
&.left {
|
&.left {
|
||||||
transform: rotateY(-90deg) translateZ(20px);
|
transform: rotateY(-90deg) translateZ(20px);
|
||||||
@ -668,11 +689,11 @@ $gradient-card: linear-gradient(
|
|||||||
}
|
}
|
||||||
&.top {
|
&.top {
|
||||||
transform: rotateX(90deg) translateZ(20px);
|
transform: rotateX(90deg) translateZ(20px);
|
||||||
border-color: $secondary;
|
border-color: $primary;
|
||||||
}
|
}
|
||||||
&.bottom {
|
&.bottom {
|
||||||
transform: rotateX(-90deg) translateZ(20px);
|
transform: rotateX(-90deg) translateZ(20px);
|
||||||
border-color: $accent;
|
border-color: $secondary;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -709,7 +730,7 @@ $gradient-card: linear-gradient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.highlight {
|
.highlight {
|
||||||
color: $secondary;
|
color: $primary;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -772,7 +793,7 @@ $gradient-card: linear-gradient(
|
|||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
180deg,
|
180deg,
|
||||||
transparent 0%,
|
transparent 0%,
|
||||||
rgba($background, 0.8) 100%
|
rgba(0, 0, 0, 0.6) 100%
|
||||||
);
|
);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -797,7 +818,7 @@ $gradient-card: linear-gradient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.preview-text {
|
.preview-text {
|
||||||
color: $text;
|
color: #fff;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
@ -840,33 +861,31 @@ $gradient-card: linear-gradient(
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: rgba($accent, 0.05);
|
background: rgba($error, 0.02);
|
||||||
|
|
||||||
.error-icon {
|
.failed-icon {
|
||||||
width: 56px;
|
width: 64px;
|
||||||
height: 56px;
|
height: 64px;
|
||||||
background: rgba($accent, 0.15);
|
background: linear-gradient(135deg, rgba($error, 0.15) 0%, rgba($error, 0.25) 100%);
|
||||||
border: 2px solid rgba($accent, 0.3);
|
border: 2px solid rgba($error, 0.3);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
color: $accent;
|
color: $error;
|
||||||
margin-bottom: 16px;
|
animation: pulse-error 2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.error-title {
|
@keyframes pulse-error {
|
||||||
margin: 0 0 4px;
|
0%, 100% {
|
||||||
font-size: 16px;
|
transform: scale(1);
|
||||||
font-weight: 600;
|
box-shadow: 0 0 0 0 rgba($error, 0.3);
|
||||||
color: $accent;
|
|
||||||
}
|
}
|
||||||
|
50% {
|
||||||
.error-text {
|
transform: scale(1.05);
|
||||||
margin: 0;
|
box-shadow: 0 0 20px 5px rgba($error, 0.15);
|
||||||
font-size: 13px;
|
|
||||||
color: $text-muted;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -882,11 +901,11 @@ $gradient-card: linear-gradient(
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 16px 24px;
|
padding: 16px 24px;
|
||||||
background: rgba($surface, 0.6);
|
background: $surface;
|
||||||
backdrop-filter: blur(20px);
|
border: 1px solid #e8e8e8;
|
||||||
border: 1px solid rgba($primary, 0.2);
|
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
|
|
||||||
.input-icon {
|
.input-icon {
|
||||||
width: 44px;
|
width: 44px;
|
||||||
@ -933,21 +952,21 @@ $gradient-card: linear-gradient(
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
color: $text-muted;
|
color: $text-secondary;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|
||||||
.tip-icon {
|
.tip-icon {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
background: rgba($primary, 0.2);
|
background: rgba($primary, 0.1);
|
||||||
border: 1px solid rgba($primary, 0.3);
|
border: 1px solid rgba($primary, 0.2);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
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-1"></div>
|
||||||
<div class="bg-gradient bg-gradient-2"></div>
|
<div class="bg-gradient bg-gradient-2"></div>
|
||||||
<div class="bg-gradient bg-gradient-3"></div>
|
<div class="bg-gradient bg-gradient-3"></div>
|
||||||
<div class="bg-grid"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 左侧生成栏 -->
|
<!-- 左侧生成栏 -->
|
||||||
<div class="left-panel">
|
<div class="left-panel">
|
||||||
<!-- Header with Logo -->
|
<!-- Header with Logo -->
|
||||||
<div class="panel-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-ring">
|
||||||
<div class="logo-icon">
|
<div class="logo-icon">
|
||||||
<ThunderboltOutlined />
|
<ThunderboltOutlined />
|
||||||
@ -170,9 +172,11 @@
|
|||||||
<div class="history-header">
|
<div class="history-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<h2 class="history-title">创作历史</h2>
|
<h2 class="history-title">创作历史</h2>
|
||||||
<span class="history-count">{{ historyList.length }} 个作品</span>
|
<span class="history-count"
|
||||||
|
>{{ allHistoryTotal || historyList.length }} 个作品</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<a class="view-all" @click="showAllHistory = true">
|
<a class="view-all" @click="goToHistory">
|
||||||
查看全部
|
查看全部
|
||||||
<ArrowRightOutlined />
|
<ArrowRightOutlined />
|
||||||
</a>
|
</a>
|
||||||
@ -195,9 +199,9 @@
|
|||||||
<p class="empty-text">开始你的第一次 3D 创作之旅吧</p>
|
<p class="empty-text">开始你的第一次 3D 创作之旅吧</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="history-grid">
|
<div v-else ref="historyGridRef" class="history-grid">
|
||||||
<div
|
<div
|
||||||
v-for="task in historyList"
|
v-for="task in displayedHistoryList"
|
||||||
:key="task.id"
|
:key="task.id"
|
||||||
class="history-card"
|
class="history-card"
|
||||||
@click="handleViewTask(task)"
|
@click="handleViewTask(task)"
|
||||||
@ -231,10 +235,9 @@
|
|||||||
"
|
"
|
||||||
class="preview-failed"
|
class="preview-failed"
|
||||||
>
|
>
|
||||||
<ExclamationCircleOutlined />
|
<div class="failed-icon">
|
||||||
<span>{{
|
<CloseOutlined />
|
||||||
task.status === "timeout" ? "已超时" : "生成失败"
|
</div>
|
||||||
}}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="preview-placeholder">
|
<div v-else class="preview-placeholder">
|
||||||
<FileImageOutlined />
|
<FileImageOutlined />
|
||||||
@ -286,88 +289,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted } from "vue"
|
import { ref, computed, onMounted, onUnmounted, nextTick } from "vue"
|
||||||
import { useRouter } from "vue-router"
|
import { useRouter, useRoute } from "vue-router"
|
||||||
import { message, Modal } from "ant-design-vue"
|
import { message, Modal } from "ant-design-vue"
|
||||||
import {
|
import {
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
PictureOutlined,
|
PictureOutlined,
|
||||||
ThunderboltOutlined,
|
ThunderboltOutlined,
|
||||||
LoadingOutlined,
|
LoadingOutlined,
|
||||||
ExclamationCircleOutlined,
|
CloseOutlined,
|
||||||
FileImageOutlined,
|
FileImageOutlined,
|
||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
ArrowRightOutlined,
|
ArrowRightOutlined,
|
||||||
|
ArrowLeftOutlined,
|
||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
} from "@ant-design/icons-vue"
|
} from "@ant-design/icons-vue"
|
||||||
import type { UploadFile } from "ant-design-vue"
|
import type { UploadFile } from "ant-design-vue"
|
||||||
@ -386,6 +325,19 @@ import dayjs from "dayjs"
|
|||||||
// 获取认证状态
|
// 获取认证状态
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const router = useRouter()
|
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 = [
|
const inputTypeOptions = [
|
||||||
@ -414,14 +366,16 @@ const currentSampleIndex = ref(0)
|
|||||||
// 历史记录
|
// 历史记录
|
||||||
const historyList = ref<AI3DTask[]>([])
|
const historyList = ref<AI3DTask[]>([])
|
||||||
const historyLoading = ref(false)
|
const historyLoading = ref(false)
|
||||||
const showAllHistory = ref(false)
|
|
||||||
const allHistoryList = ref<AI3DTask[]>([])
|
|
||||||
const allHistoryLoading = ref(false)
|
|
||||||
const allHistoryTotal = ref(0)
|
const allHistoryTotal = ref(0)
|
||||||
const allHistoryPage = ref(1)
|
|
||||||
|
// 历史网格容器引用和宽度
|
||||||
|
const historyGridRef = ref<HTMLElement | null>(null)
|
||||||
|
const historyGridWidth = ref(0)
|
||||||
|
|
||||||
// 轮询定时器
|
// 轮询定时器
|
||||||
let pollingTimer: number | null = null
|
let pollingTimer: number | null = null
|
||||||
|
// ResizeObserver 用于监听容器宽度变化
|
||||||
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
// 计算属性
|
// 计算属性
|
||||||
const currentSamples = computed(() => samplePrompts[currentSampleIndex.value])
|
const currentSamples = computed(() => samplePrompts[currentSampleIndex.value])
|
||||||
@ -440,15 +394,23 @@ const canGenerate = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const pagination = computed(() => ({
|
// 计算一行最多能展示的卡片数量
|
||||||
current: allHistoryPage.value,
|
// 卡片宽度 240px + 间距 20px = 260px
|
||||||
pageSize: 10,
|
const maxCardsPerRow = computed(() => {
|
||||||
total: allHistoryTotal.value,
|
if (historyGridWidth.value === 0) return 6 // 默认值
|
||||||
onChange: (page: number) => {
|
const cardWidth = 240
|
||||||
allHistoryPage.value = page
|
const gap = 20
|
||||||
fetchAllHistory()
|
// 可用宽度 = 容器宽度 - 左右 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 = () => {
|
const refreshSamples = () => {
|
||||||
@ -539,6 +501,10 @@ const fetchHistory = async () => {
|
|||||||
const res = await getAI3DTasks({ page: 1, pageSize: 6 })
|
const res = await getAI3DTasks({ page: 1, pageSize: 6 })
|
||||||
const data = res.data || res // 兼容不同的响应格式
|
const data = res.data || res // 兼容不同的响应格式
|
||||||
historyList.value = data.list || []
|
historyList.value = data.list || []
|
||||||
|
// 如果还没有获取过总数,则保存总数
|
||||||
|
if (!allHistoryTotal.value && data.total) {
|
||||||
|
allHistoryTotal.value = data.total
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("获取历史记录失败:", error)
|
console.error("获取历史记录失败:", error)
|
||||||
} finally {
|
} 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 = () => {
|
const startPolling = () => {
|
||||||
if (pollingTimer) return
|
if (pollingTimer) return
|
||||||
@ -591,8 +542,11 @@ const stopPolling = () => {
|
|||||||
// 预览3D模型
|
// 预览3D模型
|
||||||
const handlePreview = (task: AI3DTask) => {
|
const handlePreview = (task: AI3DTask) => {
|
||||||
if (task.resultUrl) {
|
if (task.resultUrl) {
|
||||||
const viewerUrl = `/model-viewer?url=${encodeURIComponent(task.resultUrl)}`
|
const tenantCode = route.params.tenantCode as string
|
||||||
window.open(viewerUrl, "_blank")
|
router.push({
|
||||||
|
path: `/${tenantCode}/workbench/model-viewer`,
|
||||||
|
query: { url: task.resultUrl },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -635,9 +589,6 @@ const handleDelete = (task: AI3DTask) => {
|
|||||||
await deleteAI3DTask(task.id)
|
await deleteAI3DTask(task.id)
|
||||||
message.success("删除成功")
|
message.success("删除成功")
|
||||||
fetchHistory()
|
fetchHistory()
|
||||||
if (showAllHistory.value) {
|
|
||||||
fetchAllHistory()
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error("删除失败")
|
message.error("删除失败")
|
||||||
}
|
}
|
||||||
@ -663,18 +614,6 @@ const getPreviewUrl = (task: AI3DTask) => {
|
|||||||
return ""
|
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 getStatusText = (status: string) => {
|
||||||
const texts: Record<string, string> = {
|
const texts: Record<string, string> = {
|
||||||
@ -705,9 +644,28 @@ const formatTime = (time: string) => {
|
|||||||
return dayjs(time).format("MM-DD HH:mm")
|
return dayjs(time).format("MM-DD HH:mm")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新容器宽度
|
||||||
|
const updateGridWidth = () => {
|
||||||
|
if (historyGridRef.value) {
|
||||||
|
historyGridWidth.value = historyGridRef.value.offsetWidth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 页面加载
|
// 页面加载
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
fetchHistory()
|
await fetchHistory()
|
||||||
|
|
||||||
|
// 等待 DOM 更新后计算容器宽度
|
||||||
|
await nextTick()
|
||||||
|
updateGridWidth()
|
||||||
|
|
||||||
|
// 监听容器宽度变化
|
||||||
|
if (historyGridRef.value) {
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
updateGridWidth()
|
||||||
|
})
|
||||||
|
resizeObserver.observe(historyGridRef.value)
|
||||||
|
}
|
||||||
|
|
||||||
// 检查是否有处理中的任务,有则开启轮询
|
// 检查是否有处理中的任务,有则开启轮询
|
||||||
const hasProcessing = historyList.value.some(
|
const hasProcessing = historyList.value.some(
|
||||||
@ -721,39 +679,54 @@ onMounted(() => {
|
|||||||
// 页面卸载
|
// 页面卸载
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stopPolling()
|
stopPolling()
|
||||||
|
if (resizeObserver) {
|
||||||
|
resizeObserver.disconnect()
|
||||||
|
resizeObserver = null
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Energetic Modern Color Palette
|
// 蓝色主题色彩方案 - 简洁统一
|
||||||
// ==========================================
|
// ==========================================
|
||||||
$primary: #7c3aed;
|
// 主色调 - 蓝色系
|
||||||
$primary-light: #a78bfa;
|
$primary: #0958d9; // 主题蓝色(与系统统一)
|
||||||
$primary-dark: #5b21b6;
|
$primary-light: #1677ff;
|
||||||
$secondary: #06b6d4;
|
$primary-dark: #003eb3;
|
||||||
$accent: #f43f5e;
|
$secondary: #4096ff; // 浅蓝色作为辅助色
|
||||||
$success: #10b981;
|
$accent: #1677ff; // 蓝色强调
|
||||||
$background: #0a0a12;
|
$success: #52c41a;
|
||||||
$surface: #12121e;
|
$warning: #faad14;
|
||||||
$surface-light: #1a1a2e;
|
$error: #ff4d4f;
|
||||||
$text: #e2e8f0;
|
|
||||||
$text-muted: #94a3b8;
|
|
||||||
|
|
||||||
// Gradients
|
// 背景色
|
||||||
$gradient-primary: linear-gradient(135deg, $primary 0%, #ec4899 100%);
|
$background: #f5f5f5; // 浅灰背景(与系统统一)
|
||||||
$gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
$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 {
|
.ai-3d-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: calc(100vh - 64px);
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
background: $background;
|
background: $background;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Animated Background
|
// 简化背景动画 - 保留微妙效果
|
||||||
// ==========================================
|
// ==========================================
|
||||||
.bg-animation {
|
.bg-animation {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@ -766,88 +739,67 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
|||||||
.bg-gradient {
|
.bg-gradient {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
filter: blur(120px);
|
filter: blur(100px);
|
||||||
opacity: 0.4;
|
opacity: 0.15;
|
||||||
animation: float 25s ease-in-out infinite;
|
animation: float 30s ease-in-out infinite;
|
||||||
|
|
||||||
&.bg-gradient-1 {
|
&.bg-gradient-1 {
|
||||||
width: 800px;
|
width: 600px;
|
||||||
height: 800px;
|
height: 600px;
|
||||||
background: $primary;
|
background: $primary;
|
||||||
top: -300px;
|
top: -200px;
|
||||||
left: -200px;
|
left: -100px;
|
||||||
animation-delay: 0s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.bg-gradient-2 {
|
&.bg-gradient-2 {
|
||||||
width: 600px;
|
width: 500px;
|
||||||
height: 600px;
|
height: 500px;
|
||||||
background: $secondary;
|
background: $primary-light;
|
||||||
bottom: -200px;
|
bottom: -150px;
|
||||||
right: -150px;
|
right: -100px;
|
||||||
animation-delay: -8s;
|
animation-delay: -10s;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.bg-gradient-3 {
|
&.bg-gradient-3 {
|
||||||
width: 500px;
|
width: 400px;
|
||||||
height: 500px;
|
height: 400px;
|
||||||
background: $accent;
|
background: $secondary;
|
||||||
top: 40%;
|
top: 50%;
|
||||||
right: 30%;
|
left: 50%;
|
||||||
animation-delay: -16s;
|
transform: translate(-50%, -50%);
|
||||||
opacity: 0.3;
|
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 {
|
@keyframes float {
|
||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
transform: translate(0, 0) scale(1);
|
transform: translate(0, 0) scale(1);
|
||||||
}
|
}
|
||||||
25% {
|
33% {
|
||||||
transform: translate(50px, -50px) scale(1.1);
|
transform: translate(30px, -30px) scale(1.05);
|
||||||
}
|
}
|
||||||
50% {
|
66% {
|
||||||
transform: translate(-30px, 30px) scale(0.9);
|
transform: translate(-20px, 20px) scale(0.95);
|
||||||
}
|
|
||||||
75% {
|
|
||||||
transform: translate(-50px, -30px) scale(1.05);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes gridMove {
|
|
||||||
0% {
|
|
||||||
transform: translate(0, 0);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: translate(50px, 50px);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Left Panel
|
// Left Panel - 浅色变体
|
||||||
// ==========================================
|
// ==========================================
|
||||||
.left-panel {
|
.left-panel {
|
||||||
width: 380px;
|
width: 380px;
|
||||||
height: 100%;
|
min-height: 100vh;
|
||||||
background: rgba($surface, 0.8);
|
background: rgba($surface, 0.5);
|
||||||
backdrop-filter: blur(40px);
|
backdrop-filter: blur(20px);
|
||||||
border-right: 1px solid rgba($primary, 0.1);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex-shrink: 0;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
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 {
|
.panel-logo {
|
||||||
@ -856,6 +808,26 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
border-bottom: 1px solid rgba($primary, 0.1);
|
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 {
|
.logo-ring {
|
||||||
position: relative;
|
position: relative;
|
||||||
@ -909,6 +881,7 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
|||||||
.panel-header {
|
.panel-header {
|
||||||
padding: 20px 24px;
|
padding: 20px 24px;
|
||||||
border-bottom: 1px solid rgba($primary, 0.1);
|
border-bottom: 1px solid rgba($primary, 0.1);
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
:deep(.ant-segmented) {
|
:deep(.ant-segmented) {
|
||||||
background: rgba($surface-light, 0.6);
|
background: rgba($surface-light, 0.6);
|
||||||
@ -1020,7 +993,7 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: $secondary;
|
color: $primary-light;
|
||||||
transform: rotate(180deg);
|
transform: rotate(180deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1042,8 +1015,8 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
|||||||
transition: all 0.3s;
|
transition: all 0.3s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba($secondary, 0.2);
|
background: rgba($primary-light, 0.2);
|
||||||
border-color: $secondary;
|
border-color: $primary-light;
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1057,7 +1030,7 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
|||||||
transition: all 0.3s;
|
transition: all 0.3s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: $secondary !important;
|
border-color: $primary-light !important;
|
||||||
background: rgba($surface-light, 0.8) !important;
|
background: rgba($surface-light, 0.8) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1170,13 +1143,14 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
height: 100vh;
|
||||||
|
background: rgba($surface, 0.3);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.intro-section {
|
.intro-section {
|
||||||
padding: 48px;
|
padding: 48px;
|
||||||
background: rgba($surface, 0.4);
|
flex-shrink: 0;
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
border-bottom: 1px solid rgba($primary, 0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.intro-badge {
|
.intro-badge {
|
||||||
@ -1245,17 +1219,11 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
background: rgba($surface-light, 0.4);
|
background: $surface-light;
|
||||||
backdrop-filter: blur(10px);
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
border: 1px solid rgba($primary, 0.1);
|
border-radius: 8px;
|
||||||
border-radius: 16px;
|
|
||||||
transition: all 0.3s;
|
transition: all 0.3s;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
|
||||||
&:hover {
|
|
||||||
background: rgba($surface-light, 0.6);
|
|
||||||
border-color: rgba($primary, 0.3);
|
|
||||||
transform: translateY(-4px);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-icon {
|
.feature-icon {
|
||||||
@ -1269,16 +1237,16 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
&.gradient-1 {
|
&.gradient-1 {
|
||||||
background: linear-gradient(135deg, $primary 0%, #ec4899 100%);
|
background: linear-gradient(135deg, $primary 0%, $primary-light 100%);
|
||||||
}
|
}
|
||||||
&.gradient-2 {
|
&.gradient-2 {
|
||||||
background: linear-gradient(135deg, $secondary 0%, #10b981 100%);
|
background: linear-gradient(135deg, $primary-light 0%, $accent 100%);
|
||||||
}
|
}
|
||||||
&.gradient-3 {
|
&.gradient-3 {
|
||||||
background: linear-gradient(135deg, $accent 0%, #f59e0b 100%);
|
background: linear-gradient(135deg, $accent 0%, $primary 100%);
|
||||||
}
|
}
|
||||||
&.gradient-4 {
|
&.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 {
|
.history-section {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
padding: 32px 48px;
|
padding: 32px 48px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: rgba($background, 0.5);
|
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
@ -1356,14 +1324,14 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
color: $secondary;
|
color: $primary-light;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
transition: all 0.3s;
|
transition: all 0.3s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: $primary-light;
|
color: $primary;
|
||||||
transform: translateX(4px);
|
transform: translateX(4px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1396,7 +1364,7 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:nth-child(2) {
|
&:nth-child(2) {
|
||||||
border-right-color: $secondary;
|
border-right-color: $primary-light;
|
||||||
animation-duration: 2s;
|
animation-duration: 2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1443,27 +1411,31 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
|||||||
}
|
}
|
||||||
|
|
||||||
.history-grid {
|
.history-grid {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
flex-direction: row;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
|
overflow: hidden; // 移除滚动,只展示一行
|
||||||
|
flex-wrap: nowrap; // 不换行
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-card {
|
.history-card {
|
||||||
background: rgba($surface, 0.6);
|
flex-shrink: 0;
|
||||||
backdrop-filter: blur(20px);
|
width: 240px;
|
||||||
border-radius: 16px;
|
background: $surface;
|
||||||
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.4s;
|
transition: all 0.3s;
|
||||||
border: 1px solid rgba($primary, 0.1);
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: translateY(-8px);
|
transform: translateY(-4px);
|
||||||
border-color: rgba($primary, 0.3);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
|
||||||
.card-glow {
|
.card-glow {
|
||||||
opacity: 0.6;
|
opacity: 0.3;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1471,12 +1443,12 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
|||||||
.card-glow {
|
.card-glow {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: -2px;
|
inset: -2px;
|
||||||
background: $gradient-primary;
|
background: $primary;
|
||||||
border-radius: 18px;
|
border-radius: 10px;
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
filter: blur(20px);
|
filter: blur(8px);
|
||||||
transition: opacity 0.4s;
|
transition: opacity 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-preview {
|
.card-preview {
|
||||||
@ -1515,7 +1487,30 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
|||||||
}
|
}
|
||||||
|
|
||||||
.preview-failed {
|
.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 {
|
span {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
background: $secondary;
|
background: $primary-light;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: dotPulse 1.4s ease-in-out infinite;
|
animation: dotPulse 1.4s ease-in-out infinite;
|
||||||
|
|
||||||
@ -1574,21 +1569,22 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
|||||||
|
|
||||||
&.status-processing,
|
&.status-processing,
|
||||||
&.status-pending {
|
&.status-pending {
|
||||||
background: rgba($secondary, 0.2);
|
background: rgba($primary-light, 0.2);
|
||||||
color: $secondary;
|
color: $primary-light;
|
||||||
border-color: rgba($secondary, 0.3);
|
border-color: rgba($primary-light, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.status-failed,
|
&.status-failed,
|
||||||
&.status-timeout {
|
&.status-timeout {
|
||||||
background: rgba($accent, 0.2);
|
background: rgba($error, 0.2);
|
||||||
color: $accent;
|
color: $error;
|
||||||
border-color: rgba($accent, 0.3);
|
border-color: rgba($error, 0.3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-info {
|
.card-info {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
background: rgba(9, 88, 217, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-desc {
|
.card-desc {
|
||||||
@ -1634,9 +1630,9 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
|||||||
transition: all 0.3s;
|
transition: all 0.3s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba($secondary, 0.2);
|
background: rgba($primary-light, 0.2);
|
||||||
border-color: $secondary;
|
border-color: $primary-light;
|
||||||
color: $secondary;
|
color: $primary-light;
|
||||||
transform: scale(1.1);
|
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
|
// Responsive
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.left-panel {
|
.left-panel {
|
||||||
width: 320px;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.intro-features {
|
.intro-features {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-grid {
|
.history-card {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
width: 200px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1745,8 +1681,6 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
|||||||
|
|
||||||
.left-panel {
|
.left-panel {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-right: none;
|
|
||||||
border-bottom: 1px solid rgba($primary, 0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.intro-section {
|
.intro-section {
|
||||||
@ -1757,8 +1691,8 @@ $gradient-secondary: linear-gradient(135deg, $secondary 0%, $primary 100%);
|
|||||||
padding: 24px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-grid {
|
.history-card {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
width: 180px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user