修改样式

This commit is contained in:
zhangxiaohua 2026-01-14 14:29:16 +08:00
parent 62cdebc388
commit d9abd6939c
14 changed files with 1947 additions and 766 deletions

View 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` - 模型预览页

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,5 +6,6 @@ declare module "vue-router" {
requiresAuth?: boolean; requiresAuth?: boolean;
roles?: string[]; // 需要的角色列表 roles?: string[]; // 需要的角色列表
permissions?: string[]; // 需要的权限列表 permissions?: string[]; // 需要的权限列表
hideSidebar?: boolean; // 是否隐藏侧边栏(全屏模式)
} }
} }

View File

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

View File

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

View File

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

View File

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

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

View File

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