deotalandAi/apps/frontend/src/components/modelCard/index.vue

797 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div
class="ip-card-container"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<!-- 主卡片区域 - 替换为3D模型展示容器 -->
<div class="ip-card-wrapper" >
<div
class="ip-card"
:class="{ 'loading': isGenerating, 'initializing': isInitializing }"
:style="cardStyle"
>
<!-- 如果没有模型URL但有图片URL显示图片 -->
<div v-if="!currentModelUrl && props.cardData.imageUrl" class="image-preview">
<img
:src="props.cardData.imageUrl"
:alt="altText"
class="preview-image"
@load="handleImageLoad"
@error="handleImageError"
/>
<!-- 生成模型按钮 -->
<button
v-if="!isGenerating"
class="generate-model-btn"
@click.stop="handleGenerateModel"
>
{{ t('modelCard.generateModelButton') }}
</button>
<!-- 生成进度指示器 -->
<div v-else class="generating-indicator">
<div class="spinner"></div>
<span>{{ t('modelCard.generatingIndicator') }}</span>
<div class="progress-bar">
<div class="progress-fill" :style="{ width: progressPercentage + '%' }">
</div>
<div
class="progress-background-text"
>
{{ t('modelCard.progressText', { percentage: Math.round(progressPercentage) }) }}
</div>
</div>
</div>
</div>
<!-- 否则显示3D模型 -->
<div style="position: relative;"
v-else>
<div style="position: absolute;width: 100%;height: 100%;z-index: 2;"></div>
<ThreeModelViewer
ref="threeModelViewer"
:model-url="currentModelUrl"
:container-width="cardWidth"
:show-border="false"
:border-radius="0"
:background-color="backgroundColor"
:enable-controls="!props.clickDisabled"
:auto-rotate="isRotating"
:rotation-speed="0.005"
:camera-distance="2.5"
/>
<!-- 放大镜按钮 - 右上角 -->
<!-- <button class="zoom-button" @click="handleCardClick" title="查看详情">
<span class="btn-icon">🔍</span>
</button> -->
</div>
</div>
<!-- 右侧控件区域 -->
<div class="right-controls-container" @click.stop>
<!-- 右侧圆形按钮控件 -->
<div class="right-circular-controls" v-if="generatedModelUrl">
<button class="control-button rotate-btn" title="detail" @click="handleCardClick">
<span class="btn-icon">🔍</span>
</button>
<!-- <button class="control-button export-btn" @click="exportModel">
<span class="btn-icon">📥</span>
</button>
-->
<!-- <button class="control-button reset-btn" @click="resetView">
<span class="btn-icon">🎯</span>
</button> -->
<!-- <button class="control-button more-btn" @click="toggleActions">
<span class="btn-icon">•••</span>
</button> -->
<!-- <div class="right-actions-controls" v-if="showRightControls">
<button class="control-button" @click="exportAsGLB" title="导出GLB格式">
<span class="btn-icon">📦</span>
</button>
<button class="control-button" @click="exportAsOBJ" title="导出OBJ格式">
<span class="btn-icon">📄</span>
</button>
<button class="control-button" @click="exportAsSTL" title="导出STL格式">
<span class="btn-icon">🔷</span>
</button>
<button class="control-button" @click="changeModelColor" title="改变模型颜色">
<span class="btn-icon">🎨</span>
</button>
</div> -->
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref, onMounted } from 'vue';
import ThreeModelViewer from '../ThreeModelViewer/index.vue';
import { MeshyServer } from '@deotaland/utils';
import { useI18n } from 'vue-i18n';
// 初始化国际化函数
const { t } = useI18n();
// 定义组件事件
const emit = defineEmits(['clickModel','refineModel','save-project','delete']);
const Meshy = new MeshyServer();
// 控制右侧按钮显示状态
const showRightControls = ref(false);
const threeModelViewer = ref(null);
const isRotating = ref(true);
const isGenerating = ref(false);// 控制生成模型按钮和进度指示器的显示
const isInitializing = ref(true); // 控制初始化状态
const progressPercentage = ref(0); // 生成进度百分比
// 处理卡片点击事件 - 只通过放大镜按钮触发
const handleCardClick = () => {
// 创建包含所有必要信息的对象包括正确的模型URL
const modelData = {
modelUrl: currentModelUrl.value,
imageUrl: props.cardData.imageUrl,
projectId:props.projectId,
altText: props.altText,
cardId: props.cardId,
cardWidth: props.cardWidth
};
emit('clickModel', modelData);
};
// 处理生成模型按钮点击
const handleGenerateModel = async () => {
isGenerating.value = true;
progressPercentage.value = 10;
let result;
if(props.cardData.taskId){
result = props.cardData.taskId;
TaskStatus(result,props.cardData.resultTask);
return
}
Meshy.createModelTask({
project_id: props.cardData.project_id,
image_url: props.cardData.imageUrl,
},(result,resultTask)=>{
if(result){
emit('save-project',{
...props.cardData,
resultTask:resultTask,
taskId:result
});
TaskStatus(result,resultTask);
}
},(error)=>{
emit('delete');
},{})
};
const TaskStatus = (result,resultTask)=>{
Meshy.getModelTaskStatus({
result:result,
resultTask:resultTask,
},(modelUrl)=>{
if(modelUrl){
// 模型生成完成
generatedModelUrl.value = modelUrl;
isGenerating.value = false;
progressPercentage.value = 100;
emit('save-project',{
...props.cardData,
modelUrl:modelUrl,
taskId:result,
status:'success'
});
}
},(error)=>{
console.error('模型生成失败:', error);
emit('delete');
},(progress)=>{
if (progress !== undefined) {
progressPercentage.value = progress;
}
})
}
// 组件挂载时如果有图片URL但没有模型URL自动生成模型
onMounted(() => {
// handleGenerateModel();
// return
switch (props.cardData.status) {
case 'loading':
handleGenerateModel();
break;
case 'success':
generatedModelUrl.value = props.cardData.modelUrl;
isGenerating.value = false;
progressPercentage.value = 100;
break;
default:
break;
}
});
// 处理鼠标移入事件(保留用于其他可能的交互)
const handleMouseEnter = () => {
// 现在主要通过CSS控制显示这里可以添加其他交互逻辑
};
// 处理鼠标移出事件
const handleMouseLeave = () => {
// 鼠标离开时隐藏展开的操作按钮
showRightControls.value = false;
};
// 处理图片加载完成,获取图片实际比例
const handleImageLoad = (event) => {
const img = event.target;
const width = img.naturalWidth;
const height = img.naturalHeight;
if (width > 0 && height > 0) {
imageAspectRatio.value = width / height;
console.log(`图片比例: ${width}:${height} (${imageAspectRatio.value.toFixed(2)})`);
}
};
// 处理图片加载错误
const handleImageError = (event) => {
console.warn('图片加载失败:', event.target.src);
};
// 定义组件属性
const props = defineProps({
projectId:{
type: String,
default: ''
},
cardData: {
type: Object,
default: () => ({})
},
// 背景颜色
backgroundColor: {
type: String,
default: '#1a1a1a'
},
// 卡片宽度默认200px
cardWidth: {
type: [Number, String], // 允许字符串或数字类型
default: 200
},
// 是否显示边框
showBorder: {
type: Boolean,
default: true
},
// 边框圆角
borderRadius: {
type: Number,
default: 8
},
// 是否禁用点击(用于拖动时)
clickDisabled: {
type: Boolean,
default: false
},
});
// 内部状态生成的模型URL
const generatedModelUrl = ref('');
// 图片比例
const imageAspectRatio = ref(16 / 9); // 默认比例
// 计算属性决定使用哪个模型URL
const currentModelUrl = computed(() => {
return generatedModelUrl.value || props.cardData.modelUrl;
});
// 计算卡片样式根据是否有模型使用固定9:16比例或图片实际比例
const cardStyle = computed(() => {
const width = props.cardWidth;
// 如果有模型URL使用固定9:16比例否则使用图片实际比例
const height = currentModelUrl.value
? (width * 16) / 9 // 固定9:16比例
: width / imageAspectRatio.value; // 使用图片实际比例
return {
width: `${width}px`,
height: `${height}px`,
borderRadius: `${props.borderRadius}px`,
border: props.showBorder ? '1px solid #e0e0e0' : 'none'
};
});
</script>
<style scoped>
.ip-card-container {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 16px;
position: relative;
}
/* 初始化状态 - 与IPCard组件保持一致 */
.ip-card.initializing {
transform: scale(1);
opacity: 1;
}
/* 加载状态 - 添加脉冲效果 */
.ip-card.loading {
animation: pulse 2s infinite;
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.4); }
70% { box-shadow: 0 0 0 10px rgba(59, 130, 246, 0); }
100% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0); }
}
/* 主卡片区域 */
.ip-card-wrapper {
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.ip-card {
overflow: hidden;
background: linear-gradient(135deg, rgba(107, 70, 193, 0.1) 0%, rgba(68, 51, 122, 0.05) 100%);
/* box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5); */
transition: transform 0.2s ease, box-shadow 0.2s ease;
position: relative;
border: 2px solid rgba(167, 139, 250, 0.2);
}
.ip-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 32px rgba(167, 139, 250, 0.3);
border-color: rgba(167, 139, 250, 0.4);
}
/* 图片预览区域 */
.image-preview {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
border-radius: 8px;
background-color: #1a1a1a;
}
.preview-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 8px;
}
/* 生成模型按钮 */
.generate-model-btn {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
padding: 12px 24px;
background: linear-gradient(135deg, #7C3AED 0%, #6B46C1 100%);
color: white;
border: none;
border-radius: 25px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.3s ease;
z-index: 10;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
.generate-model-btn:hover {
transform: translateX(-50%) translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
}
.generate-model-btn:active {
transform: translateX(-50%) translateY(0);
}
/* 生成进度指示器 */
.generating-indicator {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 15px 25px;
background-color: rgba(0, 0, 0, 0.8);
border-radius: 20px;
color: white;
font-size: 14px;
z-index: 10;
min-width: 200px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(5px);
}
/* 进度条样式 */
.progress-bar {
position: relative;
width: 100%;
height: 24px;
background: linear-gradient(90deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.2));
border-radius: 12px;
overflow: hidden;
margin-top: 8px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
}
.progress-fill {
position: absolute;
left: 0;
top: 0;
height: 100%;
background: linear-gradient(90deg, #3b82f6, #8b5cf6, #06b6d4);
border-radius: 12px;
transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: 8px;
box-shadow:
0 2px 8px rgba(59, 130, 246, 0.4),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
overflow: hidden;
}
.progress-fill::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
animation: shimmer 2s infinite;
}
@keyframes shimmer {
0% {
left: -100%;
}
100% {
left: 100%;
}
}
.progress-text {
color: white;
font-size: 12px;
font-weight: 600;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
white-space: nowrap;
opacity: 1;
transition: opacity 0.3s ease;
z-index: 2;
position: relative;
}
.progress-text.low-progress {
justify-self: flex-start;
padding-left: 8px;
padding-right: 0;
}
.progress-background-text {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
position: absolute;
color: rgba(255, 255, 255, 0.8);
font-size: 12px;
font-weight: 600;
z-index: 1;
pointer-events: none;
transition: opacity 0.3s ease;
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: white;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* 放大镜按钮 - 右上角 */
.zoom-button {
position: absolute;
top: 12px;
right: 12px;
width: 40px;
height: 40px;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.6);
border: 1px solid rgba(255, 255, 255, 0.2);
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
opacity: 0;
transform: scale(0.8);
z-index: 5;
backdrop-filter: blur(5px);
}
/* 鼠标悬停时显示放大镜按钮 */
.ip-card-container:hover .zoom-button {
opacity: 1;
transform: scale(1);
}
.zoom-button:hover {
background-color: rgba(0, 0, 0, 0.8);
transform: scale(1.1);
}
.zoom-button:active {
transform: scale(0.95);
}
.zoom-button .btn-icon {
font-size: 18px;
}
/* 右侧控件容器 - 使用绝对定位避免布局重排 */
.right-controls-container {
position: absolute;
left: 100%;
top: 50%;
transform: translateY(-50%);
margin-left: 24px;
z-index: 10;
}
/* 右侧圆形按钮控件 */
.right-circular-controls {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 16px;
opacity: 0;
transform: translateX(-20px);
transition: all 0.3s ease;
pointer-events: none;
}
/* 当鼠标悬停在卡片容器上时显示控件 */
.ip-card-container:hover .right-circular-controls {
opacity: 1;
transform: translateX(0);
pointer-events: auto;
}
.control-button {
width: 48px;
height: 48px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
}
.control-button:hover {
background-color: rgba(255, 255, 255, 0.2);
transform: translateY(-2px);
}
.control-button:active {
transform: translateY(0);
}
.btn-icon {
font-size: 20px;
}
/* 卡片底部信息 */
.card-info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 12px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
color: white;
}
.card-name {
font-size: 16px;
font-weight: bold;
margin-bottom: 4px;
}
.card-series {
font-size: 12px;
opacity: 0.8;
}
/* 右侧操作按钮控件 */
.right-actions-controls {
display: flex;
flex-direction: column;
gap: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding-top: 12px;
margin-top: 12px;
opacity: 0;
transform: translateY(-10px);
transition: all 0.3s ease 0.1s;
pointer-events: none;
}
/* 当显示右侧控件时,同时显示操作按钮 */
.ip-card-container:hover .right-actions-controls {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
/* more按钮激活状态 */
.more-btn.active {
background-color: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.3);
}
/* 导出按钮特殊样式 */
.control-button.export-btn {
background-color: rgba(34, 197, 94, 0.2);
border-color: rgba(34, 197, 94, 0.3);
}
.control-button.export-btn:hover {
background-color: rgba(34, 197, 94, 0.3);
border-color: rgba(34, 197, 94, 0.4);
}
/* 旋转按钮特殊样式 */
.control-button.rotate-btn {
background-color: rgba(59, 130, 246, 0.2);
border-color: rgba(59, 130, 246, 0.3);
}
.control-button.rotate-btn:hover {
background-color: rgba(59, 130, 246, 0.3);
border-color: rgba(59, 130, 246, 0.4);
}
/* 重置按钮特殊样式 */
.control-button.reset-btn {
background-color: rgba(249, 115, 22, 0.2);
border-color: rgba(249, 115, 22, 0.3);
}
.control-button.reset-btn:hover {
background-color: rgba(249, 115, 22, 0.3);
border-color: rgba(249, 115, 22, 0.4);
}
/* 操作按钮样式继承control-button保持一致性 */
.right-actions-controls .control-button.export-format-btn {
background-color: rgba(168, 85, 247, 0.2);
border-color: rgba(168, 85, 247, 0.3);
}
.right-actions-controls .control-button.export-format-btn:hover {
background-color: rgba(168, 85, 247, 0.3);
border-color: rgba(168, 85, 247, 0.4);
}
/* GLB导出按钮特殊样式 */
.right-actions-controls .control-button:nth-child(1) {
background-color: rgba(34, 197, 94, 0.2);
border-color: rgba(34, 197, 94, 0.3);
}
.right-actions-controls .control-button:nth-child(1):hover {
background-color: rgba(34, 197, 94, 0.3);
border-color: rgba(34, 197, 94, 0.4);
}
/* OBJ导出按钮特殊样式 */
.right-actions-controls .control-button:nth-child(2) {
background-color: rgba(59, 130, 246, 0.2);
border-color: rgba(59, 130, 246, 0.3);
}
.right-actions-controls .control-button:nth-child(2):hover {
background-color: rgba(59, 130, 246, 0.3);
border-color: rgba(59, 130, 246, 0.4);
}
/* STL导出按钮特殊样式 */
.right-actions-controls .control-button:nth-child(3) {
background-color: rgba(249, 115, 22, 0.2);
border-color: rgba(249, 115, 22, 0.3);
}
.right-actions-controls .control-button:nth-child(3):hover {
background-color: rgba(249, 115, 22, 0.3);
border-color: rgba(249, 115, 22, 0.4);
}
.action-icon {
font-size: 18px;
width: 24px;
text-align: center;
}
.action-text {
font-size: 14px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* 响应式设计 */
@media (max-width: 768px) {
.ip-card-container {
flex-direction: column;
gap: 16px;
padding: 8px;
}
.right-controls-container {
position: static;
transform: none;
margin-left: 0;
margin-top: 16px;
}
.right-circular-controls {
flex-direction: row;
justify-content: center;
align-items: center;
margin-bottom: 16px;
opacity: 1;
transform: none;
pointer-events: auto;
}
.right-actions-controls {
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
margin-left: 0;
opacity: 1;
transform: none;
pointer-events: auto;
}
.action-button {
padding: 8px 16px;
flex: 0 0 auto;
}
.action-text {
font-size: 12px;
}
}
</style>