init
This commit is contained in:
21
src/App.vue
Normal file
21
src/App.vue
Normal file
@ -0,0 +1,21 @@
|
||||
|
||||
<template>
|
||||
<Editor />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import MainApp from './view/MainApp.vue'
|
||||
import Editor from './view/Editor.vue'
|
||||
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#app {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
288
src/api/README.md
Normal file
288
src/api/README.md
Normal file
@ -0,0 +1,288 @@
|
||||
# API 架构重构说明
|
||||
|
||||
## 概述
|
||||
|
||||
本次重构将原有的网络请求代码进行了全面的重新设计和组织,提供了更加模块化、类型安全和易于扩展的API架构。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
src/api/
|
||||
├── types/ # 类型定义
|
||||
│ └── index.ts
|
||||
├── config/ # 配置文件
|
||||
│ └── index.ts
|
||||
├── utils/ # 工具函数
|
||||
│ └── index.ts
|
||||
├── core/ # 核心模块
|
||||
│ ├── request.ts # HTTP请求客户端
|
||||
│ ├── upload.ts # 文件上传模块
|
||||
│ └── download.ts # 文件下载模块
|
||||
├── modules/ # 业务API模块
|
||||
│ ├── editor.ts # 编辑器相关API
|
||||
│ └── resource.ts # 资源管理API
|
||||
├── examples/ # 使用示例
|
||||
│ └── usage.ts
|
||||
├── index.ts # 统一入口
|
||||
└── README.md # 说明文档
|
||||
```
|
||||
|
||||
## 主要特性
|
||||
|
||||
### 1. 类型安全
|
||||
- 完整的TypeScript类型定义
|
||||
- 泛型支持,确保请求和响应数据的类型安全
|
||||
- 严格的类型检查
|
||||
|
||||
### 2. 模块化设计
|
||||
- 核心功能与业务逻辑分离
|
||||
- 可插拔的模块设计
|
||||
- 易于扩展和维护
|
||||
|
||||
### 3. 文件上传功能
|
||||
- 普通文件上传
|
||||
- 图片上传(支持压缩)
|
||||
- 大文件分片上传
|
||||
- 批量文件上传
|
||||
- Base64上传
|
||||
- 上传进度监控
|
||||
|
||||
### 4. 文件下载功能
|
||||
- 普通文件下载
|
||||
- 分片下载(大文件)
|
||||
- 批量下载
|
||||
- 下载为不同格式(Blob、Base64、ArrayBuffer、Text)
|
||||
- 下载进度监控
|
||||
|
||||
### 5. 错误处理
|
||||
- 统一的错误处理机制
|
||||
- 自动重试功能
|
||||
- 详细的错误信息
|
||||
|
||||
### 6. 请求管理
|
||||
- 请求拦截器
|
||||
- 响应拦截器
|
||||
- 请求取消功能
|
||||
- 认证token管理
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 基本使用
|
||||
|
||||
```typescript
|
||||
import { editorApi, resourceApi, ApiManager } from '@/api'
|
||||
|
||||
// 设置认证token
|
||||
ApiManager.setAuthToken('your-token')
|
||||
|
||||
// 创建订单
|
||||
const order = await editorApi.createOrder({
|
||||
projectId: 'project-123',
|
||||
type: 'render'
|
||||
})
|
||||
|
||||
// 上传资源
|
||||
const resource = await resourceApi.uploadResource(file, {
|
||||
type: 'model',
|
||||
tags: ['建筑', '现代']
|
||||
})
|
||||
```
|
||||
|
||||
### 文件上传
|
||||
|
||||
```typescript
|
||||
import { fileUploader } from '@/api'
|
||||
|
||||
// 普通文件上传
|
||||
const result = await fileUploader.uploadFile({
|
||||
file,
|
||||
onProgress: (progress) => {
|
||||
console.log(`上传进度: ${progress}%`)
|
||||
}
|
||||
})
|
||||
|
||||
// 图片上传(带压缩)
|
||||
const imageResult = await fileUploader.uploadImage(file, {
|
||||
compress: true,
|
||||
quality: 0.8,
|
||||
maxWidth: 1920,
|
||||
maxHeight: 1080
|
||||
})
|
||||
|
||||
// 大文件分片上传
|
||||
const largeFileResult = await fileUploader.uploadLargeFile(file, {
|
||||
chunkSize: 2 * 1024 * 1024, // 2MB per chunk
|
||||
onProgress: (progress) => {
|
||||
console.log(`上传进度: ${progress}%`)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 文件下载
|
||||
|
||||
```typescript
|
||||
import { fileDownloader } from '@/api'
|
||||
|
||||
// 下载并保存文件
|
||||
await fileDownloader.downloadAndSave({
|
||||
url: 'https://example.com/file.zip',
|
||||
filename: 'download.zip'
|
||||
})
|
||||
|
||||
// 下载为Base64
|
||||
const base64 = await fileDownloader.downloadAsBase64({
|
||||
url: 'https://example.com/image.jpg'
|
||||
})
|
||||
|
||||
// 批量下载
|
||||
const result = await fileDownloader.downloadBatch([
|
||||
'https://example.com/file1.zip',
|
||||
'https://example.com/file2.zip'
|
||||
], {
|
||||
concurrent: 3,
|
||||
onProgress: (progress) => {
|
||||
console.log(`批量下载进度: ${progress}%`)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## API 模块
|
||||
|
||||
### 编辑器API (EditorApi)
|
||||
|
||||
提供编辑器相关的功能:
|
||||
|
||||
- `createOrder(params)` - 创建订单
|
||||
- `saveProject(projectData)` - 保存项目
|
||||
- `loadProject(projectId)` - 加载项目
|
||||
- `deleteProject(projectId)` - 删除项目
|
||||
- `getProjectList(params)` - 获取项目列表
|
||||
- `uploadThumbnail(file, projectId)` - 上传缩略图
|
||||
- `exportProject(projectId, format)` - 导出项目
|
||||
- `importProject(file)` - 导入项目
|
||||
|
||||
### 资源API (ResourceApi)
|
||||
|
||||
提供资源管理功能:
|
||||
|
||||
- `getResourceList(params)` - 获取资源列表
|
||||
- `getResourceDetail(resourceId)` - 获取资源详情
|
||||
- `uploadResource(file, metadata)` - 上传资源
|
||||
- `uploadMultipleResources(files, metadata)` - 批量上传资源
|
||||
- `deleteResource(resourceId)` - 删除资源
|
||||
- `downloadResource(resource)` - 下载资源
|
||||
- `searchResources(params)` - 搜索资源
|
||||
|
||||
## 配置
|
||||
|
||||
### 环境变量
|
||||
|
||||
```bash
|
||||
# .env.development
|
||||
VITE_BASE_URL=http://localhost:3000/api
|
||||
|
||||
# .env.production
|
||||
VITE_BASE_URL=https://api.example.com
|
||||
```
|
||||
|
||||
### API配置
|
||||
|
||||
```typescript
|
||||
// src/api/config/index.ts
|
||||
export const API_CONFIG = {
|
||||
BASE_URL: import.meta.env.VITE_BASE_URL,
|
||||
TIMEOUT: 15000,
|
||||
RETRY_COUNT: 3,
|
||||
RETRY_DELAY: 1000,
|
||||
// ...其他配置
|
||||
}
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
API会自动处理常见的错误情况:
|
||||
|
||||
- 网络连接失败
|
||||
- 请求超时
|
||||
- 认证失败
|
||||
- 权限不足
|
||||
- 服务器错误
|
||||
|
||||
所有API方法都返回统一的响应格式:
|
||||
|
||||
```typescript
|
||||
interface ApiResponse<T> {
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
success: boolean
|
||||
}
|
||||
```
|
||||
|
||||
## 扩展性
|
||||
|
||||
### 添加新的API模块
|
||||
|
||||
1. 在 `src/api/modules/` 目录下创建新的模块文件
|
||||
2. 定义相关的类型接口
|
||||
3. 实现API类
|
||||
4. 在 `src/api/index.ts` 中导出
|
||||
|
||||
### 添加新的请求拦截器
|
||||
|
||||
```typescript
|
||||
import { httpClient } from '@/api'
|
||||
|
||||
// 添加请求拦截器
|
||||
httpClient.instance.interceptors.request.use(
|
||||
(config) => {
|
||||
// 自定义请求处理
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
## 迁移指南
|
||||
|
||||
### 从旧API迁移
|
||||
|
||||
旧的API调用:
|
||||
```typescript
|
||||
// 旧方式
|
||||
const request = new Request()
|
||||
request.post(new ParamsReq('/api/endpoint', data))
|
||||
```
|
||||
|
||||
新的API调用:
|
||||
```typescript
|
||||
// 新方式
|
||||
import { httpClient } from '@/api'
|
||||
const response = await httpClient.post('/api/endpoint', data)
|
||||
```
|
||||
|
||||
### 兼容性
|
||||
|
||||
新的API架构与旧代码完全分离,可以逐步迁移,不会影响现有功能。
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **使用TypeScript类型**:充分利用类型定义,确保代码的类型安全
|
||||
2. **错误处理**:始终处理API调用可能出现的错误
|
||||
3. **进度监控**:对于文件上传下载,使用进度回调提升用户体验
|
||||
4. **请求取消**:在组件卸载时取消未完成的请求
|
||||
5. **缓存策略**:对于不经常变化的数据,考虑实现缓存机制
|
||||
|
||||
## 性能优化
|
||||
|
||||
1. **并发控制**:批量操作时控制并发数量
|
||||
2. **分片上传**:大文件使用分片上传
|
||||
3. **压缩优化**:图片上传时自动压缩
|
||||
4. **请求去重**:避免重复请求
|
||||
5. **缓存机制**:合理使用缓存减少网络请求
|
||||
|
||||
## 示例代码
|
||||
|
||||
详细的使用示例请参考 `src/api/examples/usage.ts` 文件。
|
||||
83
src/api/config/index.ts
Normal file
83
src/api/config/index.ts
Normal file
@ -0,0 +1,83 @@
|
||||
// API 基础配置
|
||||
export const API_CONFIG = {
|
||||
// 基础URL
|
||||
BASE_URL: import.meta.env.VITE_BASE_URL || 'https://script.jiaweijia.cn/script',
|
||||
|
||||
// 超时时间
|
||||
TIMEOUT: 15000,
|
||||
|
||||
// 重试次数
|
||||
RETRY_COUNT: 3,
|
||||
|
||||
// 重试延迟(毫秒)
|
||||
RETRY_DELAY: 1000,
|
||||
|
||||
// 默认请求头
|
||||
DEFAULT_HEADERS: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
|
||||
// 文件上传请求头
|
||||
UPLOAD_HEADERS: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
|
||||
// 响应状态码
|
||||
STATUS_CODES: {
|
||||
SUCCESS: 200,
|
||||
UNAUTHORIZED: 401,
|
||||
FORBIDDEN: 403,
|
||||
NOT_FOUND: 404,
|
||||
SERVER_ERROR: 500,
|
||||
},
|
||||
|
||||
// 业务状态码
|
||||
BUSINESS_CODES: {
|
||||
SUCCESS: 0,
|
||||
FAIL: -1,
|
||||
TOKEN_EXPIRED: 401,
|
||||
PERMISSION_DENIED: 403,
|
||||
}
|
||||
}
|
||||
|
||||
// 环境配置
|
||||
export const ENV_CONFIG = {
|
||||
isDev: import.meta.env.DEV,
|
||||
isProd: import.meta.env.PROD,
|
||||
mode: import.meta.env.MODE,
|
||||
}
|
||||
|
||||
// API 端点配置
|
||||
export const API_ENDPOINTS = {
|
||||
// 编辑器相关
|
||||
EDITOR: {
|
||||
CREATE_ORDER: '/cpq/3d/create/order',
|
||||
SAVE_PROJECT: '/editor/project/save',
|
||||
LOAD_PROJECT: '/editor/project/load',
|
||||
DELETE_PROJECT: '/editor/project/delete',
|
||||
},
|
||||
|
||||
// 文件相关
|
||||
FILE: {
|
||||
UPLOAD: '/file/upload',
|
||||
DOWNLOAD: '/file/download',
|
||||
DELETE: '/file/delete',
|
||||
LIST: '/file/list',
|
||||
},
|
||||
|
||||
// 用户相关
|
||||
USER: {
|
||||
LOGIN: '/user/login',
|
||||
LOGOUT: '/user/logout',
|
||||
INFO: '/user/info',
|
||||
UPDATE: '/user/update',
|
||||
},
|
||||
|
||||
// 资源相关
|
||||
RESOURCE: {
|
||||
LIST: '/resource/list',
|
||||
UPLOAD: '/resource/upload',
|
||||
DELETE: '/resource/delete',
|
||||
}
|
||||
}
|
||||
377
src/api/core/download.ts
Normal file
377
src/api/core/download.ts
Normal file
@ -0,0 +1,377 @@
|
||||
import { httpClient } from './request'
|
||||
import { DownloadConfig, ApiResponse } from '../types'
|
||||
import { API_ENDPOINTS } from '../config'
|
||||
import { downloadFile, generateId } from '../utils'
|
||||
|
||||
/**
|
||||
* 下载响应类型
|
||||
*/
|
||||
export interface DownloadResponse {
|
||||
blob: Blob
|
||||
filename: string
|
||||
size: number
|
||||
type: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量下载响应类型
|
||||
*/
|
||||
export interface BatchDownloadResponse {
|
||||
success: DownloadResponse[]
|
||||
failed: Array<{
|
||||
url: string
|
||||
error: string
|
||||
}>
|
||||
total: number
|
||||
successCount: number
|
||||
failedCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件下载类
|
||||
*/
|
||||
export class FileDownloader {
|
||||
private downloadQueue: Map<string, AbortController> = new Map()
|
||||
|
||||
/**
|
||||
* 下载文件
|
||||
*/
|
||||
public async downloadFile(config: DownloadConfig): Promise<DownloadResponse> {
|
||||
const {
|
||||
url,
|
||||
filename,
|
||||
params,
|
||||
onProgress
|
||||
} = config
|
||||
|
||||
// 生成下载ID
|
||||
const downloadId = generateId()
|
||||
const controller = new AbortController()
|
||||
this.downloadQueue.set(downloadId, controller)
|
||||
|
||||
try {
|
||||
const response = await httpClient.request<Blob>({
|
||||
url,
|
||||
method: 'GET',
|
||||
params,
|
||||
responseType: 'blob',
|
||||
timeout: 0, // 下载不设置超时
|
||||
})
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.message)
|
||||
}
|
||||
|
||||
const blob = response.data
|
||||
const contentType = blob.type || 'application/octet-stream'
|
||||
|
||||
// 从响应头或URL中获取文件名
|
||||
const finalFilename = filename || this.extractFilenameFromUrl(url) || 'download'
|
||||
|
||||
return {
|
||||
blob,
|
||||
filename: finalFilename,
|
||||
size: blob.size,
|
||||
type: contentType
|
||||
}
|
||||
} finally {
|
||||
this.downloadQueue.delete(downloadId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载并保存文件
|
||||
*/
|
||||
public async downloadAndSave(config: DownloadConfig): Promise<void> {
|
||||
const result = await this.downloadFile(config)
|
||||
downloadFile(result.blob, result.filename)
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件为Base64
|
||||
*/
|
||||
public async downloadAsBase64(config: DownloadConfig): Promise<string> {
|
||||
const result = await this.downloadFile(config)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const base64 = reader.result as string
|
||||
resolve(base64.split(',')[1]) // 移除data:xxx;base64,前缀
|
||||
}
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(result.blob)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件为ArrayBuffer
|
||||
*/
|
||||
public async downloadAsArrayBuffer(config: DownloadConfig): Promise<ArrayBuffer> {
|
||||
const result = await this.downloadFile(config)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as ArrayBuffer)
|
||||
reader.onerror = reject
|
||||
reader.readAsArrayBuffer(result.blob)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件为文本
|
||||
*/
|
||||
public async downloadAsText(config: DownloadConfig): Promise<string> {
|
||||
const result = await this.downloadFile(config)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.onerror = reject
|
||||
reader.readAsText(result.blob)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 分片下载(大文件)
|
||||
*/
|
||||
public async downloadLargeFile(
|
||||
config: DownloadConfig & {
|
||||
chunkSize?: number
|
||||
onChunkProgress?: (chunkIndex: number, chunkProgress: number) => void
|
||||
}
|
||||
): Promise<DownloadResponse> {
|
||||
const {
|
||||
url,
|
||||
filename,
|
||||
params,
|
||||
chunkSize = 2 * 1024 * 1024, // 2MB per chunk
|
||||
onProgress,
|
||||
onChunkProgress
|
||||
} = config
|
||||
|
||||
try {
|
||||
// 获取文件信息
|
||||
const headResponse = await httpClient.request({
|
||||
url,
|
||||
method: 'HEAD',
|
||||
params
|
||||
})
|
||||
|
||||
if (!headResponse.success) {
|
||||
throw new Error('无法获取文件信息')
|
||||
}
|
||||
|
||||
// 这里需要根据实际的响应头来获取文件大小
|
||||
// const fileSize = parseInt(headResponse.headers['content-length'] || '0')
|
||||
const fileSize = 0 // 临时设置,实际应该从响应头获取
|
||||
|
||||
if (fileSize === 0) {
|
||||
// 如果无法获取文件大小,回退到普通下载
|
||||
return this.downloadFile(config)
|
||||
}
|
||||
|
||||
const totalChunks = Math.ceil(fileSize / chunkSize)
|
||||
const chunks: Blob[] = []
|
||||
|
||||
// 下载分片
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
const start = i * chunkSize
|
||||
const end = Math.min(start + chunkSize - 1, fileSize - 1)
|
||||
|
||||
const chunkResponse = await httpClient.request<Blob>({
|
||||
url,
|
||||
method: 'GET',
|
||||
params,
|
||||
headers: {
|
||||
'Range': `bytes=${start}-${end}`
|
||||
},
|
||||
responseType: 'blob'
|
||||
})
|
||||
|
||||
if (chunkResponse.success) {
|
||||
chunks.push(chunkResponse.data)
|
||||
|
||||
// 分片进度回调
|
||||
onChunkProgress?.(i, 100)
|
||||
|
||||
// 总进度回调
|
||||
const progress = ((i + 1) / totalChunks) * 100
|
||||
onProgress?.(progress)
|
||||
} else {
|
||||
throw new Error(`分片 ${i} 下载失败: ${chunkResponse.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 合并分片
|
||||
const blob = new Blob(chunks)
|
||||
const finalFilename = filename || this.extractFilenameFromUrl(url) || 'download'
|
||||
|
||||
return {
|
||||
blob,
|
||||
filename: finalFilename,
|
||||
size: blob.size,
|
||||
type: blob.type || 'application/octet-stream'
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`分片下载失败: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量下载
|
||||
*/
|
||||
public async downloadBatch(
|
||||
urls: string[],
|
||||
options: {
|
||||
concurrent?: number
|
||||
onProgress?: (progress: number) => void
|
||||
onFileProgress?: (url: string, progress: number) => void
|
||||
onFileComplete?: (url: string, result: DownloadResponse | Error) => void
|
||||
} = {}
|
||||
): Promise<BatchDownloadResponse> {
|
||||
const {
|
||||
concurrent = 3,
|
||||
onProgress,
|
||||
onFileProgress,
|
||||
onFileComplete
|
||||
} = options
|
||||
|
||||
const results: BatchDownloadResponse = {
|
||||
success: [],
|
||||
failed: [],
|
||||
total: urls.length,
|
||||
successCount: 0,
|
||||
failedCount: 0
|
||||
}
|
||||
|
||||
const downloadUrl = async (url: string): Promise<void> => {
|
||||
try {
|
||||
const response = await this.downloadFile({
|
||||
url,
|
||||
onProgress: (progress) => onFileProgress?.(url, progress)
|
||||
})
|
||||
|
||||
results.success.push(response)
|
||||
results.successCount++
|
||||
onFileComplete?.(url, response)
|
||||
} catch (error) {
|
||||
results.failed.push({
|
||||
url,
|
||||
error: error instanceof Error ? error.message : '下载失败'
|
||||
})
|
||||
results.failedCount++
|
||||
onFileComplete?.(url, error as Error)
|
||||
}
|
||||
|
||||
// 更新总进度
|
||||
const completed = results.successCount + results.failedCount
|
||||
const progress = (completed / results.total) * 100
|
||||
onProgress?.(progress)
|
||||
}
|
||||
|
||||
// 并发下载
|
||||
const chunks = []
|
||||
for (let i = 0; i < urls.length; i += concurrent) {
|
||||
chunks.push(urls.slice(i, i + concurrent))
|
||||
}
|
||||
|
||||
for (const chunk of chunks) {
|
||||
await Promise.all(chunk.map(downloadUrl))
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量下载并打包为ZIP
|
||||
*/
|
||||
public async downloadAsZip(
|
||||
files: Array<{
|
||||
url: string
|
||||
filename?: string
|
||||
}>,
|
||||
zipFilename: string = 'download.zip'
|
||||
): Promise<void> {
|
||||
try {
|
||||
// 下载所有文件
|
||||
const downloadPromises = files.map(async (file) => {
|
||||
const result = await this.downloadFile({
|
||||
url: file.url,
|
||||
filename: file.filename
|
||||
})
|
||||
return {
|
||||
filename: result.filename,
|
||||
blob: result.blob
|
||||
}
|
||||
})
|
||||
|
||||
const downloadedFiles = await Promise.all(downloadPromises)
|
||||
|
||||
// 这里需要使用JSZip库来创建ZIP文件
|
||||
// 由于没有导入JSZip,这里只是示例代码
|
||||
console.log('需要集成JSZip库来实现ZIP打包功能')
|
||||
console.log('下载的文件:', downloadedFiles)
|
||||
|
||||
// 示例:如果有JSZip
|
||||
// const JSZip = (await import('jszip')).default
|
||||
// const zip = new JSZip()
|
||||
//
|
||||
// downloadedFiles.forEach(file => {
|
||||
// zip.file(file.filename, file.blob)
|
||||
// })
|
||||
//
|
||||
// const zipBlob = await zip.generateAsync({ type: 'blob' })
|
||||
// downloadFile(zipBlob, zipFilename)
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`ZIP下载失败: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从URL中提取文件名
|
||||
*/
|
||||
private extractFilenameFromUrl(url: string): string {
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
const pathname = urlObj.pathname
|
||||
const filename = pathname.split('/').pop()
|
||||
return filename || 'download'
|
||||
} catch {
|
||||
return 'download'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消下载
|
||||
*/
|
||||
public cancelDownload(downloadId: string): void {
|
||||
const controller = this.downloadQueue.get(downloadId)
|
||||
if (controller) {
|
||||
controller.abort()
|
||||
this.downloadQueue.delete(downloadId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消所有下载
|
||||
*/
|
||||
public cancelAllDownloads(): void {
|
||||
this.downloadQueue.forEach((controller) => {
|
||||
controller.abort()
|
||||
})
|
||||
this.downloadQueue.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取下载进度
|
||||
*/
|
||||
public getDownloadProgress(downloadId: string): number {
|
||||
// 这里可以实现获取具体下载进度的逻辑
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// 创建默认实例
|
||||
export const fileDownloader = new FileDownloader()
|
||||
344
src/api/core/request.ts
Normal file
344
src/api/core/request.ts
Normal file
@ -0,0 +1,344 @@
|
||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'
|
||||
import { ApiResponse, RequestConfig, ApiError } from '../types'
|
||||
import { API_CONFIG, ENV_CONFIG } from '../config'
|
||||
import { createApiError, retry } from '../utils'
|
||||
|
||||
/**
|
||||
* HTTP请求客户端类
|
||||
*/
|
||||
export class HttpClient {
|
||||
private instance: AxiosInstance
|
||||
private requestQueue: Map<string, AbortController> = new Map()
|
||||
|
||||
constructor() {
|
||||
this.instance = this.createInstance()
|
||||
this.setupInterceptors()
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建axios实例
|
||||
*/
|
||||
private createInstance(): AxiosInstance {
|
||||
return axios.create({
|
||||
baseURL: API_CONFIG.BASE_URL,
|
||||
timeout: API_CONFIG.TIMEOUT,
|
||||
headers: API_CONFIG.DEFAULT_HEADERS,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置拦截器
|
||||
*/
|
||||
private setupInterceptors(): void {
|
||||
// 请求拦截器
|
||||
this.instance.interceptors.request.use(
|
||||
(config) => {
|
||||
// 添加请求ID用于取消请求
|
||||
const requestId = this.generateRequestId(config)
|
||||
config.metadata = { requestId }
|
||||
|
||||
// 添加认证token
|
||||
const token = this.getAuthToken()
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
// 开发环境下打印请求信息
|
||||
if (ENV_CONFIG.isDev) {
|
||||
console.log('🚀 Request:', {
|
||||
url: config.url,
|
||||
method: config.method,
|
||||
params: config.params,
|
||||
data: config.data,
|
||||
})
|
||||
}
|
||||
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(this.handleError(error))
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
this.instance.interceptors.response.use(
|
||||
(response) => {
|
||||
// 清理请求队列
|
||||
const requestId = response.config.metadata?.requestId
|
||||
if (requestId) {
|
||||
this.requestQueue.delete(requestId)
|
||||
}
|
||||
|
||||
// 开发环境下打印响应信息
|
||||
if (ENV_CONFIG.isDev) {
|
||||
console.log('✅ Response:', {
|
||||
url: response.config.url,
|
||||
status: response.status,
|
||||
data: response.data,
|
||||
})
|
||||
}
|
||||
|
||||
// 直接返回response,在具体的请求方法中处理数据格式
|
||||
return response
|
||||
},
|
||||
(error) => {
|
||||
// 清理请求队列
|
||||
const requestId = error.config?.metadata?.requestId
|
||||
if (requestId) {
|
||||
this.requestQueue.delete(requestId)
|
||||
}
|
||||
|
||||
return Promise.reject(this.handleError(error))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理响应数据
|
||||
*/
|
||||
private handleResponse<T>(response: AxiosResponse): ApiResponse<T> {
|
||||
const { data } = response
|
||||
|
||||
// 如果后端返回的数据已经是标准格式
|
||||
if (data && typeof data.code !== 'undefined') {
|
||||
return {
|
||||
code: data.code,
|
||||
message: data.message || 'Success',
|
||||
data: data.data,
|
||||
success: data.code === API_CONFIG.BUSINESS_CODES.SUCCESS,
|
||||
}
|
||||
}
|
||||
|
||||
// 否则包装成标准格式
|
||||
return {
|
||||
code: API_CONFIG.BUSINESS_CODES.SUCCESS,
|
||||
message: 'Success',
|
||||
data: data,
|
||||
success: true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理错误
|
||||
*/
|
||||
private handleError(error: any): ApiError {
|
||||
if (ENV_CONFIG.isDev) {
|
||||
console.error('❌ Request Error:', error)
|
||||
}
|
||||
|
||||
// 请求被取消
|
||||
if (axios.isCancel(error)) {
|
||||
return createApiError(-1, '请求已取消')
|
||||
}
|
||||
|
||||
// 网络错误
|
||||
if (!error.response) {
|
||||
return createApiError(-1, '网络连接失败,请检查网络设置')
|
||||
}
|
||||
|
||||
const { status, data } = error.response as AxiosResponse
|
||||
|
||||
// 根据HTTP状态码处理
|
||||
switch (status) {
|
||||
case API_CONFIG.STATUS_CODES.UNAUTHORIZED:
|
||||
this.handleUnauthorized()
|
||||
return createApiError(status, '登录已过期,请重新登录')
|
||||
|
||||
case API_CONFIG.STATUS_CODES.FORBIDDEN:
|
||||
return createApiError(status, '没有权限访问该资源')
|
||||
|
||||
case API_CONFIG.STATUS_CODES.NOT_FOUND:
|
||||
return createApiError(status, '请求的资源不存在')
|
||||
|
||||
case API_CONFIG.STATUS_CODES.SERVER_ERROR:
|
||||
return createApiError(status, '服务器内部错误')
|
||||
|
||||
default:
|
||||
return createApiError(
|
||||
status,
|
||||
data?.message || `请求失败 (${status})`,
|
||||
data
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理未授权
|
||||
*/
|
||||
private handleUnauthorized(): void {
|
||||
// 清除token
|
||||
this.clearAuthToken()
|
||||
|
||||
// 可以在这里添加跳转到登录页的逻辑
|
||||
// router.push('/login')
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成请求ID
|
||||
*/
|
||||
private generateRequestId(config: AxiosRequestConfig): string {
|
||||
return `${config.method}_${config.url}_${Date.now()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取认证token
|
||||
*/
|
||||
private getAuthToken(): string | null {
|
||||
return localStorage.getItem('auth_token')
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除认证token
|
||||
*/
|
||||
private clearAuthToken(): void {
|
||||
localStorage.removeItem('auth_token')
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置认证token
|
||||
*/
|
||||
public setAuthToken(token: string): void {
|
||||
localStorage.setItem('auth_token', token)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用请求方法
|
||||
*/
|
||||
public async request<T = any>(config: RequestConfig): Promise<ApiResponse<T>> {
|
||||
const axiosConfig: AxiosRequestConfig = {
|
||||
url: config.url,
|
||||
method: config.method || 'GET',
|
||||
params: config.params,
|
||||
data: config.data,
|
||||
headers: config.headers,
|
||||
timeout: config.timeout,
|
||||
responseType: config.responseType || 'json',
|
||||
}
|
||||
|
||||
const response = await this.instance.request(axiosConfig)
|
||||
return this.handleResponse<T>(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* 带重试的请求方法
|
||||
*/
|
||||
public async requestWithRetry<T = any>(
|
||||
config: RequestConfig,
|
||||
retries?: number
|
||||
): Promise<ApiResponse<T>> {
|
||||
return retry(() => this.request<T>(config), retries)
|
||||
}
|
||||
|
||||
/**
|
||||
* GET请求
|
||||
*/
|
||||
public get<T = any>(
|
||||
url: string,
|
||||
params?: Record<string, any>,
|
||||
config?: Partial<RequestConfig>
|
||||
): Promise<ApiResponse<T>> {
|
||||
return this.request<T>({
|
||||
url,
|
||||
method: 'GET',
|
||||
params,
|
||||
...config,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* POST请求
|
||||
*/
|
||||
public post<T = any>(
|
||||
url: string,
|
||||
data?: any,
|
||||
config?: Partial<RequestConfig>
|
||||
): Promise<ApiResponse<T>> {
|
||||
return this.request<T>({
|
||||
url,
|
||||
method: 'POST',
|
||||
data,
|
||||
...config,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT请求
|
||||
*/
|
||||
public put<T = any>(
|
||||
url: string,
|
||||
data?: any,
|
||||
config?: Partial<RequestConfig>
|
||||
): Promise<ApiResponse<T>> {
|
||||
return this.request<T>({
|
||||
url,
|
||||
method: 'PUT',
|
||||
data,
|
||||
...config,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE请求
|
||||
*/
|
||||
public delete<T = any>(
|
||||
url: string,
|
||||
params?: Record<string, any>,
|
||||
config?: Partial<RequestConfig>
|
||||
): Promise<ApiResponse<T>> {
|
||||
return this.request<T>({
|
||||
url,
|
||||
method: 'DELETE',
|
||||
params,
|
||||
...config,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH请求
|
||||
*/
|
||||
public patch<T = any>(
|
||||
url: string,
|
||||
data?: any,
|
||||
config?: Partial<RequestConfig>
|
||||
): Promise<ApiResponse<T>> {
|
||||
return this.request<T>({
|
||||
url,
|
||||
method: 'PATCH',
|
||||
data,
|
||||
...config,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消请求
|
||||
*/
|
||||
public cancelRequest(requestId: string): void {
|
||||
const controller = this.requestQueue.get(requestId)
|
||||
if (controller) {
|
||||
controller.abort()
|
||||
this.requestQueue.delete(requestId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消所有请求
|
||||
*/
|
||||
public cancelAllRequests(): void {
|
||||
this.requestQueue.forEach((controller) => {
|
||||
controller.abort()
|
||||
})
|
||||
this.requestQueue.clear()
|
||||
}
|
||||
}
|
||||
|
||||
// 创建默认实例
|
||||
export const httpClient = new HttpClient()
|
||||
|
||||
// 扩展axios配置类型
|
||||
declare module 'axios' {
|
||||
interface AxiosRequestConfig {
|
||||
metadata?: {
|
||||
requestId: string
|
||||
}
|
||||
}
|
||||
}
|
||||
362
src/api/core/upload.ts
Normal file
362
src/api/core/upload.ts
Normal file
@ -0,0 +1,362 @@
|
||||
import { AxiosRequestConfig } from 'axios'
|
||||
import { httpClient } from './request'
|
||||
import { UploadConfig, ApiResponse } from '../types'
|
||||
import { API_CONFIG, API_ENDPOINTS } from '../config'
|
||||
import {
|
||||
validateFileType,
|
||||
formatFileSize,
|
||||
compressImage,
|
||||
readFileAsBase64,
|
||||
generateId
|
||||
} from '../utils'
|
||||
|
||||
/**
|
||||
* 文件上传响应类型
|
||||
*/
|
||||
export interface UploadResponse {
|
||||
fileId: string
|
||||
filename: string
|
||||
url: string
|
||||
size: number
|
||||
type: string
|
||||
uploadTime: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量上传响应类型
|
||||
*/
|
||||
export interface BatchUploadResponse {
|
||||
success: UploadResponse[]
|
||||
failed: Array<{
|
||||
file: File
|
||||
error: string
|
||||
}>
|
||||
total: number
|
||||
successCount: number
|
||||
failedCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件上传类
|
||||
*/
|
||||
export class FileUploader {
|
||||
private uploadQueue: Map<string, AbortController> = new Map()
|
||||
|
||||
/**
|
||||
* 单文件上传
|
||||
*/
|
||||
public async uploadFile(config: UploadConfig): Promise<ApiResponse<UploadResponse>> {
|
||||
const {
|
||||
file,
|
||||
filename = file instanceof File ? file.name : 'blob',
|
||||
fieldName = 'file',
|
||||
data = {},
|
||||
onProgress
|
||||
} = config
|
||||
|
||||
// 创建FormData
|
||||
const formData = new FormData()
|
||||
formData.append(fieldName, file, filename)
|
||||
|
||||
// 添加额外数据
|
||||
Object.keys(data).forEach(key => {
|
||||
formData.append(key, data[key])
|
||||
})
|
||||
|
||||
// 生成上传ID
|
||||
const uploadId = generateId()
|
||||
const controller = new AbortController()
|
||||
this.uploadQueue.set(uploadId, controller)
|
||||
|
||||
try {
|
||||
const response = await httpClient.request<UploadResponse>({
|
||||
url: API_ENDPOINTS.FILE.UPLOAD,
|
||||
method: 'POST',
|
||||
data: formData,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
timeout: 0, // 上传不设置超时
|
||||
responseType: 'json',
|
||||
})
|
||||
|
||||
return response
|
||||
} finally {
|
||||
this.uploadQueue.delete(uploadId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片上传(带压缩)
|
||||
*/
|
||||
public async uploadImage(
|
||||
file: File,
|
||||
options: {
|
||||
compress?: boolean
|
||||
quality?: number
|
||||
maxWidth?: number
|
||||
maxHeight?: number
|
||||
allowedTypes?: string[]
|
||||
maxSize?: number
|
||||
onProgress?: (progress: number) => void
|
||||
} = {}
|
||||
): Promise<ApiResponse<UploadResponse>> {
|
||||
const {
|
||||
compress = true,
|
||||
quality = 0.8,
|
||||
maxWidth = 1920,
|
||||
maxHeight = 1080,
|
||||
allowedTypes = ['jpg', 'jpeg', 'png', 'gif', 'webp'],
|
||||
maxSize = 10 * 1024 * 1024, // 10MB
|
||||
onProgress
|
||||
} = options
|
||||
|
||||
// 验证文件类型
|
||||
if (!validateFileType(file, allowedTypes)) {
|
||||
throw new Error(`不支持的文件类型,仅支持: ${allowedTypes.join(', ')}`)
|
||||
}
|
||||
|
||||
// 验证文件大小
|
||||
if (file.size > maxSize) {
|
||||
throw new Error(`文件大小超出限制,最大支持: ${formatFileSize(maxSize)}`)
|
||||
}
|
||||
|
||||
let uploadFile = file
|
||||
|
||||
// 压缩图片
|
||||
if (compress && file.type.startsWith('image/')) {
|
||||
try {
|
||||
const compressedBlob = await compressImage(file, quality, maxWidth, maxHeight)
|
||||
uploadFile = new File([compressedBlob], file.name, { type: file.type })
|
||||
} catch (error) {
|
||||
console.warn('图片压缩失败,使用原文件上传:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return this.uploadFile({
|
||||
file: uploadFile,
|
||||
onProgress
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64上传
|
||||
*/
|
||||
public async uploadBase64(
|
||||
file: File,
|
||||
options: {
|
||||
onProgress?: (progress: number) => void
|
||||
} = {}
|
||||
): Promise<ApiResponse<UploadResponse>> {
|
||||
const { onProgress } = options
|
||||
|
||||
try {
|
||||
const base64 = await readFileAsBase64(file)
|
||||
|
||||
const response = await httpClient.post<UploadResponse>(
|
||||
API_ENDPOINTS.FILE.UPLOAD,
|
||||
{
|
||||
filename: file.name,
|
||||
content: base64,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
encoding: 'base64'
|
||||
}
|
||||
)
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
throw new Error(`Base64上传失败: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 分片上传(大文件)
|
||||
*/
|
||||
public async uploadLargeFile(
|
||||
file: File,
|
||||
options: {
|
||||
chunkSize?: number
|
||||
onProgress?: (progress: number) => void
|
||||
onChunkProgress?: (chunkIndex: number, chunkProgress: number) => void
|
||||
} = {}
|
||||
): Promise<ApiResponse<UploadResponse>> {
|
||||
const {
|
||||
chunkSize = 2 * 1024 * 1024, // 2MB per chunk
|
||||
onProgress,
|
||||
onChunkProgress
|
||||
} = options
|
||||
|
||||
const totalChunks = Math.ceil(file.size / chunkSize)
|
||||
const uploadId = generateId()
|
||||
const uploadedChunks: string[] = []
|
||||
|
||||
try {
|
||||
// 初始化分片上传
|
||||
const initResponse = await httpClient.post('/file/upload/init', {
|
||||
filename: file.name,
|
||||
fileSize: file.size,
|
||||
totalChunks,
|
||||
uploadId
|
||||
})
|
||||
|
||||
if (!initResponse.success) {
|
||||
throw new Error(initResponse.message)
|
||||
}
|
||||
|
||||
// 上传分片
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
const start = i * chunkSize
|
||||
const end = Math.min(start + chunkSize, file.size)
|
||||
const chunk = file.slice(start, end)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('chunk', chunk)
|
||||
formData.append('chunkIndex', i.toString())
|
||||
formData.append('uploadId', uploadId)
|
||||
|
||||
const chunkResponse = await httpClient.post('/file/upload/chunk', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
}
|
||||
})
|
||||
|
||||
if (chunkResponse.success) {
|
||||
uploadedChunks.push(chunkResponse.data.chunkId)
|
||||
|
||||
// 分片进度回调
|
||||
onChunkProgress?.(i, 100)
|
||||
|
||||
// 总进度回调
|
||||
const progress = ((i + 1) / totalChunks) * 100
|
||||
onProgress?.(progress)
|
||||
} else {
|
||||
throw new Error(`分片 ${i} 上传失败: ${chunkResponse.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 合并分片
|
||||
const mergeResponse = await httpClient.post<UploadResponse>('/file/upload/merge', {
|
||||
uploadId,
|
||||
filename: file.name,
|
||||
chunks: uploadedChunks
|
||||
})
|
||||
|
||||
return mergeResponse
|
||||
} catch (error) {
|
||||
// 清理失败的上传
|
||||
try {
|
||||
await httpClient.post('/file/upload/cleanup', { uploadId })
|
||||
} catch (cleanupError) {
|
||||
console.warn('清理上传失败:', cleanupError)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量上传
|
||||
*/
|
||||
public async uploadBatch(
|
||||
files: File[],
|
||||
options: {
|
||||
concurrent?: number
|
||||
onProgress?: (progress: number) => void
|
||||
onFileProgress?: (file: File, progress: number) => void
|
||||
onFileComplete?: (file: File, result: UploadResponse | Error) => void
|
||||
} = {}
|
||||
): Promise<BatchUploadResponse> {
|
||||
const {
|
||||
concurrent = 3,
|
||||
onProgress,
|
||||
onFileProgress,
|
||||
onFileComplete
|
||||
} = options
|
||||
|
||||
const results: BatchUploadResponse = {
|
||||
success: [],
|
||||
failed: [],
|
||||
total: files.length,
|
||||
successCount: 0,
|
||||
failedCount: 0
|
||||
}
|
||||
|
||||
const uploadFile = async (file: File): Promise<void> => {
|
||||
try {
|
||||
const response = await this.uploadFile({
|
||||
file,
|
||||
onProgress: (progress) => onFileProgress?.(file, progress)
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
results.success.push(response.data)
|
||||
results.successCount++
|
||||
} else {
|
||||
results.failed.push({
|
||||
file,
|
||||
error: response.message
|
||||
})
|
||||
results.failedCount++
|
||||
}
|
||||
|
||||
onFileComplete?.(file, response.data)
|
||||
} catch (error) {
|
||||
results.failed.push({
|
||||
file,
|
||||
error: error instanceof Error ? error.message : '上传失败'
|
||||
})
|
||||
results.failedCount++
|
||||
onFileComplete?.(file, error as Error)
|
||||
}
|
||||
|
||||
// 更新总进度
|
||||
const completed = results.successCount + results.failedCount
|
||||
const progress = (completed / results.total) * 100
|
||||
onProgress?.(progress)
|
||||
}
|
||||
|
||||
// 并发上传
|
||||
const chunks = []
|
||||
for (let i = 0; i < files.length; i += concurrent) {
|
||||
chunks.push(files.slice(i, i + concurrent))
|
||||
}
|
||||
|
||||
for (const chunk of chunks) {
|
||||
await Promise.all(chunk.map(uploadFile))
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消上传
|
||||
*/
|
||||
public cancelUpload(uploadId: string): void {
|
||||
const controller = this.uploadQueue.get(uploadId)
|
||||
if (controller) {
|
||||
controller.abort()
|
||||
this.uploadQueue.delete(uploadId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消所有上传
|
||||
*/
|
||||
public cancelAllUploads(): void {
|
||||
this.uploadQueue.forEach((controller) => {
|
||||
controller.abort()
|
||||
})
|
||||
this.uploadQueue.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取上传进度
|
||||
*/
|
||||
public getUploadProgress(uploadId: string): number {
|
||||
// 这里可以实现获取具体上传进度的逻辑
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// 创建默认实例
|
||||
export const fileUploader = new FileUploader()
|
||||
417
src/api/examples/usage.ts
Normal file
417
src/api/examples/usage.ts
Normal file
@ -0,0 +1,417 @@
|
||||
/**
|
||||
* API使用示例
|
||||
* 展示如何使用重构后的API架构
|
||||
*/
|
||||
|
||||
import {
|
||||
editorApi,
|
||||
resourceApi,
|
||||
fileUploader,
|
||||
fileDownloader,
|
||||
ApiManager,
|
||||
type ProjectData,
|
||||
type ResourceData
|
||||
} from '../index'
|
||||
|
||||
/**
|
||||
* 编辑器API使用示例
|
||||
*/
|
||||
export class EditorApiExamples {
|
||||
/**
|
||||
* 创建订单示例
|
||||
*/
|
||||
static async createOrderExample() {
|
||||
try {
|
||||
const response = await editorApi.createOrder({
|
||||
projectId: 'project-123',
|
||||
type: 'render',
|
||||
options: {
|
||||
quality: 'high',
|
||||
format: 'png'
|
||||
}
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
console.log('订单创建成功:', response.data)
|
||||
return response.data
|
||||
} else {
|
||||
console.error('订单创建失败:', response.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建订单时发生错误:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存项目示例
|
||||
*/
|
||||
static async saveProjectExample() {
|
||||
try {
|
||||
const projectData: Partial<ProjectData> = {
|
||||
name: '我的3D项目',
|
||||
description: '这是一个示例项目',
|
||||
sceneData: {
|
||||
objects: [],
|
||||
lights: [],
|
||||
camera: {}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await editorApi.saveProject(projectData)
|
||||
|
||||
if (response.success) {
|
||||
console.log('项目保存成功:', response.data)
|
||||
return response.data
|
||||
} else {
|
||||
console.error('项目保存失败:', response.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存项目时发生错误:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传缩略图示例
|
||||
*/
|
||||
static async uploadThumbnailExample(file: File, projectId: string) {
|
||||
try {
|
||||
const response = await editorApi.uploadThumbnail(file, projectId)
|
||||
|
||||
if (response.success) {
|
||||
console.log('缩略图上传成功:', response.data)
|
||||
return response.data
|
||||
} else {
|
||||
console.error('缩略图上传失败:', response.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('上传缩略图时发生错误:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出项目示例
|
||||
*/
|
||||
static async exportProjectExample(projectId: string) {
|
||||
try {
|
||||
await editorApi.exportProject(projectId, 'glb')
|
||||
console.log('项目导出完成')
|
||||
} catch (error) {
|
||||
console.error('导出项目时发生错误:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源API使用示例
|
||||
*/
|
||||
export class ResourceApiExamples {
|
||||
/**
|
||||
* 上传资源示例
|
||||
*/
|
||||
static async uploadResourceExample(file: File) {
|
||||
try {
|
||||
const response = await resourceApi.uploadResource(file, {
|
||||
type: 'model',
|
||||
tags: ['建筑', '现代'],
|
||||
description: '现代建筑模型'
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
console.log('资源上传成功:', response.data)
|
||||
return response.data
|
||||
} else {
|
||||
console.error('资源上传失败:', response.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('上传资源时发生错误:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量上传资源示例
|
||||
*/
|
||||
static async uploadMultipleResourcesExample(files: File[]) {
|
||||
try {
|
||||
const response = await resourceApi.uploadMultipleResources(files, {
|
||||
type: 'texture',
|
||||
tags: ['材质', '金属']
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
console.log('批量上传成功:', response.data)
|
||||
return response.data
|
||||
} else {
|
||||
console.error('批量上传失败:', response.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('批量上传时发生错误:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索资源示例
|
||||
*/
|
||||
static async searchResourcesExample() {
|
||||
try {
|
||||
const response = await resourceApi.searchResources({
|
||||
keyword: '建筑',
|
||||
type: 'model',
|
||||
tags: ['现代'],
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
console.log('搜索结果:', response.data)
|
||||
return response.data
|
||||
} else {
|
||||
console.error('搜索失败:', response.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('搜索资源时发生错误:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载资源示例
|
||||
*/
|
||||
static async downloadResourceExample(resource: ResourceData) {
|
||||
try {
|
||||
await resourceApi.downloadResource(resource)
|
||||
console.log('资源下载完成')
|
||||
} catch (error) {
|
||||
console.error('下载资源时发生错误:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量下载资源示例
|
||||
*/
|
||||
static async downloadMultipleResourcesExample(resources: ResourceData[]) {
|
||||
try {
|
||||
await resourceApi.downloadMultipleResources(resources)
|
||||
console.log('批量下载完成')
|
||||
} catch (error) {
|
||||
console.error('批量下载时发生错误:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件上传使用示例
|
||||
*/
|
||||
export class FileUploadExamples {
|
||||
/**
|
||||
* 普通文件上传示例
|
||||
*/
|
||||
static async uploadFileExample(file: File) {
|
||||
try {
|
||||
const response = await fileUploader.uploadFile({
|
||||
file,
|
||||
onProgress: (progress) => {
|
||||
console.log(`上传进度: ${progress}%`)
|
||||
}
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
console.log('文件上传成功:', response.data)
|
||||
return response.data
|
||||
} else {
|
||||
console.error('文件上传失败:', response.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('上传文件时发生错误:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片上传示例(带压缩)
|
||||
*/
|
||||
static async uploadImageExample(file: File) {
|
||||
try {
|
||||
const response = await fileUploader.uploadImage(file, {
|
||||
compress: true,
|
||||
quality: 0.8,
|
||||
maxWidth: 1920,
|
||||
maxHeight: 1080,
|
||||
onProgress: (progress) => {
|
||||
console.log(`图片上传进度: ${progress}%`)
|
||||
}
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
console.log('图片上传成功:', response.data)
|
||||
return response.data
|
||||
} else {
|
||||
console.error('图片上传失败:', response.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('上传图片时发生错误:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 大文件分片上传示例
|
||||
*/
|
||||
static async uploadLargeFileExample(file: File) {
|
||||
try {
|
||||
const response = await fileUploader.uploadLargeFile(file, {
|
||||
chunkSize: 2 * 1024 * 1024, // 2MB per chunk
|
||||
onProgress: (progress) => {
|
||||
console.log(`大文件上传进度: ${progress}%`)
|
||||
},
|
||||
onChunkProgress: (chunkIndex, chunkProgress) => {
|
||||
console.log(`分片 ${chunkIndex} 上传进度: ${chunkProgress}%`)
|
||||
}
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
console.log('大文件上传成功:', response.data)
|
||||
return response.data
|
||||
} else {
|
||||
console.error('大文件上传失败:', response.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('上传大文件时发生错误:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量文件上传示例
|
||||
*/
|
||||
static async uploadBatchExample(files: File[]) {
|
||||
try {
|
||||
const result = await fileUploader.uploadBatch(files, {
|
||||
concurrent: 3,
|
||||
onProgress: (progress) => {
|
||||
console.log(`批量上传总进度: ${progress}%`)
|
||||
},
|
||||
onFileProgress: (file, progress) => {
|
||||
console.log(`文件 ${file.name} 上传进度: ${progress}%`)
|
||||
},
|
||||
onFileComplete: (file, result) => {
|
||||
if (result instanceof Error) {
|
||||
console.error(`文件 ${file.name} 上传失败:`, result)
|
||||
} else {
|
||||
console.log(`文件 ${file.name} 上传成功`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
console.log('批量上传结果:', result)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('批量上传时发生错误:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件下载使用示例
|
||||
*/
|
||||
export class FileDownloadExamples {
|
||||
/**
|
||||
* 普通文件下载示例
|
||||
*/
|
||||
static async downloadFileExample(url: string, filename?: string) {
|
||||
try {
|
||||
await fileDownloader.downloadAndSave({
|
||||
url,
|
||||
filename,
|
||||
onProgress: (progress) => {
|
||||
console.log(`下载进度: ${progress}%`)
|
||||
}
|
||||
})
|
||||
|
||||
console.log('文件下载完成')
|
||||
} catch (error) {
|
||||
console.error('下载文件时发生错误:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载为Base64示例
|
||||
*/
|
||||
static async downloadAsBase64Example(url: string) {
|
||||
try {
|
||||
const base64 = await fileDownloader.downloadAsBase64({ url })
|
||||
console.log('文件下载为Base64完成:', base64.substring(0, 100) + '...')
|
||||
return base64
|
||||
} catch (error) {
|
||||
console.error('下载为Base64时发生错误:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量下载示例
|
||||
*/
|
||||
static async downloadBatchExample(urls: string[]) {
|
||||
try {
|
||||
const result = await fileDownloader.downloadBatch(urls, {
|
||||
concurrent: 3,
|
||||
onProgress: (progress) => {
|
||||
console.log(`批量下载总进度: ${progress}%`)
|
||||
},
|
||||
onFileProgress: (url, progress) => {
|
||||
console.log(`文件 ${url} 下载进度: ${progress}%`)
|
||||
},
|
||||
onFileComplete: (url, result) => {
|
||||
if (result instanceof Error) {
|
||||
console.error(`文件 ${url} 下载失败:`, result)
|
||||
} else {
|
||||
console.log(`文件 ${url} 下载成功`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
console.log('批量下载结果:', result)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('批量下载时发生错误:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API管理器使用示例
|
||||
*/
|
||||
export class ApiManagerExamples {
|
||||
/**
|
||||
* 设置认证token示例
|
||||
*/
|
||||
static setAuthTokenExample(token: string) {
|
||||
ApiManager.setAuthToken(token)
|
||||
console.log('认证token已设置')
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查网络连接示例
|
||||
*/
|
||||
static async checkConnectionExample() {
|
||||
try {
|
||||
const isConnected = await ApiManager.checkConnection()
|
||||
console.log('网络连接状态:', isConnected ? '已连接' : '未连接')
|
||||
return isConnected
|
||||
} catch (error) {
|
||||
console.error('检查网络连接时发生错误:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取API配置示例
|
||||
*/
|
||||
static getConfigExample() {
|
||||
const config = ApiManager.getConfig()
|
||||
console.log('API配置:', config)
|
||||
return config
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消所有请求示例
|
||||
*/
|
||||
static cancelAllRequestsExample() {
|
||||
ApiManager.cancelAllRequests()
|
||||
console.log('所有请求已取消')
|
||||
}
|
||||
}
|
||||
116
src/api/index.ts
Normal file
116
src/api/index.ts
Normal file
@ -0,0 +1,116 @@
|
||||
// 核心模块
|
||||
export { httpClient, HttpClient } from './core/request'
|
||||
export { fileUploader, FileUploader } from './core/upload'
|
||||
export { fileDownloader, FileDownloader } from './core/download'
|
||||
|
||||
// 业务API模块
|
||||
export { editorApi, EditorApi } from './modules/editor'
|
||||
export { resourceApi, ResourceApi } from './modules/resource'
|
||||
|
||||
// 类型定义
|
||||
export type {
|
||||
ApiResponse,
|
||||
RequestConfig,
|
||||
UploadConfig,
|
||||
DownloadConfig,
|
||||
PaginationParams,
|
||||
PaginationResponse,
|
||||
ApiError
|
||||
} from './types'
|
||||
|
||||
// 配置
|
||||
export { API_CONFIG, API_ENDPOINTS, ENV_CONFIG } from './config'
|
||||
|
||||
// 工具函数
|
||||
export {
|
||||
delay,
|
||||
generateId,
|
||||
formatFileSize,
|
||||
getFileExtension,
|
||||
validateFileType,
|
||||
createApiError,
|
||||
isApiError,
|
||||
retry,
|
||||
downloadFile,
|
||||
readFileAsBase64,
|
||||
readFileAsArrayBuffer,
|
||||
compressImage
|
||||
} from './utils'
|
||||
|
||||
// 业务类型
|
||||
export type {
|
||||
ProjectData,
|
||||
OrderData
|
||||
} from './modules/editor'
|
||||
|
||||
export type {
|
||||
ResourceData,
|
||||
ResourceCategory,
|
||||
ResourceSearchParams
|
||||
} from './modules/resource'
|
||||
|
||||
// 上传下载相关类型
|
||||
export type {
|
||||
UploadResponse,
|
||||
BatchUploadResponse
|
||||
} from './core/upload'
|
||||
|
||||
export type {
|
||||
DownloadResponse,
|
||||
BatchDownloadResponse
|
||||
} from './core/download'
|
||||
|
||||
// 导入实例用于ApiManager
|
||||
import { httpClient } from './core/request'
|
||||
import { fileUploader } from './core/upload'
|
||||
import { fileDownloader } from './core/download'
|
||||
import { API_CONFIG, ENV_CONFIG } from './config'
|
||||
|
||||
/**
|
||||
* API管理器类
|
||||
* 提供统一的API管理和配置
|
||||
*/
|
||||
export class ApiManager {
|
||||
/**
|
||||
* 设置认证token
|
||||
*/
|
||||
public static setAuthToken(token: string): void {
|
||||
httpClient.setAuthToken(token)
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消所有请求
|
||||
*/
|
||||
public static cancelAllRequests(): void {
|
||||
httpClient.cancelAllRequests()
|
||||
fileUploader.cancelAllUploads()
|
||||
fileDownloader.cancelAllDownloads()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取API配置
|
||||
*/
|
||||
public static getConfig() {
|
||||
return {
|
||||
baseUrl: API_CONFIG.BASE_URL,
|
||||
timeout: API_CONFIG.TIMEOUT,
|
||||
retryCount: API_CONFIG.RETRY_COUNT,
|
||||
env: ENV_CONFIG
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查网络连接
|
||||
*/
|
||||
public static async checkConnection(): Promise<boolean> {
|
||||
try {
|
||||
const response = await httpClient.get('/health')
|
||||
return response.success
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 默认导出API管理器
|
||||
export default ApiManager
|
||||
143
src/api/modules/editor.ts
Normal file
143
src/api/modules/editor.ts
Normal file
@ -0,0 +1,143 @@
|
||||
import { httpClient } from '../core/request'
|
||||
import { fileUploader } from '../core/upload'
|
||||
import { fileDownloader } from '../core/download'
|
||||
import { ApiResponse, PaginationParams, PaginationResponse } from '../types'
|
||||
import { API_ENDPOINTS } from '../config'
|
||||
|
||||
/**
|
||||
* 项目数据类型
|
||||
*/
|
||||
export interface ProjectData {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
thumbnail?: string
|
||||
sceneData: any
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
userId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单数据类型
|
||||
*/
|
||||
export interface OrderData {
|
||||
id: string
|
||||
projectId: string
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed'
|
||||
result?: any
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑器API类
|
||||
*/
|
||||
export class EditorApi {
|
||||
/**
|
||||
* 创建订单
|
||||
*/
|
||||
public async createOrder(params: any): Promise<ApiResponse<OrderData>> {
|
||||
return httpClient.post<OrderData>(API_ENDPOINTS.EDITOR.CREATE_ORDER, params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存项目
|
||||
*/
|
||||
public async saveProject(projectData: Partial<ProjectData>): Promise<ApiResponse<ProjectData>> {
|
||||
return httpClient.post<ProjectData>(API_ENDPOINTS.EDITOR.SAVE_PROJECT, projectData)
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载项目
|
||||
*/
|
||||
public async loadProject(projectId: string): Promise<ApiResponse<ProjectData>> {
|
||||
return httpClient.get<ProjectData>(`${API_ENDPOINTS.EDITOR.LOAD_PROJECT}/${projectId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除项目
|
||||
*/
|
||||
public async deleteProject(projectId: string): Promise<ApiResponse<void>> {
|
||||
return httpClient.delete<void>(`${API_ENDPOINTS.EDITOR.DELETE_PROJECT}/${projectId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目列表
|
||||
*/
|
||||
public async getProjectList(params: PaginationParams): Promise<ApiResponse<PaginationResponse<ProjectData>>> {
|
||||
return httpClient.get<PaginationResponse<ProjectData>>(API_ENDPOINTS.EDITOR.LOAD_PROJECT, params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传项目缩略图
|
||||
*/
|
||||
public async uploadThumbnail(file: File, projectId: string): Promise<ApiResponse<{ url: string }>> {
|
||||
return fileUploader.uploadImage(file, {
|
||||
compress: true,
|
||||
quality: 0.8,
|
||||
maxWidth: 400,
|
||||
maxHeight: 300,
|
||||
onProgress: (progress) => {
|
||||
console.log(`缩略图上传进度: ${progress}%`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出项目
|
||||
*/
|
||||
public async exportProject(projectId: string, format: 'json' | 'glb' | 'fbx' = 'json'): Promise<void> {
|
||||
await fileDownloader.downloadAndSave({
|
||||
url: `/editor/project/export/${projectId}`,
|
||||
filename: `project_${projectId}.${format}`,
|
||||
params: { format }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入项目
|
||||
*/
|
||||
public async importProject(file: File): Promise<ApiResponse<ProjectData>> {
|
||||
const uploadResponse = await fileUploader.uploadFile({
|
||||
file,
|
||||
fieldName: 'projectFile',
|
||||
data: {
|
||||
action: 'import'
|
||||
}
|
||||
})
|
||||
|
||||
if (!uploadResponse.success) {
|
||||
throw new Error(uploadResponse.message)
|
||||
}
|
||||
|
||||
// 上传成功后,调用导入API
|
||||
return httpClient.post<ProjectData>('/editor/project/import', {
|
||||
fileUrl: uploadResponse.data.url
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量导出项目
|
||||
*/
|
||||
public async exportMultipleProjects(projectIds: string[], format: 'json' | 'glb' | 'fbx' = 'json'): Promise<void> {
|
||||
const downloadUrls = projectIds.map(id => `/editor/project/export/${id}?format=${format}`)
|
||||
|
||||
await fileDownloader.downloadBatch(downloadUrls, {
|
||||
concurrent: 2,
|
||||
onProgress: (progress) => {
|
||||
console.log(`批量导出进度: ${progress}%`)
|
||||
},
|
||||
onFileComplete: (url, result) => {
|
||||
if (result instanceof Error) {
|
||||
console.error(`导出失败: ${url}`, result)
|
||||
} else {
|
||||
console.log(`导出完成: ${url}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 创建默认实例
|
||||
export const editorApi = new EditorApi()
|
||||
281
src/api/modules/resource.ts
Normal file
281
src/api/modules/resource.ts
Normal file
@ -0,0 +1,281 @@
|
||||
import { httpClient } from '../core/request'
|
||||
import { fileUploader } from '../core/upload'
|
||||
import { fileDownloader } from '../core/download'
|
||||
import { ApiResponse, PaginationParams, PaginationResponse } from '../types'
|
||||
import { API_ENDPOINTS } from '../config'
|
||||
|
||||
/**
|
||||
* 资源数据类型
|
||||
*/
|
||||
export interface ResourceData {
|
||||
id: string
|
||||
name: string
|
||||
type: 'model' | 'texture' | 'material' | 'audio' | 'video' | 'other'
|
||||
url: string
|
||||
thumbnailUrl?: string
|
||||
size: number
|
||||
format: string
|
||||
tags: string[]
|
||||
description?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
userId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源分类类型
|
||||
*/
|
||||
export interface ResourceCategory {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
parentId?: string
|
||||
children?: ResourceCategory[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源搜索参数
|
||||
*/
|
||||
export interface ResourceSearchParams extends PaginationParams {
|
||||
keyword?: string
|
||||
type?: ResourceData['type']
|
||||
tags?: string[]
|
||||
categoryId?: string
|
||||
format?: string
|
||||
sizeMin?: number
|
||||
sizeMax?: number
|
||||
dateFrom?: string
|
||||
dateTo?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源API类
|
||||
*/
|
||||
export class ResourceApi {
|
||||
/**
|
||||
* 获取资源列表
|
||||
*/
|
||||
public async getResourceList(params: ResourceSearchParams): Promise<ApiResponse<PaginationResponse<ResourceData>>> {
|
||||
return httpClient.get<PaginationResponse<ResourceData>>(API_ENDPOINTS.RESOURCE.LIST, params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资源详情
|
||||
*/
|
||||
public async getResourceDetail(resourceId: string): Promise<ApiResponse<ResourceData>> {
|
||||
return httpClient.get<ResourceData>(`${API_ENDPOINTS.RESOURCE.LIST}/${resourceId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传资源
|
||||
*/
|
||||
public async uploadResource(
|
||||
file: File,
|
||||
metadata: {
|
||||
name?: string
|
||||
type: ResourceData['type']
|
||||
tags?: string[]
|
||||
description?: string
|
||||
categoryId?: string
|
||||
}
|
||||
): Promise<ApiResponse<ResourceData>> {
|
||||
// 根据文件类型选择上传方式
|
||||
let uploadResponse
|
||||
|
||||
if (metadata.type === 'texture' && file.type.startsWith('image/')) {
|
||||
// 图片资源,使用图片上传(带压缩)
|
||||
uploadResponse = await fileUploader.uploadImage(file, {
|
||||
compress: true,
|
||||
quality: 0.9,
|
||||
maxWidth: 2048,
|
||||
maxHeight: 2048,
|
||||
onProgress: (progress) => {
|
||||
console.log(`资源上传进度: ${progress}%`)
|
||||
}
|
||||
})
|
||||
} else if (file.size > 50 * 1024 * 1024) {
|
||||
// 大文件,使用分片上传
|
||||
uploadResponse = await fileUploader.uploadLargeFile(file, {
|
||||
onProgress: (progress) => {
|
||||
console.log(`大文件上传进度: ${progress}%`)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// 普通文件上传
|
||||
uploadResponse = await fileUploader.uploadFile({
|
||||
file,
|
||||
data: metadata,
|
||||
onProgress: (progress) => {
|
||||
console.log(`文件上传进度: ${progress}%`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (!uploadResponse.success) {
|
||||
throw new Error(uploadResponse.message)
|
||||
}
|
||||
|
||||
// 创建资源记录
|
||||
return httpClient.post<ResourceData>(API_ENDPOINTS.RESOURCE.UPLOAD, {
|
||||
...metadata,
|
||||
name: metadata.name || file.name,
|
||||
url: uploadResponse.data.url,
|
||||
size: file.size,
|
||||
format: file.name.split('.').pop()?.toLowerCase() || 'unknown'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量上传资源
|
||||
*/
|
||||
public async uploadMultipleResources(
|
||||
files: File[],
|
||||
metadata: {
|
||||
type: ResourceData['type']
|
||||
tags?: string[]
|
||||
categoryId?: string
|
||||
}
|
||||
): Promise<ApiResponse<ResourceData[]>> {
|
||||
const results = await fileUploader.uploadBatch(files, {
|
||||
concurrent: 3,
|
||||
onProgress: (progress) => {
|
||||
console.log(`批量上传进度: ${progress}%`)
|
||||
},
|
||||
onFileComplete: (file, result) => {
|
||||
if (result instanceof Error) {
|
||||
console.error(`文件上传失败: ${file.name}`, result)
|
||||
} else {
|
||||
console.log(`文件上传完成: ${file.name}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 创建资源记录
|
||||
const resourcePromises = results.success.map(uploadResult =>
|
||||
httpClient.post<ResourceData>(API_ENDPOINTS.RESOURCE.UPLOAD, {
|
||||
...metadata,
|
||||
name: uploadResult.filename,
|
||||
url: uploadResult.url,
|
||||
size: uploadResult.size,
|
||||
format: uploadResult.filename.split('.').pop()?.toLowerCase() || 'unknown'
|
||||
})
|
||||
)
|
||||
|
||||
const resourceResults = await Promise.all(resourcePromises)
|
||||
const successResources = resourceResults
|
||||
.filter(result => result.success)
|
||||
.map(result => result.data)
|
||||
|
||||
return {
|
||||
code: 0,
|
||||
message: 'Success',
|
||||
data: successResources,
|
||||
success: true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除资源
|
||||
*/
|
||||
public async deleteResource(resourceId: string): Promise<ApiResponse<void>> {
|
||||
return httpClient.delete<void>(`${API_ENDPOINTS.RESOURCE.DELETE}/${resourceId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除资源
|
||||
*/
|
||||
public async deleteMultipleResources(resourceIds: string[]): Promise<ApiResponse<void>> {
|
||||
return httpClient.post<void>('/resource/batch-delete', { resourceIds })
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新资源信息
|
||||
*/
|
||||
public async updateResource(
|
||||
resourceId: string,
|
||||
data: Partial<Pick<ResourceData, 'name' | 'tags' | 'description'>>
|
||||
): Promise<ApiResponse<ResourceData>> {
|
||||
return httpClient.put<ResourceData>(`/resource/${resourceId}`, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载资源
|
||||
*/
|
||||
public async downloadResource(resource: ResourceData): Promise<void> {
|
||||
await fileDownloader.downloadAndSave({
|
||||
url: resource.url,
|
||||
filename: resource.name
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量下载资源
|
||||
*/
|
||||
public async downloadMultipleResources(resources: ResourceData[]): Promise<void> {
|
||||
const downloadFiles = resources.map(resource => ({
|
||||
url: resource.url,
|
||||
filename: resource.name
|
||||
}))
|
||||
|
||||
await fileDownloader.downloadAsZip(downloadFiles, 'resources.zip')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资源分类
|
||||
*/
|
||||
public async getResourceCategories(): Promise<ApiResponse<ResourceCategory[]>> {
|
||||
return httpClient.get<ResourceCategory[]>('/resource/categories')
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建资源分类
|
||||
*/
|
||||
public async createResourceCategory(
|
||||
data: Pick<ResourceCategory, 'name' | 'description' | 'parentId'>
|
||||
): Promise<ApiResponse<ResourceCategory>> {
|
||||
return httpClient.post<ResourceCategory>('/resource/categories', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索资源
|
||||
*/
|
||||
public async searchResources(params: ResourceSearchParams): Promise<ApiResponse<PaginationResponse<ResourceData>>> {
|
||||
return httpClient.get<PaginationResponse<ResourceData>>('/resource/search', params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取热门标签
|
||||
*/
|
||||
public async getPopularTags(limit: number = 20): Promise<ApiResponse<string[]>> {
|
||||
return httpClient.get<string[]>('/resource/tags/popular', { limit })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资源统计信息
|
||||
*/
|
||||
public async getResourceStats(): Promise<ApiResponse<{
|
||||
total: number
|
||||
byType: Record<ResourceData['type'], number>
|
||||
totalSize: number
|
||||
recentUploads: number
|
||||
}>> {
|
||||
return httpClient.get('/resource/stats')
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览资源(获取预览URL)
|
||||
*/
|
||||
public async getResourcePreview(resourceId: string): Promise<ApiResponse<{ previewUrl: string }>> {
|
||||
return httpClient.get<{ previewUrl: string }>(`/resource/${resourceId}/preview`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成资源缩略图
|
||||
*/
|
||||
public async generateThumbnail(resourceId: string): Promise<ApiResponse<{ thumbnailUrl: string }>> {
|
||||
return httpClient.post<{ thumbnailUrl: string }>(`/resource/${resourceId}/thumbnail`)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建默认实例
|
||||
export const resourceApi = new ResourceApi()
|
||||
58
src/api/types/index.ts
Normal file
58
src/api/types/index.ts
Normal file
@ -0,0 +1,58 @@
|
||||
// 通用响应类型
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
success: boolean
|
||||
}
|
||||
|
||||
// 请求配置类型
|
||||
export interface RequestConfig {
|
||||
url: string
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD'
|
||||
params?: Record<string, any>
|
||||
data?: any
|
||||
headers?: Record<string, string>
|
||||
timeout?: number
|
||||
responseType?: 'json' | 'blob' | 'text' | 'arraybuffer'
|
||||
}
|
||||
|
||||
// 文件上传配置
|
||||
export interface UploadConfig {
|
||||
file: File | Blob
|
||||
filename?: string
|
||||
fieldName?: string
|
||||
data?: Record<string, any>
|
||||
onProgress?: (progress: number) => void
|
||||
}
|
||||
|
||||
// 文件下载配置
|
||||
export interface DownloadConfig {
|
||||
url: string
|
||||
filename?: string
|
||||
params?: Record<string, any>
|
||||
onProgress?: (progress: number) => void
|
||||
}
|
||||
|
||||
// 分页请求参数
|
||||
export interface PaginationParams {
|
||||
page: number
|
||||
pageSize: number
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
// 分页响应数据
|
||||
export interface PaginationResponse<T> {
|
||||
list: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
// 错误类型
|
||||
export interface ApiError {
|
||||
code: number
|
||||
message: string
|
||||
details?: any
|
||||
}
|
||||
184
src/api/utils/index.ts
Normal file
184
src/api/utils/index.ts
Normal file
@ -0,0 +1,184 @@
|
||||
import { ApiError } from '../types'
|
||||
import { API_CONFIG } from '../config'
|
||||
|
||||
/**
|
||||
* 延迟函数
|
||||
*/
|
||||
export const delay = (ms: number): Promise<void> => {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一ID
|
||||
*/
|
||||
export const generateId = (): string => {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
*/
|
||||
export const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件扩展名
|
||||
*/
|
||||
export const getFileExtension = (filename: string): string => {
|
||||
return filename.slice((filename.lastIndexOf('.') - 1 >>> 0) + 2)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证文件类型
|
||||
*/
|
||||
export const validateFileType = (file: File, allowedTypes: string[]): boolean => {
|
||||
const fileType = file.type
|
||||
const fileExtension = getFileExtension(file.name).toLowerCase()
|
||||
|
||||
return allowedTypes.some(type => {
|
||||
if (type.includes('/')) {
|
||||
return fileType === type
|
||||
}
|
||||
return fileExtension === type.toLowerCase()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建API错误对象
|
||||
*/
|
||||
export const createApiError = (
|
||||
code: number,
|
||||
message: string,
|
||||
details?: any
|
||||
): ApiError => {
|
||||
return {
|
||||
code,
|
||||
message,
|
||||
details
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为API错误
|
||||
*/
|
||||
export const isApiError = (error: any): error is ApiError => {
|
||||
return error && typeof error.code === 'number' && typeof error.message === 'string'
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试函数
|
||||
*/
|
||||
export const retry = async <T>(
|
||||
fn: () => Promise<T>,
|
||||
retries: number = API_CONFIG.RETRY_COUNT,
|
||||
delayMs: number = API_CONFIG.RETRY_DELAY
|
||||
): Promise<T> => {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (error) {
|
||||
if (retries > 0) {
|
||||
await delay(delayMs)
|
||||
return retry(fn, retries - 1, delayMs * 2) // 指数退避
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件
|
||||
*/
|
||||
export const downloadFile = (blob: Blob, filename: string): void => {
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文件为Base64
|
||||
*/
|
||||
export const readFileAsBase64 = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string
|
||||
resolve(result.split(',')[1]) // 移除data:xxx;base64,前缀
|
||||
}
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文件为ArrayBuffer
|
||||
*/
|
||||
export const readFileAsArrayBuffer = (file: File): Promise<ArrayBuffer> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as ArrayBuffer)
|
||||
reader.onerror = reject
|
||||
reader.readAsArrayBuffer(file)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 压缩图片
|
||||
*/
|
||||
export const compressImage = (
|
||||
file: File,
|
||||
quality: number = 0.8,
|
||||
maxWidth: number = 1920,
|
||||
maxHeight: number = 1080
|
||||
): Promise<Blob> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
const img = new Image()
|
||||
|
||||
img.onload = () => {
|
||||
// 计算压缩后的尺寸
|
||||
let { width, height } = img
|
||||
|
||||
if (width > maxWidth) {
|
||||
height = (height * maxWidth) / width
|
||||
width = maxWidth
|
||||
}
|
||||
|
||||
if (height > maxHeight) {
|
||||
width = (width * maxHeight) / height
|
||||
height = maxHeight
|
||||
}
|
||||
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
|
||||
// 绘制并压缩
|
||||
ctx?.drawImage(img, 0, 0, width, height)
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
resolve(blob)
|
||||
} else {
|
||||
reject(new Error('图片压缩失败'))
|
||||
}
|
||||
},
|
||||
file.type,
|
||||
quality
|
||||
)
|
||||
}
|
||||
|
||||
img.onerror = reject
|
||||
img.src = URL.createObjectURL(file)
|
||||
})
|
||||
}
|
||||
BIN
src/assets/font/QingNiaoHuaGuangJianMeiHei-2.ttf
Normal file
BIN
src/assets/font/QingNiaoHuaGuangJianMeiHei-2.ttf
Normal file
Binary file not shown.
BIN
src/assets/logo.jpg
Normal file
BIN
src/assets/logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 586 KiB |
BIN
src/assets/logo.png
Normal file
BIN
src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
46
src/assets/style/components.css
Normal file
46
src/assets/style/components.css
Normal file
@ -0,0 +1,46 @@
|
||||
.inspectortotip {
|
||||
margin-top: 5px;
|
||||
color: rgba(250, 92, 0, 0.712);
|
||||
width: 200px;
|
||||
margin-left: 20px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.container {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slider-them {
|
||||
position: absolute;
|
||||
pointer-events: auto;
|
||||
cursor: ew-resize;
|
||||
|
||||
width: 20px;
|
||||
height: 40px;
|
||||
background-color: #006eff;
|
||||
opacity: 0.7;
|
||||
border-radius: 1rem;
|
||||
|
||||
top: calc(50% - 20px);
|
||||
left: calc(50% - 10px);
|
||||
|
||||
}
|
||||
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
pointer-events: auto;
|
||||
cursor: ew-resize;
|
||||
width: 40px;
|
||||
height: 60px;
|
||||
background-color: #7777773a;
|
||||
opacity: 0.7;
|
||||
border-radius: 1rem;
|
||||
top: calc(50% - 20px);
|
||||
left: calc(50% - 10px);
|
||||
|
||||
}
|
||||
539
src/assets/style/font_oy0t1irww4k/demo.css
Normal file
539
src/assets/style/font_oy0t1irww4k/demo.css
Normal file
@ -0,0 +1,539 @@
|
||||
/* Logo 字体 */
|
||||
@font-face {
|
||||
font-family: "iconfont logo";
|
||||
src: url('https://at.alicdn.com/t/font_985780_km7mi63cihi.eot?t=1545807318834');
|
||||
src: url('https://at.alicdn.com/t/font_985780_km7mi63cihi.eot?t=1545807318834#iefix') format('embedded-opentype'),
|
||||
url('https://at.alicdn.com/t/font_985780_km7mi63cihi.woff?t=1545807318834') format('woff'),
|
||||
url('https://at.alicdn.com/t/font_985780_km7mi63cihi.ttf?t=1545807318834') format('truetype'),
|
||||
url('https://at.alicdn.com/t/font_985780_km7mi63cihi.svg?t=1545807318834#iconfont') format('svg');
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-family: "iconfont logo";
|
||||
font-size: 160px;
|
||||
font-style: normal;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* tabs */
|
||||
.nav-tabs {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-more {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 42px;
|
||||
line-height: 42px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
#tabs {
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
#tabs li {
|
||||
cursor: pointer;
|
||||
width: 100px;
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
border-bottom: 2px solid transparent;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-bottom: -1px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
|
||||
#tabs .active {
|
||||
border-bottom-color: #f00;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.tab-container .content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 页面布局 */
|
||||
.main {
|
||||
padding: 30px 100px;
|
||||
width: 960px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.main .logo {
|
||||
color: #333;
|
||||
text-align: left;
|
||||
margin-bottom: 30px;
|
||||
line-height: 1;
|
||||
height: 110px;
|
||||
margin-top: -50px;
|
||||
overflow: hidden;
|
||||
*zoom: 1;
|
||||
}
|
||||
|
||||
.main .logo a {
|
||||
font-size: 160px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.helps {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.helps pre {
|
||||
padding: 20px;
|
||||
margin: 10px 0;
|
||||
border: solid 1px #e7e1cd;
|
||||
background-color: #fffdef;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.icon_lists {
|
||||
width: 100% !important;
|
||||
overflow: hidden;
|
||||
*zoom: 1;
|
||||
}
|
||||
|
||||
.icon_lists li {
|
||||
width: 100px;
|
||||
margin-bottom: 10px;
|
||||
margin-right: 20px;
|
||||
text-align: center;
|
||||
list-style: none !important;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.icon_lists li .code-name {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.icon_lists .icon {
|
||||
display: block;
|
||||
height: 100px;
|
||||
line-height: 100px;
|
||||
font-size: 42px;
|
||||
margin: 10px auto;
|
||||
color: #333;
|
||||
-webkit-transition: font-size 0.25s linear, width 0.25s linear;
|
||||
-moz-transition: font-size 0.25s linear, width 0.25s linear;
|
||||
transition: font-size 0.25s linear, width 0.25s linear;
|
||||
}
|
||||
|
||||
.icon_lists .icon:hover {
|
||||
font-size: 100px;
|
||||
}
|
||||
|
||||
.icon_lists .svg-icon {
|
||||
/* 通过设置 font-size 来改变图标大小 */
|
||||
width: 1em;
|
||||
/* 图标和文字相邻时,垂直对齐 */
|
||||
vertical-align: -0.15em;
|
||||
/* 通过设置 color 来改变 SVG 的颜色/fill */
|
||||
fill: currentColor;
|
||||
/* path 和 stroke 溢出 viewBox 部分在 IE 下会显示
|
||||
normalize.css 中也包含这行 */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.icon_lists li .name,
|
||||
.icon_lists li .code-name {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* markdown 样式 */
|
||||
.markdown {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.markdown img {
|
||||
vertical-align: middle;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.markdown h1 {
|
||||
color: #404040;
|
||||
font-weight: 500;
|
||||
line-height: 40px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.markdown h2,
|
||||
.markdown h3,
|
||||
.markdown h4,
|
||||
.markdown h5,
|
||||
.markdown h6 {
|
||||
color: #404040;
|
||||
margin: 1.6em 0 0.6em 0;
|
||||
font-weight: 500;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.markdown h1 {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.markdown h2 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.markdown h3 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.markdown h4 {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.markdown h5 {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.markdown h6 {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.markdown hr {
|
||||
height: 1px;
|
||||
border: 0;
|
||||
background: #e9e9e9;
|
||||
margin: 16px 0;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.markdown p {
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.markdown>p,
|
||||
.markdown>blockquote,
|
||||
.markdown>.highlight,
|
||||
.markdown>ol,
|
||||
.markdown>ul {
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.markdown ul>li {
|
||||
list-style: circle;
|
||||
}
|
||||
|
||||
.markdown>ul li,
|
||||
.markdown blockquote ul>li {
|
||||
margin-left: 20px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.markdown>ul li p,
|
||||
.markdown>ol li p {
|
||||
margin: 0.6em 0;
|
||||
}
|
||||
|
||||
.markdown ol>li {
|
||||
list-style: decimal;
|
||||
}
|
||||
|
||||
.markdown>ol li,
|
||||
.markdown blockquote ol>li {
|
||||
margin-left: 20px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.markdown code {
|
||||
margin: 0 3px;
|
||||
padding: 0 5px;
|
||||
background: #eee;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.markdown strong,
|
||||
.markdown b {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown>table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0px;
|
||||
empty-cells: show;
|
||||
border: 1px solid #e9e9e9;
|
||||
width: 95%;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.markdown>table th {
|
||||
white-space: nowrap;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown>table th,
|
||||
.markdown>table td {
|
||||
border: 1px solid #e9e9e9;
|
||||
padding: 8px 16px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.markdown>table th {
|
||||
background: #F7F7F7;
|
||||
}
|
||||
|
||||
.markdown blockquote {
|
||||
font-size: 90%;
|
||||
color: #999;
|
||||
border-left: 4px solid #e9e9e9;
|
||||
padding-left: 0.8em;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.markdown blockquote p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.markdown .anchor {
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.markdown .waiting {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.markdown h1:hover .anchor,
|
||||
.markdown h2:hover .anchor,
|
||||
.markdown h3:hover .anchor,
|
||||
.markdown h4:hover .anchor,
|
||||
.markdown h5:hover .anchor,
|
||||
.markdown h6:hover .anchor {
|
||||
opacity: 1;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.markdown>br,
|
||||
.markdown>p>br {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
|
||||
.hljs {
|
||||
display: block;
|
||||
background: white;
|
||||
padding: 0.5em;
|
||||
color: #333333;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.hljs-comment,
|
||||
.hljs-meta {
|
||||
color: #969896;
|
||||
}
|
||||
|
||||
.hljs-string,
|
||||
.hljs-variable,
|
||||
.hljs-template-variable,
|
||||
.hljs-strong,
|
||||
.hljs-emphasis,
|
||||
.hljs-quote {
|
||||
color: #df5000;
|
||||
}
|
||||
|
||||
.hljs-keyword,
|
||||
.hljs-selector-tag,
|
||||
.hljs-type {
|
||||
color: #a71d5d;
|
||||
}
|
||||
|
||||
.hljs-literal,
|
||||
.hljs-symbol,
|
||||
.hljs-bullet,
|
||||
.hljs-attribute {
|
||||
color: #0086b3;
|
||||
}
|
||||
|
||||
.hljs-section,
|
||||
.hljs-name {
|
||||
color: #63a35c;
|
||||
}
|
||||
|
||||
.hljs-tag {
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.hljs-title,
|
||||
.hljs-attr,
|
||||
.hljs-selector-id,
|
||||
.hljs-selector-class,
|
||||
.hljs-selector-attr,
|
||||
.hljs-selector-pseudo {
|
||||
color: #795da3;
|
||||
}
|
||||
|
||||
.hljs-addition {
|
||||
color: #55a532;
|
||||
background-color: #eaffea;
|
||||
}
|
||||
|
||||
.hljs-deletion {
|
||||
color: #bd2c00;
|
||||
background-color: #ffecec;
|
||||
}
|
||||
|
||||
.hljs-link {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 代码高亮 */
|
||||
/* PrismJS 1.15.0
|
||||
https://prismjs.com/download.html#themes=prism&languages=markup+css+clike+javascript */
|
||||
/**
|
||||
* prism.js default theme for JavaScript, CSS and HTML
|
||||
* Based on dabblet (http://dabblet.com)
|
||||
* @author Lea Verou
|
||||
*/
|
||||
code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
color: black;
|
||||
background: none;
|
||||
text-shadow: 0 1px white;
|
||||
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
|
||||
text-align: left;
|
||||
white-space: pre;
|
||||
word-spacing: normal;
|
||||
word-break: normal;
|
||||
word-wrap: normal;
|
||||
line-height: 1.5;
|
||||
|
||||
-moz-tab-size: 4;
|
||||
-o-tab-size: 4;
|
||||
tab-size: 4;
|
||||
|
||||
-webkit-hyphens: none;
|
||||
-moz-hyphens: none;
|
||||
-ms-hyphens: none;
|
||||
hyphens: none;
|
||||
}
|
||||
|
||||
pre[class*="language-"]::-moz-selection,
|
||||
pre[class*="language-"] ::-moz-selection,
|
||||
code[class*="language-"]::-moz-selection,
|
||||
code[class*="language-"] ::-moz-selection {
|
||||
text-shadow: none;
|
||||
background: #b3d4fc;
|
||||
}
|
||||
|
||||
pre[class*="language-"]::selection,
|
||||
pre[class*="language-"] ::selection,
|
||||
code[class*="language-"]::selection,
|
||||
code[class*="language-"] ::selection {
|
||||
text-shadow: none;
|
||||
background: #b3d4fc;
|
||||
}
|
||||
|
||||
@media print {
|
||||
|
||||
code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
text-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
pre[class*="language-"] {
|
||||
padding: 1em;
|
||||
margin: .5em 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
:not(pre)>code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
background: #f5f2f0;
|
||||
}
|
||||
|
||||
/* Inline code */
|
||||
:not(pre)>code[class*="language-"] {
|
||||
padding: .1em;
|
||||
border-radius: .3em;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.token.comment,
|
||||
.token.prolog,
|
||||
.token.doctype,
|
||||
.token.cdata {
|
||||
color: slategray;
|
||||
}
|
||||
|
||||
.token.punctuation {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.namespace {
|
||||
opacity: .7;
|
||||
}
|
||||
|
||||
.token.property,
|
||||
.token.tag,
|
||||
.token.boolean,
|
||||
.token.number,
|
||||
.token.constant,
|
||||
.token.symbol,
|
||||
.token.deleted {
|
||||
color: #905;
|
||||
}
|
||||
|
||||
.token.selector,
|
||||
.token.attr-name,
|
||||
.token.string,
|
||||
.token.char,
|
||||
.token.builtin,
|
||||
.token.inserted {
|
||||
color: #690;
|
||||
}
|
||||
|
||||
.token.operator,
|
||||
.token.entity,
|
||||
.token.url,
|
||||
.language-css .token.string,
|
||||
.style .token.string {
|
||||
color: #9a6e3a;
|
||||
background: hsla(0, 0%, 100%, .5);
|
||||
}
|
||||
|
||||
.token.atrule,
|
||||
.token.attr-value,
|
||||
.token.keyword {
|
||||
color: #07a;
|
||||
}
|
||||
|
||||
.token.function,
|
||||
.token.class-name {
|
||||
color: #DD4A68;
|
||||
}
|
||||
|
||||
.token.regex,
|
||||
.token.important,
|
||||
.token.variable {
|
||||
color: #e90;
|
||||
}
|
||||
|
||||
.token.important,
|
||||
.token.bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.token.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.token.entity {
|
||||
cursor: help;
|
||||
}
|
||||
391
src/assets/style/font_oy0t1irww4k/demo_index.html
Normal file
391
src/assets/style/font_oy0t1irww4k/demo_index.html
Normal file
@ -0,0 +1,391 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>iconfont Demo</title>
|
||||
<link rel="shortcut icon" href="//img.alicdn.com/imgextra/i4/O1CN01Z5paLz1O0zuCC7osS_!!6000000001644-55-tps-83-82.svg" type="image/x-icon"/>
|
||||
<link rel="icon" type="image/svg+xml" href="//img.alicdn.com/imgextra/i4/O1CN01Z5paLz1O0zuCC7osS_!!6000000001644-55-tps-83-82.svg"/>
|
||||
<link rel="stylesheet" href="https://g.alicdn.com/thx/cube/1.3.2/cube.min.css">
|
||||
<link rel="stylesheet" href="demo.css">
|
||||
<link rel="stylesheet" href="iconfont.css">
|
||||
<script src="iconfont.js"></script>
|
||||
<!-- jQuery -->
|
||||
<script src="https://a1.alicdn.com/oss/uploads/2018/12/26/7bfddb60-08e8-11e9-9b04-53e73bb6408b.js"></script>
|
||||
<!-- 代码高亮 -->
|
||||
<script src="https://a1.alicdn.com/oss/uploads/2018/12/26/a3f714d0-08e6-11e9-8a15-ebf944d7534c.js"></script>
|
||||
<style>
|
||||
.main .logo {
|
||||
margin-top: 0;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.main .logo a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.main .logo .sub-title {
|
||||
margin-left: 0.5em;
|
||||
font-size: 22px;
|
||||
color: #fff;
|
||||
background: linear-gradient(-45deg, #3967FF, #B500FE);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main">
|
||||
<h1 class="logo"><a href="https://www.iconfont.cn/" title="iconfont 首页" target="_blank">
|
||||
<img width="200" src="https://img.alicdn.com/imgextra/i3/O1CN01Mn65HV1FfSEzR6DKv_!!6000000000514-55-tps-228-59.svg">
|
||||
|
||||
</a></h1>
|
||||
<div class="nav-tabs">
|
||||
<ul id="tabs" class="dib-box">
|
||||
<li class="dib active"><span>Unicode</span></li>
|
||||
<li class="dib"><span>Font class</span></li>
|
||||
<li class="dib"><span>Symbol</span></li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
<div class="tab-container">
|
||||
<div class="content unicode" style="display: block;">
|
||||
<ul class="icon_lists dib-box">
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">全屏</div>
|
||||
<div class="code-name">&#xe660;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">帮助</div>
|
||||
<div class="code-name">&#xe620;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">帮助_o</div>
|
||||
<div class="code-name">&#xeb72;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">半透明(2)</div>
|
||||
<div class="code-name">&#xe60b;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">持续时间</div>
|
||||
<div class="code-name">&#xe71a;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">对比</div>
|
||||
<div class="code-name">&#xe60c;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">还原</div>
|
||||
<div class="code-name">&#xe604;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">vr</div>
|
||||
<div class="code-name">&#xe70a;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">持续时间</div>
|
||||
<div class="code-name">&#xe51b;</div>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
<div class="article markdown">
|
||||
<h2 id="unicode-">Unicode 引用</h2>
|
||||
<hr>
|
||||
|
||||
<p>Unicode 是字体在网页端最原始的应用方式,特点是:</p>
|
||||
<ul>
|
||||
<li>支持按字体的方式去动态调整图标大小,颜色等等。</li>
|
||||
<li>默认情况下不支持多色,直接添加多色图标会自动去色。</li>
|
||||
</ul>
|
||||
<blockquote>
|
||||
<p>注意:新版 iconfont 支持两种方式引用多色图标:SVG symbol 引用方式和彩色字体图标模式。(使用彩色字体图标需要在「编辑项目」中开启「彩色」选项后并重新生成。)</p>
|
||||
</blockquote>
|
||||
<p>Unicode 使用步骤如下:</p>
|
||||
<h3 id="-font-face">第一步:拷贝项目下面生成的 <code>@font-face</code></h3>
|
||||
<pre><code class="language-css"
|
||||
>@font-face {
|
||||
font-family: 'iconfont';
|
||||
src: url('iconfont.ttf?t=1733881413198') format('truetype');
|
||||
}
|
||||
</code></pre>
|
||||
<h3 id="-iconfont-">第二步:定义使用 iconfont 的样式</h3>
|
||||
<pre><code class="language-css"
|
||||
>.iconfont {
|
||||
font-family: "iconfont" !important;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
</code></pre>
|
||||
<h3 id="-">第三步:挑选相应图标并获取字体编码,应用于页面</h3>
|
||||
<pre>
|
||||
<code class="language-html"
|
||||
><span class="iconfont">&#x33;</span>
|
||||
</code></pre>
|
||||
<blockquote>
|
||||
<p>"iconfont" 是你项目下的 font-family。可以通过编辑项目查看,默认是 "iconfont"。</p>
|
||||
</blockquote>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content font-class">
|
||||
<ul class="icon_lists dib-box">
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont icon-quanping"></span>
|
||||
<div class="name">
|
||||
全屏
|
||||
</div>
|
||||
<div class="code-name">.icon-quanping
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont icon-bangzhu"></span>
|
||||
<div class="name">
|
||||
帮助
|
||||
</div>
|
||||
<div class="code-name">.icon-bangzhu
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont icon-bangzhu_o"></span>
|
||||
<div class="name">
|
||||
帮助_o
|
||||
</div>
|
||||
<div class="code-name">.icon-bangzhu_o
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont icon-bantouming"></span>
|
||||
<div class="name">
|
||||
半透明(2)
|
||||
</div>
|
||||
<div class="code-name">.icon-bantouming
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont icon-chixushijian"></span>
|
||||
<div class="name">
|
||||
持续时间
|
||||
</div>
|
||||
<div class="code-name">.icon-chixushijian
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont icon-duibi"></span>
|
||||
<div class="name">
|
||||
对比
|
||||
</div>
|
||||
<div class="code-name">.icon-duibi
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont icon-huanyuan"></span>
|
||||
<div class="name">
|
||||
还原
|
||||
</div>
|
||||
<div class="code-name">.icon-huanyuan
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont icon-vr"></span>
|
||||
<div class="name">
|
||||
vr
|
||||
</div>
|
||||
<div class="code-name">.icon-vr
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont icon-chixushijian1"></span>
|
||||
<div class="name">
|
||||
持续时间
|
||||
</div>
|
||||
<div class="code-name">.icon-chixushijian1
|
||||
</div>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
<div class="article markdown">
|
||||
<h2 id="font-class-">font-class 引用</h2>
|
||||
<hr>
|
||||
|
||||
<p>font-class 是 Unicode 使用方式的一种变种,主要是解决 Unicode 书写不直观,语意不明确的问题。</p>
|
||||
<p>与 Unicode 使用方式相比,具有如下特点:</p>
|
||||
<ul>
|
||||
<li>相比于 Unicode 语意明确,书写更直观。可以很容易分辨这个 icon 是什么。</li>
|
||||
<li>因为使用 class 来定义图标,所以当要替换图标时,只需要修改 class 里面的 Unicode 引用。</li>
|
||||
</ul>
|
||||
<p>使用步骤如下:</p>
|
||||
<h3 id="-fontclass-">第一步:引入项目下面生成的 fontclass 代码:</h3>
|
||||
<pre><code class="language-html"><link rel="stylesheet" href="./iconfont.css">
|
||||
</code></pre>
|
||||
<h3 id="-">第二步:挑选相应图标并获取类名,应用于页面:</h3>
|
||||
<pre><code class="language-html"><span class="iconfont icon-xxx"></span>
|
||||
</code></pre>
|
||||
<blockquote>
|
||||
<p>"
|
||||
iconfont" 是你项目下的 font-family。可以通过编辑项目查看,默认是 "iconfont"。</p>
|
||||
</blockquote>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content symbol">
|
||||
<ul class="icon_lists dib-box">
|
||||
|
||||
<li class="dib">
|
||||
<svg class="icon svg-icon" aria-hidden="true">
|
||||
<use xlink:href="#icon-quanping"></use>
|
||||
</svg>
|
||||
<div class="name">全屏</div>
|
||||
<div class="code-name">#icon-quanping</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<svg class="icon svg-icon" aria-hidden="true">
|
||||
<use xlink:href="#icon-bangzhu"></use>
|
||||
</svg>
|
||||
<div class="name">帮助</div>
|
||||
<div class="code-name">#icon-bangzhu</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<svg class="icon svg-icon" aria-hidden="true">
|
||||
<use xlink:href="#icon-bangzhu_o"></use>
|
||||
</svg>
|
||||
<div class="name">帮助_o</div>
|
||||
<div class="code-name">#icon-bangzhu_o</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<svg class="icon svg-icon" aria-hidden="true">
|
||||
<use xlink:href="#icon-bantouming"></use>
|
||||
</svg>
|
||||
<div class="name">半透明(2)</div>
|
||||
<div class="code-name">#icon-bantouming</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<svg class="icon svg-icon" aria-hidden="true">
|
||||
<use xlink:href="#icon-chixushijian"></use>
|
||||
</svg>
|
||||
<div class="name">持续时间</div>
|
||||
<div class="code-name">#icon-chixushijian</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<svg class="icon svg-icon" aria-hidden="true">
|
||||
<use xlink:href="#icon-duibi"></use>
|
||||
</svg>
|
||||
<div class="name">对比</div>
|
||||
<div class="code-name">#icon-duibi</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<svg class="icon svg-icon" aria-hidden="true">
|
||||
<use xlink:href="#icon-huanyuan"></use>
|
||||
</svg>
|
||||
<div class="name">还原</div>
|
||||
<div class="code-name">#icon-huanyuan</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<svg class="icon svg-icon" aria-hidden="true">
|
||||
<use xlink:href="#icon-vr"></use>
|
||||
</svg>
|
||||
<div class="name">vr</div>
|
||||
<div class="code-name">#icon-vr</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<svg class="icon svg-icon" aria-hidden="true">
|
||||
<use xlink:href="#icon-chixushijian1"></use>
|
||||
</svg>
|
||||
<div class="name">持续时间</div>
|
||||
<div class="code-name">#icon-chixushijian1</div>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
<div class="article markdown">
|
||||
<h2 id="symbol-">Symbol 引用</h2>
|
||||
<hr>
|
||||
|
||||
<p>这是一种全新的使用方式,应该说这才是未来的主流,也是平台目前推荐的用法。相关介绍可以参考这篇<a href="">文章</a>
|
||||
这种用法其实是做了一个 SVG 的集合,与另外两种相比具有如下特点:</p>
|
||||
<ul>
|
||||
<li>支持多色图标了,不再受单色限制。</li>
|
||||
<li>通过一些技巧,支持像字体那样,通过 <code>font-size</code>, <code>color</code> 来调整样式。</li>
|
||||
<li>兼容性较差,支持 IE9+,及现代浏览器。</li>
|
||||
<li>浏览器渲染 SVG 的性能一般,还不如 png。</li>
|
||||
</ul>
|
||||
<p>使用步骤如下:</p>
|
||||
<h3 id="-symbol-">第一步:引入项目下面生成的 symbol 代码:</h3>
|
||||
<pre><code class="language-html"><script src="./iconfont.js"></script>
|
||||
</code></pre>
|
||||
<h3 id="-css-">第二步:加入通用 CSS 代码(引入一次就行):</h3>
|
||||
<pre><code class="language-html"><style>
|
||||
.icon {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
vertical-align: -0.15em;
|
||||
fill: currentColor;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
</code></pre>
|
||||
<h3 id="-">第三步:挑选相应图标并获取类名,应用于页面:</h3>
|
||||
<pre><code class="language-html"><svg class="icon" aria-hidden="true">
|
||||
<use xlink:href="#icon-xxx"></use>
|
||||
</svg>
|
||||
</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
$('.tab-container .content:first').show()
|
||||
|
||||
$('#tabs li').click(function (e) {
|
||||
var tabContent = $('.tab-container .content')
|
||||
var index = $(this).index()
|
||||
|
||||
if ($(this).hasClass('active')) {
|
||||
return
|
||||
} else {
|
||||
$('#tabs li').removeClass('active')
|
||||
$(this).addClass('active')
|
||||
|
||||
tabContent.hide().eq(index).fadeIn()
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
49
src/assets/style/font_oy0t1irww4k/iconfont.css
Normal file
49
src/assets/style/font_oy0t1irww4k/iconfont.css
Normal file
@ -0,0 +1,49 @@
|
||||
@font-face {
|
||||
font-family: "iconfont"; /* Project id */
|
||||
src: url('iconfont.ttf?t=1733881413198') format('truetype');
|
||||
}
|
||||
|
||||
.iconfont {
|
||||
font-family: "iconfont" !important;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.icon-quanping:before {
|
||||
content: "\e660";
|
||||
}
|
||||
|
||||
.icon-bangzhu:before {
|
||||
content: "\e620";
|
||||
}
|
||||
|
||||
.icon-bangzhu_o:before {
|
||||
content: "\eb72";
|
||||
}
|
||||
|
||||
.icon-bantouming:before {
|
||||
content: "\e60b";
|
||||
}
|
||||
|
||||
.icon-chixushijian:before {
|
||||
content: "\e71a";
|
||||
}
|
||||
|
||||
.icon-duibi:before {
|
||||
content: "\e60c";
|
||||
}
|
||||
|
||||
.icon-huanyuan:before {
|
||||
content: "\e604";
|
||||
}
|
||||
|
||||
.icon-vr:before {
|
||||
content: "\e70a";
|
||||
}
|
||||
|
||||
.icon-chixushijian1:before {
|
||||
content: "\e51b";
|
||||
}
|
||||
|
||||
1
src/assets/style/font_oy0t1irww4k/iconfont.js
Normal file
1
src/assets/style/font_oy0t1irww4k/iconfont.js
Normal file
File diff suppressed because one or more lines are too long
72
src/assets/style/font_oy0t1irww4k/iconfont.json
Normal file
72
src/assets/style/font_oy0t1irww4k/iconfont.json
Normal file
@ -0,0 +1,72 @@
|
||||
{
|
||||
"id": "",
|
||||
"name": "",
|
||||
"font_family": "iconfont",
|
||||
"css_prefix_text": "icon-",
|
||||
"description": "",
|
||||
"glyphs": [
|
||||
{
|
||||
"icon_id": "807275",
|
||||
"name": "全屏",
|
||||
"font_class": "quanping",
|
||||
"unicode": "e660",
|
||||
"unicode_decimal": 58976
|
||||
},
|
||||
{
|
||||
"icon_id": "1304879",
|
||||
"name": "帮助",
|
||||
"font_class": "bangzhu",
|
||||
"unicode": "e620",
|
||||
"unicode_decimal": 58912
|
||||
},
|
||||
{
|
||||
"icon_id": "5387852",
|
||||
"name": "帮助_o",
|
||||
"font_class": "bangzhu_o",
|
||||
"unicode": "eb72",
|
||||
"unicode_decimal": 60274
|
||||
},
|
||||
{
|
||||
"icon_id": "8883075",
|
||||
"name": "半透明(2)",
|
||||
"font_class": "bantouming",
|
||||
"unicode": "e60b",
|
||||
"unicode_decimal": 58891
|
||||
},
|
||||
{
|
||||
"icon_id": "11768105",
|
||||
"name": "持续时间",
|
||||
"font_class": "chixushijian",
|
||||
"unicode": "e71a",
|
||||
"unicode_decimal": 59162
|
||||
},
|
||||
{
|
||||
"icon_id": "15726777",
|
||||
"name": "对比",
|
||||
"font_class": "duibi",
|
||||
"unicode": "e60c",
|
||||
"unicode_decimal": 58892
|
||||
},
|
||||
{
|
||||
"icon_id": "16731254",
|
||||
"name": "还原",
|
||||
"font_class": "huanyuan",
|
||||
"unicode": "e604",
|
||||
"unicode_decimal": 58884
|
||||
},
|
||||
{
|
||||
"icon_id": "22273778",
|
||||
"name": "vr",
|
||||
"font_class": "vr",
|
||||
"unicode": "e70a",
|
||||
"unicode_decimal": 59146
|
||||
},
|
||||
{
|
||||
"icon_id": "42514699",
|
||||
"name": "持续时间",
|
||||
"font_class": "chixushijian1",
|
||||
"unicode": "e51b",
|
||||
"unicode_decimal": 58651
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
src/assets/style/font_oy0t1irww4k/iconfont.ttf
Normal file
BIN
src/assets/style/font_oy0t1irww4k/iconfont.ttf
Normal file
Binary file not shown.
104
src/assets/style/global.css
Normal file
104
src/assets/style/global.css
Normal file
@ -0,0 +1,104 @@
|
||||
/* 全局样式重置 */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: #1e1e1e;
|
||||
color: #cccccc;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #2d2d2d;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #4a4a4a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #5a5a5a;
|
||||
}
|
||||
|
||||
/* 按钮基础样式 */
|
||||
button {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* 输入框基础样式 */
|
||||
input, textarea, select {
|
||||
font-family: inherit;
|
||||
background: #2d2d2d;
|
||||
border: 1px solid #4a4a4a;
|
||||
color: #cccccc;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
input:focus, textarea:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: #007acc;
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.loading::after {
|
||||
content: '';
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid #4a4a4a;
|
||||
border-top: 3px solid #007acc;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 文本选择 */
|
||||
::selection {
|
||||
background: #007acc;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 禁用文本选择的元素 */
|
||||
.no-select {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
90
src/assets/style/loading.css
Normal file
90
src/assets/style/loading.css
Normal file
@ -0,0 +1,90 @@
|
||||
.loading-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 9999;
|
||||
background: url('/loading/加载页背景图.jpg') no-repeat center center;
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
.loading-content {
|
||||
width: 230px;
|
||||
max-width: 600px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-top: -70px;
|
||||
}
|
||||
|
||||
|
||||
.logo {
|
||||
width: 152px;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 50px;
|
||||
color: #333;
|
||||
position: relative;
|
||||
}
|
||||
.logo img{
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
.truck-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
margin-top: -40px; /* 向上移动,与进度条重叠 */
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background-color: rgba(255, 255, 255, 0.37);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
margin-bottom: 20px;
|
||||
padding: 2px;
|
||||
box-shadow: 0 0 3px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
align-items: center; /* 保持垂直居中 */
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 4px; /* 保持这个高度 */
|
||||
background-color: #1087d6;
|
||||
transition: width 0.3s;
|
||||
border-radius: 3px;
|
||||
/* 移除这两行,它们导致了中间扩散效果 */
|
||||
/* margin: 0 auto; */
|
||||
/* align-self: center; */
|
||||
|
||||
/* 添加这行确保从左边开始 */
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.truck {
|
||||
position: absolute;
|
||||
top: -16px; /* 相对于truck-container定位 */
|
||||
transform: translateX(-90%); /* 改为-90%,让小车更靠左一些 */
|
||||
transition: left 0.3s;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.truck img {
|
||||
display: block;
|
||||
width: 80px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
286
src/assets/style/media.css
Normal file
286
src/assets/style/media.css
Normal file
@ -0,0 +1,286 @@
|
||||
@charset "UTF-8";
|
||||
|
||||
|
||||
|
||||
|
||||
/* 移动设备 */
|
||||
@media screen and (max-width: 350px) {
|
||||
.title {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* 移动设备 */
|
||||
@media screen and (max-width: 640px) {
|
||||
|
||||
.loaderbox img {
|
||||
width: 200px !important;
|
||||
}
|
||||
|
||||
.jiudu {
|
||||
min-width: 70px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
|
||||
/* 横屏*/
|
||||
@media screen and (max-height: 766px) {
|
||||
|
||||
.ui-bottom {
|
||||
bottom: 20px;
|
||||
right: 10px;
|
||||
/* 通过偏移实现居中 */
|
||||
}
|
||||
|
||||
.dialog-item {
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
}
|
||||
|
||||
.dialog-item .Bar-Line {
|
||||
width: calc(5rem + 6px);
|
||||
height: calc(5rem + 6px);
|
||||
box-shadow: 0 0 0 1px #fff;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 5px 5px
|
||||
}
|
||||
|
||||
.TopInfo-content .title {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* 竖屏*/
|
||||
@media screen and (min-height: 768px) {
|
||||
|
||||
.ui-bottom {
|
||||
bottom: 20px;
|
||||
right: 10px;
|
||||
/* 通过偏移实现居中 */
|
||||
}
|
||||
|
||||
.dialog-item {
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
|
||||
}
|
||||
|
||||
.dialog-item .Bar-Line {
|
||||
width: calc(5rem + 6px);
|
||||
height: calc(5rem + 6px);
|
||||
box-shadow: 0 0 0 1px #fff;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 5px 5px
|
||||
}
|
||||
|
||||
|
||||
.TopInfo-content .title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.ColorBar1-container {
|
||||
top: 20px;
|
||||
}
|
||||
|
||||
/* .ColorBar-container {
|
||||
|
||||
left: 0;
|
||||
top: 20px;
|
||||
|
||||
flex-direction: column;
|
||||
justify-content: space-evenly;
|
||||
} */
|
||||
}
|
||||
|
||||
|
||||
.return {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
/* 移动设备 */
|
||||
@media screen and (max-width: 767px) and (min-width: 640px) {
|
||||
|
||||
/* 横屏*/
|
||||
@media screen and (max-height: 400px) {
|
||||
|
||||
.ui-bottom {
|
||||
bottom: 20px;
|
||||
right: 10px;
|
||||
/* 通过偏移实现居中 */
|
||||
}
|
||||
|
||||
.dialog-item {
|
||||
width: 7rem;
|
||||
height: 7rem;
|
||||
}
|
||||
|
||||
.dialog-item .Bar-Line {
|
||||
width: calc(7rem + 6px);
|
||||
height: calc(7rem + 6px);
|
||||
box-shadow: 0 0 0 1px #fff;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 30px 10px !important;
|
||||
}
|
||||
|
||||
.dialog-bottom {
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/* 横屏*/
|
||||
@media screen and (max-height: 766px) and (min-height: 400px) {
|
||||
|
||||
.ui-bottom {
|
||||
bottom: 20px;
|
||||
right: 10px;
|
||||
/* 通过偏移实现居中 */
|
||||
}
|
||||
|
||||
.dialog-item {
|
||||
width: 9rem;
|
||||
height: 9rem;
|
||||
}
|
||||
|
||||
.dialog-item .Bar-Line {
|
||||
width: calc(9rem + 6px);
|
||||
height: calc(9rem + 6px);
|
||||
box-shadow: 0 0 0 1px #fff;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 5px 5px
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
/* 竖屏*/
|
||||
@media screen and (min-height: 768px) {
|
||||
|
||||
|
||||
.ui-bottom {
|
||||
bottom: 20px;
|
||||
right: 10px;
|
||||
/* 通过偏移实现居中 */
|
||||
}
|
||||
|
||||
.dialog-item {
|
||||
width: 10rem;
|
||||
height: 10rem;
|
||||
|
||||
}
|
||||
|
||||
.dialog-item .Bar-Line {
|
||||
width: calc(10rem + 6px);
|
||||
height: calc(10rem + 6px);
|
||||
box-shadow: 0 0 0 1px #fff;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 5px 5px
|
||||
}
|
||||
|
||||
.TopInfo-content .title {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.return {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/* 电脑设备 */
|
||||
@media screen and (min-width: 768px) {
|
||||
|
||||
|
||||
|
||||
/* 横屏*/
|
||||
@media screen and (max-height: 400px) {
|
||||
|
||||
|
||||
|
||||
.ui-bottom {
|
||||
bottom: 20px;
|
||||
right: 10px;
|
||||
/* 通过偏移实现居中 */
|
||||
}
|
||||
|
||||
.dialog-item {
|
||||
width: 7rem;
|
||||
height: 7rem;
|
||||
}
|
||||
|
||||
.dialog-item .Bar-Line {
|
||||
width: calc(7rem + 6px);
|
||||
height: calc(7rem + 6px);
|
||||
box-shadow: 0 0 0 1px #fff;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 30px 10px !important;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
/* 横屏*/
|
||||
@media screen and (min-height: 400px) {
|
||||
|
||||
|
||||
.ui-bottom {
|
||||
right: 20px;
|
||||
bottom: 0px;
|
||||
}
|
||||
|
||||
.dialog-item {
|
||||
width: 12.5rem;
|
||||
height: 12.5rem;
|
||||
}
|
||||
|
||||
.dialog-item .Bar-Line {
|
||||
|
||||
width: calc(12.5rem + 6px);
|
||||
height: calc(12.5rem + 6px);
|
||||
box-shadow: 0 0 0 1px #fff;
|
||||
}
|
||||
|
||||
.ColorBar1-container {
|
||||
top: 40px;
|
||||
}
|
||||
|
||||
.jiudu {
|
||||
min-width: 5rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.return {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
}
|
||||
6
src/assets/style/normal.css
Normal file
6
src/assets/style/normal.css
Normal file
@ -0,0 +1,6 @@
|
||||
html,
|
||||
body {
|
||||
overflow: hidden;
|
||||
margin : 0;
|
||||
padding : 0;
|
||||
}
|
||||
8
src/assets/style/style.css
Normal file
8
src/assets/style/style.css
Normal file
@ -0,0 +1,8 @@
|
||||
.property-label {
|
||||
width: 60px;
|
||||
color: #cccccc;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
text-align: left;
|
||||
cursor: help;
|
||||
}
|
||||
414
src/components/BasicControls/Field/index.vue
Normal file
414
src/components/BasicControls/Field/index.vue
Normal file
@ -0,0 +1,414 @@
|
||||
<template>
|
||||
<div class="field-container" :class="{
|
||||
'field-disabled': disabled,
|
||||
'field-drag-over': isDragOver,
|
||||
'field-has-value': hasValue
|
||||
}" @dragover.prevent="handleDragOver" @dragleave="handleDragLeave" @drop.prevent="handleDrop">
|
||||
<!-- 字段标签 -->
|
||||
<div class="field-label" v-if="label">
|
||||
{{ label }}
|
||||
</div>
|
||||
|
||||
<!-- 字段内容区域 -->
|
||||
<div class="field-content">
|
||||
<!-- 有值时显示的内容 -->
|
||||
<div v-if="hasValue" class="field-value">
|
||||
<!-- 图标 -->
|
||||
<div class="field-icon">
|
||||
<component :is="getIconComponent()" />
|
||||
</div>
|
||||
|
||||
<!-- 名称 -->
|
||||
<div class="field-name">
|
||||
{{ modelValue?.name || 'Unnamed' }}
|
||||
</div>
|
||||
|
||||
<!-- 清除按钮 -->
|
||||
<button class="field-clear" @click="clearValue" :disabled="disabled">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 空值时显示的占位符 -->
|
||||
<div v-else class="field-placeholder">
|
||||
<div class="field-icon-placeholder">
|
||||
<component :is="getIconComponent()" />
|
||||
</div>
|
||||
<div class="field-text">
|
||||
None ({{ acceptedType }})
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 拖拽提示 -->
|
||||
<div v-if="isDragOver" class="field-drag-hint">
|
||||
<div class="drag-hint-text">
|
||||
{{ getDragHintText() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, h } from 'vue'
|
||||
import type { FieldProps, FieldEmits, FieldValue, AcceptedType } from './types'
|
||||
import { userDragStore } from 'stores/Filed'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useCursorStore } from '@/stores/Cursor'
|
||||
const dragStore = userDragStore()
|
||||
const cursorStore = useCursorStore()
|
||||
const { dragType, dragData } = storeToRefs(dragStore)
|
||||
|
||||
// 图标组件
|
||||
const TextureIcon = () => h('svg', {
|
||||
width: '16',
|
||||
height: '16',
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'currentColor'
|
||||
}, [
|
||||
h('path', {
|
||||
d: 'M8.5,13.5L11,16.5L14.5,12L19,18H5M21,19V5C21,3.89 20.1,3 19,3H5A2,2 0 0,0 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19Z'
|
||||
})
|
||||
])
|
||||
|
||||
const MeshIcon = () => h('svg', {
|
||||
width: '16',
|
||||
height: '16',
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'currentColor'
|
||||
}, [
|
||||
h('path', {
|
||||
d: 'M12,2L13.09,8.26L22,9L17.74,15.74L19,22L12,19L5,22L6.26,15.74L2,9L10.91,8.26L12,2Z'
|
||||
})
|
||||
])
|
||||
|
||||
const MaterialIcon = () => h('svg', {
|
||||
width: '16',
|
||||
height: '16',
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'currentColor'
|
||||
}, [
|
||||
h('path', {
|
||||
d: 'M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4Z'
|
||||
})
|
||||
])
|
||||
|
||||
const props = withDefaults(defineProps<FieldProps>(), {
|
||||
disabled: false,
|
||||
acceptedType: 'Texture',
|
||||
allowClear: true
|
||||
})
|
||||
|
||||
const emit = defineEmits<FieldEmits>()
|
||||
|
||||
// 状态
|
||||
const isDragOver = ref(false)
|
||||
|
||||
// 计算属性
|
||||
const hasValue = computed(() => props.modelValue !== null && props.modelValue !== undefined)
|
||||
|
||||
// 获取图标组件
|
||||
const getIconComponent = () => {
|
||||
switch (props.acceptedType) {
|
||||
case 'Texture':
|
||||
return TextureIcon
|
||||
case 'Mesh':
|
||||
return MeshIcon
|
||||
case 'Material':
|
||||
return MaterialIcon
|
||||
default:
|
||||
return TextureIcon
|
||||
}
|
||||
}
|
||||
|
||||
// 获取拖拽提示文本
|
||||
const getDragHintText = () => {
|
||||
return `Drop ${props.acceptedType} here`
|
||||
}
|
||||
|
||||
// 检查拖拽的元素是否为支持的类型
|
||||
const isValidDragItem = (dragData: any): boolean => {
|
||||
if (!dragData) return false
|
||||
|
||||
// 检查类型是否匹配
|
||||
if (dragData.type !== props.acceptedType) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 可以添加更多的验证逻辑
|
||||
// 例如检查文件扩展名、MIME类型等
|
||||
if (props.acceptedType === 'Texture') {
|
||||
const validExtensions = ['.png', '.jpg', '.jpeg', '.tga', '.bmp', '.gif']
|
||||
const fileName = dragData.name || ''
|
||||
return validExtensions.some(ext => fileName.toLowerCase().endsWith(ext))
|
||||
}
|
||||
|
||||
if (props.acceptedType === 'Mesh') {
|
||||
const validExtensions = ['.fbx', '.obj', '.dae', '.3ds', '.blend']
|
||||
const fileName = dragData.name || ''
|
||||
return validExtensions.some(ext => fileName.toLowerCase().endsWith(ext))
|
||||
}
|
||||
|
||||
if (props.acceptedType === 'Material') {
|
||||
const validExtensions = ['.mat', '.material']
|
||||
const fileName = dragData.name || ''
|
||||
return validExtensions.some(ext => fileName.toLowerCase().endsWith(ext))
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// 拖拽事件处理
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
|
||||
if (props.disabled) return
|
||||
|
||||
try {
|
||||
const match = dragType.value == props.acceptedType
|
||||
if (match) {
|
||||
isDragOver.value = true
|
||||
cursorStore.setCursor('default')
|
||||
} else {
|
||||
cursorStore.setCursor('not-allowed')
|
||||
}
|
||||
} catch {
|
||||
// 如果无法解析数据,则拒绝拖拽
|
||||
cursorStore.setCursor('not-allowed')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragLeave = () => {
|
||||
isDragOver.value = false
|
||||
}
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
if (props.disabled) return
|
||||
|
||||
isDragOver.value = false
|
||||
|
||||
|
||||
try {
|
||||
const match = dragType.value == props.acceptedType
|
||||
if (match) {
|
||||
|
||||
if (isValidDragItem(dragData.value)) {
|
||||
const fieldValue: FieldValue = {
|
||||
id: dragData.value.id || Date.now().toString(),
|
||||
name: dragData.value.name || 'Unnamed',
|
||||
type: dragData.value.type,
|
||||
path: dragData.value.path || '',
|
||||
metadata: dragData.value.metadata || {}
|
||||
}
|
||||
|
||||
emit('update:modelValue', fieldValue)
|
||||
emit('change', fieldValue)
|
||||
} else {
|
||||
// 发出错误事件
|
||||
emit('error', {
|
||||
type: 'invalid_type',
|
||||
message: `Cannot assign ${dragData.value.type || 'unknown'} to ${props.acceptedType} field`,
|
||||
expectedType: props.acceptedType,
|
||||
actualType: dragData.value.type
|
||||
})
|
||||
}
|
||||
|
||||
} else {
|
||||
cursorStore.setCursor('not-allowed')
|
||||
}
|
||||
} catch {
|
||||
// 如果无法解析数据,则拒绝拖拽
|
||||
cursorStore.setCursor('not-allowed')
|
||||
}
|
||||
|
||||
// try {
|
||||
// const dragData = JSON.parse(event.dataTransfer?.getData('application/json') || '{}')
|
||||
|
||||
// if (isValidDragItem(dragData)) {
|
||||
// const fieldValue: FieldValue = {
|
||||
// id: dragData.id || Date.now().toString(),
|
||||
// name: dragData.name || 'Unnamed',
|
||||
// type: dragData.type,
|
||||
// path: dragData.path || '',
|
||||
// metadata: dragData.metadata || {}
|
||||
// }
|
||||
|
||||
// emit('update:modelValue', fieldValue)
|
||||
// emit('change', fieldValue)
|
||||
// } else {
|
||||
// // 发出错误事件
|
||||
// emit('error', {
|
||||
// type: 'invalid_type',
|
||||
// message: `Cannot assign ${dragData.type || 'unknown'} to ${props.acceptedType} field`,
|
||||
// expectedType: props.acceptedType,
|
||||
// actualType: dragData.type
|
||||
// })
|
||||
// }
|
||||
// } catch (error) {
|
||||
// emit('error', {
|
||||
// type: 'parse_error',
|
||||
// message: 'Failed to parse drag data',
|
||||
// error
|
||||
// })
|
||||
// }
|
||||
}
|
||||
|
||||
// 清除值
|
||||
const clearValue = () => {
|
||||
if (props.disabled || !props.allowClear) return
|
||||
|
||||
emit('update:modelValue', null)
|
||||
emit('change', null)
|
||||
emit('clear')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.field-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
color: #cccccc;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.field-content {
|
||||
position: relative;
|
||||
min-height: 20px;
|
||||
background: #2d2d2d;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 2px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.field-container.field-drag-over .field-content {
|
||||
border-color: #409eff;
|
||||
background: #353535;
|
||||
box-shadow: 0 0 0 1px rgba(64, 158, 255, 0.2);
|
||||
}
|
||||
|
||||
.field-container.field-disabled .field-content {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.field-container.field-has-value .field-content {
|
||||
border-color: #5a5a5a;
|
||||
}
|
||||
|
||||
.field-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 3px 6px;
|
||||
gap: 6px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.field-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 3px 6px;
|
||||
gap: 6px;
|
||||
height: 100%;
|
||||
color: #888888;
|
||||
}
|
||||
|
||||
.field-icon,
|
||||
.field-icon-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
color: #888888;
|
||||
}
|
||||
|
||||
.field-has-value .field-icon {
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
.field-name {
|
||||
flex: 1;
|
||||
color: #cccccc;
|
||||
font-size: 10px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.field-text {
|
||||
flex: 1;
|
||||
font-size: 10px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.field-clear {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888888;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 1px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.field-clear:hover {
|
||||
background: #4a4a4a;
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
.field-clear:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.field-drag-hint {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(64, 158, 255, 0.1);
|
||||
border: 2px dashed #409eff;
|
||||
border-radius: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.drag-hint-text {
|
||||
color: #409eff;
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 悬停效果 */
|
||||
.field-content:hover {
|
||||
border-color: #5a5a5a;
|
||||
}
|
||||
|
||||
.field-container.field-disabled .field-content:hover {
|
||||
border-color: #4a4a4a;
|
||||
}
|
||||
|
||||
/* 焦点效果 */
|
||||
.field-content:focus-within {
|
||||
border-color: #409eff;
|
||||
box-shadow: 0 0 0 1px rgba(64, 158, 255, 0.2);
|
||||
}
|
||||
</style>
|
||||
34
src/components/BasicControls/Field/types.ts
Normal file
34
src/components/BasicControls/Field/types.ts
Normal file
@ -0,0 +1,34 @@
|
||||
// Field 组件类型定义
|
||||
|
||||
export type AcceptedType = 'Texture' | 'Mesh' | 'Material'
|
||||
|
||||
export interface FieldValue {
|
||||
id: string
|
||||
name: string
|
||||
type: AcceptedType
|
||||
path: string
|
||||
metadata?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface FieldError {
|
||||
type: 'invalid_type' | 'parse_error'
|
||||
message: string
|
||||
expectedType?: AcceptedType
|
||||
actualType?: string
|
||||
error?: any
|
||||
}
|
||||
|
||||
export interface FieldProps {
|
||||
modelValue?: FieldValue | null
|
||||
label?: string
|
||||
acceptedType?: AcceptedType
|
||||
disabled?: boolean
|
||||
allowClear?: boolean
|
||||
}
|
||||
|
||||
export interface FieldEmits {
|
||||
'update:modelValue': [value: FieldValue | null]
|
||||
'change': [value: FieldValue | null]
|
||||
'clear': []
|
||||
'error': [error: FieldError]
|
||||
}
|
||||
163
src/components/BasicControls/FileNode/index.css
Normal file
163
src/components/BasicControls/FileNode/index.css
Normal file
@ -0,0 +1,163 @@
|
||||
/* FileNode 组件样式 - 文件夹形式 */
|
||||
.file-node {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 2px 2px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-radius: 6px;
|
||||
position: relative;
|
||||
/* background: #353535; */
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.file-node:hover {
|
||||
background-color: #404040;
|
||||
}
|
||||
|
||||
.file-node.dragging {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.file-node.drag-over {
|
||||
background-color: rgba(64, 158, 255, 0.2);
|
||||
border: 2px dashed #409eff;
|
||||
}
|
||||
|
||||
/* 拖拽指示器 */
|
||||
.file-node.drag-over-before::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background-color: #409eff;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.file-node.drag-over-after::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background-color: #409eff;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* 文件图标 */
|
||||
.file-node-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32px;
|
||||
margin-bottom: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 文件名 */
|
||||
.file-node-name {
|
||||
font-size: 11px;
|
||||
color: #cccccc;
|
||||
text-align: center;
|
||||
word-break: break-word;
|
||||
line-height: 1.2;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* 选中效果只应用到文件名 */
|
||||
.file-node.selected .file-node-name {
|
||||
background-color: #2e70b3b7;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.file-node-name.editing {
|
||||
/* background-color: #2d2d2d;
|
||||
border: 1px solid #409eff; */
|
||||
border-radius: 2px;
|
||||
padding: 2px 4px;
|
||||
outline: none;
|
||||
width: 90%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 文件大小 */
|
||||
.file-node-size {
|
||||
font-size: 10px;
|
||||
color: #888;
|
||||
margin-top: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 拖拽鼠标样式 */
|
||||
.drag-cursor-grab {
|
||||
cursor: grab !important;
|
||||
}
|
||||
|
||||
.drag-cursor-grabbing {
|
||||
cursor: grabbing !important;
|
||||
}
|
||||
|
||||
.drag-cursor-copy {
|
||||
cursor: copy !important;
|
||||
}
|
||||
|
||||
.drag-cursor-move {
|
||||
cursor: move !important;
|
||||
}
|
||||
|
||||
.drag-cursor-not-allowed {
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.file-node {
|
||||
padding: 8px 6px;
|
||||
min-height: 70px;
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.file-node-icon {
|
||||
font-size: 28px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.file-node-name {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
.file-node-container::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.file-node-container::-webkit-scrollbar-track {
|
||||
background: #2d2d2d;
|
||||
}
|
||||
|
||||
.file-node-container::-webkit-scrollbar-thumb {
|
||||
background: #4a4a4a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.file-node-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #5a5a5a;
|
||||
}
|
||||
5
src/components/BasicControls/FileNode/index.ts
Normal file
5
src/components/BasicControls/FileNode/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
// 导出类型和常量
|
||||
export * from './types'
|
||||
|
||||
// 导出组件
|
||||
export { default } from './index.vue'
|
||||
258
src/components/BasicControls/FileNode/index.vue
Normal file
258
src/components/BasicControls/FileNode/index.vue
Normal file
@ -0,0 +1,258 @@
|
||||
<template>
|
||||
<div
|
||||
:class="nodeClasses"
|
||||
:style="nodeStyles"
|
||||
@click="handleClick"
|
||||
@dblclick="handleDoubleClick"
|
||||
@dragstart="handleDragStart"
|
||||
@dragend="handleDragEnd"
|
||||
@dragover="handleDragOver"
|
||||
@dragleave="handleDragLeave"
|
||||
@drop="handleDrop"
|
||||
:draggable="draggable"
|
||||
>
|
||||
<!-- 文件图标 -->
|
||||
<div class="file-node-icon">
|
||||
{{ FILE_ICONS[node.type] }}
|
||||
</div>
|
||||
|
||||
<!-- 文件名 -->
|
||||
<div
|
||||
v-if="!isEditing"
|
||||
class="file-node-name"
|
||||
@dblclick.stop="startEdit"
|
||||
>
|
||||
{{ node.name }}
|
||||
</div>
|
||||
<input
|
||||
v-else
|
||||
ref="editInput"
|
||||
v-model="editName"
|
||||
class="file-node-name editing"
|
||||
@blur="finishEdit"
|
||||
@keyup.enter="finishEdit"
|
||||
@keyup.esc="cancelEdit"
|
||||
@click.stop
|
||||
/>
|
||||
|
||||
<!-- 文件大小(可选显示) -->
|
||||
<div
|
||||
v-if="showSize && node.size !== undefined"
|
||||
class="file-node-size"
|
||||
>
|
||||
{{ formatFileSize(node.size) }}
|
||||
</div>
|
||||
|
||||
<!-- 修改时间 -->
|
||||
<div
|
||||
v-if="showDetails && node.lastModified"
|
||||
class="file-node-date"
|
||||
>
|
||||
{{ formatDate(node.lastModified) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, watch } from 'vue'
|
||||
import { FileNode, FileNodeType, FILE_ICONS, DRAG_CURSORS } from './types'
|
||||
import { formatFileSize, formatDate } from '../../../utils/Tools'
|
||||
import './index.css'
|
||||
|
||||
interface Props {
|
||||
node: FileNode
|
||||
depth?: number
|
||||
draggable?: boolean
|
||||
showDetails?: boolean
|
||||
selectedId?: string
|
||||
dragState?: {
|
||||
isDragging: boolean
|
||||
dragNode: FileNode | null
|
||||
dragOverNode: FileNode | null
|
||||
dropPosition: 'before' | 'after' | 'inside' | null
|
||||
}
|
||||
showSize?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'click', node: FileNode): void
|
||||
(e: 'double-click', node: FileNode): void
|
||||
(e: 'expand', node: FileNode): void
|
||||
(e: 'rename', node: FileNode, newName: string): void
|
||||
(e: 'drag-start', node: FileNode, event: DragEvent): void
|
||||
(e: 'drag-end', node: FileNode, event: DragEvent): void
|
||||
(e: 'drag-over', node: FileNode, event: DragEvent): void
|
||||
(e: 'drag-leave', node: FileNode, event: DragEvent): void
|
||||
(e: 'drop', targetNode: FileNode, dragNode: FileNode, position: string, event: DragEvent): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
depth: 0,
|
||||
draggable: true,
|
||||
showDetails: false,
|
||||
showSize: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// 编辑状态
|
||||
const isEditing = ref(false)
|
||||
const editName = ref('')
|
||||
const editInput = ref<HTMLInputElement>()
|
||||
|
||||
// 计算属性
|
||||
const hasChildren = computed(() => {
|
||||
return props.node.children && props.node.children.length > 0
|
||||
})
|
||||
|
||||
const nodeClasses = computed(() => {
|
||||
const classes = [
|
||||
'file-node',
|
||||
`type-${props.node.type.toLowerCase()}`
|
||||
]
|
||||
|
||||
if (props.node.isSelected || props.selectedId === props.node.id) {
|
||||
classes.push('selected')
|
||||
}
|
||||
|
||||
if (props.node.isDragging) {
|
||||
classes.push('dragging')
|
||||
}
|
||||
|
||||
if (props.dragState?.dragOverNode?.id === props.node.id) {
|
||||
classes.push('drag-over')
|
||||
if (props.dragState.dropPosition === 'before') {
|
||||
classes.push('drag-over-before')
|
||||
} else if (props.dragState.dropPosition === 'after') {
|
||||
classes.push('drag-over-after')
|
||||
}
|
||||
}
|
||||
|
||||
return classes
|
||||
})
|
||||
|
||||
const nodeStyles = computed(() => {
|
||||
const styles: Record<string, string> = {}
|
||||
|
||||
if (props.dragState?.isDragging) {
|
||||
if (props.dragState.dragNode?.id === props.node.id) {
|
||||
styles.cursor = DRAG_CURSORS.GRABBING
|
||||
} else {
|
||||
styles.cursor = DRAG_CURSORS.GRAB
|
||||
}
|
||||
}
|
||||
|
||||
return styles
|
||||
})
|
||||
|
||||
// 事件处理
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (!isEditing.value) {
|
||||
emit('click', props.node)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDoubleClick = (event: MouseEvent) => {
|
||||
if (!isEditing.value) {
|
||||
emit('double-click', props.node)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleExpand = () => {
|
||||
if (props.node.type === FileNodeType.Folder) {
|
||||
emit('expand', props.node)
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑功能
|
||||
const startEdit = () => {
|
||||
if (props.node.type !== FileNodeType.Folder) {
|
||||
isEditing.value = true
|
||||
editName.value = props.node.name
|
||||
nextTick(() => {
|
||||
editInput.value?.focus()
|
||||
editInput.value?.select()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const finishEdit = () => {
|
||||
if (editName.value.trim() && editName.value !== props.node.name) {
|
||||
emit('rename', props.node, editName.value.trim())
|
||||
}
|
||||
isEditing.value = false
|
||||
}
|
||||
|
||||
const cancelEdit = () => {
|
||||
editName.value = props.node.name
|
||||
isEditing.value = false
|
||||
}
|
||||
|
||||
// 拖拽功能
|
||||
const handleDragStart = (event: DragEvent) => {
|
||||
if (!props.draggable) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
event.dataTransfer!.effectAllowed = 'move'
|
||||
event.dataTransfer!.setData('text/plain', props.node.id)
|
||||
|
||||
// 设置拖拽图像
|
||||
const dragImage = event.currentTarget as HTMLElement
|
||||
event.dataTransfer!.setDragImage(dragImage, 0, 0)
|
||||
|
||||
emit('drag-start', props.node, event)
|
||||
}
|
||||
|
||||
const handleDragEnd = (event: DragEvent) => {
|
||||
emit('drag-end', props.node, event)
|
||||
}
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
event.dataTransfer!.dropEffect = 'move'
|
||||
|
||||
// 计算拖拽位置
|
||||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
const y = event.clientY - rect.top
|
||||
const height = rect.height
|
||||
|
||||
let position: 'before' | 'after' | 'inside' = 'inside'
|
||||
|
||||
if (props.node.type === FileNodeType.Folder) {
|
||||
if (y < height * 0.25) {
|
||||
position = 'before'
|
||||
} else if (y > height * 0.75) {
|
||||
position = 'after'
|
||||
} else {
|
||||
position = 'inside'
|
||||
}
|
||||
} else {
|
||||
position = y < height * 0.5 ? 'before' : 'after'
|
||||
}
|
||||
|
||||
emit('drag-over', props.node, event)
|
||||
}
|
||||
|
||||
const handleDragLeave = (event: DragEvent) => {
|
||||
emit('drag-leave', props.node, event)
|
||||
}
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
|
||||
const dragNodeId = event.dataTransfer!.getData('text/plain')
|
||||
if (dragNodeId && props.dragState?.dragNode) {
|
||||
const position = props.dragState.dropPosition || 'inside'
|
||||
emit('drop', props.node, props.dragState.dragNode, position, event)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听选中状态变化
|
||||
watch(() => props.selectedId, (newId) => {
|
||||
if (newId !== props.node.id && isEditing.value) {
|
||||
cancelEdit()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
82
src/components/BasicControls/FileNode/types.ts
Normal file
82
src/components/BasicControls/FileNode/types.ts
Normal file
@ -0,0 +1,82 @@
|
||||
// 文件节点类型枚举
|
||||
export enum FileNodeType {
|
||||
Text = 'Text',
|
||||
Folder = 'Folder',
|
||||
Model = 'Model',
|
||||
Audio = 'Audio',
|
||||
Video = 'Video',
|
||||
Material = 'Material',
|
||||
Texture = 'Texture',
|
||||
Image = 'Image',
|
||||
Script = 'Script',
|
||||
Scene = 'Scene'
|
||||
}
|
||||
|
||||
// 文件节点接口
|
||||
export interface FileNode {
|
||||
id: string
|
||||
name: string
|
||||
type: FileNodeType
|
||||
path: string
|
||||
size?: number
|
||||
lastModified?: Date
|
||||
children?: FileNode[]
|
||||
parent?: string
|
||||
isExpanded?: boolean
|
||||
isSelected?: boolean
|
||||
isDragging?: boolean
|
||||
}
|
||||
|
||||
// 拖拽状态
|
||||
export interface DragState {
|
||||
isDragging: boolean
|
||||
dragNode: FileNode | null
|
||||
dragOverNode: FileNode | null
|
||||
dropPosition: 'before' | 'after' | 'inside' | null
|
||||
}
|
||||
|
||||
// 文件图标映射
|
||||
export const FILE_ICONS: Record<FileNodeType, string> = {
|
||||
[FileNodeType.Text]: '📄',
|
||||
[FileNodeType.Folder]: '📁',
|
||||
[FileNodeType.Model]: '🎯',
|
||||
[FileNodeType.Audio]: '🎵',
|
||||
[FileNodeType.Video]: '🎬',
|
||||
[FileNodeType.Material]: '🎨',
|
||||
[FileNodeType.Texture]: '🖼️',
|
||||
[FileNodeType.Image]: '🖼️',
|
||||
[FileNodeType.Script]: '📜',
|
||||
[FileNodeType.Scene]: '🌍'
|
||||
}
|
||||
|
||||
// 文件扩展名映射
|
||||
export const FILE_EXTENSIONS: Record<string, FileNodeType> = {
|
||||
'.txt': FileNodeType.Text,
|
||||
'.md': FileNodeType.Text,
|
||||
'.js': FileNodeType.Script,
|
||||
'.ts': FileNodeType.Script,
|
||||
'.vue': FileNodeType.Script,
|
||||
'.fbx': FileNodeType.Model,
|
||||
'.obj': FileNodeType.Model,
|
||||
'.gltf': FileNodeType.Model,
|
||||
'.glb': FileNodeType.Model,
|
||||
'.mp3': FileNodeType.Audio,
|
||||
'.wav': FileNodeType.Audio,
|
||||
'.mp4': FileNodeType.Video,
|
||||
'.avi': FileNodeType.Video,
|
||||
'.png': FileNodeType.Image,
|
||||
'.jpg': FileNodeType.Image,
|
||||
'.jpeg': FileNodeType.Image,
|
||||
'.gif': FileNodeType.Image,
|
||||
'.mat': FileNodeType.Material,
|
||||
'.material': FileNodeType.Material
|
||||
}
|
||||
|
||||
// 拖拽鼠标样式
|
||||
export const DRAG_CURSORS = {
|
||||
GRAB: 'grab',
|
||||
GRABBING: 'grabbing',
|
||||
COPY: 'copy',
|
||||
MOVE: 'move',
|
||||
NOT_ALLOWED: 'not-allowed'
|
||||
}
|
||||
104
src/components/BasicControls/Input/ColorInput.vue
Normal file
104
src/components/BasicControls/Input/ColorInput.vue
Normal file
@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div class="color-input">
|
||||
<div class="color-preview" :style="{ backgroundColor: modelValue }" @click="openColorPicker"></div>
|
||||
<input
|
||||
ref="colorInput"
|
||||
type="color"
|
||||
:value="modelValue"
|
||||
@input="updateColor"
|
||||
class="color-picker"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
:value="modelValue"
|
||||
@input="updateColorText"
|
||||
@blur="validateColor"
|
||||
class="color-text"
|
||||
placeholder="#ffffff"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
// 本地类型定义
|
||||
interface ColorInputProps {
|
||||
modelValue: string
|
||||
}
|
||||
|
||||
interface ColorInputEmits {
|
||||
(e: 'update:modelValue', value: string): void
|
||||
}
|
||||
|
||||
const props = defineProps<ColorInputProps>()
|
||||
const emit = defineEmits<ColorInputEmits>()
|
||||
|
||||
const colorInput = ref<HTMLInputElement>()
|
||||
|
||||
const openColorPicker = () => {
|
||||
colorInput.value?.click()
|
||||
}
|
||||
|
||||
const updateColor = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
emit('update:modelValue', target.value)
|
||||
}
|
||||
|
||||
const updateColorText = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
emit('update:modelValue', target.value)
|
||||
}
|
||||
|
||||
const validateColor = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const value = target.value
|
||||
|
||||
// 验证颜色格式
|
||||
if (!/^#[0-9A-Fa-f]{6}$/.test(value)) {
|
||||
target.value = props.modelValue
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.color-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.color-preview {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 1px solid #333333;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.color-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 2px 4px;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #333333;
|
||||
border-radius: 2px;
|
||||
color: #ffffff;
|
||||
font-size: 11px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
.color-text:focus {
|
||||
outline: none;
|
||||
border-color: #007acc;
|
||||
}
|
||||
</style>
|
||||
71
src/components/BasicControls/Input/TextInput.vue
Normal file
71
src/components/BasicControls/Input/TextInput.vue
Normal file
@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<input
|
||||
type="text"
|
||||
:value="modelValue"
|
||||
@input="handleInput"
|
||||
@focus="onFocus"
|
||||
:placeholder="placeholder"
|
||||
:class="['text-input', { 'center': textAlign === 'center', 'uppercase': uppercase }]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TextInputProps, TextInputEmits } from './types'
|
||||
|
||||
const props = withDefaults(defineProps<TextInputProps>(), {
|
||||
textAlign: 'left',
|
||||
uppercase: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<TextInputEmits>()
|
||||
|
||||
const handleInput = (e: Event) => {
|
||||
const target = e.target as HTMLInputElement
|
||||
emit('update:modelValue', target.value)
|
||||
}
|
||||
|
||||
const onFocus = (e: Event) => {
|
||||
(e.target as HTMLInputElement).select()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.text-input {
|
||||
width: 100%;
|
||||
height: 16px;
|
||||
background: #2d2d2d;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 2px;
|
||||
color: #cccccc;
|
||||
font-size: 11px;
|
||||
padding: 0 3px;
|
||||
text-align: left;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.text-input.center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-input.uppercase {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.text-input:focus {
|
||||
border-color: #409eff;
|
||||
background: #353535;
|
||||
}
|
||||
|
||||
.text-input:hover {
|
||||
border-color: #5a5a5a;
|
||||
}
|
||||
|
||||
/* 容器自适应 */
|
||||
@container (max-width: 200px) {
|
||||
.text-input {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
92
src/components/BasicControls/Input/index.vue
Normal file
92
src/components/BasicControls/Input/index.vue
Normal file
@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<input
|
||||
type="number"
|
||||
:value="modelValue"
|
||||
@input="handleInput"
|
||||
@focus="onFocus"
|
||||
:step="step"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:placeholder="placeholder"
|
||||
:class="['number-input', { 'full-width': fullWidth }]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { NumberInputProps, NumberInputEmits } from './types'
|
||||
|
||||
const props = withDefaults(defineProps<NumberInputProps>(), {
|
||||
step: 0.1,
|
||||
fullWidth: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<NumberInputEmits>()
|
||||
|
||||
const handleInput = (e: Event) => {
|
||||
const target = e.target as HTMLInputElement
|
||||
const value = parseFloat(target.value)
|
||||
if (!isNaN(value)) {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
}
|
||||
|
||||
const onFocus = (e: Event) => {
|
||||
(e.target as HTMLInputElement).select()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.number-input {
|
||||
width: 100%;
|
||||
min-width: 30px;
|
||||
|
||||
height: 16px;
|
||||
background: #2d2d2d;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 2px;
|
||||
color: #cccccc;
|
||||
font-size: 11px;
|
||||
padding: 0 3px;
|
||||
text-align: right;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.number-input.full-width {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.number-input:focus {
|
||||
border-color: #409eff;
|
||||
background: #353535;
|
||||
}
|
||||
|
||||
.number-input:hover {
|
||||
border-color: #5a5a5a;
|
||||
}
|
||||
|
||||
/* 隐藏数字输入框的箭头 */
|
||||
.number-input::-webkit-outer-spin-button,
|
||||
.number-input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.number-input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
/* 容器自适应 */
|
||||
@container (max-width: 200px) {
|
||||
.number-input {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 250px) {
|
||||
.number-input {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
34
src/components/BasicControls/Input/types.ts
Normal file
34
src/components/BasicControls/Input/types.ts
Normal file
@ -0,0 +1,34 @@
|
||||
// NumberInput 组件类型
|
||||
export interface NumberInputProps {
|
||||
modelValue: number
|
||||
step?: number
|
||||
min?: number
|
||||
max?: number
|
||||
placeholder?: string
|
||||
fullWidth?: boolean
|
||||
}
|
||||
|
||||
export interface NumberInputEmits {
|
||||
(e: 'update:modelValue', value: number): void
|
||||
}
|
||||
|
||||
// TextInput 组件类型
|
||||
export interface TextInputProps {
|
||||
modelValue: string
|
||||
placeholder?: string
|
||||
textAlign?: 'left' | 'center' | 'right'
|
||||
uppercase?: boolean
|
||||
}
|
||||
|
||||
export interface TextInputEmits {
|
||||
(e: 'update:modelValue', value: string): void
|
||||
}
|
||||
|
||||
// ColorInput 组件类型
|
||||
export interface ColorInputProps {
|
||||
modelValue: string
|
||||
}
|
||||
|
||||
export interface ColorInputEmits {
|
||||
(e: 'update:modelValue', value: string): void
|
||||
}
|
||||
69
src/components/BasicControls/PropertyRow/index.vue
Normal file
69
src/components/BasicControls/PropertyRow/index.vue
Normal file
@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div class="property-row">
|
||||
<Tooltip v-if="tooltip" :content="tooltip" placement="top">
|
||||
<div class="property-label">{{ label }}</div>
|
||||
</Tooltip>
|
||||
<div v-else class="property-label">{{ label }}</div>
|
||||
<div class="property-content">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Tooltip from '../../public/Tooltip.vue'
|
||||
import type { PropertyRowProps } from './types'
|
||||
|
||||
defineProps<PropertyRowProps>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.property-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 2px;
|
||||
min-height: 18px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.property-label {
|
||||
width: 80px;
|
||||
color: #cccccc;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.property-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 250px) {
|
||||
.property-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.property-label {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.property-content {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 容器自适应 */
|
||||
@container (max-width: 200px) {
|
||||
.property-label {
|
||||
width: 60px;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
5
src/components/BasicControls/PropertyRow/types.ts
Normal file
5
src/components/BasicControls/PropertyRow/types.ts
Normal file
@ -0,0 +1,5 @@
|
||||
// PropertyRow 组件类型定义
|
||||
export interface PropertyRowProps {
|
||||
label: string
|
||||
tooltip?: string
|
||||
}
|
||||
66
src/components/BasicControls/Select/index.vue
Normal file
66
src/components/BasicControls/Select/index.vue
Normal file
@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<select
|
||||
:value="modelValue"
|
||||
@change="handleChange"
|
||||
class="select-input"
|
||||
>
|
||||
<option
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SelectProps, SelectEmits } from './types'
|
||||
|
||||
defineProps<SelectProps>()
|
||||
const emit = defineEmits<SelectEmits>()
|
||||
|
||||
const handleChange = (e: Event) => {
|
||||
const target = e.target as HTMLSelectElement
|
||||
const value = target.value
|
||||
emit('update:modelValue', value)
|
||||
emit('change', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.select-input {
|
||||
width: 100%;
|
||||
height: 18px;
|
||||
background: #2d2d2d;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 2px;
|
||||
color: #cccccc;
|
||||
font-size: 11px;
|
||||
padding: 0 3px;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.select-input option {
|
||||
background: #2d2d2d;
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
.select-input:focus {
|
||||
border-color: #409eff;
|
||||
background: #353535;
|
||||
}
|
||||
|
||||
.select-input:hover {
|
||||
border-color: #5a5a5a;
|
||||
}
|
||||
|
||||
/* 容器自适应 */
|
||||
@container (max-width: 200px) {
|
||||
.select-input {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
15
src/components/BasicControls/Select/types.ts
Normal file
15
src/components/BasicControls/Select/types.ts
Normal file
@ -0,0 +1,15 @@
|
||||
// Select 组件类型定义
|
||||
export interface SelectOption {
|
||||
value: string | number
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface SelectProps {
|
||||
modelValue: string | number
|
||||
options: SelectOption[]
|
||||
}
|
||||
|
||||
export interface SelectEmits {
|
||||
(e: 'update:modelValue', value: string | number): void
|
||||
(e: 'change', value: string | number): void
|
||||
}
|
||||
326
src/components/BasicControls/Slider/index.vue
Normal file
326
src/components/BasicControls/Slider/index.vue
Normal file
@ -0,0 +1,326 @@
|
||||
<template>
|
||||
<div :class="['slider-wrapper', { 'slider-vertical': vertical }]">
|
||||
<div
|
||||
ref="sliderTrack"
|
||||
:class="['slider-track', { 'slider-track-vertical': vertical }]"
|
||||
@mousedown="handleMouseDown"
|
||||
>
|
||||
<div
|
||||
:class="['slider-fill', { 'slider-fill-vertical': vertical, 'no-transition': isDragging }]"
|
||||
:style="fillStyle"
|
||||
></div>
|
||||
<div
|
||||
ref="sliderThumb"
|
||||
:class="['slider-thumb', { 'slider-thumb-vertical': vertical, 'dragging': isDragging }]"
|
||||
:style="thumbStyle"
|
||||
@mousedown="handleThumbMouseDown"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- 数值显示 -->
|
||||
<div v-if="showValue && !showInput" class="slider-value">{{ formatValue(modelValue) }}</div>
|
||||
|
||||
<!-- 输入框 -->
|
||||
<NumberInput
|
||||
v-if="showInput"
|
||||
:model-value="modelValue"
|
||||
@update:model-value="handleInputChange"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
class="slider-input"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import NumberInput from '../Input/index.vue'
|
||||
import type { SliderProps, SliderEmits } from './types'
|
||||
|
||||
const props = withDefaults(defineProps<SliderProps>(), {
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
showValue: false,
|
||||
showInput: true,
|
||||
vertical: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<SliderEmits>()
|
||||
|
||||
const sliderTrack = ref<HTMLElement>()
|
||||
const sliderThumb = ref<HTMLElement>()
|
||||
const isDragging = ref(false)
|
||||
|
||||
// 计算百分比
|
||||
const percentage = computed(() => {
|
||||
const range = props.max - props.min
|
||||
if (range === 0) return 0
|
||||
return ((props.modelValue - props.min) / range) * 100
|
||||
})
|
||||
|
||||
// 填充样式
|
||||
const fillStyle = computed(() => {
|
||||
if (props.vertical) {
|
||||
return { height: `${percentage.value}%` }
|
||||
} else {
|
||||
return { width: `${percentage.value}%` }
|
||||
}
|
||||
})
|
||||
|
||||
// 滑块样式
|
||||
const thumbStyle = computed(() => {
|
||||
if (props.vertical) {
|
||||
return { bottom: `calc(${percentage.value}% - 6px)` }
|
||||
} else {
|
||||
return { left: `calc(${percentage.value}% - 6px)` }
|
||||
}
|
||||
})
|
||||
|
||||
// 格式化显示值
|
||||
const formatValue = (value: number) => {
|
||||
if (props.step < 1) {
|
||||
return value.toFixed(2)
|
||||
} else if (props.step < 0.1) {
|
||||
return value.toFixed(3)
|
||||
} else {
|
||||
return Math.round(value).toString()
|
||||
}
|
||||
}
|
||||
|
||||
// 处理输入框数值变化
|
||||
const handleInputChange = (value: number) => {
|
||||
// 确保值在范围内
|
||||
const clampedValue = Math.max(props.min, Math.min(props.max, value))
|
||||
|
||||
if (clampedValue !== props.modelValue) {
|
||||
emit('update:modelValue', clampedValue)
|
||||
emit('change', clampedValue)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理轨道鼠标按下
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
// 如果点击的是滑块,不处理
|
||||
if (e.target === sliderThumb.value) return
|
||||
|
||||
isDragging.value = true
|
||||
updateValue(e)
|
||||
|
||||
// 使用 requestAnimationFrame 优化性能
|
||||
const handleMove = (e: MouseEvent) => {
|
||||
if (!isDragging.value) return
|
||||
requestAnimationFrame(() => updateValue(e))
|
||||
}
|
||||
|
||||
const handleUp = (e: MouseEvent) => {
|
||||
isDragging.value = false
|
||||
document.removeEventListener('mousemove', handleMove)
|
||||
document.removeEventListener('mouseup', handleUp)
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMove)
|
||||
document.addEventListener('mouseup', handleUp)
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
// 处理滑块鼠标按下
|
||||
const handleThumbMouseDown = (e: MouseEvent) => {
|
||||
isDragging.value = true
|
||||
|
||||
// 记录初始偏移量
|
||||
const rect = sliderTrack.value!.getBoundingClientRect()
|
||||
const thumbRect = sliderThumb.value!.getBoundingClientRect()
|
||||
|
||||
let offset = 0
|
||||
if (props.vertical) {
|
||||
offset = e.clientY - (thumbRect.top + thumbRect.height / 2)
|
||||
} else {
|
||||
offset = e.clientX - (thumbRect.left + thumbRect.width / 2)
|
||||
}
|
||||
|
||||
const handleMove = (e: MouseEvent) => {
|
||||
if (!isDragging.value) return
|
||||
|
||||
// 调整鼠标位置以考虑初始偏移
|
||||
const adjustedEvent = {
|
||||
...e,
|
||||
clientX: props.vertical ? e.clientX : e.clientX - offset,
|
||||
clientY: props.vertical ? e.clientY - offset : e.clientY
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => updateValue(adjustedEvent as MouseEvent))
|
||||
}
|
||||
|
||||
const handleUp = (e: MouseEvent) => {
|
||||
isDragging.value = false
|
||||
document.removeEventListener('mousemove', handleMove)
|
||||
document.removeEventListener('mouseup', handleUp)
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMove)
|
||||
document.addEventListener('mouseup', handleUp)
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
// 更新值
|
||||
const updateValue = (e: MouseEvent) => {
|
||||
if (!sliderTrack.value) return
|
||||
|
||||
const rect = sliderTrack.value.getBoundingClientRect()
|
||||
let percentage: number
|
||||
|
||||
if (props.vertical) {
|
||||
percentage = (rect.bottom - e.clientY) / rect.height
|
||||
} else {
|
||||
percentage = (e.clientX - rect.left) / rect.width
|
||||
}
|
||||
|
||||
// 限制百分比在 0-1 之间
|
||||
percentage = Math.max(0, Math.min(1, percentage))
|
||||
|
||||
// 计算新值
|
||||
const range = props.max - props.min
|
||||
let newValue = props.min + percentage * range
|
||||
|
||||
// 应用步长
|
||||
if (props.step > 0) {
|
||||
newValue = Math.round(newValue / props.step) * props.step
|
||||
}
|
||||
|
||||
// 确保值在范围内
|
||||
newValue = Math.max(props.min, Math.min(props.max, newValue))
|
||||
|
||||
// 修复浮点数精度问题
|
||||
if (props.step < 1) {
|
||||
const decimals = props.step.toString().split('.')[1]?.length || 0
|
||||
newValue = parseFloat(newValue.toFixed(decimals))
|
||||
}
|
||||
|
||||
// 减少不必要的更新
|
||||
if (Math.abs(newValue - props.modelValue) > 0.0001) {
|
||||
emit('update:modelValue', newValue)
|
||||
emit('change', newValue)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.slider-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.slider-wrapper.slider-vertical {
|
||||
flex-direction: column;
|
||||
height: 100px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.slider-track {
|
||||
position: relative;
|
||||
height: 4px;
|
||||
background: #4a4a4a;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.slider-track.slider-track-vertical {
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.slider-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background: #409eff;
|
||||
border-radius: 2px;
|
||||
transition: width 0.1s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slider-fill.no-transition {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.slider-fill.slider-fill-vertical {
|
||||
bottom: 0;
|
||||
top: auto;
|
||||
width: 100%;
|
||||
transition: height 0.1s ease;
|
||||
}
|
||||
|
||||
.slider-fill.slider-fill-vertical.no-transition {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.slider-thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #ffffff;
|
||||
border: 2px solid #409eff;
|
||||
border-radius: 50%;
|
||||
transform: translateY(-50%);
|
||||
cursor: grab;
|
||||
transition: all 0.1s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.slider-thumb.dragging {
|
||||
transition: none;
|
||||
cursor: grabbing;
|
||||
transform: translateY(-50%) scale(1.1);
|
||||
}
|
||||
|
||||
.slider-thumb.slider-thumb-vertical {
|
||||
left: 50%;
|
||||
top: auto;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.slider-thumb.slider-thumb-vertical.dragging {
|
||||
transform: translateX(-50%) scale(1.1);
|
||||
}
|
||||
|
||||
.slider-thumb:hover:not(.dragging) {
|
||||
transform: translateY(-50%) scale(1.1);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.slider-thumb.slider-thumb-vertical:hover:not(.dragging) {
|
||||
transform: translateX(-50%) scale(1.1);
|
||||
}
|
||||
|
||||
.slider-thumb:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.slider-value {
|
||||
min-width: 30px;
|
||||
color: #cccccc;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.slider-input {
|
||||
width: 60px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
16
src/components/BasicControls/Slider/types.ts
Normal file
16
src/components/BasicControls/Slider/types.ts
Normal file
@ -0,0 +1,16 @@
|
||||
// Slider 组件类型定义
|
||||
export interface SliderProps {
|
||||
modelValue: number
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
showValue?: boolean
|
||||
showInput?: boolean
|
||||
vertical?: boolean
|
||||
a?:number
|
||||
}
|
||||
|
||||
export interface SliderEmits {
|
||||
(e: 'update:modelValue', value: number): void
|
||||
(e: 'change', value: number): void
|
||||
}
|
||||
155
src/components/BasicControls/Switch/index.vue
Normal file
155
src/components/BasicControls/Switch/index.vue
Normal file
@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<div class="switch-wrapper" :class="{ 'switch-disabled': disabled }">
|
||||
<input
|
||||
type="checkbox"
|
||||
:id="switchId"
|
||||
:checked="modelValue"
|
||||
:disabled="disabled"
|
||||
@change="handleChange"
|
||||
class="switch-checkbox"
|
||||
/>
|
||||
<label :for="switchId" class="switch-label" :class="[`switch-${size}`]"></label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { SwitchProps, SwitchEmits } from './types'
|
||||
|
||||
const props = withDefaults(defineProps<SwitchProps>(), {
|
||||
disabled: false,
|
||||
size: 'medium'
|
||||
})
|
||||
|
||||
const emit = defineEmits<SwitchEmits>()
|
||||
|
||||
// 生成唯一的 ID
|
||||
const switchId = computed(() => `switch-${Math.random().toString(36).substr(2, 9)}`)
|
||||
|
||||
const handleChange = (event: Event) => {
|
||||
if (props.disabled) return
|
||||
|
||||
const target = event.target as HTMLInputElement
|
||||
const newValue = target.checked
|
||||
emit('update:modelValue', newValue)
|
||||
emit('change', newValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.switch-wrapper {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.switch-wrapper.switch-disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.switch-checkbox {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.switch-checkbox:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.switch-label {
|
||||
display: inline-block;
|
||||
background: #2d2d2d;
|
||||
border: 1px solid #5a5a5a;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: all 0.15s ease;
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.switch-label:hover {
|
||||
border-color: #6a6a6a;
|
||||
background: #353535;
|
||||
}
|
||||
|
||||
/* 选中状态的勾选标记 */
|
||||
.switch-checkbox:checked + .switch-label::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
border: solid #ffffff;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
/* Small size */
|
||||
.switch-small {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.switch-checkbox:checked + .switch-small::after {
|
||||
left: 2px;
|
||||
top: -1px;
|
||||
width: 6px;
|
||||
height: 4px;
|
||||
border-width: 0 1px 1px 0;
|
||||
}
|
||||
|
||||
/* Medium size (default) */
|
||||
.switch-medium {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.switch-checkbox:checked + .switch-medium::after {
|
||||
left: 2px;
|
||||
top: 0px;
|
||||
width: 8px;
|
||||
height: 6px;
|
||||
border-width: 0 1.5px 1.5px 0;
|
||||
}
|
||||
|
||||
/* Large size */
|
||||
.switch-large {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.switch-checkbox:checked + .switch-large::after {
|
||||
left: 3px;
|
||||
top: 1px;
|
||||
width: 9px;
|
||||
height: 7px;
|
||||
border-width: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
/* Focus styles */
|
||||
.switch-checkbox:focus + .switch-label {
|
||||
outline: 2px solid #409eff;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* Disabled state */
|
||||
.switch-disabled .switch-checkbox,
|
||||
.switch-disabled .switch-label {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.switch-disabled .switch-label:hover {
|
||||
border-color: #5a5a5a;
|
||||
background: #2d2d2d;
|
||||
}
|
||||
|
||||
.switch-disabled .switch-checkbox:checked + .switch-label:hover {
|
||||
background: #409eff;
|
||||
border-color: #409eff;
|
||||
}
|
||||
</style>
|
||||
11
src/components/BasicControls/Switch/types.ts
Normal file
11
src/components/BasicControls/Switch/types.ts
Normal file
@ -0,0 +1,11 @@
|
||||
// Switch 组件类型定义
|
||||
export interface SwitchProps {
|
||||
modelValue: boolean
|
||||
disabled?: boolean
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
}
|
||||
|
||||
export interface SwitchEmits {
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'change', value: boolean): void
|
||||
}
|
||||
19
src/components/BasicControls/index.ts
Normal file
19
src/components/BasicControls/index.ts
Normal file
@ -0,0 +1,19 @@
|
||||
// 基础组件导出
|
||||
export { default as PropertyRow } from './PropertyRow/index.vue'
|
||||
export { default as NumberInput } from './Input/index.vue'
|
||||
export { default as TextInput } from './Input/TextInput.vue'
|
||||
export { default as ColorInput } from './Input/ColorInput.vue'
|
||||
export { default as Select } from './Select/index.vue'
|
||||
export { default as Switch } from './Switch/index.vue'
|
||||
export { default as Slider } from './Slider/index.vue'
|
||||
export { default as Field } from './Field/index.vue'
|
||||
|
||||
// 类型导出
|
||||
export type { Vector3, RangeValue, SelectOption } from './types'
|
||||
export type { PropertyRowProps } from './PropertyRow/types'
|
||||
export type { NumberInputProps, NumberInputEmits, TextInputProps, TextInputEmits } from './Input/types'
|
||||
export type { SelectProps, SelectEmits } from './Select/types'
|
||||
export type { SwitchProps, SwitchEmits } from './Switch/types'
|
||||
export type { SliderProps, SliderEmits } from './Slider/types'
|
||||
export type { FieldProps, FieldEmits, FieldValue, FieldError, AcceptedType } from './Field/types'
|
||||
|
||||
17
src/components/BasicControls/types.ts
Normal file
17
src/components/BasicControls/types.ts
Normal file
@ -0,0 +1,17 @@
|
||||
// 基础组件通用类型定义
|
||||
|
||||
export interface Vector3 {
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
}
|
||||
|
||||
export interface RangeValue {
|
||||
min: number
|
||||
max: number
|
||||
}
|
||||
|
||||
export interface SelectOption {
|
||||
value: string | number
|
||||
label: string
|
||||
}
|
||||
747
src/components/BottomPanel/Console/index.vue
Normal file
747
src/components/BottomPanel/Console/index.vue
Normal file
@ -0,0 +1,747 @@
|
||||
<template>
|
||||
<div class="console">
|
||||
<!-- 控制台头部 -->
|
||||
<div class="console-header">
|
||||
<div class="console-title">
|
||||
<h3>控制台</h3>
|
||||
<span class="log-count">{{ filteredLogs.length }} 条日志</span>
|
||||
</div>
|
||||
<div class="console-actions">
|
||||
<!-- 日志级别过滤 -->
|
||||
<div class="log-filters">
|
||||
<button
|
||||
v-for="level in logLevels"
|
||||
:key="level.type"
|
||||
:class="['filter-btn', level.type, { active: activeFilters.includes(level.type) }]"
|
||||
@click="toggleFilter(level.type)"
|
||||
:title="`${level.label} (${getLogCountByType(level.type)})`"
|
||||
>
|
||||
<span class="filter-icon">{{ level.icon }}</span>
|
||||
<span class="filter-count">{{ getLogCountByType(level.type) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 控制按钮 -->
|
||||
<div class="control-buttons">
|
||||
<button @click="toggleAutoScroll" :class="{ active: autoScroll }" title="自动滚动">
|
||||
📜
|
||||
</button>
|
||||
<button @click="toggleTimestamp" :class="{ active: showTimestamp }" title="显示时间戳">
|
||||
🕐
|
||||
</button>
|
||||
<button @click="exportLogs" title="导出日志">
|
||||
💾
|
||||
</button>
|
||||
<button @click="clearLogs" title="清空日志">
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<div class="search-bar">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="搜索日志内容..."
|
||||
class="search-input"
|
||||
>
|
||||
<button v-if="searchQuery" @click="clearSearch" class="clear-search">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 日志内容区域 -->
|
||||
<div
|
||||
ref="logContainer"
|
||||
class="log-container"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<div class="log-list">
|
||||
<div
|
||||
v-for="(log, index) in filteredLogs"
|
||||
:key="log.id"
|
||||
:class="['log-item', log.type]"
|
||||
@click="selectLog(log)"
|
||||
:data-index="index"
|
||||
>
|
||||
<!-- 时间戳 -->
|
||||
<span v-if="showTimestamp" class="log-timestamp">
|
||||
{{ formatTime(log.timestamp) }}
|
||||
</span>
|
||||
|
||||
<!-- 日志级别图标 -->
|
||||
<span class="log-icon">
|
||||
{{ getLogIcon(log.type) }}
|
||||
</span>
|
||||
|
||||
<!-- 日志内容 -->
|
||||
<div class="log-content">
|
||||
<div class="log-message" v-html="highlightSearch(log.message)"></div>
|
||||
|
||||
<!-- 堆栈信息 -->
|
||||
<div v-if="log.stack && selectedLogId === log.id" class="log-stack">
|
||||
<pre>{{ log.stack }}</pre>
|
||||
</div>
|
||||
|
||||
<!-- 额外数据 -->
|
||||
<div v-if="log.data && selectedLogId === log.id" class="log-data">
|
||||
<pre>{{ JSON.stringify(log.data, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日志来源 -->
|
||||
<span v-if="log.source" class="log-source">
|
||||
{{ log.source }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="filteredLogs.length === 0" class="empty-state">
|
||||
<div v-if="logs.length === 0">
|
||||
<p>暂无日志</p>
|
||||
<button @click="addTestLogs">添加测试日志</button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<p>没有匹配的日志</p>
|
||||
<button @click="clearFilters">清除过滤器</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部状态栏 -->
|
||||
<div class="console-footer">
|
||||
<div class="status-info">
|
||||
<span>总计: {{ logs.length }} 条</span>
|
||||
<span>显示: {{ filteredLogs.length }} 条</span>
|
||||
<span v-if="selectedLogId">已选择: 1 条</span>
|
||||
</div>
|
||||
<div class="scroll-info" v-if="!isAtBottom && filteredLogs.length > 0">
|
||||
<button @click="scrollToBottom" class="scroll-to-bottom">
|
||||
滚动到底部 ↓
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue'
|
||||
|
||||
// 日志类型定义
|
||||
interface LogEntry {
|
||||
id: string
|
||||
type: 'info' | 'warn' | 'error' | 'debug' | 'success'
|
||||
message: string
|
||||
timestamp: Date
|
||||
source?: string
|
||||
stack?: string
|
||||
data?: any
|
||||
}
|
||||
|
||||
// 日志级别配置
|
||||
const logLevels = [
|
||||
{ type: 'info', label: '信息', icon: 'ℹ️' },
|
||||
{ type: 'warn', label: '警告', icon: '⚠️' },
|
||||
{ type: 'error', label: '错误', icon: '❌' },
|
||||
{ type: 'debug', label: '调试', icon: '🐛' },
|
||||
{ type: 'success', label: '成功', icon: '✅' }
|
||||
] as const
|
||||
|
||||
// 响应式数据
|
||||
const logs = ref<LogEntry[]>([])
|
||||
const activeFilters = ref<string[]>(['info', 'warn', 'error', 'debug', 'success'])
|
||||
const searchQuery = ref('')
|
||||
const selectedLogId = ref<string>('')
|
||||
const autoScroll = ref(true)
|
||||
const showTimestamp = ref(true)
|
||||
const isAtBottom = ref(true)
|
||||
|
||||
// DOM 引用
|
||||
const logContainer = ref<HTMLElement>()
|
||||
|
||||
// 计算属性
|
||||
const filteredLogs = computed(() => {
|
||||
let filtered = logs.value.filter(log => activeFilters.value.includes(log.type))
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
filtered = filtered.filter(log =>
|
||||
log.message.toLowerCase().includes(query) ||
|
||||
log.source?.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
return filtered
|
||||
})
|
||||
|
||||
// 工具函数
|
||||
function generateId(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2)
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString('zh-CN', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
fractionalSecondDigits: 3
|
||||
})
|
||||
}
|
||||
|
||||
function getLogIcon(type: string): string {
|
||||
const level = logLevels.find(l => l.type === type)
|
||||
return level?.icon || 'ℹ️'
|
||||
}
|
||||
|
||||
function getLogCountByType(type: string): number {
|
||||
return logs.value.filter(log => log.type === type).length
|
||||
}
|
||||
|
||||
function highlightSearch(text: string): string {
|
||||
if (!searchQuery.value) return text
|
||||
|
||||
const regex = new RegExp(`(${searchQuery.value})`, 'gi')
|
||||
return text.replace(regex, '<mark>$1</mark>')
|
||||
}
|
||||
|
||||
// 日志操作
|
||||
function addLog(type: LogEntry['type'], message: string, options: Partial<LogEntry> = {}) {
|
||||
const log: LogEntry = {
|
||||
id: generateId(),
|
||||
type,
|
||||
message,
|
||||
timestamp: new Date(),
|
||||
...options
|
||||
}
|
||||
|
||||
logs.value.push(log)
|
||||
|
||||
// 自动滚动到底部
|
||||
if (autoScroll.value) {
|
||||
nextTick(() => {
|
||||
scrollToBottom()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function clearLogs() {
|
||||
logs.value = []
|
||||
selectedLogId.value = ''
|
||||
}
|
||||
|
||||
function addTestLogs() {
|
||||
const testLogs = [
|
||||
{ type: 'info' as const, message: '应用程序启动成功', source: 'App.vue' },
|
||||
{ type: 'success' as const, message: '场景加载完成', source: 'SceneManager.ts' },
|
||||
{ type: 'warn' as const, message: '纹理分辨率过大,可能影响性能', source: 'TextureLoader.ts' },
|
||||
{ type: 'error' as const, message: '无法加载模型文件: model.fbx', source: 'ModelLoader.ts', stack: 'Error: File not found\n at ModelLoader.load (ModelLoader.ts:45)\n at Scene.addModel (Scene.ts:123)' },
|
||||
{ type: 'debug' as const, message: '相机位置更新', source: 'Camera.ts', data: { position: { x: 0, y: 5, z: 10 }, rotation: { x: 0, y: 0, z: 0 } } },
|
||||
{ type: 'info' as const, message: '用户交互事件: 鼠标点击', source: 'InputManager.ts' },
|
||||
{ type: 'warn' as const, message: '内存使用率较高: 85%', source: 'PerformanceMonitor.ts' }
|
||||
]
|
||||
|
||||
testLogs.forEach(log => {
|
||||
addLog(log.type, log.message, { source: log.source, stack: log.stack, data: log.data })
|
||||
})
|
||||
}
|
||||
|
||||
// 过滤器操作
|
||||
function toggleFilter(type: string) {
|
||||
const index = activeFilters.value.indexOf(type)
|
||||
if (index > -1) {
|
||||
activeFilters.value.splice(index, 1)
|
||||
} else {
|
||||
activeFilters.value.push(type)
|
||||
}
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
activeFilters.value = ['info', 'warn', 'error', 'debug', 'success']
|
||||
searchQuery.value = ''
|
||||
}
|
||||
|
||||
// 搜索操作
|
||||
function clearSearch() {
|
||||
searchQuery.value = ''
|
||||
}
|
||||
|
||||
// 日志选择
|
||||
function selectLog(log: LogEntry) {
|
||||
selectedLogId.value = selectedLogId.value === log.id ? '' : log.id
|
||||
}
|
||||
|
||||
// 滚动控制
|
||||
function handleScroll() {
|
||||
if (!logContainer.value) return
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = logContainer.value
|
||||
isAtBottom.value = scrollTop + clientHeight >= scrollHeight - 10
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
if (!logContainer.value) return
|
||||
|
||||
logContainer.value.scrollTop = logContainer.value.scrollHeight
|
||||
isAtBottom.value = true
|
||||
}
|
||||
|
||||
function toggleAutoScroll() {
|
||||
autoScroll.value = !autoScroll.value
|
||||
if (autoScroll.value) {
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTimestamp() {
|
||||
showTimestamp.value = !showTimestamp.value
|
||||
}
|
||||
|
||||
// 导出功能
|
||||
function exportLogs() {
|
||||
const logText = filteredLogs.value.map(log => {
|
||||
const timestamp = formatTime(log.timestamp)
|
||||
const level = log.type.toUpperCase()
|
||||
const source = log.source ? ` [${log.source}]` : ''
|
||||
return `[${timestamp}] ${level}${source}: ${log.message}`
|
||||
}).join('\n')
|
||||
|
||||
const blob = new Blob([logText], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `console-logs-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.txt`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
// 监听过滤器变化,自动滚动到底部
|
||||
watch(filteredLogs, () => {
|
||||
if (autoScroll.value) {
|
||||
nextTick(() => {
|
||||
scrollToBottom()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
addLog,
|
||||
clearLogs,
|
||||
info: (message: string, options?: Partial<LogEntry>) => addLog('info', message, options),
|
||||
warn: (message: string, options?: Partial<LogEntry>) => addLog('warn', message, options),
|
||||
error: (message: string, options?: Partial<LogEntry>) => addLog('error', message, options),
|
||||
debug: (message: string, options?: Partial<LogEntry>) => addLog('debug', message, options),
|
||||
success: (message: string, options?: Partial<LogEntry>) => addLog('success', message, options)
|
||||
})
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
// 监听全局错误
|
||||
window.addEventListener('error', (event) => {
|
||||
addLog('error', event.message, {
|
||||
source: event.filename,
|
||||
stack: event.error?.stack
|
||||
})
|
||||
})
|
||||
|
||||
// 监听未处理的 Promise 拒绝
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
addLog('error', `未处理的 Promise 拒绝: ${event.reason}`, {
|
||||
source: 'Promise'
|
||||
})
|
||||
})
|
||||
|
||||
// 重写 console 方法
|
||||
const originalConsole = {
|
||||
log: console.log,
|
||||
warn: console.warn,
|
||||
error: console.error,
|
||||
info: console.info,
|
||||
debug: console.debug
|
||||
}
|
||||
|
||||
console.log = (...args) => {
|
||||
originalConsole.log(...args)
|
||||
addLog('info', args.join(' '), { source: 'Console' })
|
||||
}
|
||||
|
||||
console.warn = (...args) => {
|
||||
originalConsole.warn(...args)
|
||||
addLog('warn', args.join(' '), { source: 'Console' })
|
||||
}
|
||||
|
||||
console.error = (...args) => {
|
||||
originalConsole.error(...args)
|
||||
addLog('error', args.join(' '), { source: 'Console' })
|
||||
}
|
||||
|
||||
console.info = (...args) => {
|
||||
originalConsole.info(...args)
|
||||
addLog('info', args.join(' '), { source: 'Console' })
|
||||
}
|
||||
|
||||
console.debug = (...args) => {
|
||||
originalConsole.debug(...args)
|
||||
addLog('debug', args.join(' '), { source: 'Console' })
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 恢复原始 console 方法
|
||||
// 这里可以根据需要决定是否恢复
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.console {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.console-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: #2d2d2d;
|
||||
border-bottom: 1px solid #3e3e3e;
|
||||
}
|
||||
|
||||
.console-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.console-title h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.log-count {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.console-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.log-filters {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: transparent;
|
||||
border: 1px solid #3e3e3e;
|
||||
color: #888;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
border-color: #007acc;
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: #007acc;
|
||||
border-color: #007acc;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.filter-btn.info.active {
|
||||
background: #007acc;
|
||||
}
|
||||
|
||||
.filter-btn.warn.active {
|
||||
background: #ff8c00;
|
||||
}
|
||||
|
||||
.filter-btn.error.active {
|
||||
background: #f14c4c;
|
||||
}
|
||||
|
||||
.filter-btn.debug.active {
|
||||
background: #9c27b0;
|
||||
}
|
||||
|
||||
.filter-btn.success.active {
|
||||
background: #4caf50;
|
||||
}
|
||||
|
||||
.filter-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.filter-count {
|
||||
font-size: 10px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
padding: 1px 4px;
|
||||
border-radius: 2px;
|
||||
min-width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.control-buttons {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.control-buttons button {
|
||||
background: transparent;
|
||||
border: 1px solid #3e3e3e;
|
||||
color: #888;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.control-buttons button:hover {
|
||||
border-color: #007acc;
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
.control-buttons button.active {
|
||||
background: #007acc;
|
||||
border-color: #007acc;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
position: relative;
|
||||
padding: 8px 12px;
|
||||
background: #252526;
|
||||
border-bottom: 1px solid #3e3e3e;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
background: #3c3c3c;
|
||||
border: 1px solid #3e3e3e;
|
||||
color: #d4d4d4;
|
||||
padding: 6px 12px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: #007acc;
|
||||
}
|
||||
|
||||
.clear-search {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.clear-search:hover {
|
||||
background: #3e3e3e;
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
.log-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.log-list {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.log-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.log-item:hover {
|
||||
background: #2d2d2d;
|
||||
}
|
||||
|
||||
.log-item.info {
|
||||
border-left-color: #007acc;
|
||||
}
|
||||
|
||||
.log-item.warn {
|
||||
border-left-color: #ff8c00;
|
||||
}
|
||||
|
||||
.log-item.error {
|
||||
border-left-color: #f14c4c;
|
||||
}
|
||||
|
||||
.log-item.debug {
|
||||
border-left-color: #9c27b0;
|
||||
}
|
||||
|
||||
.log-item.success {
|
||||
border-left-color: #4caf50;
|
||||
}
|
||||
|
||||
.log-timestamp {
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.log-icon {
|
||||
font-size: 12px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.log-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.log-message {
|
||||
word-break: break-word;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.log-message :deep(mark) {
|
||||
background: #ff8c00;
|
||||
color: #1e1e1e;
|
||||
padding: 1px 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.log-stack,
|
||||
.log-data {
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
background: #2d2d2d;
|
||||
border-radius: 3px;
|
||||
border-left: 3px solid #3e3e3e;
|
||||
}
|
||||
|
||||
.log-stack pre,
|
||||
.log-data pre {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.log-source {
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.empty-state button {
|
||||
background: #007acc;
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.empty-state button:hover {
|
||||
background: #005a9e;
|
||||
}
|
||||
|
||||
.console-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 12px;
|
||||
background: #2d2d2d;
|
||||
border-top: 1px solid #3e3e3e;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.status-info {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.scroll-to-bottom {
|
||||
background: #007acc;
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.scroll-to-bottom:hover {
|
||||
background: #005a9e;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
.log-container::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.log-container::-webkit-scrollbar-track {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.log-container::-webkit-scrollbar-thumb {
|
||||
background: #3e3e3e;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.log-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #4e4e4e;
|
||||
}
|
||||
</style>
|
||||
959
src/components/BottomPanel/FileSystem/index copy.vue
Normal file
959
src/components/BottomPanel/FileSystem/index copy.vue
Normal file
@ -0,0 +1,959 @@
|
||||
<template>
|
||||
<div class="file-system">
|
||||
<!-- 左侧文件夹树 -->
|
||||
<div
|
||||
class="file-tree-panel"
|
||||
:style="{ width: leftPanelWidth + 'px' }"
|
||||
>
|
||||
|
||||
|
||||
<div class="tree-content">
|
||||
<el-tree
|
||||
:data="folderTree"
|
||||
:props="treeProps"
|
||||
:expand-on-click-node="false"
|
||||
node-key="id"
|
||||
:current-node-key="selectedFolderId"
|
||||
@node-click="handleFolderClick"
|
||||
@node-expand="handleFolderExpand"
|
||||
@node-collapse="handleFolderCollapse"
|
||||
>
|
||||
<template #default="{ node, data }">
|
||||
<span class="tree-node-content">
|
||||
<i class="tree-node-icon">📁</i>
|
||||
<span class="tree-node-label">{{ node.label }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</el-tree>
|
||||
|
||||
<div v-if="folderTree.length === 0" class="empty-state">
|
||||
<p>暂无文件夹</p>
|
||||
<button @click="createFolder">创建文件夹</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 拖拽分隔条 -->
|
||||
<div
|
||||
class="resize-handle"
|
||||
@mousedown="startResize"
|
||||
></div>
|
||||
|
||||
<!-- 右侧文件列表 -->
|
||||
<div class="file-list-panel">
|
||||
<div class="list-content" :class="viewMode">
|
||||
<!-- 面包屑导航 -->
|
||||
<div class="breadcrumb">
|
||||
<span @click="navigateToRoot" class="breadcrumb-item">根目录</span>
|
||||
<span
|
||||
v-for="(folder, index) in currentPath"
|
||||
:key="folder.id"
|
||||
@click="navigateToFolder(folder, index)"
|
||||
class="breadcrumb-item"
|
||||
>
|
||||
/ {{ folder.name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 文件网格视图 -->
|
||||
<div class="file-grid" v-if="viewMode === 'grid'">
|
||||
<FileNode
|
||||
v-for="node in currentFolderFiles"
|
||||
:key="node.id"
|
||||
:node="node"
|
||||
:selected-id="selectedFileId"
|
||||
:show-size="true"
|
||||
@click="handleFileClick"
|
||||
@double-click="handleFileDoubleClick"
|
||||
@rename="handleFileRename"
|
||||
@drag-start="handleDragStart"
|
||||
@drag-end="handleDragEnd"
|
||||
@drag-over="handleDragOver"
|
||||
@drag-leave="handleDragLeave"
|
||||
@drop="handleDrop"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 文件列表视图 -->
|
||||
<div class="file-list" v-else>
|
||||
<div class="list-header">
|
||||
<div class="col-name">名称</div>
|
||||
<div class="col-type">类型</div>
|
||||
<div class="col-size">大小</div>
|
||||
<div class="col-date">修改时间</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="node in currentFolderFiles"
|
||||
:key="node.id"
|
||||
:class="['file-row', node.type.toLowerCase(), { selected: selectedFileId === node.id }]"
|
||||
@click="handleFileClick(node)"
|
||||
@dblclick="handleFileDoubleClick(node)"
|
||||
:draggable="true"
|
||||
@dragstart="handleDragStart(node, $event)"
|
||||
@dragend="handleDragEnd(node, $event)"
|
||||
@dragover="handleDragOver(node, $event)"
|
||||
@dragleave="handleDragLeave(node, $event)"
|
||||
@drop="handleDrop(node, dragState.dragNode!, 'inside', $event)"
|
||||
>
|
||||
<div class="col-name">
|
||||
<span class="file-icon">{{ getFileIcon(node.type) }}</span>
|
||||
<span class="file-name" :title="node.name">{{ node.name }}</span>
|
||||
</div>
|
||||
<div class="col-type">{{ getFileTypeLabel(node.type) }}</div>
|
||||
<div class="col-size">{{ node.size !== undefined ? formatFileSize(node.size) : '-' }}</div>
|
||||
<div class="col-date">{{ node.lastModified ? formatDate(node.lastModified) : '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="currentFolderFiles.length === 0" class="empty-state">
|
||||
<p>此文件夹为空</p>
|
||||
<div class="empty-actions">
|
||||
<button @click="createFile">创建文件</button>
|
||||
<button @click="uploadFile">上传文件</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { FileNode as FileNodeType, FileNodeType as NodeType, DragState, FILE_ICONS } from '../../BasicControls/FileNode/types'
|
||||
import { generateId, formatFileSize, formatDate } from '../../../utils/Tools'
|
||||
import FileNode from '../../BasicControls/FileNode/index.vue'
|
||||
|
||||
// 响应式数据
|
||||
const leftPanelWidth = ref(250)
|
||||
const minPanelWidth = 150
|
||||
const maxPanelWidth = 500
|
||||
|
||||
const viewMode = ref<'list' | 'grid'>('grid')
|
||||
const selectedFolderId = ref<string>('')
|
||||
const selectedFileId = ref<string>('')
|
||||
const currentPath = ref<FileNodeType[]>([])
|
||||
|
||||
// Element Plus 树组件配置
|
||||
const treeProps = {
|
||||
children: 'children',
|
||||
label: 'name'
|
||||
}
|
||||
|
||||
// 拖拽状态
|
||||
const dragState = ref<DragState>({
|
||||
isDragging: false,
|
||||
dragNode: null,
|
||||
dragOverNode: null,
|
||||
dropPosition: null
|
||||
})
|
||||
|
||||
// 模拟文件数据
|
||||
const fileTree = ref<FileNodeType[]>([
|
||||
{
|
||||
id: 'root',
|
||||
name: '项目文件',
|
||||
type: NodeType.Folder,
|
||||
path: '/',
|
||||
isExpanded: true,
|
||||
children: [
|
||||
{
|
||||
id: 'assets',
|
||||
name: 'Assets',
|
||||
type: NodeType.Folder,
|
||||
path: '/Assets',
|
||||
isExpanded: false,
|
||||
children: [
|
||||
{
|
||||
id: 'models',
|
||||
name: 'Models',
|
||||
type: NodeType.Folder,
|
||||
path: '/Assets/Models',
|
||||
children: [
|
||||
{
|
||||
id: 'model1',
|
||||
name: 'character.fbx',
|
||||
type: NodeType.Model,
|
||||
path: '/Assets/Models/character.fbx',
|
||||
size: 2048576,
|
||||
lastModified: new Date('2024-01-15')
|
||||
},
|
||||
{
|
||||
id: 'model2',
|
||||
name: 'environment.glb',
|
||||
type: NodeType.Model,
|
||||
path: '/Assets/Models/environment.glb',
|
||||
size: 5242880,
|
||||
lastModified: new Date('2024-01-18')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'textures',
|
||||
name: 'Textures',
|
||||
type: NodeType.Folder,
|
||||
path: '/Assets/Textures',
|
||||
children: [
|
||||
{
|
||||
id: 'texture1',
|
||||
name: 'wall_diffuse.png',
|
||||
type: NodeType.Texture,
|
||||
path: '/Assets/Textures/wall_diffuse.png',
|
||||
size: 1024000,
|
||||
lastModified: new Date('2024-01-10')
|
||||
},
|
||||
{
|
||||
id: 'texture2',
|
||||
name: 'floor_normal.jpg',
|
||||
type: NodeType.Image,
|
||||
path: '/Assets/Textures/floor_normal.jpg',
|
||||
size: 512000,
|
||||
lastModified: new Date('2024-01-12')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'materials',
|
||||
name: 'Materials',
|
||||
type: NodeType.Folder,
|
||||
path: '/Assets/Materials',
|
||||
children: [
|
||||
{
|
||||
id: 'material1',
|
||||
name: 'wood.material',
|
||||
type: NodeType.Material,
|
||||
path: '/Assets/Materials/wood.material',
|
||||
size: 2048,
|
||||
lastModified: new Date('2024-01-14')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'audio',
|
||||
name: 'Audio',
|
||||
type: NodeType.Folder,
|
||||
path: '/Assets/Audio',
|
||||
children: [
|
||||
{
|
||||
id: 'audio1',
|
||||
name: 'background.mp3',
|
||||
type: NodeType.Audio,
|
||||
path: '/Assets/Audio/background.mp3',
|
||||
size: 3145728,
|
||||
lastModified: new Date('2024-01-16')
|
||||
},
|
||||
{
|
||||
id: 'audio2',
|
||||
name: 'effect.wav',
|
||||
type: NodeType.Audio,
|
||||
path: '/Assets/Audio/effect.wav',
|
||||
size: 1048576,
|
||||
lastModified: new Date('2024-01-17')
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'scripts',
|
||||
name: 'Scripts',
|
||||
type: NodeType.Folder,
|
||||
path: '/Scripts',
|
||||
children: [
|
||||
{
|
||||
id: 'script1',
|
||||
name: 'main.js',
|
||||
type: NodeType.Script,
|
||||
path: '/Scripts/main.js',
|
||||
size: 4096,
|
||||
lastModified: new Date('2024-01-20')
|
||||
},
|
||||
{
|
||||
id: 'script2',
|
||||
name: 'utils.ts',
|
||||
type: NodeType.Script,
|
||||
path: '/Scripts/utils.ts',
|
||||
size: 2048,
|
||||
lastModified: new Date('2024-01-19')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'scenes',
|
||||
name: 'Scenes',
|
||||
type: NodeType.Folder,
|
||||
path: '/Scenes',
|
||||
children: [
|
||||
{
|
||||
id: 'scene1',
|
||||
name: 'main.scene',
|
||||
type: NodeType.Scene,
|
||||
path: '/Scenes/main.scene',
|
||||
size: 8192,
|
||||
lastModified: new Date('2024-01-21')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'docs',
|
||||
name: 'Documentation',
|
||||
type: NodeType.Folder,
|
||||
path: '/Documentation',
|
||||
children: [
|
||||
{
|
||||
id: 'readme',
|
||||
name: 'README.md',
|
||||
type: NodeType.Text,
|
||||
path: '/Documentation/README.md',
|
||||
size: 1024,
|
||||
lastModified: new Date('2024-01-22')
|
||||
},
|
||||
{
|
||||
id: 'guide',
|
||||
name: 'user-guide.txt',
|
||||
type: NodeType.Text,
|
||||
path: '/Documentation/user-guide.txt',
|
||||
size: 2048,
|
||||
lastModified: new Date('2024-01-23')
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
// 计算属性
|
||||
const folderTree = computed(() => {
|
||||
return buildFolderTree(fileTree.value)
|
||||
})
|
||||
|
||||
|
||||
const currentFolderFiles = computed(() => {
|
||||
let currentFolder = fileTree.value[0] // 根目录
|
||||
|
||||
for (const pathItem of currentPath.value) {
|
||||
const found = currentFolder.children?.find(child => child.id === pathItem.id)
|
||||
if (found) {
|
||||
currentFolder = found
|
||||
}
|
||||
}
|
||||
|
||||
return currentFolder.children || []
|
||||
})
|
||||
|
||||
// 工具函数
|
||||
function buildFolderTree(nodes: FileNodeType[]): FileNodeType[] {
|
||||
return nodes.map(node => {
|
||||
if (node.type === NodeType.Folder) {
|
||||
const folderNode: FileNodeType = {
|
||||
...node,
|
||||
children: node.children ? buildFolderTree(node.children.filter(child => child.type === NodeType.Folder)) : []
|
||||
}
|
||||
return folderNode
|
||||
}
|
||||
return node
|
||||
}).filter(node => node.type === NodeType.Folder)
|
||||
}
|
||||
|
||||
function findNodeById(nodes: FileNodeType[], id: string): FileNodeType | null {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) {
|
||||
return node
|
||||
}
|
||||
if (node.children) {
|
||||
const found = findNodeById(node.children, id)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getFileIcon(type: NodeType): string {
|
||||
return FILE_ICONS[type] || '📄'
|
||||
}
|
||||
|
||||
function getFileTypeLabel(type: NodeType): string {
|
||||
const typeLabels: Record<NodeType, string> = {
|
||||
[NodeType.Text]: '文本',
|
||||
[NodeType.Folder]: '文件夹',
|
||||
[NodeType.Model]: '模型',
|
||||
[NodeType.Audio]: '音频',
|
||||
[NodeType.Video]: '视频',
|
||||
[NodeType.Material]: '材质',
|
||||
[NodeType.Texture]: '纹理',
|
||||
[NodeType.Image]: '图片',
|
||||
[NodeType.Script]: '脚本',
|
||||
[NodeType.Scene]: '场景'
|
||||
}
|
||||
return typeLabels[type] || '未知'
|
||||
}
|
||||
|
||||
// 事件处理
|
||||
const handleFolderClick = (data: FileNodeType) => {
|
||||
selectedFolderId.value = data.id
|
||||
// 导航到选中的文件夹
|
||||
const pathToNode = getPathToNode(fileTree.value, data.id)
|
||||
currentPath.value = pathToNode.slice(1) // 移除根目录
|
||||
}
|
||||
|
||||
const handleFolderExpand = (data: FileNodeType) => {
|
||||
const targetNode = findNodeById(fileTree.value, data.id)
|
||||
if (targetNode) {
|
||||
targetNode.isExpanded = true
|
||||
}
|
||||
}
|
||||
|
||||
const handleFolderCollapse = (data: FileNodeType) => {
|
||||
const targetNode = findNodeById(fileTree.value, data.id)
|
||||
if (targetNode) {
|
||||
targetNode.isExpanded = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileClick = (node: FileNodeType) => {
|
||||
selectedFileId.value = node.id
|
||||
}
|
||||
|
||||
const handleFileRename = (node: FileNodeType, newName: string) => {
|
||||
const targetNode = findNodeById(fileTree.value, node.id)
|
||||
if (targetNode) {
|
||||
targetNode.name = newName
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileDoubleClick = (node: FileNodeType) => {
|
||||
if (node.type === NodeType.Folder) {
|
||||
// 进入文件夹
|
||||
currentPath.value.push(node)
|
||||
selectedFolderId.value = node.id
|
||||
selectedFileId.value = ''
|
||||
} else {
|
||||
// 打开文件
|
||||
console.log('打开文件:', node.name)
|
||||
}
|
||||
}
|
||||
|
||||
// 拖拽处理
|
||||
const handleDragStart = (node: FileNodeType, event: DragEvent) => {
|
||||
dragState.value.isDragging = true
|
||||
dragState.value.dragNode = node
|
||||
node.isDragging = true
|
||||
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
event.dataTransfer.setData('text/plain', node.id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnd = (node: FileNodeType, event: DragEvent) => {
|
||||
dragState.value.isDragging = false
|
||||
dragState.value.dragNode = null
|
||||
dragState.value.dragOverNode = null
|
||||
dragState.value.dropPosition = null
|
||||
node.isDragging = false
|
||||
}
|
||||
|
||||
const handleDragOver = (node: FileNodeType, event: DragEvent) => {
|
||||
if (dragState.value.dragNode && dragState.value.dragNode.id !== node.id) {
|
||||
event.preventDefault()
|
||||
dragState.value.dragOverNode = node
|
||||
dragState.value.dropPosition = 'inside'
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragLeave = (node: FileNodeType, event: DragEvent) => {
|
||||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
const x = event.clientX
|
||||
const y = event.clientY
|
||||
|
||||
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
|
||||
if (dragState.value.dragOverNode?.id === node.id) {
|
||||
dragState.value.dragOverNode = null
|
||||
dragState.value.dropPosition = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (targetNode: FileNodeType, dragNode: FileNodeType, position: string, event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
console.log('拖拽操作:', {
|
||||
target: targetNode.name,
|
||||
drag: dragNode.name,
|
||||
position
|
||||
})
|
||||
|
||||
// 这里实现具体的拖拽逻辑
|
||||
}
|
||||
|
||||
// 导航功能
|
||||
const navigateToRoot = () => {
|
||||
currentPath.value = []
|
||||
selectedFolderId.value = fileTree.value[0].id
|
||||
selectedFileId.value = ''
|
||||
}
|
||||
|
||||
const navigateToFolder = (folder: FileNodeType, index: number) => {
|
||||
currentPath.value = currentPath.value.slice(0, index + 1)
|
||||
selectedFolderId.value = folder.id
|
||||
selectedFileId.value = ''
|
||||
}
|
||||
|
||||
function getPathToNode(nodes: FileNodeType[], targetId: string, currentPath: FileNodeType[] = []): FileNodeType[] {
|
||||
for (const node of nodes) {
|
||||
const newPath = [...currentPath, node]
|
||||
if (node.id === targetId) {
|
||||
return newPath
|
||||
}
|
||||
if (node.children) {
|
||||
const found = getPathToNode(node.children, targetId, newPath)
|
||||
if (found.length > 0) return found
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// 操作功能
|
||||
const toggleViewMode = () => {
|
||||
viewMode.value = viewMode.value === 'list' ? 'grid' : 'list'
|
||||
}
|
||||
|
||||
const createFolder = () => {
|
||||
const newFolder: FileNodeType = {
|
||||
id: generateId(),
|
||||
name: '新建文件夹',
|
||||
type: NodeType.Folder,
|
||||
path: '/新建文件夹',
|
||||
children: []
|
||||
}
|
||||
|
||||
// 添加到当前位置
|
||||
if (currentPath.value.length === 0) {
|
||||
fileTree.value[0].children = fileTree.value[0].children || []
|
||||
fileTree.value[0].children.push(newFolder)
|
||||
} else {
|
||||
const currentFolder = findNodeById(fileTree.value, currentPath.value[currentPath.value.length - 1].id)
|
||||
if (currentFolder) {
|
||||
currentFolder.children = currentFolder.children || []
|
||||
currentFolder.children.push(newFolder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const createFile = () => {
|
||||
const newFile: FileNodeType = {
|
||||
id: generateId(),
|
||||
name: '新建文件.txt',
|
||||
type: NodeType.Text,
|
||||
path: '/新建文件.txt',
|
||||
size: 0,
|
||||
lastModified: new Date()
|
||||
}
|
||||
|
||||
// 添加到当前文件夹
|
||||
if (currentPath.value.length === 0) {
|
||||
fileTree.value[0].children = fileTree.value[0].children || []
|
||||
fileTree.value[0].children.push(newFile)
|
||||
} else {
|
||||
const currentFolder = findNodeById(fileTree.value, currentPath.value[currentPath.value.length - 1].id)
|
||||
if (currentFolder) {
|
||||
currentFolder.children = currentFolder.children || []
|
||||
currentFolder.children.push(newFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const uploadFile = () => {
|
||||
// 创建文件输入元素
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.multiple = true
|
||||
input.onchange = (e) => {
|
||||
const files = (e.target as HTMLInputElement).files
|
||||
if (files) {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
console.log('上传文件:', file.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
}
|
||||
|
||||
const refreshTree = () => {
|
||||
console.log('刷新文件树')
|
||||
}
|
||||
|
||||
// 拖拽调整面板大小
|
||||
let isResizing = false
|
||||
|
||||
const startResize = (event: MouseEvent) => {
|
||||
isResizing = true
|
||||
document.addEventListener('mousemove', handleResize)
|
||||
document.addEventListener('mouseup', stopResize)
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const handleResize = (event: MouseEvent) => {
|
||||
if (!isResizing) return
|
||||
|
||||
const container = document.querySelector('.file-system') as HTMLElement
|
||||
if (!container) return
|
||||
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const newWidth = event.clientX - containerRect.left
|
||||
|
||||
if (newWidth >= minPanelWidth && newWidth <= maxPanelWidth) {
|
||||
leftPanelWidth.value = newWidth
|
||||
}
|
||||
}
|
||||
|
||||
const stopResize = () => {
|
||||
isResizing = false
|
||||
document.removeEventListener('mousemove', handleResize)
|
||||
document.removeEventListener('mouseup', stopResize)
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
// 初始化选中根目录
|
||||
selectedFolderId.value = fileTree.value[0].id
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousemove', handleResize)
|
||||
document.removeEventListener('mouseup', stopResize)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
|
||||
.file-system {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
background: #2d2d2d;
|
||||
color: #cccccc;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
.file-tree-panel {
|
||||
background: #393939;
|
||||
border-right: 1px solid #4a4a4a;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.tree-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 4px;
|
||||
}
|
||||
|
||||
|
||||
.tree-node-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tree-node-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tree-node-label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.resize-handle {
|
||||
width: 4px;
|
||||
background: #4a4a4a;
|
||||
cursor: col-resize;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.resize-handle:hover {
|
||||
background: #409eff;
|
||||
}
|
||||
|
||||
.file-list-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.list-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: #353535;
|
||||
border-bottom: 1px solid #4a4a4a;
|
||||
font-size: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
cursor: pointer;
|
||||
color: #409eff;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.breadcrumb-item:hover {
|
||||
color: #66b1ff;
|
||||
}
|
||||
|
||||
/* 网格视图样式 */
|
||||
.file-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
|
||||
gap: 1px;
|
||||
padding: 8px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* 文件列表视图样式 */
|
||||
.file-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.list-header {
|
||||
display: flex;
|
||||
padding: 8px 12px;
|
||||
background: #353535;
|
||||
border-bottom: 1px solid #4a4a4a;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.file-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
border-radius: 3px;
|
||||
margin: 1px 4px;
|
||||
}
|
||||
|
||||
.file-row:hover {
|
||||
background: #404040;
|
||||
}
|
||||
|
||||
.file-row.selected {
|
||||
background: #2d4a6b;
|
||||
}
|
||||
|
||||
.col-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.col-type {
|
||||
width: 80px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.col-size {
|
||||
width: 80px;
|
||||
text-align: right;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.col-date {
|
||||
width: 120px;
|
||||
text-align: right;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.empty-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.empty-actions button {
|
||||
background: #409eff;
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.empty-actions button:hover {
|
||||
background: #66b1ff;
|
||||
}
|
||||
|
||||
/* 文件类型特定样式 */
|
||||
.file-item.folder .file-item-name,
|
||||
.file-row.folder .file-name {
|
||||
color: #e6a23c;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.file-item.script .file-item-name,
|
||||
.file-row.script .file-name {
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.file-item.model .file-item-name,
|
||||
.file-row.model .file-name {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.file-item.image .file-item-name,
|
||||
.file-item.texture .file-item-name,
|
||||
.file-row.image .file-name,
|
||||
.file-row.texture .file-name {
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.file-item.material .file-item-name,
|
||||
.file-row.material .file-name {
|
||||
color: #e6a23c;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
.tree-content::-webkit-scrollbar,
|
||||
.list-content::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.tree-content::-webkit-scrollbar-track,
|
||||
.list-content::-webkit-scrollbar-track {
|
||||
background: #2d2d2d;
|
||||
}
|
||||
|
||||
.tree-content::-webkit-scrollbar-thumb,
|
||||
.list-content::-webkit-scrollbar-thumb {
|
||||
background: #4a4a4a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tree-content::-webkit-scrollbar-thumb:hover,
|
||||
.list-content::-webkit-scrollbar-thumb:hover {
|
||||
background: #5a5a5a;
|
||||
}
|
||||
|
||||
/* Element Plus 树组件样式覆盖 */
|
||||
/* :deep(.el-tree) {
|
||||
background: transparent;
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
.tree-content :deep(.el-tree-node__content) {
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
margin: 2px 0;
|
||||
padding: 4px 8px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.tree-content :deep(.el-tree-node__content:hover) {
|
||||
background: #404040;
|
||||
}
|
||||
|
||||
.tree-content :deep(.el-tree-node.is-current > .el-tree-node__content) {
|
||||
background: #409eff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tree-content :deep(.el-tree-node__expand-icon) {
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tree-content :deep(.el-tree-node__expand-icon.is-leaf) {
|
||||
color: transparent;
|
||||
}
|
||||
*/
|
||||
|
||||
.tree-node-label{
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.el-tree {
|
||||
background: #1c00be00;
|
||||
}
|
||||
|
||||
|
||||
/* 设置节点的默认文字颜色 */
|
||||
::v-deep .el-tree-node__label {
|
||||
color: #ffffff;
|
||||
/* 自定义你想要的颜色 */
|
||||
}
|
||||
|
||||
/* 设置鼠标悬停时的背景颜色 */
|
||||
::v-deep .el-tree-node:hover>.el-tree-node__content {
|
||||
background-color: #f0f9eb00;
|
||||
/* 自定义悬停背景色 */
|
||||
}
|
||||
|
||||
/* 设置鼠标悬停时的文字颜色 */
|
||||
::v-deep .el-tree-node:hover .el-tree-node__label {
|
||||
color: #f8f8f8;
|
||||
/* 自定义悬停文字颜色 */
|
||||
}
|
||||
|
||||
::v-deep .el-tree-node__content {
|
||||
background-color: rgba(255, 255, 255, 0);
|
||||
/* 鼠标离开后恢复的背景色 */
|
||||
color: #333;
|
||||
/* 默认文字颜色 */
|
||||
}
|
||||
|
||||
::v-deep .el-tree-node.is-current>.el-tree-node__content {
|
||||
background-color: #ffffff28;
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
922
src/components/BottomPanel/FileSystem/index.vue
Normal file
922
src/components/BottomPanel/FileSystem/index.vue
Normal file
@ -0,0 +1,922 @@
|
||||
<template>
|
||||
<div class="file-system">
|
||||
<!-- 左侧文件夹树 -->
|
||||
<div class="file-tree-panel" :style="{ width: leftPanelWidth + 'px' }">
|
||||
|
||||
|
||||
<div class="tree-content">
|
||||
<el-tree :data="folderTree" :props="treeProps" :expand-on-click-node="false" node-key="id"
|
||||
:current-node-key="selectedFolderId" @node-click="handleFolderClick" @node-expand="handleFolderExpand"
|
||||
@node-collapse="handleFolderCollapse">
|
||||
<template #default="{ node, data }">
|
||||
<span class="tree-node-content">
|
||||
<i class="tree-node-icon">📁</i>
|
||||
<span class="tree-node-label">{{ node.label }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</el-tree>
|
||||
|
||||
<div v-if="folderTree.length === 0" class="empty-state">
|
||||
<p>暂无文件夹</p>
|
||||
<button @click="createFolder">创建文件夹</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 拖拽分隔条 -->
|
||||
<div class="resize-handle" @mousedown="startResize"></div>
|
||||
|
||||
<!-- 右侧文件列表 -->
|
||||
<div class="file-list-panel">
|
||||
<div class="list-content" :class="viewMode">
|
||||
<!-- 面包屑导航 -->
|
||||
<div class="breadcrumb">
|
||||
<span @click="navigateToRoot" class="breadcrumb-item">根目录</span>
|
||||
<span v-for="(folder, index) in currentPath" :key="folder.id" @click="navigateToFolder(folder, index)"
|
||||
class="breadcrumb-item">
|
||||
/ {{ folder.name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 文件网格视图 -->
|
||||
<div class="file-grid" v-if="viewMode === 'grid'">
|
||||
<FileNode v-for="node in currentFolderFiles" :key="node.id" :node="node" :selected-id="selectedFileId"
|
||||
:show-size="true" @click="handleFileClick" @double-click="handleFileDoubleClick" @rename="handleFileRename"
|
||||
@drag-start="handleDragStart" @drag-end="handleDragEnd" @drag-over="handleDragOver"
|
||||
@drag-leave="handleDragLeave" @drop="handleDrop" />
|
||||
</div>
|
||||
|
||||
<!-- 文件列表视图 -->
|
||||
<div class="file-list" v-else>
|
||||
<div class="list-header">
|
||||
<div class="col-name">名称</div>
|
||||
<div class="col-type">类型</div>
|
||||
<div class="col-size">大小</div>
|
||||
<div class="col-date">修改时间</div>
|
||||
</div>
|
||||
<div v-for="node in currentFolderFiles" :key="node.id"
|
||||
:class="['file-row', node.type.toLowerCase(), { selected: selectedFileId === node.id }]"
|
||||
@click="handleFileClick(node)" @dblclick="handleFileDoubleClick(node)" :draggable="true"
|
||||
@dragstart="handleDragStart(node, $event)" @dragend="handleDragEnd(node, $event)"
|
||||
@dragover="handleDragOver(node, $event)" @dragleave="handleDragLeave(node, $event)"
|
||||
@drop="handleDrop(node, dragState.dragNode!, 'inside', $event)">
|
||||
<div class="col-name">
|
||||
<span class="file-icon">{{ getFileIcon(node.type) }}</span>
|
||||
<span class="file-name" :title="node.name">{{ node.name }}</span>
|
||||
</div>
|
||||
<div class="col-type">{{ getFileTypeLabel(node.type) }}</div>
|
||||
<div class="col-size">{{ node.size !== undefined ? formatFileSize(node.size) : '-' }}</div>
|
||||
<div class="col-date">{{ node.lastModified ? formatDate(node.lastModified) : '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="currentFolderFiles.length === 0" class="empty-state">
|
||||
<p>此文件夹为空</p>
|
||||
<div class="empty-actions">
|
||||
<button @click="createFile">创建文件</button>
|
||||
<button @click="uploadFile">上传文件</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { FileNode as FileNodeType, FileNodeType as NodeType, DragState, FILE_ICONS } from '../../BasicControls/FileNode/types'
|
||||
import { generateId, formatFileSize, formatDate } from '../../../utils/Tools'
|
||||
import FileNode from '../../BasicControls/FileNode/index.vue'
|
||||
import { userDragStore } from 'stores/Filed'
|
||||
import { useCursorStore } from 'stores/Cursor'
|
||||
const dragStore = userDragStore()
|
||||
const cursorStore = useCursorStore()
|
||||
// 响应式数据
|
||||
const leftPanelWidth = ref(250)
|
||||
const minPanelWidth = 150
|
||||
const maxPanelWidth = 500
|
||||
|
||||
const viewMode = ref<'list' | 'grid'>('grid')
|
||||
const selectedFolderId = ref<string>('')
|
||||
const selectedFileId = ref<string>('')
|
||||
const currentPath = ref<FileNodeType[]>([])
|
||||
|
||||
// Element Plus 树组件配置
|
||||
const treeProps = {
|
||||
children: 'children',
|
||||
label: 'name'
|
||||
}
|
||||
|
||||
// 拖拽状态
|
||||
const dragState = ref<DragState>({
|
||||
isDragging: false,
|
||||
dragNode: null,
|
||||
dragOverNode: null,
|
||||
dropPosition: null
|
||||
})
|
||||
|
||||
// 模拟文件数据
|
||||
const fileTree = ref<FileNodeType[]>([
|
||||
{
|
||||
id: 'root',
|
||||
name: '项目文件',
|
||||
type: NodeType.Folder,
|
||||
path: '/',
|
||||
isExpanded: true,
|
||||
children: [
|
||||
{
|
||||
id: 'assets',
|
||||
name: 'Assets',
|
||||
type: NodeType.Folder,
|
||||
path: '/Assets',
|
||||
isExpanded: false,
|
||||
children: [
|
||||
{
|
||||
id: 'models',
|
||||
name: 'Models',
|
||||
type: NodeType.Folder,
|
||||
path: '/Assets/Models',
|
||||
children: [
|
||||
{
|
||||
id: 'model1',
|
||||
name: 'character.fbx',
|
||||
type: NodeType.Model,
|
||||
path: '/Assets/Models/character.fbx',
|
||||
size: 2048576,
|
||||
lastModified: new Date('2024-01-15')
|
||||
},
|
||||
{
|
||||
id: 'model2',
|
||||
name: 'environment.glb',
|
||||
type: NodeType.Model,
|
||||
path: '/Assets/Models/environment.glb',
|
||||
size: 5242880,
|
||||
lastModified: new Date('2024-01-18')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'textures',
|
||||
name: 'Textures',
|
||||
type: NodeType.Folder,
|
||||
path: '/Assets/Textures',
|
||||
children: [
|
||||
{
|
||||
id: 'texture1',
|
||||
name: 'wall_diffuse.png',
|
||||
type: NodeType.Texture,
|
||||
path: '/Assets/Textures/wall_diffuse.png',
|
||||
size: 1024000,
|
||||
lastModified: new Date('2024-01-10')
|
||||
},
|
||||
{
|
||||
id: 'texture2',
|
||||
name: 'floor_normal.jpg',
|
||||
type: NodeType.Image,
|
||||
path: '/Assets/Textures/floor_normal.jpg',
|
||||
size: 512000,
|
||||
lastModified: new Date('2024-01-12')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'materials',
|
||||
name: 'Materials',
|
||||
type: NodeType.Folder,
|
||||
path: '/Assets/Materials',
|
||||
children: [
|
||||
{
|
||||
id: 'material1',
|
||||
name: 'wood.material',
|
||||
type: NodeType.Material,
|
||||
path: '/Assets/Materials/wood.material',
|
||||
size: 2048,
|
||||
lastModified: new Date('2024-01-14')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'audio',
|
||||
name: 'Audio',
|
||||
type: NodeType.Folder,
|
||||
path: '/Assets/Audio',
|
||||
children: [
|
||||
{
|
||||
id: 'audio1',
|
||||
name: 'background.mp3',
|
||||
type: NodeType.Audio,
|
||||
path: '/Assets/Audio/background.mp3',
|
||||
size: 3145728,
|
||||
lastModified: new Date('2024-01-16')
|
||||
},
|
||||
{
|
||||
id: 'audio2',
|
||||
name: 'effect.wav',
|
||||
type: NodeType.Audio,
|
||||
path: '/Assets/Audio/effect.wav',
|
||||
size: 1048576,
|
||||
lastModified: new Date('2024-01-17')
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'scripts',
|
||||
name: 'Scripts',
|
||||
type: NodeType.Folder,
|
||||
path: '/Scripts',
|
||||
children: [
|
||||
{
|
||||
id: 'script1',
|
||||
name: 'main.js',
|
||||
type: NodeType.Script,
|
||||
path: '/Scripts/main.js',
|
||||
size: 4096,
|
||||
lastModified: new Date('2024-01-20')
|
||||
},
|
||||
{
|
||||
id: 'script2',
|
||||
name: 'utils.ts',
|
||||
type: NodeType.Script,
|
||||
path: '/Scripts/utils.ts',
|
||||
size: 2048,
|
||||
lastModified: new Date('2024-01-19')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'scenes',
|
||||
name: 'Scenes',
|
||||
type: NodeType.Folder,
|
||||
path: '/Scenes',
|
||||
children: [
|
||||
{
|
||||
id: 'scene1',
|
||||
name: 'main.scene',
|
||||
type: NodeType.Scene,
|
||||
path: '/Scenes/main.scene',
|
||||
size: 8192,
|
||||
lastModified: new Date('2024-01-21')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'docs',
|
||||
name: 'Documentation',
|
||||
type: NodeType.Folder,
|
||||
path: '/Documentation',
|
||||
children: [
|
||||
{
|
||||
id: 'readme',
|
||||
name: 'README.md',
|
||||
type: NodeType.Text,
|
||||
path: '/Documentation/README.md',
|
||||
size: 1024,
|
||||
lastModified: new Date('2024-01-22')
|
||||
},
|
||||
{
|
||||
id: 'guide',
|
||||
name: 'user-guide.txt',
|
||||
type: NodeType.Text,
|
||||
path: '/Documentation/user-guide.txt',
|
||||
size: 2048,
|
||||
lastModified: new Date('2024-01-23')
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
// 计算属性
|
||||
const folderTree = computed(() => {
|
||||
return buildFolderTree(fileTree.value)
|
||||
})
|
||||
|
||||
|
||||
const currentFolderFiles = computed(() => {
|
||||
let currentFolder = fileTree.value[0] // 根目录
|
||||
|
||||
for (const pathItem of currentPath.value) {
|
||||
const found = currentFolder.children?.find(child => child.id === pathItem.id)
|
||||
if (found) {
|
||||
currentFolder = found
|
||||
}
|
||||
}
|
||||
|
||||
return currentFolder.children || []
|
||||
})
|
||||
|
||||
// 工具函数
|
||||
function buildFolderTree(nodes: FileNodeType[]): FileNodeType[] {
|
||||
return nodes.map(node => {
|
||||
if (node.type === NodeType.Folder) {
|
||||
const folderNode: FileNodeType = {
|
||||
...node,
|
||||
children: node.children ? buildFolderTree(node.children.filter(child => child.type === NodeType.Folder)) : []
|
||||
}
|
||||
return folderNode
|
||||
}
|
||||
return node
|
||||
}).filter(node => node.type === NodeType.Folder)
|
||||
}
|
||||
|
||||
function findNodeById(nodes: FileNodeType[], id: string): FileNodeType | null {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) {
|
||||
return node
|
||||
}
|
||||
if (node.children) {
|
||||
const found = findNodeById(node.children, id)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getFileIcon(type: NodeType): string {
|
||||
return FILE_ICONS[type] || '📄'
|
||||
}
|
||||
|
||||
function getFileTypeLabel(type: NodeType): string {
|
||||
const typeLabels: Record<NodeType, string> = {
|
||||
[NodeType.Text]: '文本',
|
||||
[NodeType.Folder]: '文件夹',
|
||||
[NodeType.Model]: '模型',
|
||||
[NodeType.Audio]: '音频',
|
||||
[NodeType.Video]: '视频',
|
||||
[NodeType.Material]: '材质',
|
||||
[NodeType.Texture]: '纹理',
|
||||
[NodeType.Image]: '图片',
|
||||
[NodeType.Script]: '脚本',
|
||||
[NodeType.Scene]: '场景'
|
||||
}
|
||||
return typeLabels[type] || '未知'
|
||||
}
|
||||
|
||||
// 事件处理
|
||||
const handleFolderClick = (data: FileNodeType) => {
|
||||
selectedFolderId.value = data.id
|
||||
// 导航到选中的文件夹
|
||||
const pathToNode = getPathToNode(fileTree.value, data.id)
|
||||
currentPath.value = pathToNode.slice(1) // 移除根目录
|
||||
}
|
||||
|
||||
const handleFolderExpand = (data: FileNodeType) => {
|
||||
const targetNode = findNodeById(fileTree.value, data.id)
|
||||
if (targetNode) {
|
||||
targetNode.isExpanded = true
|
||||
}
|
||||
}
|
||||
|
||||
const handleFolderCollapse = (data: FileNodeType) => {
|
||||
const targetNode = findNodeById(fileTree.value, data.id)
|
||||
if (targetNode) {
|
||||
targetNode.isExpanded = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileClick = (node: FileNodeType) => {
|
||||
selectedFileId.value = node.id
|
||||
}
|
||||
|
||||
const handleFileRename = (node: FileNodeType, newName: string) => {
|
||||
const targetNode = findNodeById(fileTree.value, node.id)
|
||||
if (targetNode) {
|
||||
targetNode.name = newName
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileDoubleClick = (node: FileNodeType) => {
|
||||
if (node.type === NodeType.Folder) {
|
||||
// 进入文件夹
|
||||
currentPath.value.push(node)
|
||||
selectedFolderId.value = node.id
|
||||
selectedFileId.value = ''
|
||||
} else {
|
||||
// 打开文件
|
||||
}
|
||||
}
|
||||
|
||||
// 拖拽处理
|
||||
const handleDragStart = (node: FileNodeType, event: DragEvent) => {
|
||||
dragState.value.isDragging = true
|
||||
dragState.value.dragNode = node
|
||||
node.isDragging = true
|
||||
|
||||
if (event.dataTransfer) {
|
||||
cursorStore.setCursor('move')
|
||||
// event.dataTransfer.effectAllowed = 'move'
|
||||
// event.dataTransfer.setData('application/json', JSON.stringify(node))
|
||||
// event.dataTransfer.setData('text/plain', node.id)
|
||||
dragStore.setDragType(node.type)
|
||||
dragStore.setDragData(node)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnd = (node: FileNodeType, event: DragEvent) => {
|
||||
cursorStore.setCursor('default')
|
||||
dragState.value.isDragging = false
|
||||
dragState.value.dragNode = null
|
||||
dragState.value.dragOverNode = null
|
||||
dragState.value.dropPosition = null
|
||||
node.isDragging = false
|
||||
|
||||
}
|
||||
|
||||
const handleDragOver = (node: FileNodeType, event: DragEvent) => {
|
||||
if (dragState.value.dragNode && dragState.value.dragNode.id !== node.id) {
|
||||
event.preventDefault()
|
||||
dragState.value.dragOverNode = node
|
||||
dragState.value.dropPosition = 'inside'
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragLeave = (node: FileNodeType, event: DragEvent) => {
|
||||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
const x = event.clientX
|
||||
const y = event.clientY
|
||||
|
||||
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
|
||||
if (dragState.value.dragOverNode?.id === node.id) {
|
||||
dragState.value.dragOverNode = null
|
||||
dragState.value.dropPosition = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (targetNode: FileNodeType, dragNode: FileNodeType, position: string, event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
|
||||
// 这里实现具体的拖拽逻辑
|
||||
}
|
||||
|
||||
// 导航功能
|
||||
const navigateToRoot = () => {
|
||||
currentPath.value = []
|
||||
selectedFolderId.value = fileTree.value[0].id
|
||||
selectedFileId.value = ''
|
||||
}
|
||||
|
||||
const navigateToFolder = (folder: FileNodeType, index: number) => {
|
||||
currentPath.value = currentPath.value.slice(0, index + 1)
|
||||
selectedFolderId.value = folder.id
|
||||
selectedFileId.value = ''
|
||||
}
|
||||
|
||||
function getPathToNode(nodes: FileNodeType[], targetId: string, currentPath: FileNodeType[] = []): FileNodeType[] {
|
||||
for (const node of nodes) {
|
||||
const newPath = [...currentPath, node]
|
||||
if (node.id === targetId) {
|
||||
return newPath
|
||||
}
|
||||
if (node.children) {
|
||||
const found = getPathToNode(node.children, targetId, newPath)
|
||||
if (found.length > 0) return found
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// 操作功能
|
||||
const toggleViewMode = () => {
|
||||
viewMode.value = viewMode.value === 'list' ? 'grid' : 'list'
|
||||
}
|
||||
|
||||
const createFolder = () => {
|
||||
const newFolder: FileNodeType = {
|
||||
id: generateId(),
|
||||
name: '新建文件夹',
|
||||
type: NodeType.Folder,
|
||||
path: '/新建文件夹',
|
||||
children: []
|
||||
}
|
||||
|
||||
// 添加到当前位置
|
||||
if (currentPath.value.length === 0) {
|
||||
fileTree.value[0].children = fileTree.value[0].children || []
|
||||
fileTree.value[0].children.push(newFolder)
|
||||
} else {
|
||||
const currentFolder = findNodeById(fileTree.value, currentPath.value[currentPath.value.length - 1].id)
|
||||
if (currentFolder) {
|
||||
currentFolder.children = currentFolder.children || []
|
||||
currentFolder.children.push(newFolder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const createFile = () => {
|
||||
const newFile: FileNodeType = {
|
||||
id: generateId(),
|
||||
name: '新建文件.txt',
|
||||
type: NodeType.Text,
|
||||
path: '/新建文件.txt',
|
||||
size: 0,
|
||||
lastModified: new Date()
|
||||
}
|
||||
|
||||
// 添加到当前文件夹
|
||||
if (currentPath.value.length === 0) {
|
||||
fileTree.value[0].children = fileTree.value[0].children || []
|
||||
fileTree.value[0].children.push(newFile)
|
||||
} else {
|
||||
const currentFolder = findNodeById(fileTree.value, currentPath.value[currentPath.value.length - 1].id)
|
||||
if (currentFolder) {
|
||||
currentFolder.children = currentFolder.children || []
|
||||
currentFolder.children.push(newFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const uploadFile = () => {
|
||||
// 创建文件输入元素
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.multiple = true
|
||||
input.onchange = (e) => {
|
||||
const files = (e.target as HTMLInputElement).files
|
||||
if (files) {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
}
|
||||
|
||||
const refreshTree = () => {
|
||||
}
|
||||
|
||||
// 拖拽调整面板大小
|
||||
let isResizing = false
|
||||
|
||||
const startResize = (event: MouseEvent) => {
|
||||
isResizing = true
|
||||
document.addEventListener('mousemove', handleResize)
|
||||
document.addEventListener('mouseup', stopResize)
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const handleResize = (event: MouseEvent) => {
|
||||
if (!isResizing) return
|
||||
|
||||
const container = document.querySelector('.file-system') as HTMLElement
|
||||
if (!container) return
|
||||
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const newWidth = event.clientX - containerRect.left
|
||||
|
||||
if (newWidth >= minPanelWidth && newWidth <= maxPanelWidth) {
|
||||
leftPanelWidth.value = newWidth
|
||||
}
|
||||
}
|
||||
|
||||
const stopResize = () => {
|
||||
isResizing = false
|
||||
document.removeEventListener('mousemove', handleResize)
|
||||
document.removeEventListener('mouseup', stopResize)
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
// 初始化选中根目录
|
||||
selectedFolderId.value = fileTree.value[0].id
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousemove', handleResize)
|
||||
document.removeEventListener('mouseup', stopResize)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-system {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
background: #2d2d2d;
|
||||
color: #cccccc;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
.file-tree-panel {
|
||||
background: #393939;
|
||||
border-right: 1px solid #4a4a4a;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.tree-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 4px;
|
||||
}
|
||||
|
||||
|
||||
.tree-node-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tree-node-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tree-node-label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.resize-handle {
|
||||
width: 4px;
|
||||
background: #4a4a4a;
|
||||
cursor: col-resize;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.resize-handle:hover {
|
||||
background: #409eff;
|
||||
}
|
||||
|
||||
.file-list-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.list-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: #353535;
|
||||
border-bottom: 1px solid #4a4a4a;
|
||||
font-size: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
cursor: pointer;
|
||||
color: #409eff;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.breadcrumb-item:hover {
|
||||
color: #66b1ff;
|
||||
}
|
||||
|
||||
/* 网格视图样式 */
|
||||
.file-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
|
||||
gap: 1px;
|
||||
padding: 8px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* 文件列表视图样式 */
|
||||
.file-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.list-header {
|
||||
display: flex;
|
||||
padding: 8px 12px;
|
||||
background: #353535;
|
||||
border-bottom: 1px solid #4a4a4a;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.file-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
border-radius: 3px;
|
||||
margin: 1px 4px;
|
||||
}
|
||||
|
||||
.file-row:hover {
|
||||
background: #404040;
|
||||
}
|
||||
|
||||
.file-row.selected {
|
||||
background: #2d4a6b;
|
||||
}
|
||||
|
||||
.col-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.col-type {
|
||||
width: 80px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.col-size {
|
||||
width: 80px;
|
||||
text-align: right;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.col-date {
|
||||
width: 120px;
|
||||
text-align: right;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.empty-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.empty-actions button {
|
||||
background: #409eff;
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.empty-actions button:hover {
|
||||
background: #66b1ff;
|
||||
}
|
||||
|
||||
/* 文件类型特定样式 */
|
||||
.file-item.folder .file-item-name,
|
||||
.file-row.folder .file-name {
|
||||
color: #e6a23c;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.file-item.script .file-item-name,
|
||||
.file-row.script .file-name {
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.file-item.model .file-item-name,
|
||||
.file-row.model .file-name {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.file-item.image .file-item-name,
|
||||
.file-item.texture .file-item-name,
|
||||
.file-row.image .file-name,
|
||||
.file-row.texture .file-name {
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.file-item.material .file-item-name,
|
||||
.file-row.material .file-name {
|
||||
color: #e6a23c;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
.tree-content::-webkit-scrollbar,
|
||||
.list-content::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.tree-content::-webkit-scrollbar-track,
|
||||
.list-content::-webkit-scrollbar-track {
|
||||
background: #2d2d2d;
|
||||
}
|
||||
|
||||
.tree-content::-webkit-scrollbar-thumb,
|
||||
.list-content::-webkit-scrollbar-thumb {
|
||||
background: #4a4a4a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tree-content::-webkit-scrollbar-thumb:hover,
|
||||
.list-content::-webkit-scrollbar-thumb:hover {
|
||||
background: #5a5a5a;
|
||||
}
|
||||
|
||||
/* Element Plus 树组件样式覆盖 */
|
||||
/* :deep(.el-tree) {
|
||||
background: transparent;
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
.tree-content :deep(.el-tree-node__content) {
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
margin: 2px 0;
|
||||
padding: 4px 8px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.tree-content :deep(.el-tree-node__content:hover) {
|
||||
background: #404040;
|
||||
}
|
||||
|
||||
.tree-content :deep(.el-tree-node.is-current > .el-tree-node__content) {
|
||||
background: #409eff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tree-content :deep(.el-tree-node__expand-icon) {
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tree-content :deep(.el-tree-node__expand-icon.is-leaf) {
|
||||
color: transparent;
|
||||
}
|
||||
*/
|
||||
|
||||
.tree-node-label {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.el-tree {
|
||||
background: #1c00be00;
|
||||
}
|
||||
|
||||
|
||||
/* 设置节点的默认文字颜色 */
|
||||
::v-deep .el-tree-node__label {
|
||||
color: #ffffff;
|
||||
/* 自定义你想要的颜色 */
|
||||
}
|
||||
|
||||
/* 设置鼠标悬停时的背景颜色 */
|
||||
::v-deep .el-tree-node:hover>.el-tree-node__content {
|
||||
background-color: #f0f9eb00;
|
||||
/* 自定义悬停背景色 */
|
||||
}
|
||||
|
||||
/* 设置鼠标悬停时的文字颜色 */
|
||||
::v-deep .el-tree-node:hover .el-tree-node__label {
|
||||
color: #f8f8f8;
|
||||
/* 自定义悬停文字颜色 */
|
||||
}
|
||||
|
||||
::v-deep .el-tree-node__content {
|
||||
background-color: rgba(255, 255, 255, 0);
|
||||
/* 鼠标离开后恢复的背景色 */
|
||||
color: #333;
|
||||
/* 默认文字颜色 */
|
||||
}
|
||||
|
||||
::v-deep .el-tree-node.is-current>.el-tree-node__content {
|
||||
background-color: #ffffff28;
|
||||
}
|
||||
</style>
|
||||
428
src/components/BottomPanel/index.vue
Normal file
428
src/components/BottomPanel/index.vue
Normal file
@ -0,0 +1,428 @@
|
||||
<template>
|
||||
<div class="bottom-panel" :style="{ height: layoutStore.bottomPanelHeight + 'px' }">
|
||||
<!-- 拖拽调整高度的手柄 -->
|
||||
<div
|
||||
class="resize-handle"
|
||||
@mousedown="startResize"
|
||||
@dblclick="resetHeight"
|
||||
>
|
||||
<div class="resize-indicator"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel-tabs">
|
||||
<div class="tab-list">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
:class="['tab-button', { active: activeTab === tab.key }]"
|
||||
@click="setActiveTab(tab.key)"
|
||||
>
|
||||
<span class="tab-icon">{{ tab.icon }}</span>
|
||||
<span class="tab-label">{{ tab.label }}</span>
|
||||
<span v-if="tab.badge" class="tab-badge">{{ tab.badge }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-actions">
|
||||
<button @click="togglePanel" :title="layoutStore.bottomPanelCollapsed ? '展开面板' : '收起面板'">
|
||||
{{ layoutStore.bottomPanelCollapsed ? '⬆️' : '⬇️' }}
|
||||
</button>
|
||||
<button @click="closePanel" title="关闭面板">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="!layoutStore.bottomPanelCollapsed" class="panel-content">
|
||||
<!-- 文件系统 -->
|
||||
<div v-show="activeTab === 'files'" class="tab-content">
|
||||
<FileSystem ref="fileSystemRef" />
|
||||
</div>
|
||||
|
||||
<!-- 控制台 -->
|
||||
<div v-show="activeTab === 'console'" class="tab-content">
|
||||
<Console ref="consoleRef" />
|
||||
</div>
|
||||
|
||||
<!-- 终端 -->
|
||||
<div v-show="activeTab === 'terminal'" class="tab-content">
|
||||
<div class="terminal-placeholder">
|
||||
<div class="placeholder-content">
|
||||
<h3>终端</h3>
|
||||
<p>终端功能正在开发中...</p>
|
||||
<button @click="addTestTerminalOutput">添加测试输出</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 输出 -->
|
||||
<div v-show="activeTab === 'output'" class="tab-content">
|
||||
<div class="output-placeholder">
|
||||
<div class="placeholder-content">
|
||||
<h3>输出</h3>
|
||||
<p>构建输出将显示在这里</p>
|
||||
<div class="output-example">
|
||||
<pre>{{ sampleOutput }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useLayoutStore } from '@/stores/Layout'
|
||||
import FileSystem from './FileSystem/index.vue'
|
||||
import Console from './Console/index.vue'
|
||||
|
||||
// 使用布局store
|
||||
const layoutStore = useLayoutStore()
|
||||
|
||||
// 标签页配置
|
||||
const tabs = [
|
||||
{ key: 'files', label: '文件', icon: '📁', badge: null },
|
||||
{ key: 'console', label: '控制台', icon: '🖥️', badge: null },
|
||||
{ key: 'terminal', label: '终端', icon: '⚡', badge: null },
|
||||
{ key: 'output', label: '输出', icon: '📄', badge: null }
|
||||
]
|
||||
|
||||
// 响应式数据
|
||||
const activeTab = ref('files')
|
||||
|
||||
// 组件引用
|
||||
const fileSystemRef = ref()
|
||||
const consoleRef = ref()
|
||||
|
||||
// 示例输出内容
|
||||
const sampleOutput = ref(`> 正在构建项目...
|
||||
✓ 编译 TypeScript 文件
|
||||
✓ 打包资源文件
|
||||
✓ 优化代码
|
||||
✓ 生成 source map
|
||||
✅ 构建完成!
|
||||
|
||||
输出目录: dist/
|
||||
构建时间: 2.3s
|
||||
文件大小: 1.2MB`)
|
||||
|
||||
// 计算属性
|
||||
const consoleBadge = computed(() => {
|
||||
if (consoleRef.value?.logs?.length > 0) {
|
||||
return consoleRef.value.logs.length.toString()
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
// 方法
|
||||
function setActiveTab(tabKey: string) {
|
||||
activeTab.value = tabKey
|
||||
}
|
||||
|
||||
function togglePanel() {
|
||||
layoutStore.toggleBottomPanelCollapse()
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
layoutStore.hideBottomPanel()
|
||||
}
|
||||
|
||||
function addTestTerminalOutput() {
|
||||
if (consoleRef.value) {
|
||||
consoleRef.value.info('终端命令执行: npm run build', { source: 'Terminal' })
|
||||
consoleRef.value.success('构建完成', { source: 'Terminal' })
|
||||
}
|
||||
}
|
||||
|
||||
// 拖拽调整高度功能
|
||||
function startResize(event: MouseEvent) {
|
||||
layoutStore.setResizing(true, 'bottom')
|
||||
const startY = event.clientY
|
||||
const startHeight = layoutStore.bottomPanelHeight
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!layoutStore.isResizing) return
|
||||
|
||||
const deltaY = startY - e.clientY // 向上拖拽为正值
|
||||
layoutStore.setBottomPanelHeight(startHeight + deltaY)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
layoutStore.setResizing(false, null)
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
|
||||
// 保存布局配置
|
||||
layoutStore.saveLayoutConfig()
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
document.body.style.cursor = 'ns-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
function resetHeight() {
|
||||
layoutStore.setBottomPanelHeight(300) // 重置到默认高度
|
||||
layoutStore.saveLayoutConfig()
|
||||
}
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
showPanel: () => {
|
||||
layoutStore.showBottomPanel()
|
||||
},
|
||||
hidePanel: () => {
|
||||
layoutStore.hideBottomPanel()
|
||||
},
|
||||
togglePanel,
|
||||
setActiveTab,
|
||||
addLog: (type: string, message: string, options?: any) => {
|
||||
if (consoleRef.value) {
|
||||
consoleRef.value.addLog(type, message, options)
|
||||
// 自动切换到控制台标签
|
||||
if (type === 'error' || type === 'warn') {
|
||||
activeTab.value = 'console'
|
||||
}
|
||||
}
|
||||
},
|
||||
console: computed(() => consoleRef.value),
|
||||
fileSystem: computed(() => fileSystemRef.value)
|
||||
})
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
// 更新控制台徽章
|
||||
if (consoleRef.value) {
|
||||
const consoleTab = tabs.find(tab => tab.key === 'console')
|
||||
if (consoleTab) {
|
||||
consoleTab.badge = consoleBadge.value
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bottom-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #2d2d2d;
|
||||
color: #cccccc;
|
||||
border-top: 1px solid #3e3e3e;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 拖拽调整高度的手柄 */
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 6px;
|
||||
background: transparent;
|
||||
cursor: ns-resize;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.resize-handle:hover {
|
||||
background: rgba(0, 122, 204, 0.2);
|
||||
}
|
||||
|
||||
.resize-handle:active {
|
||||
background: rgba(0, 122, 204, 0.4);
|
||||
}
|
||||
|
||||
.resize-indicator {
|
||||
width: 40px;
|
||||
height: 3px;
|
||||
background: #4a4a4a;
|
||||
border-radius: 2px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.resize-handle:hover .resize-indicator {
|
||||
background: #007acc;
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.resize-handle:active .resize-indicator {
|
||||
background: #005a9e;
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.panel-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #3c3c3c;
|
||||
border-bottom: 1px solid #4a4a4a;
|
||||
|
||||
}
|
||||
|
||||
.tab-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #888;
|
||||
padding: 2px 16px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s;
|
||||
border-top: 2px solid transparent;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
background: #4a4a4a;
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
background: #2d2d2d;
|
||||
color: #ffffff;
|
||||
border-top-color: #007acc;
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
||||
.tab-badge {
|
||||
background: #f14c4c;
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
min-width: 16px;
|
||||
text-align: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tab-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.tab-actions button {
|
||||
background: transparent;
|
||||
border: 1px solid #4a4a4a;
|
||||
color: #888;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab-actions button:hover {
|
||||
background: #4a4a4a;
|
||||
border-color: #007acc;
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: #2d2d2d;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.terminal-placeholder,
|
||||
.output-placeholder {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.placeholder-content {
|
||||
text-align: center;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.placeholder-content h3 {
|
||||
margin: 0 0 12px 0;
|
||||
color: #cccccc;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.placeholder-content p {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.placeholder-content button {
|
||||
background: #007acc;
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.placeholder-content button:hover {
|
||||
background: #005a9e;
|
||||
}
|
||||
|
||||
.output-example {
|
||||
margin-top: 16px;
|
||||
text-align: left;
|
||||
background: #2d2d2d;
|
||||
border: 1px solid #3e3e3e;
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.output-example pre {
|
||||
margin: 0;
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: #d4d4d4;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.tab-button {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tab-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
95
src/components/HierarchyPanel/index.vue
Normal file
95
src/components/HierarchyPanel/index.vue
Normal file
@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div class="hierarchy-panel" @click="handlePanelClick">
|
||||
<el-tree ref="HierarchytreeRef" style="max-width: 600px" :data="HierarchytreeData" node-key="id"
|
||||
:props="defaultProps" @node-click="handleNodeClick">
|
||||
<template #default="{ node, data }">
|
||||
</template>
|
||||
</el-tree>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useHierarchyPanelStore } from 'stores/HierarchyPanel'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useInspectorPanelStore } from '@/stores/InspectorPanel'
|
||||
|
||||
const hierarchyPanelStore = useHierarchyPanelStore() // 获取HierarchyPanelStore 树结构pinia
|
||||
|
||||
const inspectorPanelStore = useInspectorPanelStore() // 获取InspectorPanelStore 面板pinia
|
||||
|
||||
const { selectedHierarchytreeData, HierarchytreeData } = storeToRefs(hierarchyPanelStore)
|
||||
|
||||
const HierarchytreeRef = ref()
|
||||
|
||||
|
||||
const defaultProps = ref({
|
||||
children: 'children',
|
||||
label: 'label'
|
||||
})
|
||||
|
||||
|
||||
watch(selectedHierarchytreeData, (node: any) => {
|
||||
// data.value = newVal
|
||||
if (node) {
|
||||
HierarchytreeRef.value.setCurrentKey(node.id)
|
||||
}
|
||||
else {
|
||||
HierarchytreeRef.value.setCurrentKey(null)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
const handleNodeClick = (node: any) => {
|
||||
console.log(node);
|
||||
|
||||
hierarchyPanelStore.setSelectedData(node)
|
||||
inspectorPanelStore.setSelecteInspectorData(node)
|
||||
}
|
||||
|
||||
const handlePanelClick = () => {
|
||||
hierarchyPanelStore.setSelectedData(null)
|
||||
inspectorPanelStore.setSelecteInspectorData(null)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hierarchy-panel {
|
||||
height: 100%;
|
||||
border-right: 1px solid #dcdfe6;
|
||||
}
|
||||
|
||||
.el-tree {
|
||||
background: #1c00be00;
|
||||
}
|
||||
|
||||
/* 设置节点的默认文字颜色 */
|
||||
::v-deep .el-tree-node__label {
|
||||
color: #ffffff;
|
||||
/* 自定义你想要的颜色 */
|
||||
}
|
||||
|
||||
/* 设置鼠标悬停时的背景颜色 */
|
||||
::v-deep .el-tree-node:hover>.el-tree-node__content {
|
||||
background-color: #f0f9eb00;
|
||||
/* 自定义悬停背景色 */
|
||||
}
|
||||
|
||||
/* 设置鼠标悬停时的文字颜色 */
|
||||
::v-deep .el-tree-node:hover .el-tree-node__label {
|
||||
color: #f8f8f8;
|
||||
/* 自定义悬停文字颜色 */
|
||||
}
|
||||
|
||||
::v-deep .el-tree-node__content {
|
||||
background-color: rgba(255, 255, 255, 0);
|
||||
/* 鼠标离开后恢复的背景色 */
|
||||
color: #333;
|
||||
/* 默认文字颜色 */
|
||||
}
|
||||
|
||||
::v-deep .el-tree-node.is-current>.el-tree-node__content {
|
||||
background-color: #ffffff28;
|
||||
}
|
||||
</style>
|
||||
1
src/components/InspectorPanel/Camera/index.ts
Normal file
1
src/components/InspectorPanel/Camera/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export const contain=["Camera"]
|
||||
480
src/components/InspectorPanel/Camera/index.vue
Normal file
480
src/components/InspectorPanel/Camera/index.vue
Normal file
@ -0,0 +1,480 @@
|
||||
<template>
|
||||
<div class="camera-panel" v-show="visible">
|
||||
<!-- Camera 标题栏 -->
|
||||
<CollapsibleHeader title="相机" :expanded="isExpanded" :enabled="isEnabled" :checkable="false" @update:expanded="isExpanded = $event"
|
||||
@update:enabled="handleEnabledChange" @action-click="handleActionClick" />
|
||||
|
||||
<!-- Camera 内容 -->
|
||||
<div class="camera-content" v-show="isExpanded">
|
||||
<!-- 相机类型选择 -->
|
||||
<PropertyRow label="类型" tooltip="选择场景中使用的相机类型">
|
||||
<Select v-model="cameraData.type" :options="cameraTypeOptions" @change="onCameraTypeChange" />
|
||||
</PropertyRow>
|
||||
|
||||
<!-- ArcRotateCamera 属性 -->
|
||||
<template v-if="cameraData.type === 'ArcRotateCamera'">
|
||||
<!-- Target -->
|
||||
<PropertyRow label="目标位置" tooltip="相机观察的目标位置 (Vector3)">
|
||||
<div class="vector3-input">
|
||||
<div v-for="axis in vector3Axes" :key="axis.key" class="value-group">
|
||||
<span class="axis-label">{{ axis.label }}</span>
|
||||
<NumberInput :model-value="cameraData.arcRotate.target[axis.key]"
|
||||
@update:model-value="updateTargetAxis(axis.key, $event)" :step="0.1" />
|
||||
</div>
|
||||
</div>
|
||||
</PropertyRow>
|
||||
|
||||
<!-- 范围输入组件 -->
|
||||
<PropertyRow v-for="range in arcRotateRanges" :key="range.key" :label="range.label" :tooltip="range.tooltip">
|
||||
<div class="range-input">
|
||||
<div class="value-group">
|
||||
<span class="axis-label">最小</span>
|
||||
<NumberInput :model-value="cameraData.arcRotate[range.minKey]" @update:model-value="range.updateMin"
|
||||
:step="range.step" :min="range.min" />
|
||||
</div>
|
||||
<div class="value-group">
|
||||
<span class="axis-label">最大</span>
|
||||
<NumberInput :model-value="cameraData.arcRotate[range.maxKey]" @update:model-value="range.updateMax"
|
||||
:step="range.step" :min="range.min" />
|
||||
</div>
|
||||
</div>
|
||||
</PropertyRow>
|
||||
|
||||
<!-- 单值属性 -->
|
||||
<!-- Wheel Delta Percentage -->
|
||||
<PropertyRow label="滚轮灵敏度" tooltip="鼠标滚轮缩放灵敏度百分比">
|
||||
<NumberInput v-model="cameraData.arcRotate.wheelDeltaPercentage" @update:model-value="updateArcRotateCamera"
|
||||
:step="0.01" :min="0" :max="1" :full-width="true" />
|
||||
</PropertyRow>
|
||||
|
||||
<!-- Panning Sensibility -->
|
||||
<PropertyRow label="平移灵敏度" tooltip="平移灵敏度 - 数值越小越灵敏">
|
||||
<NumberInput v-model="cameraData.arcRotate.panningSensibility" @update:model-value="updateArcRotateCamera"
|
||||
:step="1" :min="1" :full-width="true" />
|
||||
</PropertyRow>
|
||||
|
||||
<!-- Inertia -->
|
||||
<PropertyRow label="惯性" tooltip="移动惯性 (0-1),默认 0.9">
|
||||
<Slider v-model="cameraData.arcRotate.inertia" @change="updateArcRotateCamera" :min="0" :max="1"
|
||||
:step="0.01" />
|
||||
</PropertyRow>
|
||||
|
||||
<!-- Auto Rotation -->
|
||||
<PropertyRow label="自动旋转" tooltip="启用相机自动旋转">
|
||||
<Switch v-model="cameraData.arcRotate.autoRotation" @change="updateArcRotateCamera" size="small" />
|
||||
</PropertyRow>
|
||||
</template>
|
||||
|
||||
<!-- UniversalCamera 属性 -->
|
||||
<template v-if="cameraData.type === 'UniversalCamera'">
|
||||
<!-- 数值属性 -->
|
||||
<PropertyRow v-for="prop in universalNumericProps" :key="prop.key" :label="prop.label" :tooltip="prop.tooltip">
|
||||
<NumberInput v-model="cameraData.universal[prop.key]" @update:model-value="updateUniversalCamera"
|
||||
v-bind="prop.props" />
|
||||
</PropertyRow>
|
||||
|
||||
<!-- 键位绑定 -->
|
||||
<PropertyRow v-for="keyProp in universalKeyProps" :key="keyProp.key" :label="keyProp.label"
|
||||
:tooltip="keyProp.tooltip">
|
||||
<TextInput v-model="cameraData.universal[keyProp.key]" @update:model-value="updateUniversalCamera"
|
||||
:placeholder="keyProp.placeholder" text-align="center" :uppercase="true" />
|
||||
</PropertyRow>
|
||||
</template>
|
||||
|
||||
<!-- VR Camera 属性 -->
|
||||
<template v-if="cameraData.type === 'vrCamera'">
|
||||
<!-- 数值属性 -->
|
||||
<PropertyRow v-for="prop in vrNumericProps" :key="prop.key" :label="prop.label" :tooltip="prop.tooltip">
|
||||
<NumberInput v-model="cameraData.vr[prop.key]" @update:model-value="updateVrCamera" v-bind="prop.props" />
|
||||
</PropertyRow>
|
||||
|
||||
<!-- VR 范围输入 -->
|
||||
<PropertyRow v-for="range in vrRanges" :key="range.key" :label="range.label" :tooltip="range.tooltip">
|
||||
<div class="range-input">
|
||||
<div class="value-group">
|
||||
<span class="axis-label">最小</span>
|
||||
<NumberInput :model-value="cameraData.vr[range.minKey]" @update:model-value="range.updateMin"
|
||||
:step="range.step" :min="range.min" />
|
||||
</div>
|
||||
<div class="value-group">
|
||||
<span class="axis-label">最大</span>
|
||||
<NumberInput :model-value="cameraData.vr[range.maxKey]" @update:model-value="range.updateMax"
|
||||
:step="range.step" :min="range.min" />
|
||||
</div>
|
||||
</div>
|
||||
</PropertyRow>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, reactive, computed } from 'vue'
|
||||
import { useInspectorPanelStore } from 'stores/InspectorPanel'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import CollapsibleHeader from 'components/public/CollapsibleHeader.vue'
|
||||
import PropertyRow from 'components/BasicControls/PropertyRow/index.vue'
|
||||
import Select from 'components/BasicControls/Select/index.vue'
|
||||
import NumberInput from 'components/BasicControls/Input/index.vue'
|
||||
import Slider from 'components/BasicControls/Slider/index.vue'
|
||||
import Switch from 'components/BasicControls/Switch/index.vue'
|
||||
import TextInput from 'components/BasicControls/Input/TextInput.vue'
|
||||
import { useInspectorPanel, DEFAULT_COMPONENT_CONFIGS } from '../../../composables/useInspectorPanel'
|
||||
import type { SelectOption } from 'components/BasicControls/Select/types'
|
||||
import type {
|
||||
Vector3,
|
||||
CameraData,
|
||||
} from './types'
|
||||
|
||||
import { contain } from "./index"
|
||||
|
||||
const inspectorPanelStore = useInspectorPanelStore()
|
||||
const { selecteInspectorData } = storeToRefs(inspectorPanelStore)
|
||||
|
||||
// 使用通用的 Inspector Panel 逻辑
|
||||
const {
|
||||
isExpanded,
|
||||
isEnabled,
|
||||
createEnabledChangeHandler,
|
||||
createActionClickHandler,
|
||||
createShowMoreHandler
|
||||
} = useInspectorPanel()
|
||||
|
||||
// 相机类型选项
|
||||
const cameraTypeOptions: SelectOption[] = [
|
||||
{ value: 'ArcRotateCamera', label: '弧形旋转相机' },
|
||||
{ value: 'UniversalCamera', label: '通用相机' },
|
||||
{ value: 'vrCamera', label: 'VR相机' }
|
||||
]
|
||||
|
||||
// 相机数据
|
||||
const cameraData = reactive<CameraData>({
|
||||
type: 'ArcRotateCamera',
|
||||
arcRotate: { ...DEFAULT_COMPONENT_CONFIGS.camera.arcRotate },
|
||||
universal: { ...DEFAULT_COMPONENT_CONFIGS.camera.universal },
|
||||
vr: { ...DEFAULT_COMPONENT_CONFIGS.camera.vr }
|
||||
})
|
||||
|
||||
// Vector3 轴配置
|
||||
const vector3Axes = [
|
||||
{ key: 'x' as keyof Vector3, label: 'X' },
|
||||
{ key: 'y' as keyof Vector3, label: 'Y' },
|
||||
{ key: 'z' as keyof Vector3, label: 'Z' }
|
||||
]
|
||||
const visible = ref<any>({})
|
||||
const inspectorPanelData = ref<any>(
|
||||
{
|
||||
id: "",
|
||||
label: '',
|
||||
type: "",
|
||||
}
|
||||
)
|
||||
watch(selecteInspectorData, (node: any) => {
|
||||
if (!node) {
|
||||
visible.value = false
|
||||
return;
|
||||
}
|
||||
if (contain.includes(node.type)) {
|
||||
visible.value = true
|
||||
} else {
|
||||
visible.value = false
|
||||
}
|
||||
inspectorPanelData.value = node
|
||||
})
|
||||
|
||||
|
||||
// 更新函数
|
||||
const updateArcRotateCamera = () => {
|
||||
// console.log('弧形旋转相机已更新:', cameraData.arcRotate)
|
||||
// 这里可以添加实际的更新逻辑
|
||||
}
|
||||
|
||||
const updateUniversalCamera = () => {
|
||||
//console.log('通用相机已更新:', cameraData.universal)
|
||||
// 这里可以添加实际的更新逻辑
|
||||
}
|
||||
|
||||
const updateVrCamera = () => {
|
||||
//console.log('VR相机已更新:', cameraData.vr)
|
||||
// 这里可以添加实际的更新逻辑
|
||||
}
|
||||
|
||||
// 范围更新函数
|
||||
const updateBetaMin = (value: number) => {
|
||||
cameraData.arcRotate.lowerBetaLimit = value
|
||||
updateArcRotateCamera()
|
||||
}
|
||||
|
||||
const updateBetaMax = (value: number) => {
|
||||
cameraData.arcRotate.upperBetaLimit = value
|
||||
updateArcRotateCamera()
|
||||
}
|
||||
|
||||
const updateRadiusMin = (value: number) => {
|
||||
cameraData.arcRotate.lowerRadiusLimit = value
|
||||
updateArcRotateCamera()
|
||||
}
|
||||
|
||||
const updateRadiusMax = (value: number) => {
|
||||
cameraData.arcRotate.upperRadiusLimit = value
|
||||
updateArcRotateCamera()
|
||||
}
|
||||
|
||||
const updateVrBetaMin = (value: number) => {
|
||||
cameraData.vr.lowerBetaLimit = value
|
||||
updateVrCamera()
|
||||
}
|
||||
|
||||
const updateVrBetaMax = (value: number) => {
|
||||
cameraData.vr.upperBetaLimit = value
|
||||
updateVrCamera()
|
||||
}
|
||||
|
||||
const updateVrRadiusMin = (value: number) => {
|
||||
cameraData.vr.lowerRadiusLimit = value
|
||||
updateVrCamera()
|
||||
}
|
||||
|
||||
const updateVrRadiusMax = (value: number) => {
|
||||
cameraData.vr.upperRadiusLimit = value
|
||||
updateVrCamera()
|
||||
}
|
||||
|
||||
const updateTargetAxis = (axis: keyof Vector3, value: number) => {
|
||||
cameraData.arcRotate.target[axis] = value
|
||||
updateArcRotateCamera()
|
||||
}
|
||||
|
||||
// 弧形旋转相机范围配置
|
||||
const arcRotateRanges = [
|
||||
{
|
||||
key: 'beta',
|
||||
label: 'Beta角度限制',
|
||||
tooltip: '垂直角度限制 (弧度)',
|
||||
minKey: 'lowerBetaLimit' as const,
|
||||
maxKey: 'upperBetaLimit' as const,
|
||||
step: 0.1,
|
||||
updateMin: updateBetaMin,
|
||||
updateMax: updateBetaMax
|
||||
},
|
||||
{
|
||||
key: 'radius',
|
||||
label: '半径限制',
|
||||
tooltip: '距离目标的缩放距离限制',
|
||||
minKey: 'lowerRadiusLimit' as const,
|
||||
maxKey: 'upperRadiusLimit' as const,
|
||||
step: 0.1,
|
||||
min: 0,
|
||||
updateMin: updateRadiusMin,
|
||||
updateMax: updateRadiusMax
|
||||
}
|
||||
]
|
||||
|
||||
// 通用相机数值属性配置
|
||||
const universalNumericProps = [
|
||||
{
|
||||
key: 'speed' as const,
|
||||
label: '移动速度',
|
||||
tooltip: '相机的移动速度',
|
||||
props: { step: 0.1, min: 0, 'full-width': true }
|
||||
},
|
||||
{
|
||||
key: 'angularSensibility' as const,
|
||||
label: '旋转灵敏度',
|
||||
tooltip: '鼠标旋转灵敏度',
|
||||
props: { step: 1, min: 1, 'full-width': true }
|
||||
}
|
||||
]
|
||||
|
||||
// 通用相机键位属性配置
|
||||
const universalKeyProps = [
|
||||
{
|
||||
key: 'keysUp' as const,
|
||||
label: '前进键',
|
||||
tooltip: '相机移动的键位绑定 (默认WASD)',
|
||||
placeholder: 'W'
|
||||
},
|
||||
{
|
||||
key: 'keysDown' as const,
|
||||
label: '后退键',
|
||||
tooltip: '相机移动的键位绑定 (默认WASD)',
|
||||
placeholder: 'S'
|
||||
},
|
||||
{
|
||||
key: 'keysLeft' as const,
|
||||
label: '左移键',
|
||||
tooltip: '相机移动的键位绑定 (默认WASD)',
|
||||
placeholder: 'A'
|
||||
},
|
||||
{
|
||||
key: 'keysRight' as const,
|
||||
label: '右移键',
|
||||
tooltip: '相机移动的键位绑定 (默认WASD)',
|
||||
placeholder: 'D'
|
||||
}
|
||||
]
|
||||
|
||||
// VR相机数值属性配置
|
||||
const vrNumericProps = [
|
||||
{
|
||||
key: 'angularSensibility' as const,
|
||||
label: '旋转灵敏度',
|
||||
tooltip: 'VR相机的鼠标旋转灵敏度',
|
||||
props: { step: 1, min: 1, 'full-width': true }
|
||||
},
|
||||
{
|
||||
key: 'wheelDeltaPercentage' as const,
|
||||
label: '滚轮灵敏度',
|
||||
tooltip: 'VR相机的鼠标滚轮缩放灵敏度',
|
||||
props: { step: 0.01, min: 0, max: 1, 'full-width': true }
|
||||
}
|
||||
]
|
||||
|
||||
// VR相机范围配置
|
||||
const vrRanges = [
|
||||
{
|
||||
key: 'beta',
|
||||
label: 'Beta角度限制',
|
||||
tooltip: 'VR相机的垂直角度限制 (弧度)',
|
||||
minKey: 'lowerBetaLimit' as const,
|
||||
maxKey: 'upperBetaLimit' as const,
|
||||
step: 0.1,
|
||||
updateMin: updateVrBetaMin,
|
||||
updateMax: updateVrBetaMax
|
||||
},
|
||||
{
|
||||
key: 'radius',
|
||||
label: '半径限制',
|
||||
tooltip: 'VR相机的缩放距离限制',
|
||||
minKey: 'lowerRadiusLimit' as const,
|
||||
maxKey: 'upperRadiusLimit' as const,
|
||||
step: 0.1,
|
||||
min: 0,
|
||||
updateMin: updateVrRadiusMin,
|
||||
updateMax: updateVrRadiusMax
|
||||
}
|
||||
]
|
||||
|
||||
// 相机类型变化处理
|
||||
const onCameraTypeChange = () => {
|
||||
console.log('相机类型已更改为:', cameraData.type)
|
||||
// 这里可以添加相机类型切换的逻辑
|
||||
}
|
||||
|
||||
// 重置相机
|
||||
const resetCamera = () => {
|
||||
switch (cameraData.type) {
|
||||
case 'ArcRotateCamera':
|
||||
Object.assign(cameraData.arcRotate, DEFAULT_COMPONENT_CONFIGS.camera.arcRotate)
|
||||
updateArcRotateCamera()
|
||||
break
|
||||
case 'UniversalCamera':
|
||||
Object.assign(cameraData.universal, DEFAULT_COMPONENT_CONFIGS.camera.universal)
|
||||
updateUniversalCamera()
|
||||
break
|
||||
case 'vrCamera':
|
||||
Object.assign(cameraData.vr, DEFAULT_COMPONENT_CONFIGS.camera.vr)
|
||||
updateVrCamera()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 处理启用状态变化
|
||||
const handleEnabledChange = createEnabledChangeHandler('Camera')
|
||||
|
||||
// 处理操作按钮点击
|
||||
const handleActionClick = createActionClickHandler(
|
||||
resetCamera,
|
||||
createShowMoreHandler('Camera')
|
||||
)
|
||||
|
||||
// 监听选中对象变化
|
||||
watch(selecteInspectorData, (node: any) => {
|
||||
if (node && node.camera) {
|
||||
// 更新相机数据
|
||||
const camera = node.camera
|
||||
if (camera.type) {
|
||||
cameraData.type = camera.type
|
||||
}
|
||||
|
||||
// 根据相机类型更新对应数据
|
||||
if (camera.type === 'ArcRotateCamera' && camera.arcRotate) {
|
||||
Object.assign(cameraData.arcRotate, camera.arcRotate)
|
||||
} else if (camera.type === 'UniversalCamera' && camera.universal) {
|
||||
Object.assign(cameraData.universal, camera.universal)
|
||||
} else if (camera.type === 'vrCamera' && camera.vr) {
|
||||
Object.assign(cameraData.vr, camera.vr)
|
||||
}
|
||||
|
||||
isEnabled.value = camera.isEnabled !== false
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.camera-panel {
|
||||
background: #393939;
|
||||
border-bottom: 1px solid #2d2d2d;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-size: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.camera-content {
|
||||
padding: 4px 8px 8px 8px;
|
||||
}
|
||||
|
||||
.vector3-input {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vector3-input .value-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.vector3-input .axis-label {
|
||||
width: 20px;
|
||||
color: #888888;
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.range-input {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.range-input .value-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.range-input .axis-label {
|
||||
width: 20px;
|
||||
color: #888888;
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 容器自适应 */
|
||||
@container (max-width: 200px) {
|
||||
|
||||
.vector3-input .axis-label,
|
||||
.range-input .axis-label {
|
||||
width: 15px;
|
||||
font-size: 9px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
57
src/components/InspectorPanel/Camera/types.ts
Normal file
57
src/components/InspectorPanel/Camera/types.ts
Normal file
@ -0,0 +1,57 @@
|
||||
// Camera 组件类型定义
|
||||
|
||||
export interface Vector3 {
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
}
|
||||
|
||||
export interface ArcRotateCameraData {
|
||||
target: Vector3
|
||||
lowerBetaLimit: number
|
||||
upperBetaLimit: number
|
||||
lowerRadiusLimit: number
|
||||
upperRadiusLimit: number
|
||||
wheelDeltaPercentage: number
|
||||
panningSensibility: number
|
||||
inertia: number
|
||||
autoRotation: boolean
|
||||
}
|
||||
|
||||
export interface UniversalCameraData {
|
||||
speed: number
|
||||
angularSensibility: number
|
||||
keysUp: string
|
||||
keysDown: string
|
||||
keysLeft: string
|
||||
keysRight: string
|
||||
}
|
||||
|
||||
export interface VrCameraData {
|
||||
angularSensibility: number
|
||||
lowerBetaLimit: number
|
||||
upperBetaLimit: number
|
||||
lowerRadiusLimit: number
|
||||
upperRadiusLimit: number
|
||||
wheelDeltaPercentage: number
|
||||
}
|
||||
|
||||
export interface CameraData {
|
||||
type: 'ArcRotateCamera' | 'UniversalCamera' | 'vrCamera'
|
||||
arcRotate: ArcRotateCameraData
|
||||
universal: UniversalCameraData
|
||||
vr: VrCameraData
|
||||
}
|
||||
|
||||
export interface CameraTypeOption {
|
||||
label: string
|
||||
value: 'ArcRotateCamera' | 'UniversalCamera' | 'vrCamera'
|
||||
}
|
||||
|
||||
export interface CameraProps {
|
||||
// 如果有 props 的话可以在这里定义
|
||||
}
|
||||
|
||||
export interface CameraEmits {
|
||||
// 如果有 emits 的话可以在这里定义
|
||||
}
|
||||
255
src/components/InspectorPanel/Header/index.vue
Normal file
255
src/components/InspectorPanel/Header/index.vue
Normal file
@ -0,0 +1,255 @@
|
||||
<template>
|
||||
<div class="inspector-header-panel">
|
||||
<div class="header-content">
|
||||
<!-- 左侧图标和激活状态 -->
|
||||
<div class="header-left">
|
||||
<Switch v-model="isActive" @change="toggleActive" size="small" />
|
||||
<!-- 对象名称输入框 -->
|
||||
<div class="object-name">
|
||||
<TextInput v-model="objectName" placeholder="GameObject" @update:modelValue="updateObjectName"
|
||||
class="name-input-wrapper" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧内容 -->
|
||||
<div class="header-right">
|
||||
<!-- 透明模式 -->
|
||||
<PropertyRow label="图层" tooltip="图层">
|
||||
<Select v-model="selectedLayer" :options="layerOptions" @change="updateLayer" />
|
||||
</PropertyRow>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { useInspectorPanelStore } from 'stores/InspectorPanel'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Switch from 'components/BasicControls/Switch/index.vue'
|
||||
import TextInput from 'components/BasicControls/Input/TextInput.vue'
|
||||
import Select from 'components/BasicControls/Select/index.vue'
|
||||
import PropertyRow from 'components/BasicControls/PropertyRow/index.vue'
|
||||
import type { SelectOption } from 'components/BasicControls/Select/types'
|
||||
|
||||
const inspectorPanelStore = useInspectorPanelStore()
|
||||
const { selecteInspectorData } = storeToRefs(inspectorPanelStore)
|
||||
|
||||
// 对象属性
|
||||
const isActive = ref(true)
|
||||
const objectName = ref('Directional Light')
|
||||
const isStatic = ref(false)
|
||||
const selectedTag = ref('Untagged')
|
||||
const selectedLayer = ref('Default')
|
||||
|
||||
// 图层选项
|
||||
const layerOptions: SelectOption[] = [
|
||||
{ value: 'Default', label: '默认' },
|
||||
{ value: 'TransparentFX', label: '透明特效' },
|
||||
{ value: 'Ignore Raycast', label: '忽略射线' },
|
||||
{ value: 'Water', label: '水面' },
|
||||
{ value: 'UI', label: '用户界面' },
|
||||
{ value: 'PostProcessing', label: '后期处理' }
|
||||
]
|
||||
|
||||
// 切换激活状态
|
||||
const toggleActive = () => {
|
||||
if (selecteInspectorData.value) {
|
||||
(selecteInspectorData.value as any).setEnabled(isActive.value)
|
||||
console.log(`对象${isActive.value ? '已激活' : '已停用'}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新对象名称
|
||||
const updateObjectName = () => {
|
||||
if (selecteInspectorData.value && objectName.value.trim()) {
|
||||
(selecteInspectorData.value as any).name = objectName.value.trim()
|
||||
console.log('对象名称已更新:', objectName.value)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新Static状态
|
||||
const updateStatic = () => {
|
||||
if (selecteInspectorData.value) {
|
||||
console.log(`静态${isStatic.value ? '已启用' : '已禁用'}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新Tag
|
||||
const updateTag = () => {
|
||||
if (selecteInspectorData.value) {
|
||||
console.log(`标签已更改为 ${selectedTag.value}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新Layer
|
||||
const updateLayer = () => {
|
||||
if (selecteInspectorData.value) {
|
||||
console.log(`图层已更改为 ${selectedLayer.value}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听选中对象变化
|
||||
watch(selecteInspectorData, (node: any) => {
|
||||
if (node) {
|
||||
// 更新界面数据
|
||||
objectName.value = node.label || 'GameObject'
|
||||
isActive.value = node.isEnabled !== false
|
||||
console.log(node);
|
||||
|
||||
// 根据对象类型设置不同的默认值
|
||||
if (node.label?.includes('Light')) {
|
||||
selectedTag.value = 'Untagged'
|
||||
selectedLayer.value = 'Default'
|
||||
} else if (node.label?.includes('Camera')) {
|
||||
selectedTag.value = 'MainCamera'
|
||||
selectedLayer.value = 'Default'
|
||||
} else {
|
||||
selectedTag.value = 'Untagged'
|
||||
selectedLayer.value = 'Default'
|
||||
}
|
||||
} else {
|
||||
// 重置为默认值
|
||||
objectName.value = 'GameObject'
|
||||
isActive.value = true
|
||||
isStatic.value = false
|
||||
selectedTag.value = 'Untagged'
|
||||
selectedLayer.value = 'Default'
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.inspector-header-panel {
|
||||
background: #393939;
|
||||
border-bottom: 1px solid #2d2d2d;
|
||||
padding: 8px 12px;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.object-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: #4a4a4a;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #5a5a5a;
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.object-name {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.name-input-wrapper {
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.name-input-wrapper :deep(.text-input) {
|
||||
height: 24px;
|
||||
font-size: 12px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.static-item {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.property-label {
|
||||
width: 0px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 自定义复选框 */
|
||||
.checkbox-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.custom-checkbox {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #2d2d2d;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 2px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.checkbox-label:hover {
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
.custom-checkbox:checked+.checkbox-label {
|
||||
background: #409eff;
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
.custom-checkbox:checked+.checkbox-label::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
top: 1px;
|
||||
width: 6px;
|
||||
height: 9px;
|
||||
border: solid white;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.object-properties {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.property-item {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
</style>
|
||||
31
src/components/InspectorPanel/Header/types.ts
Normal file
31
src/components/InspectorPanel/Header/types.ts
Normal file
@ -0,0 +1,31 @@
|
||||
// Header 组件类型定义
|
||||
|
||||
export interface HeaderData {
|
||||
isActive: boolean
|
||||
objectName: string
|
||||
isStatic: boolean
|
||||
selectedTag: string
|
||||
selectedLayer: string
|
||||
}
|
||||
|
||||
export interface HeaderProps {
|
||||
// 如果有 props 的话可以在这里定义
|
||||
}
|
||||
|
||||
export interface HeaderEmits {
|
||||
// 如果有 emits 的话可以在这里定义
|
||||
}
|
||||
|
||||
export type TagType = 'Untagged' | 'Respawn' | 'Finish' | 'EditorOnly' | 'MainCamera' | 'Player' | 'GameController'
|
||||
|
||||
export type LayerType = 'Default' | 'TransparentFX' | 'Ignore Raycast' | 'Water' | 'UI' | 'PostProcessing'
|
||||
|
||||
export interface TagOption {
|
||||
label: string
|
||||
value: TagType
|
||||
}
|
||||
|
||||
export interface LayerOption {
|
||||
label: string
|
||||
value: LayerType
|
||||
}
|
||||
1
src/components/InspectorPanel/Light/index.ts
Normal file
1
src/components/InspectorPanel/Light/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export const contain=["Light"]
|
||||
181
src/components/InspectorPanel/Light/index.vue
Normal file
181
src/components/InspectorPanel/Light/index.vue
Normal file
@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<div class="light-panel" v-show="visible">
|
||||
<CollapsibleHeader
|
||||
title="灯光"
|
||||
:expanded="isExpanded"
|
||||
:enabled="isEnabled"
|
||||
:checkable="false"
|
||||
@update:expanded="isExpanded = $event"
|
||||
@update:enabled="handleEnabledChange"
|
||||
@action-click="handleActionClick"
|
||||
>
|
||||
<div class="light-content">
|
||||
<!-- Type 属性 -->
|
||||
<PropertyRow
|
||||
label="类型"
|
||||
tooltip="指定当前光源的类型。可能的类型有方向光、聚光灯、点光源和区域光。"
|
||||
>
|
||||
<Select
|
||||
v-model="lightData.type"
|
||||
:options="lightTypeOptions"
|
||||
@change="updateLightType"
|
||||
/>
|
||||
</PropertyRow>
|
||||
|
||||
<!-- Color 属性 -->
|
||||
<PropertyRow
|
||||
label="颜色"
|
||||
tooltip="此光源发出的光的颜色。"
|
||||
>
|
||||
<ColorInput
|
||||
:model-value="lightData.color"
|
||||
@update:model-value="updateLightColor"
|
||||
/>
|
||||
</PropertyRow>
|
||||
|
||||
<!-- Intensity 属性 -->
|
||||
<PropertyRow
|
||||
label="强度"
|
||||
tooltip="光的亮度。数值越高产生的光越亮"
|
||||
>
|
||||
<Slider
|
||||
v-model="lightData.intensity"
|
||||
:min="0"
|
||||
:max="10"
|
||||
:step="0.1"
|
||||
:show-value="true"
|
||||
@change="updateLightIntensity"
|
||||
/>
|
||||
</PropertyRow>
|
||||
|
||||
</div>
|
||||
</CollapsibleHeader>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { useInspectorPanelStore } from 'stores/InspectorPanel'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import CollapsibleHeader from 'components/public/CollapsibleHeader.vue'
|
||||
import PropertyRow from 'components/BasicControls/PropertyRow/index.vue'
|
||||
import Select from 'components/BasicControls/Select/index.vue'
|
||||
import ColorInput from 'components/BasicControls/Input/ColorInput.vue'
|
||||
import Slider from 'components/BasicControls/Slider/index.vue'
|
||||
import Switch from 'components/BasicControls/Switch/index.vue'
|
||||
import { useInspectorPanel, DEFAULT_COMPONENT_CONFIGS, colorUtils } from '../../../composables/useInspectorPanel'
|
||||
import {contain} from "./index"
|
||||
const inspectorPanelStore = useInspectorPanelStore()
|
||||
const { selecteInspectorData } = storeToRefs(inspectorPanelStore)
|
||||
|
||||
// 使用通用的 Inspector Panel 逻辑
|
||||
const {
|
||||
isExpanded,
|
||||
isEnabled,
|
||||
createEnabledChangeHandler,
|
||||
createActionClickHandler,
|
||||
createShowMoreHandler
|
||||
} = useInspectorPanel()
|
||||
|
||||
// Light数据
|
||||
const lightData = reactive({ ...DEFAULT_COMPONENT_CONFIGS.light })
|
||||
|
||||
// 光源类型选项
|
||||
const lightTypeOptions = [
|
||||
{ label: '方向光', value: 'directional' },
|
||||
{ label: '点光源', value: 'point' },
|
||||
{ label: '聚光灯', value: 'spot' },
|
||||
{ label: '区域光', value: 'area' }
|
||||
]
|
||||
|
||||
const visible = ref<any>({})
|
||||
const inspectorPanelData = ref<any>({})
|
||||
watch(selecteInspectorData, (node: any) => {
|
||||
if (!node) {
|
||||
visible.value = false
|
||||
return;
|
||||
}
|
||||
|
||||
if(contain.includes(node.type)){
|
||||
visible.value = true
|
||||
}else{
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
inspectorPanelData.value = node
|
||||
})
|
||||
|
||||
|
||||
|
||||
// 更新函数
|
||||
const updateLightType = () => {
|
||||
if (selecteInspectorData.value && (selecteInspectorData.value as any)?.lightType) {
|
||||
(selecteInspectorData.value as any).lightType = lightData.type
|
||||
}
|
||||
}
|
||||
|
||||
const updateLightColor = (color: string) => {
|
||||
lightData.color = color
|
||||
if (selecteInspectorData.value && (selecteInspectorData.value as any)?.lightColor) {
|
||||
const rgb = colorUtils.hexStringToRgb(color)
|
||||
const lightColorObj = (selecteInspectorData.value as any).lightColor
|
||||
lightColorObj.r = rgb.r
|
||||
lightColorObj.g = rgb.g
|
||||
lightColorObj.b = rgb.b
|
||||
}
|
||||
}
|
||||
|
||||
const updateLightIntensity = () => {
|
||||
if (selecteInspectorData.value && (selecteInspectorData.value as any)?.lightIntensity !== undefined) {
|
||||
(selecteInspectorData.value as any).lightIntensity = lightData.intensity
|
||||
}
|
||||
}
|
||||
|
||||
// 重置Light
|
||||
const resetLight = () => {
|
||||
Object.assign(lightData, DEFAULT_COMPONENT_CONFIGS.light)
|
||||
updateLightType()
|
||||
updateLightColor(lightData.color)
|
||||
updateLightIntensity()
|
||||
}
|
||||
|
||||
// 处理启用状态变化
|
||||
const handleEnabledChange = createEnabledChangeHandler('光源', (enabled: boolean) => {
|
||||
if (selecteInspectorData.value && (selecteInspectorData.value as any)?.setEnabled) {
|
||||
(selecteInspectorData.value as any).setEnabled(enabled)
|
||||
}
|
||||
})
|
||||
|
||||
// 处理操作按钮点击
|
||||
const handleActionClick = createActionClickHandler(
|
||||
resetLight,
|
||||
createShowMoreHandler('光源')
|
||||
)
|
||||
|
||||
// 监听选中对象变化
|
||||
watch(selecteInspectorData, (node: any) => {
|
||||
if (node) {
|
||||
// 更新Light数据
|
||||
if (node.lightType) {
|
||||
lightData.type = node.lightType
|
||||
}
|
||||
if (node.lightColor) {
|
||||
lightData.color = colorUtils.rgbToHexString(node.lightColor)
|
||||
}
|
||||
if (node.lightIntensity !== undefined) {
|
||||
lightData.intensity = node.lightIntensity
|
||||
}
|
||||
isEnabled.value = node.isEnabled !== false
|
||||
} else {
|
||||
// 重置为默认值
|
||||
Object.assign(lightData, DEFAULT_COMPONENT_CONFIGS.light)
|
||||
isEnabled.value = true
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.light-content {
|
||||
/* Remove redundant styling since CollapsibleHeader handles container */
|
||||
}
|
||||
</style>
|
||||
28
src/components/InspectorPanel/Light/types.ts
Normal file
28
src/components/InspectorPanel/Light/types.ts
Normal file
@ -0,0 +1,28 @@
|
||||
// Light 组件类型定义
|
||||
|
||||
export interface RGB {
|
||||
r: number
|
||||
g: number
|
||||
b: number
|
||||
}
|
||||
|
||||
export interface LightData {
|
||||
type: 'directional' | 'point' | 'spot' | 'area'
|
||||
color: string
|
||||
intensity: number
|
||||
}
|
||||
|
||||
export interface LightProps {
|
||||
// 如果有 props 的话可以在这里定义
|
||||
}
|
||||
|
||||
export interface LightEmits {
|
||||
// 如果有 emits 的话可以在这里定义
|
||||
}
|
||||
|
||||
export type LightType = 'directional' | 'point' | 'spot' | 'area'
|
||||
|
||||
export interface LightTypeOption {
|
||||
label: string
|
||||
value: LightType
|
||||
}
|
||||
1
src/components/InspectorPanel/Material/index.ts
Normal file
1
src/components/InspectorPanel/Material/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export const contain=["Material"]
|
||||
197
src/components/InspectorPanel/Material/index.vue
Normal file
197
src/components/InspectorPanel/Material/index.vue
Normal file
@ -0,0 +1,197 @@
|
||||
<template>
|
||||
<div class="material-panel" v-show="visible">
|
||||
<CollapsibleHeader title="材质"
|
||||
:expanded="isExpanded"
|
||||
:enabled="isEnabled"
|
||||
:checkable="false"
|
||||
@update:expanded="isExpanded = $event"
|
||||
@update:enabled="handleEnabledChange"
|
||||
@action-click="handleActionClick">
|
||||
<div class="material-content">
|
||||
<!-- 材质类型 -->
|
||||
<PropertyRow
|
||||
label="材质类型"
|
||||
tooltip="材质类型"
|
||||
>
|
||||
<Select
|
||||
v-model="materialData.type"
|
||||
:options="materialTypeOptions"
|
||||
@change="updateMaterialProperty('type', $event)"
|
||||
/>
|
||||
</PropertyRow>
|
||||
|
||||
<!-- 基础颜色 -->
|
||||
<PropertyRow
|
||||
label="基础颜色"
|
||||
tooltip="物体基础颜色"
|
||||
>
|
||||
<ColorInput
|
||||
:model-value="colorToHex(materialData.albedoColor)"
|
||||
@update:model-value="updateAlbedoColor"
|
||||
/>
|
||||
</PropertyRow>
|
||||
|
||||
<!-- 贴图 -->
|
||||
<PropertyRow
|
||||
label="基础贴图"
|
||||
tooltip="颜色贴图"
|
||||
>
|
||||
<Field
|
||||
v-model="albedoTextureValue"
|
||||
accepted-type="Texture"
|
||||
@change="updateAlbedoTexture"
|
||||
/>
|
||||
</PropertyRow>
|
||||
|
||||
<!-- 透明度 -->
|
||||
<PropertyRow
|
||||
label="透明度"
|
||||
tooltip="材质透明度 (0-1)"
|
||||
>
|
||||
<Slider
|
||||
v-model="materialData.alpha"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
:show-value="true"
|
||||
@update:model-value="updateMaterialProperty('alpha', $event)"
|
||||
/>
|
||||
</PropertyRow>
|
||||
|
||||
|
||||
|
||||
<!-- 透明模式 -->
|
||||
<PropertyRow
|
||||
label="透明模式"
|
||||
tooltip="透明模式"
|
||||
>
|
||||
<Select
|
||||
v-model="materialData.transparencyMode"
|
||||
:options="transparencyModeOptions"
|
||||
@change="updateMaterialProperty('transparencyMode', $event)"
|
||||
/>
|
||||
</PropertyRow>
|
||||
</div>
|
||||
</CollapsibleHeader>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import CollapsibleHeader from 'components/public/CollapsibleHeader.vue'
|
||||
import PropertyRow from 'components/BasicControls/PropertyRow/index.vue'
|
||||
import Select from 'components/BasicControls/Select/index.vue'
|
||||
import ColorInput from 'components/BasicControls/Input/ColorInput.vue'
|
||||
import NumberInput from 'components/BasicControls/Input/index.vue'
|
||||
import Slider from 'components/BasicControls/Slider/index.vue'
|
||||
import Field from 'components/BasicControls/Field/index.vue'
|
||||
import { useInspectorPanel, DEFAULT_COMPONENT_CONFIGS, colorUtils } from '../../../composables/useInspectorPanel'
|
||||
import type {
|
||||
MaterialData,
|
||||
Color3,
|
||||
MaterialTypeOption,
|
||||
TransparencyModeOption
|
||||
} from './types'
|
||||
import type { FieldValue } from 'components/BasicControls/Field/types'
|
||||
import {contain} from "./index"
|
||||
import { useInspectorPanelStore } from 'stores/InspectorPanel'
|
||||
import { storeToRefs } from 'pinia'
|
||||
const inspectorPanelStore = useInspectorPanelStore()
|
||||
const { selecteInspectorData } = storeToRefs(inspectorPanelStore)
|
||||
// 使用通用的 Inspector Panel 逻辑
|
||||
const {
|
||||
isExpanded,
|
||||
isEnabled,
|
||||
createEnabledChangeHandler,
|
||||
createActionClickHandler,
|
||||
createShowMoreHandler,
|
||||
createPropertyUpdater
|
||||
} = useInspectorPanel()
|
||||
|
||||
// Material 数据
|
||||
const materialData = reactive<MaterialData>({ ...DEFAULT_COMPONENT_CONFIGS.material })
|
||||
|
||||
// 贴图字段值
|
||||
const albedoTextureValue = ref<FieldValue | null>(null)
|
||||
|
||||
// 材质类型选项
|
||||
const materialTypeOptions: MaterialTypeOption[] = [
|
||||
{ label: 'PBR材质', value: 'PBRMaterial' }
|
||||
]
|
||||
|
||||
// 透明模式选项
|
||||
const transparencyModeOptions: TransparencyModeOption[] = [
|
||||
{ label: '不透明', value: 0 },
|
||||
{ label: '透明测试', value: 1 },
|
||||
{ label: '透明混合', value: 2 },
|
||||
{ label: '预乘透明混合', value: 3 }
|
||||
]
|
||||
|
||||
const visible = ref<any>({})
|
||||
const inspectorPanelData = ref<any>({})
|
||||
watch(selecteInspectorData, (node: any) => {
|
||||
|
||||
if (!node) {
|
||||
visible.value = false
|
||||
return;
|
||||
}
|
||||
|
||||
if(contain.includes(node.type)){
|
||||
visible.value = true
|
||||
}else{
|
||||
visible.value = false
|
||||
}
|
||||
inspectorPanelData.value = node
|
||||
})
|
||||
|
||||
|
||||
|
||||
// 更新 Material 属性
|
||||
const updateMaterialProperty = createPropertyUpdater(materialData, '材质', (property: keyof MaterialData, value: any) => {
|
||||
// 这里可以添加与 Babylon.js 的交互逻辑
|
||||
// 例如:updateSelectedMaterial(property, value)
|
||||
})
|
||||
|
||||
// 更新基础颜色
|
||||
const updateAlbedoColor = (hex: string) => {
|
||||
materialData.albedoColor = colorUtils.hexToRgb(hex)
|
||||
// console.log('基础颜色已更新:', materialData.albedoColor)
|
||||
|
||||
// 这里可以添加与 Babylon.js 的交互逻辑
|
||||
}
|
||||
|
||||
// 更新贴图
|
||||
const updateAlbedoTexture = (value: FieldValue | null) => {
|
||||
materialData.albedoTexture = value?.path || null
|
||||
// console.log('基础贴图已更新:', materialData.albedoTexture)
|
||||
|
||||
// 这里可以添加与 Babylon.js 的交互逻辑
|
||||
}
|
||||
|
||||
// 重置 Material 设置
|
||||
const resetMaterialSettings = () => {
|
||||
Object.assign(materialData, DEFAULT_COMPONENT_CONFIGS.material)
|
||||
albedoTextureValue.value = null
|
||||
// console.log('材质设置已重置')
|
||||
}
|
||||
|
||||
// 处理启用状态变化
|
||||
const handleEnabledChange = createEnabledChangeHandler('材质', (enabled: boolean) => {
|
||||
// 这里可以添加与 Babylon.js 的交互逻辑
|
||||
})
|
||||
|
||||
// 处理操作按钮点击
|
||||
const handleActionClick = createActionClickHandler(
|
||||
resetMaterialSettings,
|
||||
createShowMoreHandler('材质')
|
||||
)
|
||||
|
||||
// 颜色转换为十六进制
|
||||
const colorToHex = colorUtils.rgbToHex
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.material-content {
|
||||
/* Remove redundant styling since CollapsibleHeader handles container */
|
||||
}
|
||||
</style>
|
||||
36
src/components/InspectorPanel/Material/types.ts
Normal file
36
src/components/InspectorPanel/Material/types.ts
Normal file
@ -0,0 +1,36 @@
|
||||
// Material 组件类型定义
|
||||
|
||||
export type MaterialType = 'PBRMaterial'
|
||||
|
||||
export interface Color3 {
|
||||
r: number
|
||||
g: number
|
||||
b: number
|
||||
}
|
||||
|
||||
export interface MaterialData {
|
||||
type: MaterialType
|
||||
albedoColor: Color3
|
||||
albedoTexture: string | null
|
||||
alpha: number
|
||||
transparencyMode: number
|
||||
}
|
||||
|
||||
export interface MaterialProps {
|
||||
// 可以添加其他 props
|
||||
}
|
||||
|
||||
export interface MaterialEmits {
|
||||
// 可以添加事件定义
|
||||
}
|
||||
|
||||
export interface MaterialTypeOption {
|
||||
label: string
|
||||
value: MaterialType
|
||||
}
|
||||
|
||||
// 透明模式选项
|
||||
export interface TransparencyModeOption {
|
||||
label: string
|
||||
value: number
|
||||
}
|
||||
1
src/components/InspectorPanel/Mesh/index.ts
Normal file
1
src/components/InspectorPanel/Mesh/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export const contain=["Mesh"]
|
||||
134
src/components/InspectorPanel/Mesh/index.vue
Normal file
134
src/components/InspectorPanel/Mesh/index.vue
Normal file
@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div class="mesh-panel" v-show="visible">
|
||||
<CollapsibleHeader title="网格"
|
||||
:expanded="isExpanded"
|
||||
:enabled="isEnabled"
|
||||
:checkable="false"
|
||||
@update:expanded="isExpanded = $event"
|
||||
@update:enabled="handleEnabledChange"
|
||||
@action-click="handleActionClick">
|
||||
<div class="mesh-properties">
|
||||
<!-- 是否可被鼠标拾取 -->
|
||||
<PropertyRow
|
||||
label="可拾取"
|
||||
tooltip="是否可被鼠标拾取"
|
||||
>
|
||||
<Switch
|
||||
v-model="meshData.isPickable"
|
||||
@change="updateMeshProperty('isPickable', $event)"
|
||||
/>
|
||||
</PropertyRow>
|
||||
|
||||
<!-- 是否参与碰撞检测 -->
|
||||
<PropertyRow
|
||||
label="碰撞检测"
|
||||
tooltip="是否参与碰撞检测"
|
||||
>
|
||||
<Switch
|
||||
v-model="meshData.checkCollisions"
|
||||
@change="updateMeshProperty('checkCollisions', $event)"
|
||||
/>
|
||||
</PropertyRow>
|
||||
<!-- 贴图 -->
|
||||
<PropertyRow
|
||||
label="材质"
|
||||
tooltip="材质"
|
||||
>
|
||||
<Field
|
||||
v-model="MaterialValue"
|
||||
accepted-type="Material"
|
||||
@change="updateAlbedoTexture"
|
||||
/>
|
||||
</PropertyRow>
|
||||
</div>
|
||||
</CollapsibleHeader>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import CollapsibleHeader from 'components/public/CollapsibleHeader.vue'
|
||||
import PropertyRow from 'components/BasicControls/PropertyRow/index.vue'
|
||||
import Switch from 'components/BasicControls/Switch/index.vue'
|
||||
import { useInspectorPanel, DEFAULT_COMPONENT_CONFIGS } from '../../../composables/useInspectorPanel'
|
||||
import type { MeshData } from './types'
|
||||
import {contain} from "./index"
|
||||
import { useInspectorPanelStore } from 'stores/InspectorPanel'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Field from 'components/BasicControls/Field/index.vue'
|
||||
import type { FieldValue } from 'components/BasicControls/Field/types'
|
||||
const inspectorPanelStore = useInspectorPanelStore()
|
||||
const { selecteInspectorData } = storeToRefs(inspectorPanelStore)
|
||||
// 使用通用的 Inspector Panel 逻辑
|
||||
const {
|
||||
isExpanded,
|
||||
isEnabled,
|
||||
createEnabledChangeHandler,
|
||||
createActionClickHandler,
|
||||
createShowMoreHandler,
|
||||
createPropertyUpdater
|
||||
} = useInspectorPanel()
|
||||
|
||||
|
||||
// Mesh 数据
|
||||
const meshData = reactive<MeshData>({ ...DEFAULT_COMPONENT_CONFIGS.mesh })
|
||||
|
||||
const visible = ref<any>({})
|
||||
const inspectorPanelData = ref<any>({})
|
||||
|
||||
// 贴图字段值
|
||||
const MaterialValue = ref<FieldValue | null>(null)
|
||||
|
||||
watch(selecteInspectorData, (node: any) => {
|
||||
if (!node) {
|
||||
|
||||
visible.value = false
|
||||
return;
|
||||
}
|
||||
|
||||
if(contain.includes(node.type)){
|
||||
visible.value = true
|
||||
|
||||
}else{
|
||||
visible.value = false
|
||||
}
|
||||
inspectorPanelData.value = node
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
// 更新 Mesh 属性
|
||||
const updateMeshProperty = createPropertyUpdater(meshData, '网格', (property: keyof MeshData, value: boolean) => {
|
||||
// 这里可以添加与 Babylon.js 的交互逻辑
|
||||
// 例如:updateSelectedMesh(property, value)
|
||||
})
|
||||
|
||||
// 重置 Mesh 设置
|
||||
const resetMeshSettings = () => {
|
||||
Object.assign(meshData, DEFAULT_COMPONENT_CONFIGS.mesh)
|
||||
// console.log('网格设置已重置')
|
||||
}
|
||||
|
||||
// 处理启用状态变化
|
||||
const handleEnabledChange = createEnabledChangeHandler('网格', (enabled: boolean) => {
|
||||
// 这里可以添加与 Babylon.js 的交互逻辑
|
||||
})
|
||||
|
||||
// 处理操作按钮点击
|
||||
const handleActionClick = createActionClickHandler(
|
||||
resetMeshSettings,
|
||||
createShowMoreHandler('网格')
|
||||
)
|
||||
|
||||
const updateAlbedoTexture = (value: FieldValue | null) => {
|
||||
console.log('更新基础贴图', value)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mesh-properties {
|
||||
/* Remove redundant styling since CollapsibleHeader handles container */
|
||||
}
|
||||
</style>
|
||||
14
src/components/InspectorPanel/Mesh/types.ts
Normal file
14
src/components/InspectorPanel/Mesh/types.ts
Normal file
@ -0,0 +1,14 @@
|
||||
// Mesh 组件类型定义
|
||||
|
||||
export interface MeshData {
|
||||
isPickable: boolean
|
||||
checkCollisions: boolean
|
||||
}
|
||||
|
||||
export interface MeshProps {
|
||||
// 可以添加其他 props
|
||||
}
|
||||
|
||||
export interface MeshEmits {
|
||||
// 可以添加事件定义
|
||||
}
|
||||
1
src/components/InspectorPanel/Transform/index.ts
Normal file
1
src/components/InspectorPanel/Transform/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export const contain=["Camera","Light","Mesh"]
|
||||
275
src/components/InspectorPanel/Transform/index.vue
Normal file
275
src/components/InspectorPanel/Transform/index.vue
Normal file
@ -0,0 +1,275 @@
|
||||
<template>
|
||||
<div class="transform-panel" v-show="visible">
|
||||
<!-- Transform 标题栏 -->
|
||||
<CollapsibleHeader
|
||||
title="变换"
|
||||
:expanded="isExpanded"
|
||||
:enabled="isEnabled"
|
||||
:checkable="false"
|
||||
@update:expanded="isExpanded = $event"
|
||||
@update:enabled="handleEnabledChange"
|
||||
@action-click="handleActionClick"
|
||||
>
|
||||
|
||||
<!-- Transform 内容 -->
|
||||
<div class="transform-content" v-show="isExpanded">
|
||||
<!-- 循环生成属性行 -->
|
||||
<div
|
||||
v-for="property in transformProperties"
|
||||
:key="property.name"
|
||||
class="property-row"
|
||||
>
|
||||
<Tooltip :content="property.tooltip" placement="top">
|
||||
<div class="property-label">{{ property.label }}</div>
|
||||
</Tooltip>
|
||||
<div class="property-values">
|
||||
<div
|
||||
v-for="axis in axes"
|
||||
:key="axis"
|
||||
class="value-group"
|
||||
>
|
||||
<span class="axis-label">{{ axis }}</span>
|
||||
<NumberInput
|
||||
v-model="transformData[property.name][axis.toLowerCase() as keyof Vector3]"
|
||||
@update:modelValue="property.updateFn"
|
||||
:step="property.step"
|
||||
:min="property.min"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleHeader>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, reactive, computed } from 'vue'
|
||||
import { useInspectorPanelStore } from 'stores/InspectorPanel'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import CollapsibleHeader from 'components/public/CollapsibleHeader.vue'
|
||||
import Tooltip from 'components/public/Tooltip.vue'
|
||||
import NumberInput from 'components/BasicControls/Input/index.vue'
|
||||
import type {
|
||||
Vector3,
|
||||
TransformData,
|
||||
} from './types'
|
||||
import {contain} from "./index"
|
||||
const inspectorPanelStore = useInspectorPanelStore()
|
||||
const { selecteInspectorData } = storeToRefs(inspectorPanelStore)
|
||||
|
||||
// 组件状态
|
||||
const isExpanded = ref(true)
|
||||
const isEnabled = ref(true)
|
||||
|
||||
// 轴标签
|
||||
const axes = ['X', 'Y', 'Z'] as const
|
||||
|
||||
// Transform数据
|
||||
const transformData = reactive<TransformData>({
|
||||
position: { x: 0, y: 3, z: 0 },
|
||||
rotation: { x: 50, y: -30, z: 0 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
})
|
||||
|
||||
|
||||
const visible = ref<any>({})
|
||||
const inspectorPanelData = ref<any>({})
|
||||
watch(selecteInspectorData, (node: any) => {
|
||||
|
||||
if (!node) {
|
||||
visible.value = false
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if(contain.includes(node.type)){
|
||||
visible.value = true
|
||||
}else{
|
||||
visible.value = false
|
||||
}
|
||||
inspectorPanelData.value = node
|
||||
})
|
||||
|
||||
|
||||
|
||||
// 更新函数
|
||||
const updatePosition = () => {
|
||||
// console.log('位置已更新:', transformData.position)
|
||||
// 这里可以添加实际的更新逻辑
|
||||
}
|
||||
|
||||
const updateRotation = () => {
|
||||
// console.log('旋转已更新:', transformData.rotation)
|
||||
// 这里可以添加实际的更新逻辑
|
||||
}
|
||||
|
||||
const updateScale = () => {
|
||||
// console.log('缩放已更新:', transformData.scale)
|
||||
// 这里可以添加实际的更新逻辑
|
||||
}
|
||||
|
||||
// Transform属性配置
|
||||
const transformProperties = computed(() => [
|
||||
{
|
||||
name: 'position' as keyof TransformData,
|
||||
label: '位置',
|
||||
tooltip: '变换在3D空间中的位置。相对于父对象的X、Y、Z坐标。',
|
||||
step: 0.1,
|
||||
updateFn: updatePosition
|
||||
},
|
||||
{
|
||||
name: 'rotation' as keyof TransformData,
|
||||
label: '旋转',
|
||||
tooltip: '变换的旋转角度(度)。X、Y、Z轴的欧拉角。',
|
||||
step: 1,
|
||||
updateFn: updateRotation
|
||||
},
|
||||
{
|
||||
name: 'scale' as keyof TransformData,
|
||||
label: '缩放',
|
||||
tooltip: '变换的缩放比例。值为1表示原始大小,2表示两倍大小,0.5表示一半大小。',
|
||||
step: 0.1,
|
||||
min: 0,
|
||||
updateFn: updateScale
|
||||
}
|
||||
])
|
||||
|
||||
// 处理启用状态变化
|
||||
const handleEnabledChange = (enabled: boolean) => {
|
||||
isEnabled.value = enabled
|
||||
console.log('变换已启用:', enabled)
|
||||
}
|
||||
|
||||
// 处理操作按钮点击
|
||||
const handleActionClick = (actionName: string) => {
|
||||
switch (actionName) {
|
||||
case 'reset':
|
||||
resetTransform()
|
||||
break
|
||||
case 'more':
|
||||
showMore()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 重置Transform
|
||||
const resetTransform = () => {
|
||||
transformData.position = { x: 0, y: 0, z: 0 }
|
||||
transformData.rotation = { x: 0, y: 0, z: 0 }
|
||||
transformData.scale = { x: 1, y: 1, z: 1 }
|
||||
updatePosition()
|
||||
updateRotation()
|
||||
updateScale()
|
||||
console.log('变换设置已重置')
|
||||
}
|
||||
|
||||
// 显示更多选项
|
||||
const showMore = () => {
|
||||
console.log('显示更多变换选项')
|
||||
}
|
||||
|
||||
// 监听选中对象变化
|
||||
watch(selecteInspectorData, (node: any) => {
|
||||
if (node) {
|
||||
// 更新Transform数据
|
||||
if (node.position) {
|
||||
transformData.position = { ...node.position }
|
||||
}
|
||||
if (node.rotation) {
|
||||
transformData.rotation = {
|
||||
x: Math.round((node.rotation.x || 0) * 180 / Math.PI),
|
||||
y: Math.round((node.rotation.y || 0) * 180 / Math.PI),
|
||||
z: Math.round((node.rotation.z || 0) * 180 / Math.PI)
|
||||
}
|
||||
}
|
||||
if (node.scaling) {
|
||||
transformData.scale = { ...node.scaling }
|
||||
}
|
||||
isEnabled.value = node.isEnabled !== false
|
||||
} else {
|
||||
// 重置为默认值
|
||||
transformData.position = { x: 0, y: 0, z: 0 }
|
||||
transformData.rotation = { x: 0, y: 0, z: 0 }
|
||||
transformData.scale = { x: 1, y: 1, z: 1 }
|
||||
isEnabled.value = true
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.transform-panel {
|
||||
background: #393939;
|
||||
border-bottom: 1px solid #2d2d2d;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-size: 12px;
|
||||
width: 100%;
|
||||
|
||||
}
|
||||
|
||||
.transform-content {
|
||||
padding: 4px 8px 8px 8px;
|
||||
}
|
||||
|
||||
.property-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 2px;
|
||||
min-height: 18px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
|
||||
.property-values {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.value-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.axis-label {
|
||||
width: 8px;
|
||||
color: #888888;
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 250px) {
|
||||
.property-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.property-label {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.property-values {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 容器自适应 */
|
||||
@container (max-width: 200px) {
|
||||
.property-label {
|
||||
width: 40px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.axis-label {
|
||||
width: 6px;
|
||||
font-size: 9px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
40
src/components/InspectorPanel/Transform/types.ts
Normal file
40
src/components/InspectorPanel/Transform/types.ts
Normal file
@ -0,0 +1,40 @@
|
||||
// Transform 组件类型定义
|
||||
|
||||
export interface Vector3 {
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
}
|
||||
|
||||
export interface TransformData {
|
||||
position: Vector3
|
||||
rotation: Vector3
|
||||
scale: Vector3
|
||||
}
|
||||
|
||||
export interface TransformProperty {
|
||||
name: keyof TransformData
|
||||
label: string
|
||||
tooltip: string
|
||||
step: number
|
||||
min?: number
|
||||
updateFn: () => void
|
||||
}
|
||||
|
||||
export interface TransformProps {
|
||||
// 如果有 props 的话可以在这里定义
|
||||
}
|
||||
|
||||
export interface TransformEmits {
|
||||
// 如果有 emits 的话可以在这里定义
|
||||
}
|
||||
|
||||
export type AxisType = 'X' | 'Y' | 'Z'
|
||||
|
||||
export type TransformPropertyType = 'position' | 'rotation' | 'scale'
|
||||
|
||||
export interface TransformActionType {
|
||||
name: string
|
||||
icon?: string
|
||||
tooltip?: string
|
||||
}
|
||||
115
src/components/InspectorPanel/index.vue
Normal file
115
src/components/InspectorPanel/index.vue
Normal file
@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<div class="inspector-panel">
|
||||
<!-- 面板标题栏 -->
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">检查器</span>
|
||||
<div class="panel-actions">
|
||||
<button class="action-btn" title="锁定检查器">🔒</button>
|
||||
<button class="action-btn" title="更多选项">⋯</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 检查器内容 -->
|
||||
<div class="inspector-content">
|
||||
<Header></Header>
|
||||
<Transform ></Transform>
|
||||
<Mesh ></Mesh>
|
||||
<Material ></Material>
|
||||
<Light ></Light>
|
||||
<Camera></Camera>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import none from './none.vue'
|
||||
import Header from './Header/index.vue'
|
||||
import Transform from './Transform/index.vue'
|
||||
import Mesh from './Mesh/index.vue'
|
||||
import Material from './Material/index.vue'
|
||||
import Light from './Light/index.vue'
|
||||
import Camera from './Camera/index.vue'
|
||||
import { useInspectorPanelStore } from 'stores/InspectorPanel'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const inspectorPanelStore = useInspectorPanelStore()
|
||||
const { selecteInspectorData } = storeToRefs(inspectorPanelStore)
|
||||
|
||||
const inspectorPanelData = ref<any>({})
|
||||
|
||||
onMounted(() => {
|
||||
inspectorPanelStore.setSelecteInspectorData(null)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.inspector-panel {
|
||||
height: 100%;
|
||||
background: #393939;
|
||||
border-left: 1px solid #4a4a4a;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
height: 32px;
|
||||
background: #3c3c3c;
|
||||
border-bottom: 1px solid #4a4a4a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: #4a4a4a;
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
.inspector-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
.inspector-content::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.inspector-content::-webkit-scrollbar-track {
|
||||
background: #2d2d2d;
|
||||
}
|
||||
|
||||
.inspector-content::-webkit-scrollbar-thumb {
|
||||
background: #4a4a4a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.inspector-content::-webkit-scrollbar-thumb:hover {
|
||||
background: #5a5a5a;
|
||||
}
|
||||
</style>
|
||||
20
src/components/InspectorPanel/none.vue
Normal file
20
src/components/InspectorPanel/none.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="inspector-none">
|
||||
您点击了全部
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useInspectorPanelStore } from 'stores/InspectorPanel'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
47
src/components/Loading.vue
Normal file
47
src/components/Loading.vue
Normal file
@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div class="loading-container">
|
||||
<div class="loading-content">
|
||||
<div class="logo"><img :src="logo" alt=""></div>
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar" :style="{ width: progress + '%' }"></div>
|
||||
</div>
|
||||
<div class="truck-container">
|
||||
<div class="truck" :style="{ left: progress * 0.99 + '%' }">
|
||||
<img :src="car" alt="小车" >
|
||||
</div>
|
||||
</div>
|
||||
<div class="loading-text">{{ loadingText }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { userLoadingStore } from '@/stores/zguiy';
|
||||
|
||||
const loadingStore = userLoadingStore();
|
||||
const { DebugText, loadProgress } = storeToRefs(loadingStore);
|
||||
// 获取环境变量中的资源路径
|
||||
const resourcePath = import.meta.env.VITE_PUBLIC_URL;
|
||||
|
||||
const logo = ref(`${resourcePath}logo/加载LOGO.png`);
|
||||
const car = ref(`${resourcePath}loading/che.png`);
|
||||
|
||||
// 使用store中的加载进度
|
||||
const progress = computed(() => loadProgress.value);
|
||||
const loadingText = ref('加载中...');
|
||||
|
||||
|
||||
|
||||
// 监听DebugText变化,显示调试信息
|
||||
watch(DebugText, (newVal) => {
|
||||
if (newVal) {
|
||||
loadingText.value = newVal;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
241
src/components/Pano.vue
Normal file
241
src/components/Pano.vue
Normal file
@ -0,0 +1,241 @@
|
||||
<template>
|
||||
<div class="app-pano">
|
||||
<div id="web-pano"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { userCarStore, userSellingPointStore } from '@/stores/zguiy'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { ref, onMounted, onUnmounted, defineExpose, watch } from 'vue'
|
||||
|
||||
const krpano = ref<any>(null)
|
||||
const isLoaded = ref(false)
|
||||
const scriptId = 'panoscript'
|
||||
const baseUrl = `${import.meta.env.VITE_PUBLIC_URL}`
|
||||
const hotspotImage = `${import.meta.env.VITE_PUBLIC_URL}`
|
||||
const sllingPointStore = userSellingPointStore()
|
||||
const carStore = userCarStore();
|
||||
const { isInterior } = storeToRefs(carStore);
|
||||
const scale = ref(0.1)
|
||||
const hotspots = ref<any>([ {
|
||||
name: 'spot0',
|
||||
image: `${baseUrl}resources/btn_热点.png`,
|
||||
ath: 35.465,
|
||||
atv: 17.311,
|
||||
style: 'skin_hotspotstyle',
|
||||
title: '悬浮大屏',
|
||||
scale:scale.value,
|
||||
content: '10.1寸悬浮大屏,显示清晰,触感灵敏,操作流畅,科技感拉满。标配电动空调,响应迅速,冷暖随心。'
|
||||
},
|
||||
{
|
||||
name: 'spot1',
|
||||
image: `${baseUrl}resources/btn_热点.png`,
|
||||
ath: 19.434,
|
||||
atv: 29.863,
|
||||
style: 'skin_hotspotstyle',
|
||||
title: '换挡模块',
|
||||
scale:scale.value,
|
||||
content: '业内首款怀挡设计纯电小卡,极大节省空间,更便捷的换挡操作,提高了驾驶的舒适性,更安全的驾驶行车'
|
||||
},
|
||||
{
|
||||
name: 'spot1',
|
||||
image: `${baseUrl}resources/btn_热点.png`,
|
||||
ath: 55.045,
|
||||
atv: 20.812,
|
||||
style: 'skin_hotspotstyle',
|
||||
title: '储物空间',
|
||||
scale:scale.value,
|
||||
content: '副驾、副仪表板储物空间,可满足不同情形下的储物需求。方便拿取物品'
|
||||
},
|
||||
{
|
||||
name: 'spot1683588860',
|
||||
image: `${baseUrl}resources/btn_热点.png`,
|
||||
ath: -29.437,
|
||||
atv: 31.364,
|
||||
style: 'skin_hotspotstyle',
|
||||
title: '方向盘模块',
|
||||
scale:scale.value,
|
||||
content: '多功能方向盘+定速巡航,满足客户长途驾车需求。'
|
||||
},
|
||||
{
|
||||
name: 'spot1683589018',
|
||||
image: `${baseUrl}resources/btn_热点.png`,
|
||||
ath: -0.279,
|
||||
atv: 22.188,
|
||||
style: 'skin_hotspotstyle',
|
||||
title: '液晶仪表模块',
|
||||
scale:scale.value,
|
||||
content: '全系标配4.3寸液晶仪表。信息丰富,助力安全驾驶。'
|
||||
},
|
||||
{
|
||||
name: 'spot1683589206',
|
||||
image: `${baseUrl}resources/btn_热点.png`,
|
||||
ath: 89.822,
|
||||
atv: 37.924,
|
||||
style: 'skin_hotspotstyle',
|
||||
title: '座椅模块',
|
||||
scale:scale.value,
|
||||
content: '手刹放平设计,满足驾驶员平躺休息的需求。'
|
||||
},
|
||||
|
||||
])
|
||||
|
||||
|
||||
//是否进入内室 暂停3D渲染
|
||||
watch(isInterior, (newVal) => {
|
||||
if(newVal){
|
||||
rotateAndZoom()
|
||||
}
|
||||
else{
|
||||
//直接设置视角为初始
|
||||
if (krpano.value) {
|
||||
krpano.value.set("view.hlookat", -90.203);
|
||||
krpano.value.set("view.vlookat", 30.058);
|
||||
krpano.value.set("view.fov", 120.000);
|
||||
}
|
||||
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
const createKrpano = () => {
|
||||
if (isLoaded.value) return
|
||||
|
||||
// 避免重复添加脚本
|
||||
if (document.getElementById(scriptId)) {
|
||||
embedViewer()
|
||||
return
|
||||
}
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.type = 'text/javascript'
|
||||
script.src = `${baseUrl}pano/tour.js`
|
||||
script.id = scriptId
|
||||
|
||||
script.onload = embedViewer
|
||||
document.head.appendChild(script)
|
||||
}
|
||||
|
||||
const embedViewer = () => {
|
||||
embedpano({
|
||||
swf: `${baseUrl}pano/tour.swf`,
|
||||
xml: `${baseUrl}pano/tour.xml`,
|
||||
target: 'web-pano',
|
||||
html5: 'auto',
|
||||
mobilescale: 1.0,
|
||||
passQueryParameters: true,
|
||||
onready: krpanoOnReady
|
||||
})
|
||||
}
|
||||
const hotspotClickHandler = (name: string) => {
|
||||
console.log(name);
|
||||
|
||||
}
|
||||
|
||||
// 新增:旋转和推进视角的函数
|
||||
const rotateAndZoom = () => {
|
||||
if (!krpano.value) return;
|
||||
|
||||
// 获取当前视角
|
||||
const currentHlookat = krpano.value.get("view.hlookat");
|
||||
const currentFov = krpano.value.get("view.fov");
|
||||
|
||||
// 旋转90度
|
||||
krpano.value.call(`tween(view.hlookat, ${currentHlookat + 90}, 1.5, easeInOutQuad)`);
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
const krpanoOnReady = (interfaceInstance: any) => {
|
||||
krpano.value = interfaceInstance
|
||||
isLoaded.value = true
|
||||
setTimeout(() => {
|
||||
initHotspot()
|
||||
}, 2000);
|
||||
|
||||
}
|
||||
|
||||
const initHotspot = () => {
|
||||
addHotspots(hotspots.value)
|
||||
}
|
||||
|
||||
// 添加热点函数
|
||||
const addHotspots = (hotspots: Array<{
|
||||
name: string,
|
||||
image: string,
|
||||
ath: number,
|
||||
atv: number,
|
||||
style?: string,
|
||||
title?: string,
|
||||
content?: string
|
||||
}>) => {
|
||||
|
||||
if (!krpano.value || !isLoaded.value) return
|
||||
|
||||
|
||||
for(let i=0;i<hotspots.length;i++){
|
||||
let hs = krpano.value.addhotspot();
|
||||
const hotspot=hotspots[i]
|
||||
hs.url = hotspot.image;
|
||||
hs.ath = hotspot.ath;
|
||||
hs.atv = hotspot.atv;
|
||||
hs.style = hotspot.style;
|
||||
hs.alpha = 0;
|
||||
hs.scale=scale.value
|
||||
hs.onloaded = ()=> { krpano.value.tween(hs, { scale: 0.2, alpha: 1 }); };
|
||||
hs.onclick = (hs) => {
|
||||
showFeatureInfo(hotspot)
|
||||
|
||||
};
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
const showFeatureInfo = (hotspot: any) => {
|
||||
console.log(hotspot);
|
||||
|
||||
// if (sllingPointStore.isOpen) return;
|
||||
sllingPointStore.setTitle(hotspot.title)
|
||||
sllingPointStore.setContent(hotspot.content)
|
||||
sllingPointStore.setIsOpen(true)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
console.log(1111);
|
||||
createKrpano();
|
||||
|
||||
|
||||
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 清理 krpano 脚本和实例(可选)
|
||||
const script = document.getElementById(scriptId)
|
||||
if (script) {
|
||||
script.remove()
|
||||
}
|
||||
isLoaded.value = false
|
||||
krpano.value = null
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-pano {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#web-pano {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
893
src/components/SceneViewPanel/GameView.vue
Normal file
893
src/components/SceneViewPanel/GameView.vue
Normal file
@ -0,0 +1,893 @@
|
||||
<template>
|
||||
<div class="game-view">
|
||||
<!-- 游戏运行工具栏 -->
|
||||
<div class="game-toolbar">
|
||||
<div class="toolbar-group">
|
||||
<button v-for="btn in playControlButtons" :key="btn.key"
|
||||
:class="['toolbar-btn', { active: btn.active }]" @click="btn.action" :disabled="btn.disabled"
|
||||
:title="btn.title">
|
||||
{{ btn.icon }}
|
||||
</button>
|
||||
<div class="toolbar-separator"></div>
|
||||
<button class="toolbar-btn" @click="resetGame" title="重置游戏">
|
||||
🔄
|
||||
</button>
|
||||
<div class="toolbar-separator"></div>
|
||||
|
||||
<!-- 设备分辨率调试组 -->
|
||||
<div class="device-selector">
|
||||
<select v-model="selectedDevice" @change="changeDeviceResolution" class="device-dropdown"
|
||||
title="选择设备分辨率">
|
||||
<option v-for="device in deviceOptions" :key="device.value" :value="device.value">
|
||||
{{ device.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="toolbar-separator"></div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-group">
|
||||
<span class="fps-counter">FPS: {{ fps }}</span>
|
||||
<div class="toolbar-separator"></div>
|
||||
<button v-for="btn in rightButtons" :key="btn.key" :class="['toolbar-btn', { active: btn.active }]"
|
||||
@click="btn.action" :title="btn.title">
|
||||
{{ btn.icon }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 游戏运行内容 -->
|
||||
<div class="game-content">
|
||||
<!-- 3D游戏渲染区域 -->
|
||||
<div class="game-viewport" :style="viewportStyles">
|
||||
<div class="game-container" :style="containerStyles">
|
||||
<canvas id="gameCanvas" ref="gameCanvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 自定义分辨率弹窗 -->
|
||||
<div v-if="showCustomDialog" class="custom-resolution-dialog">
|
||||
<div class="dialog-content">
|
||||
<h3>自定义分辨率</h3>
|
||||
<div class="input-group">
|
||||
<label>宽度:</label>
|
||||
<input type="number" v-model="customWidth" min="320" max="7680" placeholder="1920" />
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>高度:</label>
|
||||
<input type="number" v-model="customHeight" min="240" max="4320" placeholder="1080" />
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<button @click="applyCustomResolution" class="apply-btn">应用</button>
|
||||
<button @click="cancelCustomResolution" class="cancel-btn">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 游戏信息覆盖层 -->
|
||||
<div class="game-overlay" v-show="showDebugInfo">
|
||||
<div class="debug-info">
|
||||
<div v-for="section in debugSections" :key="section.title" class="debug-section">
|
||||
<h4>{{ section.title }}</h4>
|
||||
<p v-for="item in section.items" :key="item.label">
|
||||
{{ item.label }}: {{ item.value }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 运行状态指示器 -->
|
||||
<div class="status-indicator">
|
||||
<span :class="['status-dot', { playing: isPlaying, paused: isPaused }]"></span>
|
||||
<span class="status-text">
|
||||
{{ isPlaying ? (isPaused ? '已暂停' : '运行中') : '已停止' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
import { useLayoutStore } from '@/stores/Layout'
|
||||
|
||||
const gameCanvas = ref<HTMLCanvasElement>()
|
||||
|
||||
// 使用布局store来监听BottomPanel高度变化
|
||||
const layoutStore = useLayoutStore()
|
||||
|
||||
// 游戏状态
|
||||
const isPlaying = ref(false)
|
||||
const isPaused = ref(false)
|
||||
const isFullscreen = ref(false)
|
||||
const showDebugInfo = ref(false)
|
||||
|
||||
// 设备分辨率相关
|
||||
const selectedDevice = ref('free')
|
||||
const currentScale = ref(1)
|
||||
const currentWidth = ref(1920)
|
||||
const currentHeight = ref(1080)
|
||||
const showCustomDialog = ref(false)
|
||||
const customWidth = ref(1920)
|
||||
const customHeight = ref(1080)
|
||||
|
||||
// 自适应缩放相关
|
||||
const baseViewportHeight = ref(0) // 基准视口高度
|
||||
const autoScale = ref(1) // 自动计算的缩放比例
|
||||
|
||||
// 性能指标
|
||||
const fps = ref(60)
|
||||
const renderTime = ref(16.7)
|
||||
const memoryUsage = ref(128)
|
||||
const objectCount = ref(0)
|
||||
const triangleCount = ref(0)
|
||||
const materialCount = ref(0)
|
||||
const devicePixelRatio = ref(window.devicePixelRatio || 1)
|
||||
|
||||
// 设备选项配置
|
||||
const deviceOptions = [
|
||||
{ value: 'free', label: 'Free Aspect' },
|
||||
{ value: '16:9', label: '16:9 Aspect' },
|
||||
{ value: '16:10', label: '16:10 Aspect' },
|
||||
// 桌面端分辨率
|
||||
{ value: '1920x1080', label: 'Full HD (1920x1080)' },
|
||||
{ value: '2560x1440', label: '2K QHD (2560x1440)' },
|
||||
{ value: '3840x2160', label: '4K UHD (3840x2160)' },
|
||||
{ value: '1366x768', label: 'HD (1366x768)' },
|
||||
{ value: '1440x900', label: 'WXGA+ (1440x900)' },
|
||||
// 移动端分辨率 (竖屏)
|
||||
{ value: '375x667', label: 'iPhone SE (375x667)' },
|
||||
{ value: '390x844', label: 'iPhone 12/13/14 (390x844)' },
|
||||
{ value: '414x896', label: 'iPhone 11/XR (414x896)' },
|
||||
{ value: '393x851', label: 'Pixel 7 (393x851)' },
|
||||
{ value: '360x640', label: 'Android Small (360x640)' },
|
||||
{ value: '412x915', label: 'Android Large (412x915)' },
|
||||
// 平板端分辨率
|
||||
{ value: '768x1024', label: 'iPad (768x1024)' },
|
||||
{ value: '820x1180', label: 'iPad Air (820x1180)' },
|
||||
{ value: '1024x1366', label: 'iPad Pro 12.9" (1024x1366)' },
|
||||
{ value: 'custom', label: '自定义' }
|
||||
]
|
||||
|
||||
// 设备分辨率映射
|
||||
const deviceResolutions: Record<string, { width: number; height: number }> = {
|
||||
'16:9': { width: 1920, height: 1080 },
|
||||
'16:10': { width: 1920, height: 1200 },
|
||||
// 桌面端分辨率
|
||||
'1920x1080': { width: 1920, height: 1080 },
|
||||
'2560x1440': { width: 2560, height: 1440 },
|
||||
'3840x2160': { width: 3840, height: 2160 },
|
||||
'1366x768': { width: 1366, height: 768 },
|
||||
'1440x900': { width: 1440, height: 900 },
|
||||
// 移动端分辨率 (竖屏)
|
||||
'375x667': { width: 375, height: 667 },
|
||||
'390x844': { width: 390, height: 844 },
|
||||
'414x896': { width: 414, height: 896 },
|
||||
'393x851': { width: 393, height: 851 },
|
||||
'360x640': { width: 360, height: 640 },
|
||||
'412x915': { width: 412, height: 915 },
|
||||
// 平板端分辨率
|
||||
'768x1024': { width: 768, height: 1024 },
|
||||
'820x1180': { width: 820, height: 1180 },
|
||||
'1024x1366': { width: 1024, height: 1366 }
|
||||
}
|
||||
|
||||
// 计算属性
|
||||
const currentResolution = computed(() => {
|
||||
return `${currentWidth.value}x${currentHeight.value}`
|
||||
})
|
||||
|
||||
const playControlButtons = computed(() => [
|
||||
{
|
||||
key: 'play',
|
||||
icon: isPlaying.value ? '⏸️' : '▶️',
|
||||
action: togglePlay,
|
||||
active: isPlaying.value,
|
||||
disabled: false,
|
||||
title: isPlaying.value ? '停止运行' : '开始运行'
|
||||
},
|
||||
{
|
||||
key: 'pause',
|
||||
icon: '⏯️',
|
||||
action: pauseGame,
|
||||
active: false,
|
||||
disabled: !isPlaying.value,
|
||||
title: '暂停'
|
||||
},
|
||||
{
|
||||
key: 'step',
|
||||
icon: '⏭️',
|
||||
action: stepFrame,
|
||||
active: false,
|
||||
disabled: !isPaused.value,
|
||||
title: '单步执行'
|
||||
}
|
||||
])
|
||||
|
||||
const rightButtons = computed(() => [
|
||||
{
|
||||
key: 'debug',
|
||||
icon: '🐛',
|
||||
action: toggleDebugInfo,
|
||||
active: showDebugInfo.value,
|
||||
title: '显示/隐藏调试信息'
|
||||
},
|
||||
{
|
||||
key: 'fullscreen',
|
||||
icon: '⛶',
|
||||
action: toggleFullscreen,
|
||||
active: isFullscreen.value,
|
||||
title: '全屏运行'
|
||||
}
|
||||
])
|
||||
|
||||
const debugSections = computed(() => [
|
||||
{
|
||||
title: '性能信息',
|
||||
items: [
|
||||
{ label: 'FPS', value: fps.value },
|
||||
{ label: '渲染时间', value: `${renderTime.value}ms` },
|
||||
{ label: '内存使用', value: `${memoryUsage.value}MB` }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '场景信息',
|
||||
items: [
|
||||
{ label: '对象数量', value: objectCount.value },
|
||||
{ label: '三角面数', value: triangleCount.value },
|
||||
{ label: '材质数量', value: materialCount.value }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '分辨率信息',
|
||||
items: [
|
||||
{ label: '当前分辨率', value: currentResolution.value },
|
||||
{ label: '基础缩放', value: `${currentScale.value}x` },
|
||||
{ label: '自适应缩放', value: `${autoScale.value.toFixed(2)}x` },
|
||||
{ label: '最终缩放', value: `${(currentScale.value * autoScale.value).toFixed(2)}x` },
|
||||
{ label: '设备像素比', value: devicePixelRatio.value }
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
const viewportStyles = computed(() => {
|
||||
const baseStyles = {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center'
|
||||
}
|
||||
|
||||
return selectedDevice.value === 'free'
|
||||
? baseStyles
|
||||
: { ...baseStyles, background: '#2d2d2d' }
|
||||
})
|
||||
|
||||
const containerStyles = computed(() => {
|
||||
if (selectedDevice.value === 'free') {
|
||||
return { width: '100%', height: '100%' }
|
||||
}
|
||||
|
||||
// 计算最终缩放比例:基础缩放 * 自适应缩放
|
||||
const finalScale = currentScale.value * autoScale.value
|
||||
const scaledWidth = currentWidth.value * finalScale
|
||||
const scaledHeight = currentHeight.value * finalScale
|
||||
|
||||
return {
|
||||
width: `${scaledWidth}px`,
|
||||
height: `${scaledHeight}px`,
|
||||
border: '2px solid #409eff',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
background: '#1e1e1e'
|
||||
}
|
||||
})
|
||||
|
||||
// 防抖函数
|
||||
const debounce = (func: Function, wait: number) => {
|
||||
let timeout: NodeJS.Timeout
|
||||
return function executedFunction(...args: any[]) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout)
|
||||
func(...args)
|
||||
}
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(later, wait)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化基准视口高度
|
||||
const initializeBaseViewport = () => {
|
||||
// 使用requestAnimationFrame确保DOM完全渲染
|
||||
requestAnimationFrame(() => {
|
||||
const gameContent = document.querySelector('.game-content') as HTMLElement
|
||||
if (gameContent) {
|
||||
baseViewportHeight.value = gameContent.clientHeight
|
||||
|
||||
// 初始化后立即更新自适应缩放
|
||||
updateAutoScale()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 防抖的基准视口初始化
|
||||
const debouncedInitializeBaseViewport = debounce(initializeBaseViewport, 150)
|
||||
|
||||
// 监听BottomPanel高度变化,实现自适应缩放
|
||||
watch(() => layoutStore.bottomPanelHeight, (newHeight, oldHeight) => {
|
||||
if (selectedDevice.value !== 'free' && oldHeight !== undefined) {
|
||||
// 使用requestAnimationFrame避免频繁更新导致的抖动
|
||||
requestAnimationFrame(() => {
|
||||
updateAutoScale()
|
||||
})
|
||||
}
|
||||
}, { flush: 'post' }) // 在DOM更新后执行
|
||||
|
||||
// 更新自适应缩放比例
|
||||
const updateAutoScale = () => {
|
||||
if (selectedDevice.value === 'free') {
|
||||
autoScale.value = 1
|
||||
return
|
||||
}
|
||||
|
||||
const gameContent = document.querySelector('.game-content') as HTMLElement
|
||||
if (!gameContent || baseViewportHeight.value <= 0) return
|
||||
|
||||
const currentAvailableHeight = gameContent.clientHeight
|
||||
const scaleRatio = currentAvailableHeight / baseViewportHeight.value
|
||||
|
||||
// 限制缩放范围
|
||||
const newAutoScale = Math.max(0.1, Math.min(2.0, scaleRatio))
|
||||
|
||||
if (Math.abs(autoScale.value - newAutoScale) > 0.01) { // 避免微小变化
|
||||
autoScale.value = newAutoScale
|
||||
applyResolution()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initGameRuntime()
|
||||
updateCurrentResolution()
|
||||
debouncedInitializeBaseViewport()
|
||||
|
||||
// 监听窗口大小变化
|
||||
window.addEventListener('resize', handleWindowResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanupGame()
|
||||
window.removeEventListener('resize', handleWindowResize)
|
||||
})
|
||||
|
||||
// 处理窗口大小变化
|
||||
const handleWindowResize = () => {
|
||||
if (selectedDevice.value !== 'free') {
|
||||
debouncedInitializeBaseViewport()
|
||||
}
|
||||
}
|
||||
|
||||
const initGameRuntime = () => {
|
||||
console.log('初始化游戏运行环境')
|
||||
}
|
||||
|
||||
const cleanupGame = () => {
|
||||
console.log('清理游戏资源')
|
||||
}
|
||||
|
||||
const updateCurrentResolution = () => {
|
||||
if (gameCanvas.value) {
|
||||
const rect = gameCanvas.value.getBoundingClientRect()
|
||||
currentWidth.value = Math.round(rect.width)
|
||||
currentHeight.value = Math.round(rect.height)
|
||||
}
|
||||
}
|
||||
|
||||
const changeDeviceResolution = () => {
|
||||
const device = selectedDevice.value
|
||||
|
||||
if (device === 'free') {
|
||||
// 自由模式下重置自适应缩放
|
||||
autoScale.value = 1
|
||||
updateCurrentResolution()
|
||||
} else if (device === 'custom') {
|
||||
showCustomDialog.value = true
|
||||
return
|
||||
} else if (deviceResolutions[device]) {
|
||||
const { width, height } = deviceResolutions[device]
|
||||
currentWidth.value = width
|
||||
currentHeight.value = height
|
||||
|
||||
// 切换到固定分辨率时,重新初始化基准视口和自适应缩放
|
||||
setTimeout(() => {
|
||||
debouncedInitializeBaseViewport()
|
||||
}, 100) // 延迟一点确保DOM更新完成
|
||||
}
|
||||
|
||||
applyResolution()
|
||||
}
|
||||
|
||||
const applyResolution = () => {
|
||||
if (!gameCanvas.value) return
|
||||
|
||||
gameCanvas.value.style.width = '100%'
|
||||
gameCanvas.value.style.height = '100%'
|
||||
|
||||
// 计算最终缩放比例:基础缩放 * 自适应缩放
|
||||
const finalScale = currentScale.value * autoScale.value
|
||||
const scaledWidth = currentWidth.value * finalScale
|
||||
const scaledHeight = currentHeight.value * finalScale
|
||||
|
||||
// 只在值真正改变时才更新canvas尺寸
|
||||
const newCanvasWidth = scaledWidth * devicePixelRatio.value
|
||||
const newCanvasHeight = scaledHeight * devicePixelRatio.value
|
||||
|
||||
if (gameCanvas.value.width !== newCanvasWidth || gameCanvas.value.height !== newCanvasHeight) {
|
||||
gameCanvas.value.width = newCanvasWidth
|
||||
gameCanvas.value.height = newCanvasHeight
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const applyCustomResolution = () => {
|
||||
if (customWidth.value > 0 && customHeight.value > 0) {
|
||||
currentWidth.value = customWidth.value
|
||||
currentHeight.value = customHeight.value
|
||||
showCustomDialog.value = false
|
||||
applyResolution()
|
||||
}
|
||||
}
|
||||
|
||||
const cancelCustomResolution = () => {
|
||||
showCustomDialog.value = false
|
||||
selectedDevice.value = 'free'
|
||||
}
|
||||
|
||||
const togglePlay = () => {
|
||||
isPlaying.value ? stopGame() : startGame()
|
||||
}
|
||||
|
||||
const startGame = () => {
|
||||
isPlaying.value = true
|
||||
isPaused.value = false
|
||||
console.log('开始运行游戏')
|
||||
}
|
||||
|
||||
const stopGame = () => {
|
||||
isPlaying.value = false
|
||||
isPaused.value = false
|
||||
console.log('停止游戏')
|
||||
}
|
||||
|
||||
const pauseGame = () => {
|
||||
if (isPlaying.value) {
|
||||
isPaused.value = !isPaused.value
|
||||
console.log(isPaused.value ? '暂停游戏' : '恢复游戏')
|
||||
}
|
||||
}
|
||||
|
||||
const stepFrame = () => {
|
||||
console.log('单步执行一帧')
|
||||
}
|
||||
|
||||
const resetGame = () => {
|
||||
stopGame()
|
||||
console.log('重置游戏')
|
||||
}
|
||||
|
||||
const toggleDebugInfo = () => {
|
||||
showDebugInfo.value = !showDebugInfo.value
|
||||
}
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
isFullscreen.value = !isFullscreen.value
|
||||
console.log('全屏模式:', isFullscreen.value)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
startGame,
|
||||
stopGame,
|
||||
pauseGame,
|
||||
resetGame,
|
||||
isPlaying: () => isPlaying.value
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.game-view {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.game-toolbar {
|
||||
height: 40px;
|
||||
background: #2d2d2d;
|
||||
border-bottom: 1px solid #4a4a4a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 12px;
|
||||
color: #cccccc;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
min-width: 32px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toolbar-btn:hover:not(:disabled) {
|
||||
background: #4a4a4a;
|
||||
border-color: #5a5a5a;
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
.toolbar-btn:active:not(:disabled) {
|
||||
background: #2d2d2d;
|
||||
border-color: #007acc;
|
||||
}
|
||||
|
||||
.toolbar-btn.active {
|
||||
background: #007acc;
|
||||
border-color: #005a9e;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.toolbar-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.toolbar-separator {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: #4a4a4a;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.fps-counter {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.game-content {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.game-viewport {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.game-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#gameCanvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
background: #1e1e1e;
|
||||
border: none;
|
||||
outline: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 设备选择器样式 */
|
||||
.device-selector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.device-dropdown {
|
||||
background: #3d3d3d;
|
||||
border: 1px solid #5a5a5a;
|
||||
color: #cccccc;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.device-dropdown:hover {
|
||||
border-color: #007acc;
|
||||
}
|
||||
|
||||
.device-dropdown:focus {
|
||||
border-color: #007acc;
|
||||
box-shadow: 0 0 0 2px rgba(0, 122, 204, 0.2);
|
||||
}
|
||||
|
||||
.device-info {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.resolution-text {
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
.scale-text {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.scale-btn {
|
||||
min-width: 40px;
|
||||
font-size: 11px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.scale-btn.active {
|
||||
background: #007acc;
|
||||
border-color: #005a9e;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* 自定义分辨率弹窗 */
|
||||
.custom-resolution-dialog {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
background: #2d2d2d;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
min-width: 300px;
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
.dialog-content h3 {
|
||||
margin: 0 0 16px 0;
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
min-width: 50px;
|
||||
font-size: 14px;
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
flex: 1;
|
||||
background: #3d3d3d;
|
||||
border: 1px solid #5a5a5a;
|
||||
color: #cccccc;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input-group input:focus {
|
||||
border-color: #007acc;
|
||||
box-shadow: 0 0 0 2px rgba(0, 122, 204, 0.2);
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.apply-btn,
|
||||
.cancel-btn {
|
||||
padding: 6px 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.apply-btn {
|
||||
background: #007acc;
|
||||
color: #ffffff;
|
||||
border-color: #005a9e;
|
||||
}
|
||||
|
||||
.apply-btn:hover {
|
||||
background: #005a9e;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: transparent;
|
||||
color: #cccccc;
|
||||
border-color: #5a5a5a;
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
background: #4a4a4a;
|
||||
border-color: #6a6a6a;
|
||||
}
|
||||
|
||||
.game-overlay {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
color: #cccccc;
|
||||
font-size: 12px;
|
||||
max-width: 250px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.debug-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.debug-section h4 {
|
||||
margin: 0 0 4px 0;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.debug-section p {
|
||||
margin: 2px 0;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
color: #cccccc;
|
||||
font-size: 12px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #666;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.status-dot.playing {
|
||||
background: #4CAF50;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.status-dot.paused {
|
||||
background: #FF9800;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.game-toolbar {
|
||||
padding: 0 8px;
|
||||
height: 36px;
|
||||
flex-wrap: wrap;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.toolbar-group {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
padding: 4px 6px;
|
||||
min-width: 28px;
|
||||
height: 24px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.device-selector {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.device-dropdown {
|
||||
min-width: 140px;
|
||||
font-size: 11px;
|
||||
padding: 3px 6px;
|
||||
}
|
||||
|
||||
.device-info {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.scale-btn {
|
||||
min-width: 32px;
|
||||
font-size: 10px;
|
||||
padding: 3px 4px;
|
||||
}
|
||||
|
||||
.game-overlay {
|
||||
max-width: 200px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.custom-resolution-dialog .dialog-content {
|
||||
min-width: 280px;
|
||||
margin: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.device-selector {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.device-dropdown {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.device-info {
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
237
src/components/SceneViewPanel/SceneToolbar.vue
Normal file
237
src/components/SceneViewPanel/SceneToolbar.vue
Normal file
@ -0,0 +1,237 @@
|
||||
<template>
|
||||
<div class="scene-toolbar">
|
||||
<!-- 左侧工具组 -->
|
||||
<div class="toolbar-group">
|
||||
<button
|
||||
:class="['toolbar-btn', { active: activeTool === 'select' }]"
|
||||
@click="setActiveTool('select')"
|
||||
title="选择工具"
|
||||
>
|
||||
🔍
|
||||
</button>
|
||||
<button
|
||||
:class="['toolbar-btn', { active: activeTool === 'move' }]"
|
||||
@click="setActiveTool('move')"
|
||||
title="移动工具"
|
||||
>
|
||||
✋
|
||||
</button>
|
||||
<button
|
||||
:class="['toolbar-btn', { active: activeTool === 'rotate' }]"
|
||||
@click="setActiveTool('rotate')"
|
||||
title="旋转工具"
|
||||
>
|
||||
🔄
|
||||
</button>
|
||||
<button
|
||||
:class="['toolbar-btn', { active: activeTool === 'scale' }]"
|
||||
@click="setActiveTool('scale')"
|
||||
title="缩放工具"
|
||||
>
|
||||
📏
|
||||
</button>
|
||||
<div class="toolbar-separator"></div>
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
@click="resetCamera"
|
||||
title="重置相机"
|
||||
>
|
||||
📷
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 中间视图控制组 -->
|
||||
<div class="toolbar-group">
|
||||
<button
|
||||
:class="['toolbar-btn', { active: showGrid }]"
|
||||
@click="toggleGrid"
|
||||
title="显示/隐藏网格"
|
||||
>
|
||||
⊞
|
||||
</button>
|
||||
<button
|
||||
:class="['toolbar-btn', { active: showWireframe }]"
|
||||
@click="toggleWireframe"
|
||||
title="线框模式"
|
||||
>
|
||||
⧉
|
||||
</button>
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
@click="focusSelected"
|
||||
title="聚焦选中对象"
|
||||
>
|
||||
🎯
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 右侧渲染控制组 -->
|
||||
<div class="toolbar-group">
|
||||
<button
|
||||
:class="['toolbar-btn', { active: showStats }]"
|
||||
@click="toggleStats"
|
||||
title="显示/隐藏统计信息"
|
||||
>
|
||||
📊
|
||||
</button>
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
@click="takeScreenshot"
|
||||
title="截图"
|
||||
>
|
||||
📸
|
||||
</button>
|
||||
<div class="toolbar-separator"></div>
|
||||
<button
|
||||
:class="['toolbar-btn', { active: isFullscreen }]"
|
||||
@click="toggleFullscreen"
|
||||
title="全屏"
|
||||
>
|
||||
⛶
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
// 工具状态
|
||||
const activeTool = ref('select')
|
||||
const showGrid = ref(true)
|
||||
const showWireframe = ref(false)
|
||||
const showStats = ref(false)
|
||||
const isFullscreen = ref(false)
|
||||
|
||||
// 设置活动工具
|
||||
const setActiveTool = (tool: string) => {
|
||||
activeTool.value = tool
|
||||
// 这里可以添加工具切换的逻辑
|
||||
console.log('切换工具:', tool)
|
||||
}
|
||||
|
||||
// 重置相机
|
||||
const resetCamera = () => {
|
||||
console.log('重置相机')
|
||||
// 这里添加重置相机的逻辑
|
||||
}
|
||||
|
||||
// 切换网格显示
|
||||
const toggleGrid = () => {
|
||||
showGrid.value = !showGrid.value
|
||||
console.log('网格显示:', showGrid.value)
|
||||
// 这里添加网格显示切换的逻辑
|
||||
}
|
||||
|
||||
// 切换线框模式
|
||||
const toggleWireframe = () => {
|
||||
showWireframe.value = !showWireframe.value
|
||||
console.log('线框模式:', showWireframe.value)
|
||||
// 这里添加线框模式切换的逻辑
|
||||
}
|
||||
|
||||
// 聚焦选中对象
|
||||
const focusSelected = () => {
|
||||
console.log('聚焦选中对象')
|
||||
// 这里添加聚焦选中对象的逻辑
|
||||
}
|
||||
|
||||
// 切换统计信息显示
|
||||
const toggleStats = () => {
|
||||
showStats.value = !showStats.value
|
||||
console.log('统计信息显示:', showStats.value)
|
||||
// 这里添加统计信息显示切换的逻辑
|
||||
}
|
||||
|
||||
// 截图
|
||||
const takeScreenshot = () => {
|
||||
console.log('截图')
|
||||
// 这里添加截图的逻辑
|
||||
}
|
||||
|
||||
// 切换全屏
|
||||
const toggleFullscreen = () => {
|
||||
isFullscreen.value = !isFullscreen.value
|
||||
console.log('全屏模式:', isFullscreen.value)
|
||||
// 这里添加全屏切换的逻辑
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.scene-toolbar {
|
||||
height: 40px;
|
||||
background: #2d2d2d;
|
||||
border-bottom: 1px solid #4a4a4a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 12px;
|
||||
color: #cccccc;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
min-width: 32px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toolbar-btn:hover {
|
||||
background: #4a4a4a;
|
||||
border-color: #5a5a5a;
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
.toolbar-btn:active {
|
||||
background: #2d2d2d;
|
||||
border-color: #007acc;
|
||||
}
|
||||
|
||||
.toolbar-btn.active {
|
||||
background: #007acc;
|
||||
border-color: #005a9e;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.toolbar-separator {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: #4a4a4a;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.scene-toolbar {
|
||||
padding: 0 8px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.toolbar-group {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
padding: 4px 6px;
|
||||
min-width: 28px;
|
||||
height: 24px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
172
src/components/SceneViewPanel/SceneView.vue
Normal file
172
src/components/SceneViewPanel/SceneView.vue
Normal file
@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<div class="scene-view">
|
||||
<!-- 场景工具栏 -->
|
||||
<SceneToolbar />
|
||||
|
||||
<!-- 编辑场景内容 -->
|
||||
<div class="scene-content">
|
||||
<!-- 3D编辑器渲染区域 -->
|
||||
<div class="editor-viewport">
|
||||
|
||||
<Load3D></Load3D>
|
||||
<canvas id="renderDom" ref="sceneCanvas"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- 场景信息覆盖层 -->
|
||||
<div class="scene-overlay">
|
||||
<div class="scene-info">
|
||||
<span class="scene-mode">编辑模式</span>
|
||||
<span class="scene-stats">对象: 0 | 三角面: 0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import SceneToolbar from './SceneToolbar.vue'
|
||||
import Load3D from 'components/public/Load3D.vue'
|
||||
import MainEditor from 'script/MainEditor'
|
||||
import { useLayoutStore } from '@/stores/Layout'
|
||||
const sceneCanvas = ref<HTMLCanvasElement>()
|
||||
const layoutStore= useLayoutStore()
|
||||
const mainEditor = new MainEditor()
|
||||
|
||||
const {}=layoutStore
|
||||
onMounted(() => {
|
||||
// 初始化3D编辑器场景
|
||||
initSceneEditor()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 清理资源
|
||||
cleanupScene()
|
||||
})
|
||||
|
||||
const initSceneEditor = () => {
|
||||
// 确保canvas元素已经挂载到DOM
|
||||
if (sceneCanvas.value) {
|
||||
mainEditor.Awake()
|
||||
} else {
|
||||
// 延迟初始化,确保DOM已准备好
|
||||
setTimeout(() => {
|
||||
if (sceneCanvas.value) {
|
||||
mainEditor.Awake()
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
const cleanupScene = () => {
|
||||
// 这里添加场景清理逻辑
|
||||
}
|
||||
|
||||
// 暴露给父组件的方法
|
||||
defineExpose({
|
||||
initSceneEditor,
|
||||
cleanupScene
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.scene-view {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.scene-content {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-viewport {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#renderDom {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
background: #1e1e1e;
|
||||
border: none;
|
||||
outline: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.scene-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.scene-info {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
color: #cccccc;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.scene-mode {
|
||||
color: #4CAF50;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.scene-stats {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* 移动端优化 */
|
||||
@media (max-width: 768px) {
|
||||
.scene-info {
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
gap: 12px;
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 小屏幕设备 */
|
||||
@media (max-width: 480px) {
|
||||
.scene-info {
|
||||
bottom: 6px;
|
||||
left: 6px;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
font-size: 10px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.scene-stats {
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 横屏模式优化 */
|
||||
@media (orientation: landscape) and (max-height: 500px) {
|
||||
.scene-info {
|
||||
bottom: 6px;
|
||||
left: 6px;
|
||||
padding: 4px 8px;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
296
src/components/SceneViewPanel/index.vue
Normal file
296
src/components/SceneViewPanel/index.vue
Normal file
@ -0,0 +1,296 @@
|
||||
<template>
|
||||
<div class="sceneView-panel">
|
||||
<!-- Tab 切换栏 -->
|
||||
<div class="tab-header">
|
||||
<div class="tab-list">
|
||||
<button
|
||||
:class="['tab-button', { active: activeTab === 'scene' }]"
|
||||
@click="switchTab('scene')"
|
||||
>
|
||||
<div class="tab-info">
|
||||
<span class="tab-label">Scene</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
:class="['tab-button', { active: activeTab === 'game' }]"
|
||||
@click="switchTab('game')"
|
||||
>
|
||||
<div class="tab-info">
|
||||
<span class="tab-label">Game</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab 操作按钮 -->
|
||||
<div class="tab-actions">
|
||||
<button
|
||||
v-if="activeTab === 'game'"
|
||||
:class="['action-btn', { active: gameViewRef?.isPlaying() }]"
|
||||
@click="toggleGamePlay"
|
||||
:title="gameViewRef?.isPlaying() ? '停止运行' : '开始运行'"
|
||||
>
|
||||
{{ gameViewRef?.isPlaying() ? '⏸️' : '▶️' }}
|
||||
</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
@click="refreshCurrentView"
|
||||
title="刷新当前视图"
|
||||
>
|
||||
🔄
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 内容区域 -->
|
||||
<div class="tab-content">
|
||||
<!-- Scene 编辑场景 -->
|
||||
<div v-if="activeTab === 'scene'" class="tab-pane">
|
||||
<SceneView ref="sceneViewRef" />
|
||||
</div>
|
||||
|
||||
<!-- Game 运行场景 -->
|
||||
<div v-if="activeTab === 'game'" class="tab-pane">
|
||||
<GameView ref="gameViewRef" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import SceneView from './SceneView.vue'
|
||||
import GameView from './GameView.vue'
|
||||
|
||||
// Tab 状态
|
||||
const activeTab = ref<'scene' | 'game'>('scene')
|
||||
|
||||
// 组件引用
|
||||
const sceneViewRef = ref<InstanceType<typeof SceneView>>()
|
||||
const gameViewRef = ref<InstanceType<typeof GameView>>()
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化默认场景
|
||||
initializeViews()
|
||||
})
|
||||
|
||||
// 初始化视图
|
||||
const initializeViews = () => {
|
||||
console.log('初始化场景视图面板')
|
||||
}
|
||||
|
||||
// 切换Tab
|
||||
const switchTab = (tab: 'scene' | 'game') => {
|
||||
if (activeTab.value === tab) return
|
||||
|
||||
// 如果从Game切换到Scene,停止游戏运行
|
||||
if (activeTab.value === 'game' && gameViewRef.value?.isPlaying()) {
|
||||
gameViewRef.value.stopGame()
|
||||
}
|
||||
|
||||
activeTab.value = tab
|
||||
console.log('切换到:', tab === 'scene' ? '编辑场景' : '运行场景')
|
||||
|
||||
// 可以在这里添加切换时的特殊逻辑
|
||||
if (tab === 'scene') {
|
||||
// 切换到编辑模式的逻辑
|
||||
onSwitchToScene()
|
||||
} else {
|
||||
// 切换到运行模式的逻辑
|
||||
onSwitchToGame()
|
||||
}
|
||||
}
|
||||
|
||||
// 切换到编辑场景
|
||||
const onSwitchToScene = () => {
|
||||
console.log('进入编辑模式')
|
||||
// 这里可以添加编辑模式的初始化逻辑
|
||||
}
|
||||
|
||||
// 切换到运行场景
|
||||
const onSwitchToGame = () => {
|
||||
console.log('进入运行模式')
|
||||
// 这里可以添加运行模式的初始化逻辑
|
||||
}
|
||||
|
||||
// 切换游戏播放状态
|
||||
const toggleGamePlay = () => {
|
||||
if (gameViewRef.value) {
|
||||
if (gameViewRef.value.isPlaying()) {
|
||||
gameViewRef.value.stopGame()
|
||||
} else {
|
||||
gameViewRef.value.startGame()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新当前视图
|
||||
const refreshCurrentView = () => {
|
||||
if (activeTab.value === 'scene') {
|
||||
sceneViewRef.value?.initSceneEditor()
|
||||
console.log('刷新编辑场景')
|
||||
} else {
|
||||
gameViewRef.value?.resetGame()
|
||||
console.log('刷新运行场景')
|
||||
}
|
||||
}
|
||||
|
||||
// 暴露给父组件的方法
|
||||
defineExpose({
|
||||
switchTab,
|
||||
activeTab: () => activeTab.value,
|
||||
getSceneView: () => sceneViewRef.value,
|
||||
getGameView: () => gameViewRef.value
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sceneView-panel {
|
||||
height: 100%;
|
||||
background: #1e1e1e;
|
||||
border-right: 1px solid #dcdfe6;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tab-header {
|
||||
background: #2d2d2d;
|
||||
border-bottom: 1px solid #4a4a4a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-list {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
padding: 5px;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 80px;
|
||||
position: relative;
|
||||
border-top: 2px solid transparent;
|
||||
}
|
||||
|
||||
.tab-info{
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
background: #3a3a3a;
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
background: #1e1e1e;
|
||||
color: #ffffff;
|
||||
border-top: 2px solid #007acc;
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.tab-label {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tab-description {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab-button.active .tab-description {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.tab-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
min-width: 32px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: #4a4a4a;
|
||||
border-color: #5a5a5a;
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
.action-btn.active {
|
||||
background: #28a745;
|
||||
border-color: #1e7e34;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tab-pane {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
|
||||
|
||||
.tab-button {
|
||||
padding: 6px 12px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.tab-label {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tab-description {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 4px 6px;
|
||||
min-width: 28px;
|
||||
height: 24px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
151
src/components/ToolbarPanel/index.vue
Normal file
151
src/components/ToolbarPanel/index.vue
Normal file
@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<div class="toolbar-panel">
|
||||
<!-- 左侧工具组 -->
|
||||
<div class="toolbar-group">
|
||||
<button class="toolbar-btn" title="新建场景">
|
||||
📄
|
||||
</button>
|
||||
<button class="toolbar-btn" title="打开场景">
|
||||
📁
|
||||
</button>
|
||||
<button class="toolbar-btn" title="保存场景">
|
||||
💾
|
||||
</button>
|
||||
<div class="toolbar-separator"></div>
|
||||
<button class="toolbar-btn" title="撤销">
|
||||
↶
|
||||
</button>
|
||||
<button class="toolbar-btn" title="重做">
|
||||
↷
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 中间运行按钮 -->
|
||||
<div class="toolbar-group">
|
||||
<button class="toolbar-btn run-btn" title="运行场景">
|
||||
▶️
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 右侧重置布局按钮 -->
|
||||
<div class="toolbar-group">
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
@click="layoutStore.resetLayout"
|
||||
title="重置布局"
|
||||
>
|
||||
🔄
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useLayoutStore } from '@/stores/Layout'
|
||||
|
||||
// 使用布局store
|
||||
const layoutStore = useLayoutStore()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toolbar-panel {
|
||||
height: 100%;
|
||||
background: #3c3c3c;
|
||||
border-bottom: 1px solid #4a4a4a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 12px;
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
.toolbar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
min-width: 32px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toolbar-btn:hover {
|
||||
background: #4a4a4a;
|
||||
border-color: #5a5a5a;
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
.toolbar-btn:active {
|
||||
background: #2d2d2d;
|
||||
border-color: #007acc;
|
||||
}
|
||||
|
||||
.toolbar-btn.active {
|
||||
background: #007acc;
|
||||
border-color: #005a9e;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.run-btn {
|
||||
background: #28a745;
|
||||
border-color: #1e7e34;
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
min-width: 48px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.run-btn:hover {
|
||||
background: #218838;
|
||||
border-color: #1c7430;
|
||||
}
|
||||
|
||||
.run-btn:active {
|
||||
background: #1e7e34;
|
||||
border-color: #1c7430;
|
||||
}
|
||||
|
||||
.toolbar-separator {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: #4a4a4a;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.toolbar-panel {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.toolbar-group {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
padding: 4px 6px;
|
||||
min-width: 28px;
|
||||
height: 24px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.run-btn {
|
||||
min-width: 40px;
|
||||
height: 28px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
223
src/components/View.vue
Normal file
223
src/components/View.vue
Normal file
@ -0,0 +1,223 @@
|
||||
<template>
|
||||
<div class="view-wrapper">
|
||||
<div class="app-view">
|
||||
<canvas id="renderDom"></canvas>
|
||||
</div>
|
||||
|
||||
<div style="width: 400px;height: 400px;padding: 20px; display:
|
||||
flex; flex-direction: column; gap: 20px;position: absolute;left: 20px;top: 20px;" v-show="!isPhone()">
|
||||
<el-color-picker show-alpha v-model="color" />
|
||||
|
||||
<div>清漆</div>
|
||||
<el-slider :step="0.01" :min="0" :max="1" v-model="cheqi" />
|
||||
<div>清漆粗糙度</div>
|
||||
<el-slider :step="0.01" :min="0" :max="1" v-model="cheqiRoughness" />
|
||||
<div>HDR</div>
|
||||
<el-slider :step="0.1" :min="0" :max="5" v-model="hdr" @input="changeHDRIntensity" />
|
||||
<div>金属度</div>
|
||||
<el-slider :step="0.01" :min="0" :max="1" v-model="metal" @input="changeMetalRoughness" />
|
||||
<div>粗糙度</div>
|
||||
<el-slider :step="0.01" :min="0" :max="1" v-model="roughness" @input="changeMetalRoughness" />
|
||||
<div>菲涅尔</div>
|
||||
<el-slider :step="0.1" :min="1" :max="3" v-model="fresnel" @input="changeFresnel" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import MainEditor from "script/MainEditor";
|
||||
import { userLoadingStore, userCacheStore } from '@/stores/zguiy';
|
||||
import { ref, watch } from "vue";
|
||||
import { isPhone } from "@/script/utils/Tools";
|
||||
const color = ref('#409EFF')
|
||||
const cheqi = ref(1)
|
||||
const cheqiRoughness = ref(0)
|
||||
const hdr = ref(1)
|
||||
const metal = ref(0)
|
||||
const roughness = ref(0)
|
||||
const fresnel = ref(1)
|
||||
const light = ref(1)
|
||||
const light2 = ref(1)
|
||||
let mainEditor = new MainEditor();
|
||||
// 获取loadingStore
|
||||
const loadingStore = userLoadingStore();
|
||||
const cacheStore = userCacheStore();
|
||||
// 是否暂停渲染
|
||||
let isRenderingPaused = false;
|
||||
|
||||
const createScene = async () => {
|
||||
await mainEditor.Awake();
|
||||
}
|
||||
|
||||
const loadModel = async (modelUrl: string, complete: Function) => {
|
||||
|
||||
mainEditor.loadModel({
|
||||
modelUrlList: [modelUrl],
|
||||
success: () => {
|
||||
// 将加载进度更新为98%
|
||||
loadingStore.setLoadProgress(98);
|
||||
complete();
|
||||
},
|
||||
error: (e: any) => {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
|
||||
const moveTo = (position: any, lookAt: any, complete: () => void) => {
|
||||
mainEditor.mainApp.appCamera.moveTo(position)
|
||||
}
|
||||
|
||||
const disPose = async () => {
|
||||
await mainEditor.dispose()
|
||||
console.log(222333);
|
||||
|
||||
cacheStore.setCache(true)
|
||||
}
|
||||
|
||||
const initSetMaterial = (oldList: any) => {
|
||||
mainEditor.mainApp.gameManager.initSetMaterial(oldList)
|
||||
}
|
||||
|
||||
const changeSetMaterial = (oldList: Array<any>) => {
|
||||
// mainEditor.mainApp.gameManager.changeSetMaterial(oldList)
|
||||
}
|
||||
|
||||
// 暂停渲染,减轻移动端GPU负担
|
||||
const pauseRender = () => {
|
||||
if (!isRenderingPaused && mainEditor.mainApp) {
|
||||
isRenderingPaused = true;
|
||||
// 停止渲染循环
|
||||
mainEditor.mainApp.unUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复渲染
|
||||
const resumeRender = () => {
|
||||
if (isRenderingPaused && mainEditor.mainApp) {
|
||||
isRenderingPaused = false;
|
||||
// 重新开始渲染循环
|
||||
|
||||
mainEditor.mainApp.update();
|
||||
}
|
||||
}
|
||||
|
||||
const startLoading = () => {
|
||||
// 重置加载进度为0
|
||||
loadingStore.setLoadProgress(0);
|
||||
}
|
||||
|
||||
const setSceneConfig = (sceneConfig: any) => {
|
||||
const { cameraPosition, cameraTarget, angularSensibility } = sceneConfig
|
||||
mainEditor.mainApp.appCamera.setPosition(cameraPosition.x, cameraPosition.y, cameraPosition.z)
|
||||
mainEditor.mainApp.appCamera.setTarget(cameraTarget.x, cameraTarget.y, cameraTarget.z)
|
||||
mainEditor.mainApp.appCamera.setAngularSensibility(angularSensibility)
|
||||
}
|
||||
|
||||
const fadeOut = (isb: boolean) => {
|
||||
mainEditor.mainApp.appCamera.fadeOut(isb)
|
||||
}
|
||||
|
||||
const dimensionsVisible = (isb: boolean) => {
|
||||
mainEditor.mainApp.dimensions.toggle(isb)
|
||||
}
|
||||
const chedeng = (isb: boolean) => {
|
||||
mainEditor.mainApp.gameManager.chedeng(isb)
|
||||
}
|
||||
|
||||
|
||||
//金属度粗糙度
|
||||
const changeMetalRoughness = () => {
|
||||
mainEditor.mainApp.gameManager.changeMetalRoughness(metal.value, roughness.value)
|
||||
}
|
||||
|
||||
//菲涅尔
|
||||
const changeFresnel = () => {
|
||||
mainEditor.mainApp.gameManager.setFresnel(fresnel.value)
|
||||
}
|
||||
|
||||
const changeHDRIntensity = () => {
|
||||
mainEditor.mainApp.appEnv.changeHDRIntensity(hdr.value)
|
||||
}
|
||||
const openDoor = (isb: boolean) => {
|
||||
mainEditor.mainApp.gameManager.playDoorAnimation(isb)
|
||||
}
|
||||
const toggleVisibility = (isb: boolean) => {
|
||||
mainEditor.mainApp.appGroud.toggleVisibility(isb)
|
||||
}
|
||||
const visibleHotspot = (isb: boolean) => {
|
||||
mainEditor.mainApp.appHotspot.visible(isb)
|
||||
}
|
||||
|
||||
const transitionPanorama = (isb: boolean) => {
|
||||
mainEditor.mainApp.appEnv.transitionPanorama(isb)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
createScene,
|
||||
loadModel,
|
||||
startLoading,
|
||||
initSetMaterial,
|
||||
changeSetMaterial,
|
||||
moveTo,
|
||||
setSceneConfig,
|
||||
pauseRender,
|
||||
resumeRender,
|
||||
fadeOut,
|
||||
dimensionsVisible,
|
||||
chedeng,
|
||||
openDoor,
|
||||
toggleVisibility,
|
||||
transitionPanorama,
|
||||
visibleHotspot,
|
||||
disPose
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
/* background-color: #efeff1; */
|
||||
/* background: url("/resources/微信图片_20250512185835.jpg") no-repeat center center fixed; */
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
#renderDom {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#button {
|
||||
width: 100px;
|
||||
height: 60px;
|
||||
background-color: red;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
z-index: 99;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.time {
|
||||
width: 150px;
|
||||
height: 40px;
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
background-color: red;
|
||||
left: 50%;
|
||||
margin-left: -75px;
|
||||
z-index: 99;
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
line-height: 40px;
|
||||
}
|
||||
</style>
|
||||
76
src/components/public/ClearCache.vue
Normal file
76
src/components/public/ClearCache.vue
Normal file
@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div class="clear-cache-btn" @click="clearCache">
|
||||
<span class="icon">
|
||||
<svg viewBox="0 0 24 24" width="24" height="24">
|
||||
<path fill="currentColor" d="M17.65,6.35C16.2,4.9 14.21,4 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20C15.73,20 18.84,17.45 19.73,14H17.65C16.83,16.33 14.61,18 12,18A6,6 0 0,1 6,12A6,6 0 0,1 12,6C13.66,6 15.14,6.69 16.22,7.78L13,11H20V4L17.65,6.35Z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="text">清除缓存</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { mainApp } from '@/main';
|
||||
import { ref } from 'vue';
|
||||
|
||||
// 是否正在清理缓存
|
||||
const isClearing = ref(false);
|
||||
|
||||
// 清除缓存方法
|
||||
const clearCache = async () => {
|
||||
// 防止重复点击
|
||||
if (isClearing.value) return;
|
||||
|
||||
try {
|
||||
isClearing.value = true;
|
||||
|
||||
// 调用主应用的清除缓存方法
|
||||
await mainApp.clearCache();
|
||||
|
||||
// 显示成功提示
|
||||
alert('缓存清理成功!');
|
||||
} catch (error) {
|
||||
console.error('缓存清理失败:', error);
|
||||
alert('缓存清理失败,请稍后重试!');
|
||||
} finally {
|
||||
isClearing.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.clear-cache-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 16px;
|
||||
background-color: #1976d2;
|
||||
color: white;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.clear-cache-btn:hover {
|
||||
background-color: #1565c0;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.clear-cache-btn:active {
|
||||
background-color: #0d47a1;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
214
src/components/public/CollapsibleHeader.vue
Normal file
214
src/components/public/CollapsibleHeader.vue
Normal file
@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<div class="collapsible-header-container">
|
||||
<div class="collapsible-header">
|
||||
<div class="header-left">
|
||||
<div
|
||||
class="expand-arrow"
|
||||
:class="{ expanded: isExpanded }"
|
||||
@click="toggleExpand"
|
||||
v-if="expandable"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
v-if="checkable"
|
||||
v-model="isEnabled"
|
||||
@change="handleEnabledChange"
|
||||
size="medium"
|
||||
:disabled="false"
|
||||
/>
|
||||
|
||||
<span class="header-title">{{ title }}</span>
|
||||
</div>
|
||||
|
||||
<div class="header-right" v-if="showActions">
|
||||
<button
|
||||
v-for="action in actions"
|
||||
:key="action.name"
|
||||
class="action-btn"
|
||||
:title="action.tooltip"
|
||||
@click="handleActionClick(action)"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path :d="action.icon"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div class="collapsible-content" v-if="isExpanded">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Switch } from '../BasicControls'
|
||||
|
||||
// 定义Props类型
|
||||
interface ActionItem {
|
||||
name: string
|
||||
tooltip: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
expandable?: boolean
|
||||
checkable?: boolean
|
||||
enabled?: boolean
|
||||
expanded?: boolean
|
||||
showActions?: boolean
|
||||
actions?: ActionItem[]
|
||||
}
|
||||
|
||||
// 定义Emits
|
||||
interface Emits {
|
||||
(e: 'update:expanded', value: boolean): void
|
||||
(e: 'update:enabled', value: boolean): void
|
||||
(e: 'action-click', actionName: string): void
|
||||
}
|
||||
|
||||
// Props定义
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
expandable: true,
|
||||
checkable: true,
|
||||
enabled: true,
|
||||
expanded: true,
|
||||
showActions: true,
|
||||
actions: () => [
|
||||
{
|
||||
name: 'reset',
|
||||
tooltip: 'Reset',
|
||||
icon: 'M12 6v3l4-4-4-4v3c-4.42 0-8 3.58-8 8 0 1.57.46 3.03 1.24 4.26L6.7 14.8c-.45-.83-.7-1.79-.7-2.8 0-3.31 2.69-6 6-6zm6.76 1.74L17.3 9.2c.44.84.7 1.79.7 2.8 0 3.31-2.69 6-6 6v-3l-4 4 4 4v-3c4.42 0 8-3.58 8-8 0-1.57-.46-3.03-1.24-4.26z'
|
||||
},
|
||||
{
|
||||
name: 'more',
|
||||
tooltip: 'More',
|
||||
icon: 'M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// Emits定义
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// 内部状态
|
||||
const isExpanded = ref(props.expanded)
|
||||
const isEnabled = ref(props.enabled)
|
||||
|
||||
// 切换展开状态
|
||||
const toggleExpand = () => {
|
||||
isExpanded.value = !isExpanded.value
|
||||
emit('update:expanded', isExpanded.value)
|
||||
}
|
||||
|
||||
// 处理启用状态变化
|
||||
const handleEnabledChange = (enabled: boolean) => {
|
||||
isEnabled.value = enabled
|
||||
emit('update:enabled', isEnabled.value)
|
||||
}
|
||||
|
||||
// 处理操作按钮点击
|
||||
const handleActionClick = (action: ActionItem) => {
|
||||
emit('action-click', action.name)
|
||||
}
|
||||
|
||||
// 监听外部props变化
|
||||
watch(() => props.expanded, (newVal) => {
|
||||
isExpanded.value = newVal
|
||||
})
|
||||
|
||||
watch(() => props.enabled, (newVal) => {
|
||||
isEnabled.value = newVal
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.collapsible-header-container {
|
||||
background: #393939;
|
||||
border-bottom: 1px solid #2d2d2d;
|
||||
}
|
||||
|
||||
.collapsible-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 4px;
|
||||
background: #4a4a4a;
|
||||
border-bottom: 1px solid #2d2d2d;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.collapsible-content {
|
||||
padding: 8px;
|
||||
background: #393939;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.expand-arrow {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: #cccccc;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.expand-arrow.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
color: #cccccc;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #cccccc;
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
border-radius: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: #5a5a5a;
|
||||
}
|
||||
|
||||
/* 深色主题适配 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.collapsible-header {
|
||||
background: #4a4a4a;
|
||||
border-bottom-color: #2d2d2d;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
147
src/components/public/Contrast.vue
Normal file
147
src/components/public/Contrast.vue
Normal file
@ -0,0 +1,147 @@
|
||||
<template>
|
||||
|
||||
<div class="contrast" v-show="visible">
|
||||
|
||||
<div style="display: flex;flex-direction: column;">
|
||||
|
||||
<div style="display: flex;flex-direction: row;">
|
||||
<div class="img-box-title img-box-title-old">
|
||||
<div>{{ degree || '' }}成新</div>
|
||||
</div>
|
||||
<div class="img-box-title img-box-title-new">
|
||||
<div>全新</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-show="ContrastList.length === 0" description="暂无选中套餐" />
|
||||
<div class="contrast-content">
|
||||
<div v-for="(item, index) in ContrastList" :key="index" style="display: flex;flex-direction: row;">
|
||||
<div class="img-box">
|
||||
<img :src="oldImg(item.name)" alt="">
|
||||
<div class="img-box-name">{{ item.name }}</div>
|
||||
</div>
|
||||
<div class="img-box">
|
||||
<img :src="newImg(item.name)" alt="">
|
||||
<div class="img-box-name">{{ item.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="close" @click="handleClose"><el-icon>
|
||||
<ArrowLeft />
|
||||
</el-icon></div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { AppConfig } from 'script/core';
|
||||
import { ref, toRefs, watch, onUnmounted } from 'vue'
|
||||
const props = defineProps<{ visible: boolean, ContrastList: any, degreeId: string }>()
|
||||
const { visible, ContrastList, degreeId } = toRefs(props)
|
||||
const degree = ref<string>("")
|
||||
|
||||
const emit = defineEmits(['update:visible'])
|
||||
onUnmounted(() => {
|
||||
|
||||
})
|
||||
|
||||
watch(() => degreeId.value, (newVal, oldVal) => {
|
||||
const degreeItem = AppConfig.Degree.find((item) => item.id === newVal)
|
||||
degree.value = degreeItem?.name
|
||||
console.log(degreeId.value);
|
||||
|
||||
})
|
||||
|
||||
const oldImg = ((name: string) => {
|
||||
return `${AppConfig.Global.publicURL}src/img/${name}/${name}${degree.value}.png`
|
||||
})
|
||||
const newImg = ((name: string) => {
|
||||
return `${AppConfig.Global.publicURL}src/img/${name}/${name}10.png`
|
||||
})
|
||||
|
||||
|
||||
const handleClose = () => {
|
||||
|
||||
emit("update:visible", false)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.img-box-title {
|
||||
width: 49%;
|
||||
margin: 0 1%;
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.img-box-title div {
|
||||
padding: 20px
|
||||
}
|
||||
|
||||
.img-box-title-old {
|
||||
background: linear-gradient(to bottom, rgba(255, 166, 0, 0.5), rgba(255, 255, 255, 0.5));
|
||||
}
|
||||
|
||||
.img-box-title-new {
|
||||
background: linear-gradient(to bottom, rgba(0, 68, 255, 0.5), rgba(255, 255, 255, 0.5));
|
||||
}
|
||||
|
||||
.contrast {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #fff;
|
||||
z-index: 1000;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.contrast-content {
|
||||
max-height: calc(100vh - 60px);
|
||||
/* 设置最大高度为视口高度的 80% */
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.img-box {
|
||||
width: 49%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 1%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.img-box img {
|
||||
width: 100%;
|
||||
background-color: rgba(223, 223, 223, 0.616);
|
||||
object-fit: over
|
||||
}
|
||||
|
||||
.img-box span {}
|
||||
|
||||
.img-box-name {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
background-color: #000000a8;
|
||||
padding: 4px;
|
||||
border-radius: 5px;
|
||||
color: white;
|
||||
|
||||
}
|
||||
|
||||
.close {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background-color: rgb(255, 255, 255);
|
||||
position: fixed;
|
||||
top: 70px;
|
||||
left: 2px;
|
||||
border-radius: 50%;
|
||||
text-align: center;
|
||||
line-height: 50px;
|
||||
font-size: 30px
|
||||
}
|
||||
</style>
|
||||
211
src/components/public/Dialog.vue
Normal file
211
src/components/public/Dialog.vue
Normal file
@ -0,0 +1,211 @@
|
||||
<template>
|
||||
|
||||
<div class="control-panel" v-show="isVideoVisible">
|
||||
|
||||
<div class="dialog">
|
||||
|
||||
<div class="dialog-top">
|
||||
|
||||
<div class="title">
|
||||
产品介绍视频
|
||||
</div>
|
||||
|
||||
<div class="dialog-close" @click="handleClose">
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
|
||||
<div class="video-container">
|
||||
<video ref="videoPlayer" controls playsinline webkit-playsinline x5-playsinline>
|
||||
<source :src="videoUrl" type="video/mp4">
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-bottom"></div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, getCurrentInstance, nextTick, toRefs, watch } from "vue";
|
||||
import { userVideoStore } from "@/stores/zguiy";
|
||||
import { storeToRefs } from "pinia";
|
||||
const videoStore = userVideoStore()
|
||||
const { isVideoVisible } = storeToRefs(videoStore);
|
||||
|
||||
const videoUrl = `${import.meta.env.VITE_PUBLIC_URL}src/resources/bg.webm`
|
||||
const videoPlayer = ref();
|
||||
|
||||
|
||||
// 监听visible变化,当弹窗打开或关闭时,优化性能处理
|
||||
watch(isVideoVisible, (newVal) => {
|
||||
if (newVal) {
|
||||
// 弹窗打开时,设置适当的z-index
|
||||
document.body.style.overflow = 'hidden'; // 阻止背景滚动
|
||||
showVideo()
|
||||
} else {
|
||||
// 弹窗关闭时恢复
|
||||
document.body.style.overflow = '';
|
||||
hiddenVidio()
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
})
|
||||
const handleClose = () => {
|
||||
videoStore.setVideoVisible(false)
|
||||
}
|
||||
|
||||
// 显示视频弹窗
|
||||
const showVideo = () => {
|
||||
|
||||
// 等待DOM更新后自动播放视频
|
||||
setTimeout(() => {
|
||||
if (videoPlayer.value) {
|
||||
videoPlayer.value.play().catch((error: unknown) => {
|
||||
console.log('视频自动播放受限:', error);
|
||||
});
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
|
||||
// 监听视频弹窗关闭
|
||||
const hiddenVidio = () => {
|
||||
|
||||
if (videoPlayer.value) {
|
||||
videoPlayer.value.pause();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.control-panel {
|
||||
position: fixed;
|
||||
/* 改为fixed避免移动端滚动影响 */
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #00000028;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 999;
|
||||
/* 确保在最顶层 */
|
||||
}
|
||||
|
||||
.control-panel .dialog {
|
||||
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: #ffffff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: all 0.3s ease-in-out;
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.control-panel .dialog .dialog-top {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
border-top-left-radius: 10px;
|
||||
border-top-right-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: right;
|
||||
background-color: rgb(255, 255, 255)
|
||||
}
|
||||
|
||||
.control-panel .dialog .dialog-top .dialog-close {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 10px;
|
||||
margin-top: 10px;
|
||||
background-color: red;
|
||||
cursor: pointer;
|
||||
|
||||
}
|
||||
|
||||
.control-panel .dialog .dialog-top .dialog-close:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
flex: 1;
|
||||
background-color: rgba(0, 0, 0, 0.76);
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
overflow: hidden;
|
||||
/* 防止内容溢出 */
|
||||
}
|
||||
|
||||
.dialog-bottom {
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.full {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.no-full {
|
||||
max-width: 90%;
|
||||
/* 移动端优化宽度 */
|
||||
max-height: 80vh;
|
||||
/* 使用vh单位以适应移动端视口 */
|
||||
width: 90%;
|
||||
/* 确保在小屏幕上不会太小 */
|
||||
height: auto;
|
||||
/* 自适应高度 */
|
||||
}
|
||||
|
||||
/* 为移动端添加特定样式 */
|
||||
@media (max-width: 768px) {
|
||||
.no-full {
|
||||
max-width: 95%;
|
||||
width: 95%;
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
.control-panel .dialog .dialog-top {
|
||||
height: 30px;
|
||||
/* 移动端顶部高度稍小 */
|
||||
}
|
||||
|
||||
.dialog-bottom {
|
||||
height: 1rem;
|
||||
/* 移动端底部高度稍小 */
|
||||
}
|
||||
}
|
||||
.video-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.video-container video {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
</style>
|
||||
145
src/components/public/FeatureInfo copy.vue
Normal file
145
src/components/public/FeatureInfo copy.vue
Normal file
@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<div class="feature-info-container" :class="{ 'active': isActive }" v-show="isOpen || animating">
|
||||
<div class="feature-info">
|
||||
<div class="feature-header">
|
||||
|
||||
<div class="title">{{ title }}</div>
|
||||
<div class="close-btn" @click="handleClose">×</div>
|
||||
</div>
|
||||
<div class="feature-divider"></div>
|
||||
<div class="feature-content">
|
||||
<div class="feature-info-content">
|
||||
<p>{{ content }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, toRefs, nextTick } from 'vue';
|
||||
//引入刚才的pinia
|
||||
import { userSellingPointStore } from '@/stores/zguiy';
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
const sllingPointStore = userSellingPointStore()
|
||||
|
||||
const {isOpen,content,title} =storeToRefs(sllingPointStore)
|
||||
|
||||
|
||||
const animating = ref(false);
|
||||
const isActive = ref(false);
|
||||
const timer: any = ref(null);
|
||||
|
||||
// 监听可见性变化来控制动画
|
||||
watch(isOpen, (newVal, oldVal) => {
|
||||
|
||||
|
||||
if (newVal !== oldVal) {
|
||||
clearTimeout(timer.value);
|
||||
if (newVal) {
|
||||
// 显示元素以便开始动画
|
||||
animating.value = true;
|
||||
// 先让组件渲染(没有active类),然后使用nextTick添加active类实现动画
|
||||
isActive.value = false;
|
||||
// 使用 nextTick 确保 DOM 已更新
|
||||
nextTick(() => {
|
||||
// 使用 setTimeout 在下一帧添加 active 类,确保动画生效
|
||||
timer.value = setTimeout(() => {
|
||||
isActive.value = true;
|
||||
}, 20);
|
||||
});
|
||||
} else {
|
||||
// 移除active类,开始关闭动画
|
||||
isActive.value = false;
|
||||
|
||||
// 当隐藏时,给动画足够时间完成
|
||||
timer.value = setTimeout(() => {
|
||||
animating.value = false;
|
||||
}, 800); // 与CSS动画持续时间匹配
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (isOpen.value) {
|
||||
animating.value = true;
|
||||
|
||||
// 在组件挂载时也需要同样的处理
|
||||
nextTick(() => {
|
||||
timer.value = setTimeout(() => {
|
||||
isActive.value = true;
|
||||
}, 20);
|
||||
});
|
||||
}
|
||||
});
|
||||
const handleClose=()=>{
|
||||
sllingPointStore.setIsOpen(false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.feature-info-container {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
width: 80%;
|
||||
z-index: 999;
|
||||
transition: transform 0.8s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
|
||||
}
|
||||
|
||||
.feature-info-container.active {
|
||||
/* transform: translateY(0); */
|
||||
}
|
||||
|
||||
.feature-info {
|
||||
/* background-color: white; */
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 -4px 10px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
background: url(@/assets/img/btn_产品卖点背景.png) no-repeat center center;
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
.feature-header {
|
||||
padding:30px 16px 14px 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: bold;
|
||||
font-size: 20px;
|
||||
color: #333;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 34px;
|
||||
cursor: pointer;
|
||||
color: #000000;
|
||||
margin-top: -30px;
|
||||
}
|
||||
|
||||
.feature-divider {
|
||||
height: 1px;
|
||||
background-color: #888888;
|
||||
margin: 0 16px;
|
||||
}
|
||||
|
||||
.feature-content {
|
||||
padding: 15px 16px;
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
157
src/components/public/FeatureInfo.vue
Normal file
157
src/components/public/FeatureInfo.vue
Normal file
@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<div class="mask" v-show="isOpen || animating" @click="handleClose"></div>
|
||||
<div class="feature-info-container" :class="{ 'active': isActive }" v-show="isOpen || animating">
|
||||
<div class="feature-info">
|
||||
<div class="feature-header">
|
||||
|
||||
<div class="title">{{ title }}</div>
|
||||
<div class="close-btn" @click="handleClose">×</div>
|
||||
</div>
|
||||
<div class="feature-divider"></div>
|
||||
<div class="feature-content">
|
||||
<div class="feature-info-content">
|
||||
<p>{{ content }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, toRefs, nextTick } from 'vue';
|
||||
//引入刚才的pinia
|
||||
import { userSellingPointStore } from '@/stores/zguiy';
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
const sllingPointStore = userSellingPointStore()
|
||||
|
||||
const {isOpen,content,title} =storeToRefs(sllingPointStore)
|
||||
|
||||
|
||||
const animating = ref(false);
|
||||
const isActive = ref(false);
|
||||
const timer: any = ref(null);
|
||||
|
||||
// 监听可见性变化来控制动画
|
||||
// watch(isOpen, (newVal, oldVal) => {
|
||||
|
||||
|
||||
// if (newVal !== oldVal) {
|
||||
// clearTimeout(timer.value);
|
||||
// if (newVal) {
|
||||
// // 显示元素以便开始动画
|
||||
// animating.value = true;
|
||||
// // 先让组件渲染(没有active类),然后使用nextTick添加active类实现动画
|
||||
// isActive.value = false;
|
||||
// // 使用 nextTick 确保 DOM 已更新
|
||||
// nextTick(() => {
|
||||
// // 使用 setTimeout 在下一帧添加 active 类,确保动画生效
|
||||
// timer.value = setTimeout(() => {
|
||||
// isActive.value = true;
|
||||
// }, 20);
|
||||
// });
|
||||
// } else {
|
||||
// // 移除active类,开始关闭动画
|
||||
// isActive.value = false;
|
||||
|
||||
// // 当隐藏时,给动画足够时间完成
|
||||
// timer.value = setTimeout(() => {
|
||||
// animating.value = false;
|
||||
// }, 800); // 与CSS动画持续时间匹配
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
|
||||
onMounted(() => {
|
||||
if (isOpen.value) {
|
||||
animating.value = true;
|
||||
|
||||
// 在组件挂载时也需要同样的处理
|
||||
nextTick(() => {
|
||||
timer.value = setTimeout(() => {
|
||||
isActive.value = true;
|
||||
}, 20);
|
||||
});
|
||||
}
|
||||
});
|
||||
const handleClose=()=>{
|
||||
sllingPointStore.setIsOpen(false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.feature-info-container {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
width: 80%;
|
||||
z-index: 999;
|
||||
transition: transform 0.8s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
|
||||
}
|
||||
|
||||
.feature-info-container.active {
|
||||
/* transform: translateY(0); */
|
||||
}
|
||||
|
||||
.feature-info {
|
||||
/* background-color: white; */
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 -4px 10px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
background-color: white;
|
||||
/* background: url(@/assets/img/btn_产品卖点背景.png) no-repeat center center; */
|
||||
/* height: 220px; */
|
||||
|
||||
}
|
||||
|
||||
.feature-header {
|
||||
padding:25px 16px 14px 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: bold;
|
||||
font-size: 20px;
|
||||
color: #333;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32px;
|
||||
cursor: pointer;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.feature-divider {
|
||||
height: 1px;
|
||||
background-color: #888888;
|
||||
margin: 0 16px;
|
||||
}
|
||||
|
||||
.feature-content {
|
||||
padding: 15px 16px 30px 16px;
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(255, 255, 255, 0);
|
||||
z-index: 998;
|
||||
}
|
||||
</style>
|
||||
92
src/components/public/Load3D.vue
Normal file
92
src/components/public/Load3D.vue
Normal file
@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div class="load3D" >
|
||||
<div class="load3d-cube">
|
||||
<div class="face load3d-front"></div>
|
||||
<div class="face load3d-back"></div>
|
||||
<div class="face load3d-right"></div>
|
||||
<div class="face load3d-left"></div>
|
||||
<div class="face load3d-top"></div>
|
||||
<div class="face load3d-bottom"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, getCurrentInstance } from 'vue'
|
||||
|
||||
let logo = `${import.meta.env.BASE_URL}src/ui/logo/hitachi.png`
|
||||
|
||||
onMounted(()=>{
|
||||
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.load3D{
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: #d8d8d83d;
|
||||
opacity: 0.8;
|
||||
position: absolute;
|
||||
z-index: 11;
|
||||
display: none;
|
||||
}
|
||||
.load3d-cube {
|
||||
width: var(--cube-size);
|
||||
height: var(--cube-size);
|
||||
position: relative;
|
||||
transform-style: preserve-3d;
|
||||
transform: rotateX(45deg) rotateY(45deg);
|
||||
animation: rotateCube 3s infinite linear;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
|
||||
|
||||
}
|
||||
|
||||
.face {
|
||||
position: absolute;
|
||||
width: var(--cube-size);
|
||||
height: var(--cube-size);
|
||||
background: rgba(77, 77, 77, 0.8);
|
||||
border: 2px solid #fff;
|
||||
}
|
||||
|
||||
.load3d-front {
|
||||
transform: rotateY(0deg) translateZ(calc(var(--cube-size) / 2));
|
||||
}
|
||||
|
||||
.load3d-back {
|
||||
transform: rotateY(180deg) translateZ(calc(var(--cube-size) / 2));
|
||||
}
|
||||
|
||||
.load3d-right {
|
||||
transform: rotateY(90deg) translateZ(calc(var(--cube-size) / 2));
|
||||
}
|
||||
|
||||
.load3d-left {
|
||||
transform: rotateY(-90deg) translateZ(calc(var(--cube-size) / 2));
|
||||
}
|
||||
|
||||
.load3d-top {
|
||||
transform: rotateX(90deg) translateZ(calc(var(--cube-size) / 2));
|
||||
}
|
||||
|
||||
.load3d-bottom {
|
||||
transform: rotateX(-90deg) translateZ(calc(var(--cube-size) / 2));
|
||||
}
|
||||
|
||||
@keyframes rotateCube {
|
||||
0% {
|
||||
transform: rotateX(45deg) rotateY(45deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotateX(45deg) rotateY(405deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
144
src/components/public/PackageComponent.vue
Normal file
144
src/components/public/PackageComponent.vue
Normal file
@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<div class="StateTable-container">
|
||||
|
||||
<div class="title dynamics-font">「焕新-3D演示」</div>
|
||||
|
||||
<div class="StateTable-content">
|
||||
<div :class="{ 'item': true, 'active': item.active }" v-for="(item, index) in packageList" :key="index"
|
||||
@click="handleClick(item)">
|
||||
<div class="item-Line" v-show="item.active"></div>
|
||||
<div :class="{ 'menu-item': true, 'active': item.active }">
|
||||
<span class="dynamics-font">{{ item.name }}</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, getCurrentInstance, nextTick, defineProps, toRefs } from "vue";
|
||||
|
||||
|
||||
const props = defineProps<{ packageList: any }>()
|
||||
|
||||
const emit = defineEmits(['changePackage'])
|
||||
|
||||
const { packageList } = toRefs(props)
|
||||
|
||||
const handleClick = (item: any) => {
|
||||
emit("changePackage", item)
|
||||
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.StateTable-container {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
background-color: #00000000;
|
||||
}
|
||||
|
||||
.StateTable-container .StateTable-content {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 14px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.StateTable-container .StateTable-content .backgroundLine {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 1px;
|
||||
background-color: #ffffffa0;
|
||||
}
|
||||
|
||||
.StateTable-container .StateTable-content .item {
|
||||
right: 20px;
|
||||
position: relative;
|
||||
height: 30px;
|
||||
background-color: #ffffff00;
|
||||
padding: 0 5px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.StateTable-container .StateTable-content .item .tableName {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
color: #ff0000;
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.StateTable-container .StateTable-content .item .clickBox {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
top: -1.25rem;
|
||||
left: -3.5rem;
|
||||
height: 3rem;
|
||||
width: 5rem;
|
||||
}
|
||||
|
||||
.StateTable-container .StateTable-content .item .tableName div {
|
||||
font-size: .7rem;
|
||||
white-space: nowrap;
|
||||
transition: all .3s;
|
||||
}
|
||||
|
||||
.StateTable-container .StateTable-content .item .item-Line {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
left: -3px;
|
||||
width: calc(.5rem + 6px);
|
||||
height: calc(.5rem + 6px);
|
||||
}
|
||||
|
||||
|
||||
.StateTable-container .StateTable-content .item.active {
|
||||
color: rgb(255, 255, 255);
|
||||
}
|
||||
|
||||
|
||||
.StateTable-container .StateTable-content .item .font-active {
|
||||
color: rgb(255, 255, 255);
|
||||
font-size: 0.9rem !important;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
position: relative;
|
||||
text-shadow: #000000 1px 1px 10px;
|
||||
|
||||
}
|
||||
|
||||
.menu-item.active::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -10px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background-color: #ff0000;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 10px;
|
||||
display: flex;
|
||||
color: rgb(255, 255, 255);
|
||||
flex-direction: row;
|
||||
font-weight: 600;
|
||||
text-shadow: 1px 1px 10px #000000;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
</style>
|
||||
277
src/components/public/PackageOptionBar.vue
Normal file
277
src/components/public/PackageOptionBar.vue
Normal file
@ -0,0 +1,277 @@
|
||||
<template>
|
||||
|
||||
|
||||
<div class="ColorBar-container" style="opacity: 1; transform: none;">
|
||||
<div class="ColorBar-content dynamics-font" ref="Content">
|
||||
|
||||
<div :class="{ Bar: true, BarLine: item.active && packageOptionType.type === 'fixed' }"
|
||||
|
||||
v-for="(item, index) in PackageOptionList" :key="index" @click="handleSelectPackage(item)">
|
||||
<div v-show="packageOptionType.type === 'free'" style="margin-right: 10px;">
|
||||
<el-checkbox disabled v-model="item.active" />
|
||||
</div>
|
||||
<span class="dynamics-font">{{ item.name }} </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="to_right jiantou" v-show="PackageOptionList.length > 0 && isJiantou"></div>
|
||||
</div>
|
||||
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import { ref, onMounted, getCurrentInstance, nextTick, toRefs, watch, computed } from "vue";
|
||||
|
||||
const props = defineProps<{ PackageOptionList: any, packageOptionType: any }>()
|
||||
|
||||
const emit = defineEmits(['changePackageOptionFixed', 'changePackageOptionFree', 'changePackage'])
|
||||
|
||||
const { PackageOptionList, packageOptionType } = toRefs(props)
|
||||
|
||||
const packageId = ref("")
|
||||
|
||||
const Content = ref()
|
||||
|
||||
const isJiantou = ref(true)
|
||||
|
||||
//点击套餐或者部件名
|
||||
const handleSelectPackage = (item: any) => {
|
||||
const isFixed = packageOptionType.value.type === "fixed";
|
||||
if (item.active) {
|
||||
item.active = false;
|
||||
|
||||
|
||||
|
||||
} else {
|
||||
const isFixed = packageOptionType.value.type === "fixed";
|
||||
|
||||
if (isFixed) {
|
||||
PackageOptionList.value.forEach((element: any) => {
|
||||
element.active = false;
|
||||
});
|
||||
item.active = true;
|
||||
}
|
||||
else {
|
||||
item.active = true;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
if (isFixed) {
|
||||
|
||||
|
||||
emit("changePackageOptionFixed", { packageOptionId: item.id, active: item.active })
|
||||
|
||||
}
|
||||
else {
|
||||
const selectedList = PackageOptionList.value.filter((element: any) => element.active) //筛选出为true的选项
|
||||
emit("changePackageOptionFree", { packageOptionId: item.id, active: item.active, selectedList: selectedList })
|
||||
}
|
||||
|
||||
|
||||
setTimeout(() => {
|
||||
packageId.value = item.id;
|
||||
}, 30);
|
||||
|
||||
};
|
||||
|
||||
|
||||
watch(PackageOptionList, (newVal, oldVal) => {
|
||||
setTimeout(() => {
|
||||
if (isScrollbarVisible(Content.value)) {
|
||||
console.log("滚动条已出现");
|
||||
isJiantou.value = true
|
||||
} else {
|
||||
console.log("滚动条未出现");
|
||||
isJiantou.value = false
|
||||
}
|
||||
}, 200);
|
||||
|
||||
})
|
||||
|
||||
|
||||
|
||||
function isScrollbarVisible(element) {
|
||||
const overflowX = window.getComputedStyle(element).overflowX;
|
||||
console.log(overflowX);
|
||||
|
||||
return (
|
||||
(overflowX === "scroll" || overflowX === "auto") &&
|
||||
element.scrollWidth > element.clientWidth
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ColorBar-container {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
background-color: #ffffff00;
|
||||
margin-top: 10px;
|
||||
|
||||
}
|
||||
|
||||
.ColorBar-container .ColorBar-content {
|
||||
/* width: 100%; */
|
||||
position: relative;
|
||||
/* margin-bottom: 5vmin; */
|
||||
background-color: rgba(255, 255, 255, 0);
|
||||
align-items: center;
|
||||
border-top: 1px solid #ccc;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.ColorBar-container .ColorBar-content .Bar {
|
||||
min-width: 70px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
margin: 4px;
|
||||
padding: 0px 6px;
|
||||
/* border-radius: 1.5rem; */
|
||||
background-size: 100% 100%;
|
||||
border: #6e6c6c9c 1px solid;
|
||||
display: flex;
|
||||
background-color: #43434366;
|
||||
|
||||
|
||||
}
|
||||
|
||||
.ColorBar-container .ColorBar-content .BarLine {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
margin: 4px;
|
||||
padding: 0px 6px;
|
||||
/* border-radius: 1.5rem; */
|
||||
background-size: 100% 100%;
|
||||
border: #eb0000 1px solid !important;
|
||||
background-color: #ff00008f
|
||||
}
|
||||
|
||||
.ColorBar-container .ColorBar-content .Bar span {
|
||||
display: block;
|
||||
line-height: 2rem;
|
||||
text-align: center;
|
||||
|
||||
white-space: nowrap;
|
||||
/* 禁止换行 */
|
||||
width: 100%;
|
||||
/* 设置宽度适配父容器 */
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
|
||||
.ColorBar1-container {
|
||||
position: absolute;
|
||||
bottom: 6%;
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
justify-content: space-evenly;
|
||||
|
||||
}
|
||||
|
||||
.ColorBar1-container .ColorBar-content {
|
||||
position: relative;
|
||||
margin-bottom: 5vmin;
|
||||
height: 3rem;
|
||||
background-color: #ccc3;
|
||||
/* border-radius: 1.5rem; */
|
||||
|
||||
display: flex;
|
||||
flex: row;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
border-radius: 5px;
|
||||
|
||||
|
||||
}
|
||||
|
||||
.ColorBar1-container .ColorBar-content .Bar {
|
||||
pointer-events: all;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
margin: .5rem;
|
||||
width: 4rem;
|
||||
height: 2rem;
|
||||
/* border-radius: 1.5rem; */
|
||||
background-size: 100% 100%;
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.ColorBar1-container .ColorBar-content .BarLine {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
left: -3px;
|
||||
width: calc(4rem + 6px);
|
||||
height: calc(2rem + 6px);
|
||||
box-shadow: 0 0 0 1px #fff;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.ColorBar1-container .ColorBar-content .Bar span {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
line-height: 2rem;
|
||||
text-align: center;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
/*箭头向右*/
|
||||
.to_right {
|
||||
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 10px solid #007400;
|
||||
border-top: 10px solid transparent;
|
||||
border-bottom: 10px solid transparent;
|
||||
}
|
||||
|
||||
.jiantou {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 65px;
|
||||
animation: move-left-right 2s linear infinite;
|
||||
z-index: 999;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:deep(.el-checkbox__input.is-disabled.is-checked .el-checkbox__inner:after)
|
||||
{
|
||||
border-color: red;
|
||||
}
|
||||
|
||||
@keyframes move-left-right {
|
||||
0% {
|
||||
transform: translateX(0px);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateX(-20px);
|
||||
/* 向右移动的距离 */
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(0px);
|
||||
/* 回到起始位置 */
|
||||
}
|
||||
}
|
||||
</style>
|
||||
57
src/components/public/PanTarget.vue
Normal file
57
src/components/public/PanTarget.vue
Normal file
@ -0,0 +1,57 @@
|
||||
<template>
|
||||
|
||||
<div class="slot pan-target" >
|
||||
<slot name="pan-target">
|
||||
<div id="default-pan-target">
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, getCurrentInstance, nextTick, toRefs } from "vue";
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.slot.pan-target {
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate3d(-50%, -50%, 0);
|
||||
background-color: transparent;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
pointer-events: none;
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
.slot {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
slot {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.slot>* {
|
||||
pointer-events: initial;
|
||||
}
|
||||
|
||||
#default-pan-target {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid white;
|
||||
box-shadow: 0px 0px 2px 1px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
</style>
|
||||
75
src/components/public/ToTip.vue
Normal file
75
src/components/public/ToTip.vue
Normal file
@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div class="to-tip-container">
|
||||
<div class="to-tip-box" v-show="isTipVisible">
|
||||
<div class="to-tip-content" @click="toTipVisibleFunc">
|
||||
<img src="../../assets/img/bangzhu1.png" alt="" v-show="nextNum">
|
||||
<img src="../../assets/img/bangzhu2.png" alt="" v-show="!nextNum">
|
||||
</div>
|
||||
<div class="next arrow-right" @click="nextFunc"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { toRefs, onUnmounted, computed, ref } from 'vue'
|
||||
|
||||
const props = defineProps({ visible: Boolean })
|
||||
const { visible } = toRefs(props)
|
||||
const emit = defineEmits(['update:visible'])
|
||||
const isTipVisible = computed(() => visible.value);
|
||||
|
||||
const nextNum=ref(true)
|
||||
|
||||
const toTipVisibleFunc = () => {
|
||||
emit("update:visible", false)
|
||||
}
|
||||
|
||||
const nextFunc=()=>{
|
||||
|
||||
nextNum.value=!nextNum.value
|
||||
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.to-tip-box {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: #41414131;
|
||||
z-index: 1020;
|
||||
|
||||
}
|
||||
|
||||
.to-tip-content {
|
||||
width: 80%;
|
||||
max-width: 600px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 89;
|
||||
}
|
||||
|
||||
.to-tip-content img {
|
||||
width: 100%;
|
||||
}
|
||||
.arrow-right {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 10px solid transparent;
|
||||
border-bottom: 10px solid transparent;
|
||||
border-left: 15px solid black;
|
||||
}
|
||||
.next{
|
||||
position: absolute;
|
||||
right: 0px;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 89;
|
||||
}
|
||||
</style>
|
||||
352
src/components/public/Tooltip.vue
Normal file
352
src/components/public/Tooltip.vue
Normal file
@ -0,0 +1,352 @@
|
||||
<template>
|
||||
<div
|
||||
class="tooltip-wrapper"
|
||||
@mouseenter="showTooltip"
|
||||
@mouseleave="hideTooltip"
|
||||
ref="triggerRef"
|
||||
>
|
||||
<!-- 触发元素插槽 -->
|
||||
<slot></slot>
|
||||
|
||||
<!-- 提示框 -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="visible"
|
||||
ref="tooltipRef"
|
||||
class="tooltip-popper"
|
||||
:class="[`tooltip-${placement}`, themeClass]"
|
||||
:style="tooltipStyleComputed"
|
||||
>
|
||||
<div class="tooltip-content">
|
||||
<slot name="content">
|
||||
|
||||
{{ content }}</slot>
|
||||
</div>
|
||||
<div class="tooltip-arrow" :class="`arrow-${placement}`"></div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, onMounted, onUnmounted, type CSSProperties } from 'vue'
|
||||
|
||||
// 定义Props类型
|
||||
interface Props {
|
||||
content?: string
|
||||
placement?: 'top' | 'bottom' | 'left' | 'right' | 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end'
|
||||
theme?: 'dark' | 'light'
|
||||
disabled?: boolean
|
||||
showDelay?: number
|
||||
hideDelay?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
// Props定义
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
content: '',
|
||||
placement: 'top',
|
||||
theme: 'dark',
|
||||
disabled: false,
|
||||
showDelay: 100,
|
||||
hideDelay: 100,
|
||||
offset: 8
|
||||
})
|
||||
|
||||
// 响应式数据
|
||||
const visible = ref(false)
|
||||
const triggerRef = ref<HTMLElement>()
|
||||
const tooltipRef = ref<HTMLElement>()
|
||||
const showTimer = ref<number>()
|
||||
const hideTimer = ref<number>()
|
||||
|
||||
// 计算样式类
|
||||
const themeClass = computed(() => `tooltip-${props.theme}`)
|
||||
|
||||
// 提示框位置样式
|
||||
const tooltipStyle = ref({
|
||||
position: 'absolute' as const,
|
||||
top: '0px',
|
||||
left: '0px',
|
||||
zIndex: 2000
|
||||
})
|
||||
|
||||
// 计算样式(修复类型错误)
|
||||
const tooltipStyleComputed = computed((): CSSProperties => ({
|
||||
position: tooltipStyle.value.position,
|
||||
top: tooltipStyle.value.top,
|
||||
left: tooltipStyle.value.left,
|
||||
zIndex: tooltipStyle.value.zIndex
|
||||
}))
|
||||
|
||||
// 显示提示框
|
||||
const showTooltip = () => {
|
||||
if (props.disabled) return
|
||||
|
||||
clearTimeout(hideTimer.value)
|
||||
showTimer.value = window.setTimeout(() => {
|
||||
visible.value = true
|
||||
nextTick(() => {
|
||||
updatePosition()
|
||||
})
|
||||
}, props.showDelay)
|
||||
}
|
||||
|
||||
// 隐藏提示框
|
||||
const hideTooltip = () => {
|
||||
clearTimeout(showTimer.value)
|
||||
hideTimer.value = window.setTimeout(() => {
|
||||
visible.value = false
|
||||
}, props.hideDelay)
|
||||
}
|
||||
|
||||
// 更新提示框位置
|
||||
const updatePosition = () => {
|
||||
if (!triggerRef.value || !tooltipRef.value) return
|
||||
|
||||
const triggerRect = triggerRef.value.getBoundingClientRect()
|
||||
const tooltipRect = tooltipRef.value.getBoundingClientRect()
|
||||
const { placement, offset } = props
|
||||
|
||||
let top = 0
|
||||
let left = 0
|
||||
|
||||
switch (placement) {
|
||||
case 'top':
|
||||
top = triggerRect.top - tooltipRect.height - offset
|
||||
left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2
|
||||
break
|
||||
case 'top-start':
|
||||
top = triggerRect.top - tooltipRect.height - offset
|
||||
left = triggerRect.left
|
||||
break
|
||||
case 'top-end':
|
||||
top = triggerRect.top - tooltipRect.height - offset
|
||||
left = triggerRect.right - tooltipRect.width
|
||||
break
|
||||
case 'bottom':
|
||||
top = triggerRect.bottom + offset
|
||||
left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2
|
||||
break
|
||||
case 'bottom-start':
|
||||
top = triggerRect.bottom + offset
|
||||
left = triggerRect.left
|
||||
break
|
||||
case 'bottom-end':
|
||||
top = triggerRect.bottom + offset
|
||||
left = triggerRect.right - tooltipRect.width
|
||||
break
|
||||
case 'left':
|
||||
top = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2
|
||||
left = triggerRect.left - tooltipRect.width - offset
|
||||
break
|
||||
case 'right':
|
||||
top = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2
|
||||
left = triggerRect.right + offset
|
||||
break
|
||||
}
|
||||
|
||||
// 边界检测和调整
|
||||
const viewport = {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight
|
||||
}
|
||||
|
||||
// 水平边界检测
|
||||
if (left < 0) {
|
||||
left = 8
|
||||
} else if (left + tooltipRect.width > viewport.width) {
|
||||
left = viewport.width - tooltipRect.width - 8
|
||||
}
|
||||
|
||||
// 垂直边界检测
|
||||
if (top < 0) {
|
||||
top = 8
|
||||
} else if (top + tooltipRect.height > viewport.height) {
|
||||
top = viewport.height - tooltipRect.height - 8
|
||||
}
|
||||
|
||||
tooltipStyle.value = {
|
||||
position: 'absolute' as const,
|
||||
top: `${top + window.scrollY}px`,
|
||||
left: `${left + window.scrollX}px`,
|
||||
zIndex: 2000
|
||||
}
|
||||
}
|
||||
|
||||
// 监听窗口大小变化
|
||||
const handleResize = () => {
|
||||
if (visible.value) {
|
||||
updatePosition()
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', handleResize)
|
||||
window.addEventListener('scroll', handleResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimeout(showTimer.value)
|
||||
clearTimeout(hideTimer.value)
|
||||
window.removeEventListener('resize', handleResize)
|
||||
window.removeEventListener('scroll', handleResize)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tooltip-wrapper {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.tooltip-popper {
|
||||
position: absolute;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
min-height: 10px;
|
||||
word-wrap: break-word;
|
||||
visibility: visible;
|
||||
transform-origin: center bottom;
|
||||
animation: tooltip-fade-in 0.15s ease-out;
|
||||
}
|
||||
|
||||
.tooltip-content {
|
||||
padding: 8px 12px;
|
||||
max-width: 300px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* 深色主题 */
|
||||
.tooltip-dark {
|
||||
background: rgba(48, 49, 51, 0.9);
|
||||
color: #ffffff;
|
||||
border: 1px solid #4a4a4a;
|
||||
}
|
||||
|
||||
.tooltip-dark .tooltip-content {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* 浅色主题 */
|
||||
.tooltip-light {
|
||||
background: #ffffff;
|
||||
color: #606266;
|
||||
border: 1px solid #e4e7ed;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.tooltip-light .tooltip-content {
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
/* 箭头样式 */
|
||||
.tooltip-arrow {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
/* 深色主题箭头 */
|
||||
.tooltip-dark .arrow-top {
|
||||
bottom: -6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-width: 6px 6px 0 6px;
|
||||
border-color: rgba(48, 49, 51, 0.9) transparent transparent transparent;
|
||||
}
|
||||
|
||||
.tooltip-dark .arrow-bottom {
|
||||
top: -6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-width: 0 6px 6px 6px;
|
||||
border-color: transparent transparent rgba(48, 49, 51, 0.9) transparent;
|
||||
}
|
||||
|
||||
.tooltip-dark .arrow-left {
|
||||
right: -6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border-width: 6px 0 6px 6px;
|
||||
border-color: transparent transparent transparent rgba(48, 49, 51, 0.9);
|
||||
}
|
||||
|
||||
.tooltip-dark .arrow-right {
|
||||
left: -6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border-width: 6px 6px 6px 0;
|
||||
border-color: transparent rgba(48, 49, 51, 0.9) transparent transparent;
|
||||
}
|
||||
|
||||
/* 浅色主题箭头 */
|
||||
.tooltip-light .arrow-top {
|
||||
bottom: -6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-width: 6px 6px 0 6px;
|
||||
border-color: #ffffff transparent transparent transparent;
|
||||
}
|
||||
|
||||
.tooltip-light .arrow-bottom {
|
||||
top: -6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-width: 0 6px 6px 6px;
|
||||
border-color: transparent transparent #ffffff transparent;
|
||||
}
|
||||
|
||||
.tooltip-light .arrow-left {
|
||||
right: -6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border-width: 6px 0 6px 6px;
|
||||
border-color: transparent transparent transparent #ffffff;
|
||||
}
|
||||
|
||||
.tooltip-light .arrow-right {
|
||||
left: -6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border-width: 6px 6px 6px 0;
|
||||
border-color: transparent #ffffff transparent transparent;
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
@keyframes tooltip-fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* 不同位置的箭头调整 */
|
||||
.tooltip-top-start .arrow-top,
|
||||
.tooltip-top-end .arrow-top {
|
||||
left: 12px;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.tooltip-top-end .arrow-top {
|
||||
left: auto;
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
.tooltip-bottom-start .arrow-bottom,
|
||||
.tooltip-bottom-end .arrow-bottom {
|
||||
left: 12px;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.tooltip-bottom-end .arrow-bottom {
|
||||
left: auto;
|
||||
right: 12px;
|
||||
}
|
||||
</style>
|
||||
160
src/composables/useInspectorPanel.ts
Normal file
160
src/composables/useInspectorPanel.ts
Normal file
@ -0,0 +1,160 @@
|
||||
import { ref, reactive } from 'vue'
|
||||
|
||||
// 通用的 Inspector Panel 状态管理
|
||||
export function useInspectorPanel() {
|
||||
// 组件状态
|
||||
const isExpanded = ref(true)
|
||||
const isEnabled = ref(true)
|
||||
|
||||
// 通用的处理启用状态变化
|
||||
const createEnabledChangeHandler = (componentName: string, customHandler?: (enabled: boolean) => void) => {
|
||||
return (enabled: boolean) => {
|
||||
isEnabled.value = enabled
|
||||
console.log(`${componentName} 启用状态已更改:`, enabled)
|
||||
|
||||
if (customHandler) {
|
||||
customHandler(enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 通用的处理操作按钮点击
|
||||
const createActionClickHandler = (resetFn: () => void, showMoreFn?: () => void) => {
|
||||
return (actionName: string) => {
|
||||
switch (actionName) {
|
||||
case 'reset':
|
||||
resetFn()
|
||||
break
|
||||
case 'more':
|
||||
if (showMoreFn) {
|
||||
showMoreFn()
|
||||
} else {
|
||||
console.log('显示更多选项')
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 通用的显示更多选项
|
||||
const createShowMoreHandler = (componentName: string) => {
|
||||
return () => {
|
||||
console.log(`显示更多${componentName}选项`)
|
||||
}
|
||||
}
|
||||
|
||||
// 通用的属性更新函数
|
||||
const createPropertyUpdater = <T extends Record<string, any>>(
|
||||
data: T,
|
||||
componentName: string,
|
||||
customHandler?: (property: keyof T, value: any) => void
|
||||
) => {
|
||||
return (property: keyof T, value: any) => {
|
||||
(data as any)[property] = value
|
||||
console.log(`${componentName} ${String(property)} 已更新:`, value)
|
||||
|
||||
if (customHandler) {
|
||||
customHandler(property, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isExpanded,
|
||||
isEnabled,
|
||||
createEnabledChangeHandler,
|
||||
createActionClickHandler,
|
||||
createShowMoreHandler,
|
||||
createPropertyUpdater
|
||||
}
|
||||
}
|
||||
|
||||
// 常用的默认值配置
|
||||
export const DEFAULT_COMPONENT_CONFIGS = {
|
||||
material: {
|
||||
type: 'PBRMaterial' as const,
|
||||
albedoColor: { r: 1, g: 1, b: 1 },
|
||||
albedoTexture: null,
|
||||
alpha: 1.0,
|
||||
transparencyMode: 0
|
||||
},
|
||||
mesh: {
|
||||
isPickable: true,
|
||||
checkCollisions: false
|
||||
},
|
||||
light: {
|
||||
type: 'directional',
|
||||
color: '#ffffff',
|
||||
intensity: 1
|
||||
},
|
||||
transform: {
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
},
|
||||
camera: {
|
||||
arcRotate: {
|
||||
target: { x: 0, y: 0, z: 0 },
|
||||
lowerBetaLimit: 0.1,
|
||||
upperBetaLimit: Math.PI - 0.1,
|
||||
lowerRadiusLimit: 1,
|
||||
upperRadiusLimit: 100,
|
||||
wheelDeltaPercentage: 0.01,
|
||||
panningSensibility: 1000,
|
||||
inertia: 0.9,
|
||||
autoRotation: false
|
||||
},
|
||||
universal: {
|
||||
speed: 1,
|
||||
angularSensibility: 2000,
|
||||
keysUp: 'W',
|
||||
keysDown: 'S',
|
||||
keysLeft: 'A',
|
||||
keysRight: 'D'
|
||||
},
|
||||
vr: {
|
||||
angularSensibility: 2000,
|
||||
lowerBetaLimit: 0.1,
|
||||
upperBetaLimit: Math.PI - 0.1,
|
||||
lowerRadiusLimit: 1,
|
||||
upperRadiusLimit: 100,
|
||||
wheelDeltaPercentage: 0.01
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 颜色转换工具函数
|
||||
export const colorUtils = {
|
||||
// RGB 转十六进制
|
||||
rgbToHex: (color: { r: number; g: number; b: number }): string => {
|
||||
const r = Math.round(color.r * 255).toString(16).padStart(2, '0')
|
||||
const g = Math.round(color.g * 255).toString(16).padStart(2, '0')
|
||||
const b = Math.round(color.b * 255).toString(16).padStart(2, '0')
|
||||
return `#${r}${g}${b}`
|
||||
},
|
||||
|
||||
// 十六进制转 RGB
|
||||
hexToRgb: (hex: string): { r: number; g: number; b: number } => {
|
||||
const r = parseInt(hex.slice(1, 3), 16) / 255
|
||||
const g = parseInt(hex.slice(3, 5), 16) / 255
|
||||
const b = parseInt(hex.slice(5, 7), 16) / 255
|
||||
return { r, g, b }
|
||||
},
|
||||
|
||||
// RGB 转十六进制字符串(用于 Light 组件)
|
||||
rgbToHexString: (color: { r: number; g: number; b: number }): string => {
|
||||
const r = Math.round((color.r || 1) * 255)
|
||||
const g = Math.round((color.g || 1) * 255)
|
||||
const b = Math.round((color.b || 1) * 255)
|
||||
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`
|
||||
},
|
||||
|
||||
// 十六进制字符串转 RGB(用于 Light 组件)
|
||||
hexStringToRgb: (hex: string): { r: number; g: number; b: number } => {
|
||||
const cleanHex = hex.replace('#', '')
|
||||
const r = parseInt(cleanHex.substr(0, 2), 16) / 255
|
||||
const g = parseInt(cleanHex.substr(2, 2), 16) / 255
|
||||
const b = parseInt(cleanHex.substr(4, 2), 16) / 255
|
||||
return { r, g, b }
|
||||
}
|
||||
}
|
||||
8
src/env.d.ts
vendored
Normal file
8
src/env.d.ts
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
35
src/main.ts
Normal file
35
src/main.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import '@/assets/style/normal.css'
|
||||
import '@/assets/style/components.css'
|
||||
import '@/assets/style/global.css'
|
||||
import '@/assets/style/media.css'
|
||||
import '@/assets/style/style.css'
|
||||
import '@/script/hotspot/style/point.css'
|
||||
|
||||
import { Slider } from 'vant'
|
||||
import pinia from '@/stores'
|
||||
import { MainApp } from '@/script/core'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
|
||||
|
||||
|
||||
// 创建Vue应用实例
|
||||
const app = createApp(App)
|
||||
app.use(Slider)
|
||||
app.use(pinia)
|
||||
app.use(ElementPlus)
|
||||
app.mount('#app')
|
||||
|
||||
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 创建并导出MainApp实例
|
||||
export const mainApp = new MainApp()
|
||||
52
src/script/MainEditor.ts
Normal file
52
src/script/MainEditor.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { AppConfig, MainApp } from '@/script/core'
|
||||
class MainEditor {
|
||||
mainApp!: MainApp
|
||||
constructor() {
|
||||
this.mainApp = new MainApp()
|
||||
|
||||
}
|
||||
async Awake() {
|
||||
this.mainApp.Awake()
|
||||
|
||||
}
|
||||
|
||||
loadModel(config: AppConfig){
|
||||
this.mainApp.loadAConfig(config)
|
||||
this.mainApp.loadModel()
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @param from 从哪开始
|
||||
* @param to 从哪结束
|
||||
* @param speed 速度
|
||||
* @returns
|
||||
*/
|
||||
|
||||
|
||||
|
||||
|
||||
//分辨率等级
|
||||
setLevel(level: number) {
|
||||
// this.mainApp.appEngin.object.setHardwareScalingLevel(level)
|
||||
}
|
||||
|
||||
onClickModel(callback: Function) {
|
||||
// this.mainApp.appRay.onPointerDown(callback)
|
||||
}
|
||||
// 在MainEditor.ts中完善dispose方法
|
||||
async dispose() {
|
||||
|
||||
// 释放引擎资源
|
||||
await this.mainApp.dispose();
|
||||
|
||||
}
|
||||
|
||||
|
||||
clearCache(){
|
||||
this.mainApp.clearCache()
|
||||
}
|
||||
}
|
||||
export default MainEditor
|
||||
9
src/script/base/Monobehiver.ts
Normal file
9
src/script/base/Monobehiver.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { MainApp } from 'script/core'
|
||||
|
||||
export class Monobehiver {
|
||||
mainApp!: MainApp
|
||||
constructor(mainApp: MainApp) {
|
||||
this.mainApp = mainApp
|
||||
}
|
||||
Awake() {}
|
||||
}
|
||||
1
src/script/base/index.ts
Normal file
1
src/script/base/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { Monobehiver } from './Monobehiver'
|
||||
20
src/script/core.ts
Normal file
20
src/script/core.ts
Normal file
@ -0,0 +1,20 @@
|
||||
export { AppCamera } from 'script/public/AppCamera'
|
||||
export { AppEngin } from 'script/public/AppEngin'
|
||||
export { AppEnv } from 'script/public/AppEnv'
|
||||
export { AppModel } from 'script/public/AppModel'
|
||||
export { AppScene } from 'script/public/AppScene'
|
||||
export { AppLight } from 'script/public/AppLight'
|
||||
export { MainApp } from 'script/public/MainApp'
|
||||
export { AppConfig } from 'script/public/AppConfig'
|
||||
export { AppRay } from 'script/public/AppRay'
|
||||
export { GameManager } from 'script/public/GameManager'
|
||||
export { AppDom } from 'script/public/AppDom'
|
||||
export { AppGround } from 'script/public/AppGround'
|
||||
export { AppTween } from 'script/public/AppTween'
|
||||
export { BoxHelper } from 'script/utils/BoxHelper'
|
||||
export { Dimensions } from 'script/utils/Dimensions'
|
||||
export { AppHotspot } from 'script/public/AppHotspot'
|
||||
export { ForMatTime } from 'script/utils/ForMatTime'
|
||||
export { AppFPS } from 'script/public/AppFPS'
|
||||
export { AppGlowLayer } from 'script/public/AppGlowLayer'
|
||||
export * from 'script/utils'
|
||||
115
src/script/hotspot/HotSpot.ts
Normal file
115
src/script/hotspot/HotSpot.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import { Point_Pool } from './Point_Pool'
|
||||
import { Point } from './Point'
|
||||
import {
|
||||
AbstractMesh,
|
||||
Camera,
|
||||
Engine,
|
||||
Matrix,
|
||||
Scene,
|
||||
SpriteManager,
|
||||
Vector3
|
||||
} from '@babylonjs/core'
|
||||
import { HotspotPrams } from './HotspotPrams'
|
||||
import { GameManager, MainApp } from '@/script/core'
|
||||
|
||||
class HotSpot {
|
||||
_point_Pool!: Point_Pool
|
||||
body!: HTMLElement
|
||||
_camera!: Camera
|
||||
mainApp!: MainApp
|
||||
spriteManagerTrees!: SpriteManager
|
||||
constructor(mainAPP: MainApp) {
|
||||
this.mainApp = mainAPP
|
||||
}
|
||||
Awake() {
|
||||
this._camera = this.mainApp.appCamera.object
|
||||
this._point_Pool = new Point_Pool()
|
||||
this.body = this.mainApp.appDom.body
|
||||
this.spriteManagerTrees = new SpriteManager(
|
||||
'treesManager',
|
||||
`${import.meta.env.VITE_PUBLIC_URL}resources/btn_热点.png`,
|
||||
0,
|
||||
0,
|
||||
this.mainApp.appScene.object
|
||||
)
|
||||
}
|
||||
vector!: Vector3
|
||||
halfW!: number
|
||||
halfH!: number
|
||||
annotation!: HTMLElement
|
||||
modedl!: AbstractMesh
|
||||
//创建圆点并且生成事件 类型
|
||||
Point_Event(prams: HotspotPrams) {
|
||||
|
||||
|
||||
let { position, disposition, onload, onCallBack } = prams
|
||||
let _point = new Point(this.spriteManagerTrees)
|
||||
_point.init(position, disposition, onload, onCallBack)
|
||||
|
||||
this.body.appendChild(_point.annotation)
|
||||
this._point_Pool.Add_point(_point)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this._point_Pool) {
|
||||
for (let i = 0, item; (item = this._point_Pool.points[i++]); ) {
|
||||
this.updateAnnotationOpacity(item)
|
||||
this.updateScreenPosition(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Enable_All(visible: boolean) {
|
||||
if (this._point_Pool) {
|
||||
this._point_Pool.Enable_All(visible)
|
||||
}
|
||||
}
|
||||
|
||||
updateAnnotationOpacity(point: Point) {
|
||||
point.spriteBehindObject =
|
||||
this.distance(this._camera.position, point.sprite.position) >
|
||||
this.distance(this._camera.position, point.disposition)
|
||||
point.sprite.size = point.spriteBehindObject ? 0 : 1
|
||||
|
||||
// this.distance(this._camera.position, Vector3.Zero())
|
||||
}
|
||||
|
||||
distance(v1: Vector3, v2: Vector3) {
|
||||
let x = Math.pow(v1.x - v2.x, 2)
|
||||
let y = Math.pow(v1.y - v2.y, 2)
|
||||
let z = Math.pow(v1.z - v2.z, 2)
|
||||
|
||||
let distance = Math.sqrt(x + y + z)
|
||||
return distance
|
||||
}
|
||||
|
||||
updateScreenPosition(point: Point) {
|
||||
this.halfW = document.body.offsetWidth
|
||||
this.halfH = document.body.offsetHeight
|
||||
let vector = this.worldToScreen(
|
||||
point.position,
|
||||
this.mainApp.appCamera.object,
|
||||
this.mainApp.appScene.object,
|
||||
this.mainApp.appEngin.object
|
||||
)
|
||||
point.annotation.style.left = vector.x / 2 + 'px'
|
||||
point.annotation.style.top = vector.y / 2 + 'px'
|
||||
point.annotation.style.opacity = point.spriteBehindObject ? '0' : '1'
|
||||
point.annotation.style.pointerEvents = point.spriteBehindObject
|
||||
? 'none'
|
||||
: 'auto'
|
||||
}
|
||||
|
||||
worldToScreen(point: Vector3, camera: Camera, scene: Scene, engine: Engine) {
|
||||
return Vector3.Project(
|
||||
point,
|
||||
Matrix.Identity(),
|
||||
scene.getTransformMatrix(),
|
||||
camera.viewport.toGlobal(
|
||||
engine.getRenderWidth(),
|
||||
engine.getRenderHeight()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
export { HotSpot }
|
||||
18
src/script/hotspot/HotspotPrams.ts
Normal file
18
src/script/hotspot/HotspotPrams.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { Vector3 } from '@babylonjs/core'
|
||||
export class HotspotPrams {
|
||||
constructor(
|
||||
position: Vector3,
|
||||
disposition: Vector3,
|
||||
onload: Function,
|
||||
onCallBack: Function,
|
||||
) {
|
||||
this.position = position
|
||||
this.disposition = disposition
|
||||
this.onload = onload
|
||||
this.onCallBack = onCallBack
|
||||
}
|
||||
position!: Vector3
|
||||
disposition!: Vector3
|
||||
onload!: Function
|
||||
onCallBack!: Function
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user