|
| 1 | +'use client'; |
| 2 | + |
| 3 | +import LogoLoading from '@/components/basic/loading'; |
| 4 | +import { XMarkIcon } from '@heroicons/react/24/outline'; |
| 5 | +import { OrbitControls, PerspectiveCamera } from '@react-three/drei'; |
| 6 | +import { Canvas } from '@react-three/fiber'; |
| 7 | +import { Suspense } from 'react'; |
| 8 | +import { getDeviceInfo } from './deviceInfo'; |
| 9 | + |
| 10 | +// 导入设备组件 |
| 11 | +import { |
| 12 | + AGVRobot, |
| 13 | + Beaker, |
| 14 | + Centrifuge, |
| 15 | + LiquidHandlerModel, |
| 16 | + Microscope, |
| 17 | + Monitor, |
| 18 | + PetriDishStack, |
| 19 | + PipetteRack, |
| 20 | + ReagentBottle, |
| 21 | + ReagentRack, |
| 22 | + SampleRack, |
| 23 | + StorageCabinet, |
| 24 | +} from './deviceComponents'; |
| 25 | + |
| 26 | +interface DeviceDetailModalProps { |
| 27 | + deviceId: string; |
| 28 | + onClose: () => void; |
| 29 | + isAnimating?: boolean; |
| 30 | + onToggleAnimation?: () => void; |
| 31 | +} |
| 32 | + |
| 33 | +// 设备渲染映射 |
| 34 | +function DeviceRenderer({ |
| 35 | + deviceId, |
| 36 | + isAnimating = false, |
| 37 | +}: { |
| 38 | + deviceId: string; |
| 39 | + isAnimating?: boolean; |
| 40 | +}) { |
| 41 | + const position: [number, number, number] = [0, 0, 0]; |
| 42 | + |
| 43 | + switch (deviceId) { |
| 44 | + case 'liquid-handler': |
| 45 | + return ( |
| 46 | + <LiquidHandlerModel position={position} isAnimating={isAnimating} /> |
| 47 | + ); |
| 48 | + case 'microscope': |
| 49 | + return <Microscope position={position} isAnimating={isAnimating} />; |
| 50 | + case 'monitor': |
| 51 | + return <Monitor position={position} />; |
| 52 | + case 'agv-robot': |
| 53 | + return ( |
| 54 | + <AGVRobot |
| 55 | + position={position} |
| 56 | + rotation={[0, Math.PI / 4, 0]} |
| 57 | + isAnimating={isAnimating} |
| 58 | + /> |
| 59 | + ); |
| 60 | + case 'centrifuge': |
| 61 | + return <Centrifuge position={position} isAnimating={isAnimating} />; |
| 62 | + case 'pipette-rack': |
| 63 | + return <PipetteRack position={position} />; |
| 64 | + case 'beaker': |
| 65 | + return <Beaker position={position} color="#3b82f6" />; |
| 66 | + case 'storage-cabinet': |
| 67 | + return <StorageCabinet position={position} />; |
| 68 | + case 'reagent-rack': |
| 69 | + return <ReagentRack position={position} />; |
| 70 | + case 'reagent-bottle': |
| 71 | + return <ReagentBottle position={position} color="#ef4444" size="large" />; |
| 72 | + case 'petri-dish': |
| 73 | + return <PetriDishStack position={position} />; |
| 74 | + case 'sample-rack': |
| 75 | + return <SampleRack position={position} />; |
| 76 | + default: |
| 77 | + return null; |
| 78 | + } |
| 79 | +} |
| 80 | + |
| 81 | +// 根据设备类型调整相机位置 |
| 82 | +function getCameraPosition(deviceId: string): [number, number, number] { |
| 83 | + const positions: Record<string, [number, number, number]> = { |
| 84 | + 'liquid-handler': [0, 2, 3], |
| 85 | + microscope: [0.5, 0.8, 1.2], |
| 86 | + monitor: [0, 0.8, 1.5], |
| 87 | + 'agv-robot': [2, 2, 3], |
| 88 | + centrifuge: [0.4, 0.4, 0.8], |
| 89 | + 'pipette-rack': [0.3, 0.3, 0.6], |
| 90 | + beaker: [0.3, 0.3, 0.5], |
| 91 | + 'storage-cabinet': [0, 2, 3], |
| 92 | + 'reagent-rack': [0.5, 0.5, 1], |
| 93 | + 'reagent-bottle': [0.3, 0.3, 0.5], |
| 94 | + 'petri-dish': [0.2, 0.2, 0.4], |
| 95 | + 'sample-rack': [0.5, 0.5, 1], |
| 96 | + }; |
| 97 | + return positions[deviceId] || [0, 1, 2]; |
| 98 | +} |
| 99 | + |
| 100 | +export default function DeviceDetailModal({ |
| 101 | + deviceId, |
| 102 | + onClose, |
| 103 | + isAnimating = false, |
| 104 | + onToggleAnimation, |
| 105 | +}: DeviceDetailModalProps) { |
| 106 | + const deviceInfo = getDeviceInfo(deviceId); |
| 107 | + const cameraPos = getCameraPosition(deviceId); |
| 108 | + |
| 109 | + // 支持动画的设备列表 |
| 110 | + const animatableDevices = [ |
| 111 | + 'liquid-handler', |
| 112 | + 'microscope', |
| 113 | + 'agv-robot', |
| 114 | + 'centrifuge', |
| 115 | + ]; |
| 116 | + const canAnimate = animatableDevices.includes(deviceId); |
| 117 | + |
| 118 | + if (!deviceInfo) { |
| 119 | + return null; |
| 120 | + } |
| 121 | + |
| 122 | + return ( |
| 123 | + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"> |
| 124 | + <div className="relative h-[90vh] w-[90vw] rounded-2xl bg-white dark:bg-neutral-900 shadow-2xl overflow-hidden"> |
| 125 | + {/* 关闭按钮 */} |
| 126 | + <button |
| 127 | + onClick={onClose} |
| 128 | + className="absolute top-4 right-4 z-10 rounded-full bg-white/90 dark:bg-neutral-800/90 p-2 shadow-lg hover:bg-white dark:hover:bg-neutral-700 transition-colors" |
| 129 | + > |
| 130 | + <XMarkIcon className="h-6 w-6 text-neutral-900 dark:text-neutral-100" /> |
| 131 | + </button> |
| 132 | + |
| 133 | + <div className="flex h-full"> |
| 134 | + {/* 左侧:3D 视图 */} |
| 135 | + <div className="flex-1 relative"> |
| 136 | + <Suspense |
| 137 | + fallback={ |
| 138 | + <div className="flex h-full w-full items-center justify-center"> |
| 139 | + <LogoLoading variant="large" animationType="galaxy" /> |
| 140 | + </div> |
| 141 | + } |
| 142 | + > |
| 143 | + <Canvas shadows dpr={[1, 2]} gl={{ antialias: true }}> |
| 144 | + <PerspectiveCamera makeDefault position={cameraPos} fov={50} /> |
| 145 | + <OrbitControls |
| 146 | + enableZoom |
| 147 | + enablePan |
| 148 | + autoRotate |
| 149 | + autoRotateSpeed={2} |
| 150 | + minDistance={0.5} |
| 151 | + maxDistance={5} |
| 152 | + /> |
| 153 | + |
| 154 | + {/* 光照 */} |
| 155 | + <ambientLight intensity={0.8} /> |
| 156 | + <directionalLight |
| 157 | + position={[5, 5, 5]} |
| 158 | + intensity={1.2} |
| 159 | + castShadow |
| 160 | + shadow-mapSize={[1024, 1024]} |
| 161 | + /> |
| 162 | + <directionalLight position={[-5, 3, -5]} intensity={0.5} /> |
| 163 | + <spotLight |
| 164 | + position={[0, 5, 0]} |
| 165 | + angle={0.5} |
| 166 | + penumbra={1} |
| 167 | + intensity={0.8} |
| 168 | + castShadow |
| 169 | + /> |
| 170 | + |
| 171 | + {/* 设备模型 */} |
| 172 | + <DeviceRenderer deviceId={deviceId} isAnimating={isAnimating} /> |
| 173 | + |
| 174 | + {/* 地面 */} |
| 175 | + <mesh |
| 176 | + rotation={[-Math.PI / 2, 0, 0]} |
| 177 | + position={[0, -0.5, 0]} |
| 178 | + receiveShadow |
| 179 | + > |
| 180 | + <planeGeometry args={[10, 10]} /> |
| 181 | + <meshStandardMaterial |
| 182 | + color="#f5f5f5" |
| 183 | + metalness={0.1} |
| 184 | + roughness={0.8} |
| 185 | + /> |
| 186 | + </mesh> |
| 187 | + |
| 188 | + {/* 背景网格 */} |
| 189 | + <gridHelper |
| 190 | + args={[10, 20, '#d1d5db', '#e5e7eb']} |
| 191 | + position={[0, -0.49, 0]} |
| 192 | + /> |
| 193 | + </Canvas> |
| 194 | + </Suspense> |
| 195 | + |
| 196 | + {/* 操作提示 */} |
| 197 | + <div className="absolute bottom-4 left-4 bg-white/90 dark:bg-neutral-800/90 backdrop-blur-sm px-4 py-2 rounded-lg shadow-lg"> |
| 198 | + <p className="text-sm text-neutral-600 dark:text-neutral-300"> |
| 199 | + 🖱️ 拖动旋转 | 滚轮缩放 | 右键平移 |
| 200 | + </p> |
| 201 | + </div> |
| 202 | + </div> |
| 203 | + |
| 204 | + {/* 右侧:设备信息 */} |
| 205 | + <div className="w-96 bg-gradient-to-br from-indigo-50 to-purple-50 dark:from-neutral-800 dark:to-neutral-900 p-8 overflow-y-auto custom-scrollbar"> |
| 206 | + <div className="space-y-6"> |
| 207 | + {/* 标题 */} |
| 208 | + <div> |
| 209 | + <h2 className="text-3xl font-bold text-neutral-900 dark:text-white mb-2"> |
| 210 | + {deviceInfo.name} |
| 211 | + </h2> |
| 212 | + <p className="text-lg text-neutral-600 dark:text-neutral-400"> |
| 213 | + {deviceInfo.nameEn} |
| 214 | + </p> |
| 215 | + </div> |
| 216 | + |
| 217 | + {/* 分隔线 */} |
| 218 | + <div className="h-px bg-gradient-to-r from-indigo-200 via-purple-200 to-transparent dark:from-indigo-800 dark:via-purple-800" /> |
| 219 | + |
| 220 | + {/* 描述 */} |
| 221 | + <div> |
| 222 | + <h3 className="text-sm font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-2"> |
| 223 | + 设备简介 |
| 224 | + </h3> |
| 225 | + <p className="text-neutral-700 dark:text-neutral-300 leading-relaxed"> |
| 226 | + {deviceInfo.description} |
| 227 | + </p> |
| 228 | + </div> |
| 229 | + |
| 230 | + {/* 规格参数 */} |
| 231 | + {deviceInfo.specs && deviceInfo.specs.length > 0 && ( |
| 232 | + <div> |
| 233 | + <h3 className="text-sm font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-3"> |
| 234 | + 技术规格 |
| 235 | + </h3> |
| 236 | + <ul className="space-y-2"> |
| 237 | + {deviceInfo.specs.map((spec, index) => ( |
| 238 | + <li |
| 239 | + key={index} |
| 240 | + className="flex items-start gap-2 text-neutral-700 dark:text-neutral-300" |
| 241 | + > |
| 242 | + <span className="text-indigo-600 dark:text-indigo-400 mt-1"> |
| 243 | + ▹ |
| 244 | + </span> |
| 245 | + <span>{spec}</span> |
| 246 | + </li> |
| 247 | + ))} |
| 248 | + </ul> |
| 249 | + </div> |
| 250 | + )} |
| 251 | + |
| 252 | + {/* 使用场景 */} |
| 253 | + {deviceInfo.usage && ( |
| 254 | + <div> |
| 255 | + <h3 className="text-sm font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-2"> |
| 256 | + 应用场景 |
| 257 | + </h3> |
| 258 | + <p className="text-neutral-700 dark:text-neutral-300 leading-relaxed bg-white/50 dark:bg-neutral-800/50 p-4 rounded-lg"> |
| 259 | + {deviceInfo.usage} |
| 260 | + </p> |
| 261 | + </div> |
| 262 | + )} |
| 263 | + |
| 264 | + {/* 动画控制按钮 */} |
| 265 | + {canAnimate && onToggleAnimation && ( |
| 266 | + <div> |
| 267 | + <button |
| 268 | + onClick={onToggleAnimation} |
| 269 | + className={`w-full px-4 py-3 rounded-lg font-medium transition-all duration-300 ${ |
| 270 | + isAnimating |
| 271 | + ? 'bg-indigo-600 hover:bg-indigo-700 text-white' |
| 272 | + : 'bg-indigo-100 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:hover:bg-indigo-800/50 text-indigo-700 dark:text-indigo-300' |
| 273 | + }`} |
| 274 | + > |
| 275 | + <div className="flex items-center justify-center gap-2"> |
| 276 | + {isAnimating ? ( |
| 277 | + <> |
| 278 | + <svg |
| 279 | + className="w-5 h-5" |
| 280 | + fill="none" |
| 281 | + stroke="currentColor" |
| 282 | + viewBox="0 0 24 24" |
| 283 | + > |
| 284 | + <path |
| 285 | + strokeLinecap="round" |
| 286 | + strokeLinejoin="round" |
| 287 | + strokeWidth={2} |
| 288 | + d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z" |
| 289 | + /> |
| 290 | + </svg> |
| 291 | + <span>停止动画演示</span> |
| 292 | + </> |
| 293 | + ) : ( |
| 294 | + <> |
| 295 | + <svg |
| 296 | + className="w-5 h-5" |
| 297 | + fill="none" |
| 298 | + stroke="currentColor" |
| 299 | + viewBox="0 0 24 24" |
| 300 | + > |
| 301 | + <path |
| 302 | + strokeLinecap="round" |
| 303 | + strokeLinejoin="round" |
| 304 | + strokeWidth={2} |
| 305 | + d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" |
| 306 | + /> |
| 307 | + <path |
| 308 | + strokeLinecap="round" |
| 309 | + strokeLinejoin="round" |
| 310 | + strokeWidth={2} |
| 311 | + d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" |
| 312 | + /> |
| 313 | + </svg> |
| 314 | + <span>播放动画演示</span> |
| 315 | + </> |
| 316 | + )} |
| 317 | + </div> |
| 318 | + </button> |
| 319 | + {isAnimating && ( |
| 320 | + <p className="mt-2 text-xs text-center text-neutral-500 dark:text-neutral-400"> |
| 321 | + 正在演示设备工作流程 |
| 322 | + </p> |
| 323 | + )} |
| 324 | + </div> |
| 325 | + )} |
| 326 | + |
| 327 | + {/* 装饰性图标 */} |
| 328 | + <div className="pt-6 flex items-center justify-center"> |
| 329 | + <div className="flex gap-3"> |
| 330 | + <div className="h-2 w-2 rounded-full bg-indigo-400 dark:bg-indigo-600 animate-pulse" /> |
| 331 | + <div className="h-2 w-2 rounded-full bg-purple-400 dark:bg-purple-600 animate-pulse delay-75" /> |
| 332 | + <div className="h-2 w-2 rounded-full bg-pink-400 dark:bg-pink-600 animate-pulse delay-150" /> |
| 333 | + </div> |
| 334 | + </div> |
| 335 | + </div> |
| 336 | + </div> |
| 337 | + </div> |
| 338 | + </div> |
| 339 | + </div> |
| 340 | + ); |
| 341 | +} |
0 commit comments