From b83d9377c0cfdc95b9dd7221a7bbad12239551ea Mon Sep 17 00:00:00 2001 From: xiaoma Date: Mon, 2 Feb 2026 21:03:41 +0800 Subject: [PATCH] fix: use 3Dmodule zip --- frontend/package.json | 1 + frontend/src/components/ModelViewer.jsx | 120 +++++++++++++++++++++++- frontend/src/pages/Home.jsx | 23 +++-- frontend/src/pages/ProductDetail.jsx | 13 +++ 4 files changed, 145 insertions(+), 12 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 5757c39..0712903 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ "antd": "^6.2.2", "axios": "^1.13.4", "framer-motion": "^12.29.2", + "jszip": "^3.10.1", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.13.0", diff --git a/frontend/src/components/ModelViewer.jsx b/frontend/src/components/ModelViewer.jsx index d766735..db7a4b4 100644 --- a/frontend/src/components/ModelViewer.jsx +++ b/frontend/src/components/ModelViewer.jsx @@ -1,16 +1,21 @@ -import React, { Suspense } from 'react'; +import React, { Suspense, useState, useEffect } from 'react'; import { Canvas, useLoader } from '@react-three/fiber'; import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader'; import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'; import { OrbitControls, Stage, useProgress, Environment, ContactShadows } from '@react-three/drei'; import { Spin } from 'antd'; +import JSZip from 'jszip'; +import * as THREE from 'three'; const Model = ({ objPath, mtlPath, scale = 1 }) => { - const materials = useLoader(MTLLoader, mtlPath); + // If mtlPath is provided, load materials first + const materials = mtlPath ? useLoader(MTLLoader, mtlPath) : null; const obj = useLoader(OBJLoader, objPath, (loader) => { - materials.preload(); - loader.setMaterials(materials); + if (materials) { + materials.preload(); + loader.setMaterials(materials); + } }); const clone = obj.clone(); @@ -46,6 +51,111 @@ const LoadingOverlay = () => { }; const ModelViewer = ({ objPath, mtlPath, scale = 1, autoRotate = true }) => { + const [paths, setPaths] = useState(null); + const [unzipping, setUnzipping] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let isMounted = true; + const blobUrls = []; + + const loadPaths = async () => { + if (!objPath) return; + + // 如果是 zip 文件 + if (objPath.toLowerCase().endsWith('.zip')) { + setUnzipping(true); + setError(null); + try { + const response = await fetch(objPath); + const arrayBuffer = await response.arrayBuffer(); + const zip = await JSZip.loadAsync(arrayBuffer); + + let extractedObj = null; + let extractedMtl = null; + const fileMap = {}; + + // 1. 提取所有文件并创建 Blob URL 映射 + for (const [filename, file] of Object.entries(zip.files)) { + if (file.dir) continue; + + const content = await file.async('blob'); + const url = URL.createObjectURL(content); + blobUrls.push(url); + + // 记录文件名到 URL 的映射,用于后续材质引用图片等情况 + const baseName = filename.split('/').pop(); + fileMap[baseName] = url; + + if (filename.toLowerCase().endsWith('.obj')) { + extractedObj = url; + } else if (filename.toLowerCase().endsWith('.mtl')) { + extractedMtl = url; + } + } + + if (isMounted) { + if (extractedObj) { + setPaths({ obj: extractedObj, mtl: extractedMtl }); + } else { + setError('压缩包内未找到 .obj 模型文件'); + } + } + } catch (err) { + console.error('Error unzipping model:', err); + if (isMounted) setError('加载压缩包失败'); + } finally { + if (isMounted) setUnzipping(false); + } + } else { + // 普通路径 + setPaths({ obj: objPath, mtl: mtlPath }); + } + }; + + loadPaths(); + + return () => { + isMounted = false; + // 清理 Blob URL 释放内存 + blobUrls.forEach(url => URL.revokeObjectURL(url)); + }; + }, [objPath, mtlPath]); + + if (unzipping) { + return ( +
+ +
正在解压 3D 资源...
+
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (!paths) return null; + return (
@@ -56,7 +166,7 @@ const ModelViewer = ({ objPath, mtlPath, scale = 1, autoRotate = true }) => { - + diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx index 376f213..ed87ca2 100644 --- a/frontend/src/pages/Home.jsx +++ b/frontend/src/pages/Home.jsx @@ -131,14 +131,23 @@ const Home = () => { justifyContent: 'center', alignItems: 'center', color: '#444', - borderBottom: '1px solid rgba(255,255,255,0.05)' + borderBottom: '1px solid rgba(255,255,255,0.05)', + overflow: 'hidden' }}> - - - + {product.static_image_url ? ( + {product.name} + ) : ( + + + + )}
} onClick={() => navigate(`/product/${product.id}`)} diff --git a/frontend/src/pages/ProductDetail.jsx b/frontend/src/pages/ProductDetail.jsx index e212373..d5adf1b 100644 --- a/frontend/src/pages/ProductDetail.jsx +++ b/frontend/src/pages/ProductDetail.jsx @@ -64,6 +64,17 @@ const ProductDetail = () => { const getModelPaths = (p) => { if (!p) return null; + + // 优先使用后台配置的 3D 模型 URL + if (p.model_3d_url) { + return { obj: p.model_3d_url }; + } + + // 如果有静态图,且没有特定的 3D 模型 URL,则优先显示静态图,不进入下方的通用 3D 模板匹配 + if (p.static_image_url) { + return null; + } + const text = (p.name + p.description).toLowerCase(); if (text.includes('mini')) { @@ -117,6 +128,8 @@ const ProductDetail = () => { }}> {modelPaths ? ( + ) : product.static_image_url ? ( + {product.name} ) : ( )}