feat: 模型库页面添加拖拽打开模型功能并优化提示文字显示

- 添加拖拽文件到界面打开模型功能
- 添加拖拽时的视觉反馈效果(蓝色虚线边框和背景高亮)
- 重构文件加载逻辑,提取loadModelFiles函数复用代码
- 支持拖拽和点击两种方式打开模型文件
- 优化空状态提示文字颜色,从次要文字色改为主文字色,提升可读性
- 添加文件格式验证,只加载支持的3D模型格式

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
sladro 2025-10-11 14:33:35 +08:00
parent a8cf0d1329
commit 5ba9231473

View File

@ -1,106 +1,425 @@
<script>
export default {
name: 'ModelGalleryPage'
}
</script>
<template>
<div class="gallery-container">
<!-- 页面头部 -->
<div class="gallery-header">
<div class="header-content">
<div class="header-icon">
<i class="fas fa-images"></i>
<div class="model-gallery-page">
<!-- 工具栏 -->
<div class="toolbar">
<button class="toolbar-btn toolbar-btn-primary" @click="openModel">
<i class="fas fa-folder-open"></i>
<span>打开模型</span>
</button>
</div>
<!-- 主内容区域 -->
<div class="main-content">
<!-- 模型树侧边栏 -->
<div v-if="hasModel && modelTree" class="model-tree-sidebar">
<div class="sidebar-header">
<i class="fas fa-sitemap"></i>
<span>模型结构树</span>
</div>
<div class="header-text">
<h2>模型库</h2>
<p>浏览和管理您的模型资源</p>
<div class="tree-container">
<ModelTreeNode :node="modelTree" />
</div>
</div>
<!-- 3D查看器容器 -->
<div
class="viewer-wrapper"
:class="{ dragging: isDragging }"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@drop="handleDrop"
>
<div v-if="!hasModel" class="empty-state">
<div class="empty-icon">
<i class="fas fa-cube"></i>
</div>
<h3>未加载模型</h3>
<p>点击"打开模型"按钮选择3D模型文件</p>
<p>或将模型文件拖拽到此处</p>
<p class="supported-formats">支持格式: OBJ, STL, GLTF, GLB, PLY, 3DS, FBX</p>
</div>
<div ref="viewerContainer" class="viewer-container" :class="{ hidden: !hasModel }"></div>
<div v-if="isLoading" class="loading-overlay">
<div class="loading-spinner"></div>
<p>加载模型中...</p>
</div>
</div>
</div>
<!-- 页面内容 -->
<div class="gallery-content">
<div class="placeholder-section">
<div class="placeholder-icon">
<i class="fas fa-cube"></i>
</div>
<h3>模型库功能开发中</h3>
<p>这里将展示所有已保存的模型文件方便您快速查看和管理</p>
<div class="feature-preview">
<div class="preview-item">
<i class="fas fa-folder-open"></i>
<span>模型分类浏览</span>
</div>
<div class="preview-item">
<i class="fas fa-search"></i>
<span>快速搜索定位</span>
</div>
<div class="preview-item">
<i class="fas fa-download"></i>
<span>一键导入导出</span>
</div>
</div>
<!-- 模型信息面板 -->
<div v-if="modelInfo" class="model-info-panel">
<div class="info-item">
<i class="fas fa-file"></i>
<span class="info-label">文件名:</span>
<span class="info-value">{{ modelInfo.fileName }}</span>
</div>
<div class="info-divider"></div>
<div class="info-item">
<i class="fas fa-cube"></i>
<span class="info-label">顶点数:</span>
<span class="info-value">{{ modelInfo.vertices?.toLocaleString() || '-' }}</span>
</div>
<div class="info-divider"></div>
<div class="info-item">
<i class="fas fa-shapes"></i>
<span class="info-label">面数:</span>
<span class="info-value">{{ modelInfo.faces?.toLocaleString() || '-' }}</span>
</div>
<div class="info-divider"></div>
<div class="info-item">
<i class="fas fa-layer-group"></i>
<span class="info-label">网格数:</span>
<span class="info-value">{{ modelInfo.meshes || '-' }}</span>
</div>
</div>
</div>
</template>
<script setup>
// -
import * as OV from 'online-3d-viewer'
import { ref, onMounted, onBeforeUnmount } from 'vue'
import ModelTreeNode from '@/components/ui/ModelTreeNode.vue'
//
const viewerContainer = ref(null)
let viewer = null
//
const hasModel = ref(false)
const isLoading = ref(false)
const modelInfo = ref(null)
const modelTree = ref(null) //
const isDragging = ref(false) //
//
onMounted(() => {
if (viewerContainer.value) {
viewer = new OV.EmbeddedViewer(viewerContainer.value, {
backgroundColor: new OV.RGBAColor(220, 222, 225, 255),
defaultColor: new OV.RGBColor(150, 150, 150),
edgeSettings: new OV.EdgeSettings(false, new OV.RGBColor(80, 80, 80), 1),
onModelLoaded: handleModelLoaded
})
}
})
//
onBeforeUnmount(() => {
if (viewer) {
viewer.Destroy()
viewer = null
}
})
//
const openModel = () => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.obj,.stl,.gltf,.glb,.ply,.3ds,.fbx,.ifc,.off,.3dm'
input.multiple = true
input.onchange = async (e) => {
const files = Array.from(e.target.files)
if (files.length > 0) {
await loadModelFiles(files)
}
}
input.click()
}
//
const loadModelFiles = async (files) => {
isLoading.value = true
hasModel.value = false
modelInfo.value = null
try {
await viewer.LoadModelFromFileList(files)
hasModel.value = true
modelInfo.value = {
fileName: files[0].name
}
} catch (error) {
hasModel.value = false
isLoading.value = false
}
}
//
const supportedExtensions = ['.obj', '.stl', '.gltf', '.glb', '.ply', '.3ds', '.fbx', '.ifc', '.off', '.3dm']
//
const handleDragOver = (e) => {
e.preventDefault()
e.stopPropagation()
isDragging.value = true
}
//
const handleDragLeave = (e) => {
e.preventDefault()
e.stopPropagation()
//
if (e.target === e.currentTarget) {
isDragging.value = false
}
}
//
const handleDrop = async (e) => {
e.preventDefault()
e.stopPropagation()
isDragging.value = false
const files = Array.from(e.dataTransfer.files)
if (files.length === 0) return
//
const validFiles = files.filter((file) => {
const ext = '.' + file.name.split('.').pop().toLowerCase()
return supportedExtensions.includes(ext)
})
if (validFiles.length > 0) {
await loadModelFiles(validFiles)
}
}
//
const buildModelTree = (model) => {
if (!model) return null
//
const buildNodeTree = (node, depth = 0) => {
const nodeName = node.GetName() || (node.HasParent() ? '未命名节点' : '根节点')
const nodeData = {
name: nodeName,
type: 'node',
depth: depth,
children: [],
expanded: depth < 2 //
}
//
for (const childNode of node.GetChildNodes()) {
nodeData.children.push(buildNodeTree(childNode, depth + 1))
}
//
for (const meshIndex of node.GetMeshIndices()) {
const mesh = model.GetMesh(meshIndex)
const meshName = mesh.GetName() || `网格 ${meshIndex}`
nodeData.children.push({
name: meshName,
type: 'mesh',
depth: depth + 1,
meshIndex: meshIndex,
vertexCount: mesh.VertexCount(),
triangleCount: mesh.TriangleCount()
})
}
return nodeData
}
const rootNode = model.GetRootNode()
return buildNodeTree(rootNode)
}
//
const handleModelLoaded = () => {
isLoading.value = false
// canvas
setTimeout(() => {
if (viewer) {
viewer.Resize()
}
}, 100)
//
try {
const model = viewer.GetModel()
if (model) {
let totalVertices = 0
let totalFaces = 0
let meshCount = 0
//
model.EnumerateMeshes((mesh) => {
totalVertices += mesh.VertexCount()
totalFaces += mesh.TriangleCount()
meshCount++
})
if (modelInfo.value) {
modelInfo.value.vertices = totalVertices
modelInfo.value.faces = totalFaces
modelInfo.value.meshes = meshCount
}
//
modelTree.value = buildModelTree(model)
}
} catch (error) {
console.error('获取模型统计信息失败:', error)
}
}
</script>
<style scoped>
.gallery-container {
padding: var(--spacing-2xl);
max-width: 900px;
margin: 0 auto;
.model-gallery-page {
height: 100%;
display: flex;
flex-direction: column;
background: var(--color-bg-primary);
}
.gallery-header {
margin-bottom: var(--spacing-3xl);
/* 主内容区域样式 */
.main-content {
flex: 1;
display: flex;
overflow: hidden;
}
.header-content {
/* 工具栏样式 */
.toolbar {
display: flex;
align-items: center;
gap: var(--spacing-xl);
gap: var(--spacing-sm);
padding: var(--spacing-lg);
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border-primary);
flex-shrink: 0;
}
.header-icon {
width: var(--size-64);
height: var(--size-64);
background: var(--color-primary-gradient);
border-radius: var(--size-border-radius-card);
.toolbar-btn {
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-4xl);
color: white;
box-shadow: 0 4px 16px var(--color-primary-rgb);
}
.header-text h2 {
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-lg);
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border-primary);
border-radius: var(--size-border-radius-button);
color: var(--color-text-primary);
margin: 0 0 var(--spacing-sm) 0;
font-size: var(--font-size-5xl);
font-weight: var(--font-weight-semibold);
font-size: var(--font-size-base);
cursor: pointer;
transition: var(--transition-all);
}
.header-text p {
color: var(--color-text-secondary);
margin: 0;
.toolbar-btn:hover:not(:disabled) {
background: var(--color-bg-hover);
border-color: var(--color-primary);
transform: translateY(-1px);
}
.toolbar-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.toolbar-btn-primary {
background: var(--color-primary-gradient);
border-color: transparent;
color: white;
}
.toolbar-btn-primary:hover:not(:disabled) {
background: var(--color-primary-gradient-hover);
box-shadow: 0 4px 15px var(--color-primary-rgb-3);
}
.toolbar-btn i {
font-size: var(--font-size-lg);
}
.gallery-content {
.toolbar-divider {
width: 1px;
height: var(--spacing-3xl);
background: var(--color-border-primary);
margin: 0 var(--spacing-sm);
}
/* 模型树侧边栏样式 */
.model-tree-sidebar {
width: 300px;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-primary);
border-radius: var(--size-border-radius-card);
padding: var(--spacing-5xl);
border-right: 1px solid var(--color-border-primary);
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.placeholder-section {
.sidebar-header {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-lg);
background: var(--color-bg-tertiary);
border-bottom: 1px solid var(--color-border-primary);
color: var(--color-text-primary);
font-weight: var(--font-weight-semibold);
font-size: var(--font-size-base);
}
.sidebar-header i {
color: var(--color-primary);
}
.tree-container {
flex: 1;
overflow-y: auto;
padding: var(--spacing-sm);
}
/* 查看器容器样式 */
.viewer-wrapper {
flex: 1;
position: relative;
overflow: hidden;
background: var(--color-bg-viewer-light);
transition: var(--transition-all);
}
/* 拖拽高亮样式 */
.viewer-wrapper.dragging {
background: rgba(59, 130, 246, 0.05);
border: 3px dashed var(--color-primary);
box-shadow: inset 0 0 20px rgba(59, 130, 246, 0.2);
}
.viewer-wrapper.dragging .empty-state {
opacity: 0.7;
transform: scale(0.95);
transition: var(--transition-all);
}
.viewer-container {
width: 100%;
height: 100%;
}
.viewer-container.hidden {
display: none;
}
/* 空状态样式 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
text-align: center;
padding: var(--spacing-4xl) var(--spacing-2xl);
padding: var(--spacing-3xl);
}
.placeholder-icon {
.empty-icon {
width: var(--size-96);
height: var(--size-96);
background: var(--color-primary-gradient);
@ -108,93 +427,167 @@
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-6xl);
color: white;
margin: 0 auto var(--spacing-2xl);
margin-bottom: var(--spacing-2xl);
box-shadow: 0 8px 32px var(--color-primary-rgb-3);
}
.placeholder-section h3 {
color: var(--color-text-primary);
margin: 0 0 var(--spacing-md) 0;
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-semibold);
.empty-icon i {
font-size: var(--font-size-6xl);
color: white;
}
.placeholder-section p {
color: var(--color-text-secondary);
margin: 0 0 var(--spacing-3xl) 0;
.empty-state h3 {
color: var(--color-text-primary);
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-semibold);
margin: 0 0 var(--spacing-md) 0;
}
.empty-state p {
color: var(--color-text-primary);
font-size: var(--font-size-lg);
margin: 0 0 var(--spacing-sm) 0;
line-height: 1.6;
}
.feature-preview {
display: flex;
gap: var(--spacing-2xl);
justify-content: center;
flex-wrap: wrap;
margin-top: var(--spacing-3xl);
.supported-formats {
color: var(--color-text-tertiary);
font-size: var(--font-size-sm);
margin-top: var(--spacing-lg);
padding: var(--spacing-md) var(--spacing-xl);
background: var(--color-bg-secondary);
border-radius: var(--size-border-radius);
border: 1px solid var(--color-border-primary);
}
.preview-item {
/* 加载状态样式 */
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(30, 35, 45, 0.9);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 100;
}
.loading-spinner {
width: var(--size-64);
height: var(--size-64);
border: 4px solid var(--color-border-primary);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-overlay p {
color: var(--color-text-primary);
font-size: var(--font-size-lg);
margin-top: var(--spacing-xl);
}
/* 模型信息面板样式 */
.model-info-panel {
display: flex;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-xl);
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border-primary);
border-radius: var(--size-border-radius);
min-width: var(--size-150);
transition: var(--transition-all);
padding: var(--spacing-lg) var(--spacing-xl);
background: var(--color-bg-secondary);
border-top: 1px solid var(--color-border-primary);
flex-shrink: 0;
}
.preview-item:hover {
background: var(--color-bg-hover);
border-color: var(--color-primary);
transform: translateY(-2px);
.info-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.preview-item i {
font-size: var(--font-size-3xl);
.info-item i {
color: var(--color-primary);
font-size: var(--font-size-base);
}
.preview-item span {
.info-label {
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
}
.info-value {
color: var(--color-text-primary);
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
font-weight: var(--font-weight-semibold);
}
.info-divider {
width: 1px;
height: var(--spacing-2xl);
background: var(--color-border-primary);
margin: 0 var(--spacing-sm);
}
/* 响应式设计 */
@media (max-width: 768px) {
.gallery-container {
padding: var(--spacing-lg);
.main-content {
flex-direction: column;
}
.header-content {
gap: var(--spacing-md);
.model-tree-sidebar {
width: 100%;
max-height: 200px;
border-right: none;
border-bottom: 1px solid var(--color-border-primary);
}
.header-icon {
width: var(--size-48);
height: var(--size-48);
font-size: var(--font-size-2xl);
.toolbar {
padding: var(--spacing-md);
gap: var(--spacing-xs);
flex-wrap: wrap;
}
.header-text h2 {
font-size: var(--font-size-3xl);
.toolbar-btn span {
display: none;
}
.placeholder-icon {
.toolbar-btn {
padding: var(--spacing-sm);
}
.model-info-panel {
flex-wrap: wrap;
padding: var(--spacing-md);
}
.info-item {
flex: 1 1 45%;
}
.empty-icon {
width: var(--size-64);
height: var(--size-64);
}
.empty-icon i {
font-size: var(--font-size-4xl);
}
.feature-preview {
flex-direction: column;
align-items: stretch;
.empty-state h3 {
font-size: var(--font-size-xl);
}
.empty-state p {
font-size: var(--font-size-base);
}
}
</style>