Skip to content

Commit ecea6ae

Browse files
committed
feat: Add 3D Lab interactive visualization with device components
- Create interactive 3D lab scene using React Three Fiber - Implement 12+ device components (liquid handler, microscope, centrifuge, AGV robot, etc.) - Add device detail modal with 3D preview and specifications - Support device animations and interactive highlighting - Include device information database - Update navbar with 3D Lab navigation links - Enhance scrollbar styling for better UX
1 parent f3e8b4b commit ecea6ae

11 files changed

Lines changed: 2161 additions & 12 deletions

‎web/index.css‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,36 @@
110110
scrollbar-color: rgba(156, 163, 175, 0.4) transparent;
111111
}
112112

113+
/* Webkit 浏览器滚动条样式补充 */
114+
.custom-scrollbar::-webkit-scrollbar {
115+
width: 8px;
116+
height: 8px;
117+
}
118+
119+
.custom-scrollbar::-webkit-scrollbar-track {
120+
background: transparent;
121+
border-radius: 4px;
122+
}
123+
124+
.custom-scrollbar::-webkit-scrollbar-thumb {
125+
background-color: rgba(156, 163, 175, 0.4);
126+
border-radius: 4px;
127+
border: 2px solid transparent;
128+
background-clip: padding-box;
129+
}
130+
131+
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
132+
background-color: rgba(156, 163, 175, 0.6);
133+
}
134+
135+
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
136+
background-color: rgba(156, 163, 175, 0.5);
137+
}
138+
139+
.dark .custom-scrollbar::-webkit-scrollbar-thumb:hover {
140+
background-color: rgba(156, 163, 175, 0.7);
141+
}
142+
113143
/* 全局背景色设置,防止滚动时出现白色 */
114144
html,
115145
body {
Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
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

Comments
 (0)