diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5d49de4..5b8ff5d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,7 +5,8 @@ repos: rev: v6.0.0 hooks: - id: trailing-whitespace + exclude: '\.(yaml|yml|json|md|sh|go|conf)$|^(launch/|infra/|docker/|\.github/)' - id: end-of-file-fixer - exclude: '\.(yaml|yml|json|md)$' + exclude: '\.(yaml|yml|json|md|sh|go|conf)$|^(launch/|infra/|docker/)' - id: check-yaml - id: check-added-large-files diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d0336ef --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["setuptools>=45", "wheel", "setuptools-scm[toml]>=6.2"] +build-backend = "setuptools.build_meta" + +[project] +name = "studio" +version = "0.1.0" +description = "Studio - Interactive laboratory visualization and management system" +readme = "README.md" +requires-python = ">=3.8" +license = {text = "MIT"} +authors = [ + {name = "ScienceOL", email = "contact@sciol.ac.cn"} +] + +dependencies = [] + +[project.optional-dependencies] +dev = [] + +[tool.setuptools] +packages = ["service"] diff --git a/service/pyproject.toml b/service/pyproject.toml new file mode 100644 index 0000000..fe9957f --- /dev/null +++ b/service/pyproject.toml @@ -0,0 +1,18 @@ +[build-system] +requires = ["setuptools>=45", "wheel", "setuptools-scm[toml]>=6.2"] +build-backend = "setuptools.build_meta" + +[project] +name = "studio-service" +version = "0.1.0" +description = "Studio Service - Go-based backend service" +readme = "README.md" +requires-python = ">=3.8" + +dependencies = [] + +[project.optional-dependencies] +dev = ["pre-commit>=3.5.0"] + +[tool.setuptools] +packages = [] diff --git a/service/uv.lock b/service/uv.lock new file mode 100644 index 0000000..64e2178 --- /dev/null +++ b/service/uv.lock @@ -0,0 +1,2 @@ +version = 1 +requires-python = ">=3.8" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..64e2178 --- /dev/null +++ b/uv.lock @@ -0,0 +1,2 @@ +version = 1 +requires-python = ">=3.8" diff --git a/web/index.css b/web/index.css index 0e92213..32af51e 100644 --- a/web/index.css +++ b/web/index.css @@ -110,6 +110,36 @@ scrollbar-color: rgba(156, 163, 175, 0.4) transparent; } +/* Webkit 浏览器滚动条样式补充 */ +.custom-scrollbar::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.custom-scrollbar::-webkit-scrollbar-track { + background: transparent; + border-radius: 4px; +} + +.custom-scrollbar::-webkit-scrollbar-thumb { + background-color: rgba(156, 163, 175, 0.4); + border-radius: 4px; + border: 2px solid transparent; + background-clip: padding-box; +} + +.custom-scrollbar::-webkit-scrollbar-thumb:hover { + background-color: rgba(156, 163, 175, 0.6); +} + +.dark .custom-scrollbar::-webkit-scrollbar-thumb { + background-color: rgba(156, 163, 175, 0.5); +} + +.dark .custom-scrollbar::-webkit-scrollbar-thumb:hover { + background-color: rgba(156, 163, 175, 0.7); +} + /* 全局背景色设置,防止滚动时出现白色 */ html, body { diff --git a/web/src/app/3D_lab/DeviceDetailModal.tsx b/web/src/app/3D_lab/DeviceDetailModal.tsx new file mode 100644 index 0000000..7e9349f --- /dev/null +++ b/web/src/app/3D_lab/DeviceDetailModal.tsx @@ -0,0 +1,341 @@ +'use client'; + +import LogoLoading from '@/components/basic/loading'; +import { XMarkIcon } from '@heroicons/react/24/outline'; +import { OrbitControls, PerspectiveCamera } from '@react-three/drei'; +import { Canvas } from '@react-three/fiber'; +import { Suspense } from 'react'; +import { getDeviceInfo } from './deviceInfo'; + +// 导入设备组件 +import { + AGVRobot, + Beaker, + Centrifuge, + LiquidHandlerModel, + Microscope, + Monitor, + PetriDishStack, + PipetteRack, + ReagentBottle, + ReagentRack, + SampleRack, + StorageCabinet, +} from './deviceComponents'; + +interface DeviceDetailModalProps { + deviceId: string; + onClose: () => void; + isAnimating?: boolean; + onToggleAnimation?: () => void; +} + +// 设备渲染映射 +function DeviceRenderer({ + deviceId, + isAnimating = false, +}: { + deviceId: string; + isAnimating?: boolean; +}) { + const position: [number, number, number] = [0, 0, 0]; + + switch (deviceId) { + case 'liquid-handler': + return ( + + ); + case 'microscope': + return ; + case 'monitor': + return ; + case 'agv-robot': + return ( + + ); + case 'centrifuge': + return ; + case 'pipette-rack': + return ; + case 'beaker': + return ; + case 'storage-cabinet': + return ; + case 'reagent-rack': + return ; + case 'reagent-bottle': + return ; + case 'petri-dish': + return ; + case 'sample-rack': + return ; + default: + return null; + } +} + +// 根据设备类型调整相机位置 +function getCameraPosition(deviceId: string): [number, number, number] { + const positions: Record = { + 'liquid-handler': [0, 2, 3], + microscope: [0.5, 0.8, 1.2], + monitor: [0, 0.8, 1.5], + 'agv-robot': [2, 2, 3], + centrifuge: [0.4, 0.4, 0.8], + 'pipette-rack': [0.3, 0.3, 0.6], + beaker: [0.3, 0.3, 0.5], + 'storage-cabinet': [0, 2, 3], + 'reagent-rack': [0.5, 0.5, 1], + 'reagent-bottle': [0.3, 0.3, 0.5], + 'petri-dish': [0.2, 0.2, 0.4], + 'sample-rack': [0.5, 0.5, 1], + }; + return positions[deviceId] || [0, 1, 2]; +} + +export default function DeviceDetailModal({ + deviceId, + onClose, + isAnimating = false, + onToggleAnimation, +}: DeviceDetailModalProps) { + const deviceInfo = getDeviceInfo(deviceId); + const cameraPos = getCameraPosition(deviceId); + + // 支持动画的设备列表 + const animatableDevices = [ + 'liquid-handler', + 'microscope', + 'agv-robot', + 'centrifuge', + ]; + const canAnimate = animatableDevices.includes(deviceId); + + if (!deviceInfo) { + return null; + } + + return ( +
+
+ {/* 关闭按钮 */} + + +
+ {/* 左侧:3D 视图 */} +
+ + +
+ } + > + + + + + {/* 光照 */} + + + + + + {/* 设备模型 */} + + + {/* 地面 */} + + + + + + {/* 背景网格 */} + + + + + {/* 操作提示 */} +
+

+ 🖱️ 拖动旋转 | 滚轮缩放 | 右键平移 +

+
+
+ + {/* 右侧:设备信息 */} +
+
+ {/* 标题 */} +
+

+ {deviceInfo.name} +

+

+ {deviceInfo.nameEn} +

+
+ + {/* 分隔线 */} +
+ + {/* 描述 */} +
+

+ 设备简介 +

+

+ {deviceInfo.description} +

+
+ + {/* 规格参数 */} + {deviceInfo.specs && deviceInfo.specs.length > 0 && ( +
+

+ 技术规格 +

+
    + {deviceInfo.specs.map((spec, index) => ( +
  • + + ▹ + + {spec} +
  • + ))} +
+
+ )} + + {/* 使用场景 */} + {deviceInfo.usage && ( +
+

+ 应用场景 +

+

+ {deviceInfo.usage} +

+
+ )} + + {/* 动画控制按钮 */} + {canAnimate && onToggleAnimation && ( +
+ + {isAnimating && ( +

+ 正在演示设备工作流程 +

+ )} +
+ )} + + {/* 装饰性图标 */} +
+
+
+
+
+
+
+
+
+
+
+
+ ); +} diff --git a/web/src/app/3D_lab/InteractiveLabScene.tsx b/web/src/app/3D_lab/InteractiveLabScene.tsx new file mode 100644 index 0000000..7a9b759 --- /dev/null +++ b/web/src/app/3D_lab/InteractiveLabScene.tsx @@ -0,0 +1,805 @@ +'use client'; + +import LogoLoading from '@/components/basic/loading'; +import { + Html, + OrbitControls, + PerspectiveCamera, + useGLTF, +} from '@react-three/drei'; +import { Canvas, useFrame } from '@react-three/fiber'; +import { Suspense, useRef, useState } from 'react'; +import type { Group, Mesh } from 'three'; +// 使用原生动画,无需额外依赖 +import { getDeviceInfo } from './deviceInfo'; +// 导入所有设备组件 +import { + Beaker, + Monitor, + PetriDishStack, + PipetteRack, + ReagentBottle, + ReagentRack, + SampleRack, + StorageCabinet, +} from './deviceComponents'; + +type Position3D = [number, number, number]; +type Rotation3D = [number, number, number]; + +interface PositionProps { + position?: Position3D; +} + +interface PositionRotationProps { + position?: Position3D; + rotation?: Rotation3D; +} + +interface LabBenchProps { + position?: Position3D; + width?: number; + depth?: number; +} + +interface ClickableDeviceProps extends PositionProps { + deviceId: string; + children: React.ReactNode; + onDeviceClick: (deviceId: string) => void; + isHighlighted?: boolean; + isAnimating?: boolean; + disabled?: boolean; +} + +// 可点击设备包装器 +function ClickableDevice({ + deviceId, + position = [0, 0, 0], + children, + onDeviceClick, + isHighlighted = false, + isAnimating = false, + disabled = false, +}: ClickableDeviceProps) { + const [hovered, setHovered] = useState(false); + const groupRef = useRef(null); + const deviceInfo = getDeviceInfo(deviceId); + + // 高亮动画 - 使用 useFrame 实现平滑过渡 + const targetScale = isHighlighted ? 1.05 : hovered ? 1.02 : 1; + const currentScale = useRef(1); + + useFrame(() => { + if (groupRef.current) { + currentScale.current += (targetScale - currentScale.current) * 0.1; + groupRef.current.scale.setScalar(currentScale.current); + } + }); + + // 点击动画 + useFrame((state) => { + if (groupRef.current && isAnimating) { + // 简单的脉冲动画 + const pulse = Math.sin(state.clock.elapsedTime * 2) * 0.05; + groupRef.current.scale.setScalar(1 + pulse); + } + }); + + return ( + { + if (disabled) return; + e.stopPropagation(); + onDeviceClick(deviceId); + }} + onPointerOver={(e) => { + if (disabled) return; + e.stopPropagation(); + setHovered(true); + document.body.style.cursor = 'pointer'; + }} + onPointerOut={() => { + if (disabled) return; + setHovered(false); + document.body.style.cursor = 'default'; + }} + > + {children} + + {/* 高亮光晕效果 */} + {(hovered || isHighlighted) && ( + + + + + )} + + {/* 悬浮提示 - 只在hover时显示,不在highlighted时显示,避免遮挡场景 */} + {hovered && !isHighlighted && deviceInfo && ( + +
+
{deviceInfo.name}
+
点击查看详情
+
+ + )} +
+ ); +} + +// 移液站模型组件 - 支持动画 +function LiquidHandlerModel({ + position = [0, 0.1, 0], + isAnimating = false, +}: PositionProps & { isAnimating?: boolean }) { + const OSS_BASE_URL = + 'https://storage.sciol.ac.cn/library/liquid_transform_xyz/meshes'; + + const baseLink = useGLTF(`${OSS_BASE_URL}/base_link.glb`); + const xLink = useGLTF(`${OSS_BASE_URL}/x_link.glb`); + const yLink = useGLTF(`${OSS_BASE_URL}/y_link.glb`); + const zLink = useGLTF(`${OSS_BASE_URL}/z_link.glb`); + + const xLinkRef = useRef(null); + const yLinkRef = useRef(null); + const zLinkRef = useRef(null); + + // 移液站工作动画:X、Y、Z 轴移动 + useFrame((state) => { + if (isAnimating) { + const time = state.clock.elapsedTime; + if (xLinkRef.current) { + xLinkRef.current.position.x = Math.sin(time * 0.5) * 0.3; + } + if (yLinkRef.current) { + yLinkRef.current.position.y = Math.sin(time * 0.7 + 1) * 0.2; + } + if (zLinkRef.current) { + zLinkRef.current.position.z = Math.sin(time * 0.6 + 2) * 0.25; + } + } + }); + + return ( + + + + + + + + + + + + + ); +} + +// 试剂瓶组 +function ReagentBottles({ position = [0, 0, 0] }: PositionProps) { + const bottles = [ + { pos: [0, 0, 0], color: '#ef4444' }, + { pos: [0.2, 0, 0], color: '#3b82f6' }, + { pos: [0.4, 0, 0], color: '#10b981' }, + { pos: [0, 0, 0.2], color: '#f59e0b' }, + { pos: [0.2, 0, 0.2], color: '#8b5cf6' }, + { pos: [0.4, 0, 0.2], color: '#06b6d4' }, + ]; + + return ( + + {bottles.map((bottle, i) => ( + + + + + + + + + + + + + + + ))} + + ); +} + +// 移液枪架 +function PipetteStand({ position = [0, 0, 0] }: PositionProps) { + return ( + + + + + + + + + + {[0, 0.1, 0.2].map((y, i) => ( + + + + + + + ))} + + ); +} + +// 实验室货架 +function LabShelf({ + position = [0, 0, 0], + rotation = [0, 0, 0], +}: PositionRotationProps) { + return ( + + {[ + [-0.4, 0.75, -0.2], + [0.4, 0.75, -0.2], + [-0.4, 0.75, 0.2], + [0.4, 0.75, 0.2], + ].map((pos, i) => ( + + + + + ))} + {[0.3, 0.7, 1.1, 1.5].map((y, i) => ( + + + + + ))} + + ); +} + +// 实验台组件 +function LabBench({ + position = [0, 0, 0], + width = 5, + depth = 2.5, +}: LabBenchProps) { + return ( + + + + + + {[ + [-width / 2 + 0.15, 0.45, -depth / 2 + 0.15], + [width / 2 - 0.15, 0.45, -depth / 2 + 0.15], + [-width / 2 + 0.15, 0.45, depth / 2 - 0.15], + [width / 2 - 0.15, 0.45, depth / 2 - 0.15], + ].map((pos, i) => ( + + + + + ))} + + + + + + ); +} + +// 显微镜 - 支持动画 +function Microscope({ + position = [0, 0, 0], + isAnimating = false, +}: PositionProps & { isAnimating?: boolean }) { + const lensRef = useRef(null); + + // 显微镜观察动画:镜头上下移动 + useFrame((state) => { + if (lensRef.current && isAnimating) { + const time = state.clock.elapsedTime; + lensRef.current.position.y = 0.5 + Math.sin(time * 1.5) * 0.05; + } + }); + + return ( + + + + + + + + + + + + + + + + + + + ); +} + +// AGV机器人 - 支持动画 +function AGVRobot({ + position = [0, 0, 0], + rotation = [0, 0, 0], + isAnimating = false, +}: PositionRotationProps & { isAnimating?: boolean }) { + const robotRef = useRef(null); + const armRef = useRef(null); + + // AGV移动和机械臂动画 + useFrame((state) => { + if (isAnimating) { + const time = state.clock.elapsedTime; + if (robotRef.current) { + // 前后移动 + robotRef.current.position.z = Math.sin(time * 0.3) * 0.5; + } + if (armRef.current) { + // 机械臂摆动 + armRef.current.rotation.y = Math.sin(time * 0.5) * 0.3; + armRef.current.rotation.z = Math.sin(time * 0.7) * 0.2; + } + } + }); + + return ( + + + + + + {[ + [-0.52, 0.1, 0.38], + [0.52, 0.1, 0.38], + [-0.52, 0.1, -0.38], + [0.52, 0.1, -0.38], + ].map((pos, i) => ( + + + + + ))} + + + + + + + + + + + + + + + + ); +} + +// 离心机 - 支持动画 +function Centrifuge({ + position = [0, 0, 0], + isAnimating = false, +}: PositionProps & { isAnimating?: boolean }) { + const rotorRef = useRef(null); + + // 离心机旋转动画 + + return ( + + + + + + + + + + + + + + + + + + + ); +} + +// 其他设备组件(简化版,从原文件导入) +// 为了简化,这里只实现关键的可点击设备 +// 其他设备可以从 deviceComponents.tsx 导入 + +interface InteractiveLabSceneProps { + onDeviceClick: (deviceId: string) => void; + highlightedDevice?: string | null; + animatingDevice?: string | null; + disabled?: boolean; // 禁用交互(当模态框打开时) +} + +// 交互式实验室场景 +function InteractiveLabScene({ + onDeviceClick, + highlightedDevice, + animatingDevice, + disabled = false, +}: InteractiveLabSceneProps) { + return ( + <> + + + + {/* 环境光照 */} + + + + + {/* 天花板灯光 */} + {[ + [-4, 4.5, -2], + [4, 4.5, -2], + [-4, 4.5, 3], + [4, 4.5, 3], + [0, 4.5, 0], + ].map((pos, i) => ( + + ))} + + {/* 中央工作台 */} + + + + + + + + + + + + + + + + {/* 左侧工作台 */} + + + + + + + + + + + + + + + + + + + + + {/* 右侧工作台 */} + + + + + + + + + + + + + + + + + {/* 后方工作台 */} + + + + + + + + + + + + + + + + + {/* 前方工作台 */} + + + + + + + + {/* AGV 小车 */} + + + + + + + + {/* 储物柜 - 靠墙排列 */} + + + + + + + + {/* 开放式货架 - 摆满试剂 */} + + + + {/* 架子上的物品 - 左侧 */} + + + + + + + + + {/* 架子上的物品 - 右侧 */} + + + + + + + + + {/* 地板 */} + + + + + + + ); +} + +// 加载占位组件 +function LoadingFallback() { + return ( +
+ +
+ ); +} + +// 主组件 +export default function InteractiveLabSceneComponent({ + onDeviceClick, + highlightedDevice, + animatingDevice, +}: InteractiveLabSceneProps) { + return ( +
+ }> + + + + +
+ ); +} diff --git a/web/src/app/3D_lab/deviceComponents.tsx b/web/src/app/3D_lab/deviceComponents.tsx new file mode 100644 index 0000000..849c91d --- /dev/null +++ b/web/src/app/3D_lab/deviceComponents.tsx @@ -0,0 +1,505 @@ +// 从 LabScene3D 导出所有设备组件,供 DeviceDetailModal 使用 +import { useGLTF } from '@react-three/drei'; +import { useFrame } from '@react-three/fiber'; +import type { JSX } from 'react'; +import { useRef } from 'react'; +import type { Group, Mesh } from 'three'; + +type Position3D = [number, number, number]; +type Rotation3D = [number, number, number]; + +interface PositionProps { + position?: Position3D; +} + +interface PositionRotationProps { + position?: Position3D; + rotation?: Rotation3D; +} + +// 移液站模型组件 +export function LiquidHandlerModel({ + position = [0, 0.1, 0], + isAnimating = false, +}: PositionProps & { isAnimating?: boolean }): JSX.Element { + const OSS_BASE_URL = + 'https://storage.sciol.ac.cn/library/liquid_transform_xyz/meshes'; + + const baseLink = useGLTF(`${OSS_BASE_URL}/base_link.glb`); + const xLink = useGLTF(`${OSS_BASE_URL}/x_link.glb`); + const yLink = useGLTF(`${OSS_BASE_URL}/y_link.glb`); + const zLink = useGLTF(`${OSS_BASE_URL}/z_link.glb`); + + const xLinkRef = useRef(null); + const yLinkRef = useRef(null); + const zLinkRef = useRef(null); + + // 移液站工作动画:X、Y、Z 轴移动 + useFrame((state) => { + if (isAnimating) { + const time = state.clock.elapsedTime; + if (xLinkRef.current) { + xLinkRef.current.position.x = Math.sin(time * 0.5) * 0.3; + } + if (yLinkRef.current) { + yLinkRef.current.position.y = Math.sin(time * 0.7 + 1) * 0.2; + } + if (zLinkRef.current) { + zLinkRef.current.position.z = Math.sin(time * 0.6 + 2) * 0.25; + } + } + }); + + return ( + + + + + + + + + + + + + ); +} + +// 显微镜 +export function Microscope({ + position = [0, 0, 0], + isAnimating = false, +}: PositionProps & { isAnimating?: boolean }): JSX.Element { + const lensRef = useRef(null); + + // 显微镜观察动画:镜头上下移动 + useFrame((state) => { + if (lensRef.current && isAnimating) { + const time = state.clock.elapsedTime; + lensRef.current.position.y = 0.5 + Math.sin(time * 1.5) * 0.05; + } + }); + + return ( + + + + + + + + + + + + + + + + + + + ); +} + +// 显示器 +export function Monitor({ + position = [0, 0, 0], + rotation = [0, 0, 0], +}: PositionRotationProps): JSX.Element { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +// AGV机器人(简化版,只显示核心部分) +export function AGVRobot({ + position = [0, 0, 0], + rotation = [0, 0, 0], + isAnimating = false, +}: PositionRotationProps & { isAnimating?: boolean }): JSX.Element { + const robotRef = useRef(null); + const armRef = useRef(null); + + // AGV移动和机械臂动画 + useFrame((state) => { + if (isAnimating) { + const time = state.clock.elapsedTime; + if (robotRef.current) { + // 前后移动 + robotRef.current.position.z = Math.sin(time * 0.3) * 0.5; + } + if (armRef.current) { + // 机械臂摆动 + armRef.current.rotation.y = Math.sin(time * 0.5) * 0.3; + armRef.current.rotation.z = Math.sin(time * 0.7) * 0.2; + } + } + }); + + return ( + + + + + + {[ + [-0.52, 0.1, 0.38], + [0.52, 0.1, 0.38], + [-0.52, 0.1, -0.38], + [0.52, 0.1, -0.38], + ].map((pos, i) => ( + + + + + ))} + + + + + + + + + + + + + + + + ); +} + +// 离心机 +export function Centrifuge({ + position = [0, 0, 0], + isAnimating = false, +}: PositionProps & { isAnimating?: boolean }): JSX.Element { + const rotorRef = useRef(null); + + // 离心机旋转动画 + useFrame(() => { + if (rotorRef.current && isAnimating) { + rotorRef.current.rotation.y += 0.1; + } + }); + + return ( + + + + + + + + + + + + + + + + + + + ); +} + +// 移液枪架 +export function PipetteRack({ + position = [0, 0, 0], +}: PositionProps): JSX.Element { + const colors = ['#ef4444', '#3b82f6', '#10b981', '#f59e0b']; + + return ( + + + + + + {colors.map((color, i) => ( + + + + + + + ))} + + ); +} + +// 烧杯 +export function Beaker({ + position = [0, 0, 0], + color = '#3b82f6', +}: PositionProps & { color?: string }): JSX.Element { + return ( + + + + + + + + + + + ); +} + +// 储物柜 +export function StorageCabinet({ + position = [0, 0, 0], +}: PositionProps): JSX.Element { + return ( + + + + + + {[ + [0.2, 1.35, 0.301], + [-0.2, 1.35, 0.301], + [0.2, 0.45, 0.301], + [-0.2, 0.45, 0.301], + ].map((pos, i) => ( + + + + + ))} + + ); +} + +// 试剂瓶架 +export function ReagentRack({ + position = [0, 0, 0], +}: PositionProps): JSX.Element { + const colors = [ + '#ef4444', + '#f59e0b', + '#10b981', + '#3b82f6', + '#8b5cf6', + '#ec4899', + ]; + const bottles = []; + + for (let row = 0; row < 3; row++) { + for (let col = 0; col < 3; col++) { + bottles.push({ + pos: [col * 0.15 - 0.15, 0, row * 0.15 - 0.15] as Position3D, + color: colors[(row * 3 + col) % colors.length], + }); + } + } + + return ( + + + + + + {bottles.map((bottle, i) => ( + + ))} + + ); +} + +// 试剂瓶 +export function ReagentBottle({ + position = [0, 0, 0], + color = '#3b82f6', + size = 'medium', +}: PositionProps & { + color?: string; + size?: 'small' | 'medium' | 'large'; +}): JSX.Element { + const sizes = { + small: { radius: 0.04, height: 0.12 }, + medium: { radius: 0.06, height: 0.2 }, + large: { radius: 0.08, height: 0.28 }, + }; + const { radius, height } = sizes[size]; + + return ( + + + + + + + + + + + + + + + ); +} + +// 培养皿 +export function PetriDishStack({ + position = [0, 0, 0], +}: PositionProps): JSX.Element { + return ( + + {[0, 0.015, 0.03, 0.045, 0.06].map((y, i) => ( + + + + + ))} + + ); +} + +// 样品架 +export function SampleRack({ + position = [0, 0, 0], +}: PositionProps): JSX.Element { + return ( + + + + + + {Array.from({ length: 48 }).map((_, i) => { + const row = Math.floor(i / 8); + const col = i % 8; + return ( + + + + + ); + })} + + ); +} diff --git a/web/src/app/3D_lab/deviceInfo.ts b/web/src/app/3D_lab/deviceInfo.ts new file mode 100644 index 0000000..c1f00b9 --- /dev/null +++ b/web/src/app/3D_lab/deviceInfo.ts @@ -0,0 +1,118 @@ +// 设备信息配置 +export interface DeviceInfo { + id: string; + name: string; + nameEn: string; + description: string; + specs?: string[]; + usage?: string; +} + +export const DEVICE_INFO: Record = { + 'liquid-handler': { + id: 'liquid-handler', + name: '自动移液工作站', + nameEn: 'Liquid Handler', + description: '高精度自动化液体处理系统,用于样品分配、稀释和混合操作', + specs: ['精度:±1.5%', '工作范围:0.5-1000μL', '96/384孔板兼容'], + usage: '用于高通量样品制备、PCR反应体系配置、细胞培养等实验', + }, + microscope: { + id: 'microscope', + name: '光学显微镜', + nameEn: 'Microscope', + description: '高分辨率光学显微镜,用于细胞观察和微观结构分析', + specs: ['放大倍数:40x-1000x', '分辨率:0.2μm', '数字成像系统'], + usage: '用于细胞形态观察、组织切片分析、微生物检测等', + }, + monitor: { + id: 'monitor', + name: '工作站电脑', + nameEn: 'Workstation', + description: '实验室数据处理和设备控制工作站', + specs: ['24英寸4K显示屏', '高性能处理器', '专业图形显卡'], + usage: '用于实验数据分析、设备程序控制、结果可视化', + }, + 'agv-robot': { + id: 'agv-robot', + name: '智能AGV运输机器人', + nameEn: 'AGV Robot', + description: '配备6轴机械臂的自主移动机器人,实现实验室自动化物流', + specs: ['载重:50kg', '精度:±2mm', '6自由度机械臂', '自主导航'], + usage: '用于样品运输、耗材配送、设备间协作等自动化任务', + }, + centrifuge: { + id: 'centrifuge', + name: '台式离心机', + nameEn: 'Centrifuge', + description: '高速台式离心机,用于样品分离和沉淀', + specs: ['最高转速:15000 rpm', '容量:24×1.5ml', '温度控制:-10~40℃'], + usage: '用于DNA/RNA提取、蛋白质纯化、细胞分离等', + }, + 'pipette-rack': { + id: 'pipette-rack', + name: '移液枪架', + nameEn: 'Pipette Rack', + description: '多通道移液枪存储架,配备不同量程移液枪', + specs: ['容纳数量:4-8支', '量程:0.5-1000μL', '不锈钢材质'], + usage: '用于存放和快速取用各种规格的移液枪', + }, + beaker: { + id: 'beaker', + name: '烧杯', + nameEn: 'Beaker', + description: '标准实验室玻璃烧杯,用于溶液配置和反应', + specs: ['容量:50-1000ml', '材质:硼硅玻璃', '耐温:-70~500℃'], + usage: '用于溶液混合、加热反应、样品储存等', + }, + 'storage-cabinet': { + id: 'storage-cabinet', + name: '实验室储物柜', + nameEn: 'Storage Cabinet', + description: '标准实验室储物柜,用于存放试剂和耗材', + specs: ['尺寸:90×180×60cm', '防腐蚀材质', '多层分隔'], + usage: '用于存放化学试剂、实验耗材、个人防护用品等', + }, + 'reagent-rack': { + id: 'reagent-rack', + name: '试剂瓶架', + nameEn: 'Reagent Rack', + description: '多位试剂瓶存储架,整齐存放各类试剂', + specs: ['容量:9-16瓶', '防腐蚀托盘', '标签系统'], + usage: '用于分类存放和管理各种化学试剂、缓冲液等', + }, + 'reagent-bottle': { + id: 'reagent-bottle', + name: '试剂瓶', + nameEn: 'Reagent Bottle', + description: '标准实验室试剂瓶,密封保存各类试剂', + specs: ['容量:50-1000ml', '材质:玻璃/塑料', '密封瓶盖'], + usage: '用于存放和使用各种化学试剂、溶液', + }, + 'petri-dish': { + id: 'petri-dish', + name: '培养皿', + nameEn: 'Petri Dish', + description: '无菌塑料培养皿,用于微生物和细胞培养', + specs: ['直径:90mm', '材质:聚苯乙烯', '灭菌处理'], + usage: '用于细菌培养、细胞培养、菌落计数等', + }, + 'sample-rack': { + id: 'sample-rack', + name: '微孔板', + nameEn: 'Microplate', + description: '96孔标准微孔板,用于高通量实验', + specs: ['规格:96/384孔', '材质:聚丙烯', '体积:50-300μL/孔'], + usage: '用于ELISA、PCR、细胞培养等高通量实验', + }, +}; + +// 获取设备信息 +export function getDeviceInfo(deviceId: string): DeviceInfo | null { + return DEVICE_INFO[deviceId] || null; +} + +// 获取所有可交互设备列表 +export function getAllDeviceIds(): string[] { + return Object.keys(DEVICE_INFO); +} diff --git a/web/src/app/3D_lab/page.tsx b/web/src/app/3D_lab/page.tsx new file mode 100644 index 0000000..c4c8db4 --- /dev/null +++ b/web/src/app/3D_lab/page.tsx @@ -0,0 +1,224 @@ +'use client'; + +import { useUI } from '@/hooks/useUI'; +import { useEffect, useState } from 'react'; +import DeviceDetailModal from './DeviceDetailModal'; +import { getAllDeviceIds, getDeviceInfo } from './deviceInfo'; +import InteractiveLabScene from './InteractiveLabScene'; +import './styles.css'; + +export default function Lab3DPage() { + // 初始化主题系统,跟随主页设置 + useUI(); + + const [selectedDevice, setSelectedDevice] = useState(null); + const [showDeviceList, setShowDeviceList] = useState(false); + const [highlightedDevice, setHighlightedDevice] = useState( + null + ); + const [animatingDevice, setAnimatingDevice] = useState(null); + + const deviceIds = getAllDeviceIds(); + + // 处理设备点击 + const handleDeviceClick = (deviceId: string) => { + setSelectedDevice(deviceId); + setHighlightedDevice(deviceId); + // 自动开始动画演示 + setAnimatingDevice(deviceId); + }; + + // 当模态框关闭时,停止高亮和动画 + useEffect(() => { + if (!selectedDevice) { + setHighlightedDevice(null); + setAnimatingDevice(null); + } + }, [selectedDevice]); + + // 处理动画控制 + const handleToggleAnimation = (deviceId: string) => { + if (animatingDevice === deviceId) { + setAnimatingDevice(null); + } else { + setAnimatingDevice(deviceId); + } + }; + + return ( +
+ {/* 3D 交互式场景 */} + + + {/* 页面标题 */} +
+
+

+ 3D 智能实验室 +

+

+ Interactive Laboratory Visualization +

+
+
+ + {/* 设备列表按钮 */} +
+ +
+ + {/* 设备列表侧边栏 */} + {showDeviceList && ( +
+
+ {/* 头部 */} +
+

+ 实验室设备 +

+ +
+ + {/* 滚动内容区域 */} +
+
+ {deviceIds.map((deviceId) => { + const info = getDeviceInfo(deviceId); + if (!info) return null; + + return ( + + ); + })} +
+
+
+
+ )} + + {/* 操作指南 */} +
+
+
+
+ 🖱️ + 拖动旋转 +
+
+ ⚙️ + 滚轮缩放 +
+
+ 👆 + 点击设备查看 +
+
+ 📋 + 右上查看列表 +
+
+
+
+ + {/* 快速访问设备卡片(底部) */} +
+
+
+
+ {['liquid-handler', 'microscope', 'agv-robot', 'centrifuge'].map( + (deviceId) => { + const info = getDeviceInfo(deviceId); + if (!info) return null; + + return ( + + ); + } + )} +
+
+
+
+ + {/* 设备详情模态框 */} + {selectedDevice && ( + setSelectedDevice(null)} + isAnimating={animatingDevice === selectedDevice} + onToggleAnimation={() => handleToggleAnimation(selectedDevice)} + /> + )} +
+ ); +} diff --git a/web/src/app/3D_lab/styles.css b/web/src/app/3D_lab/styles.css new file mode 100644 index 0000000..73a0a84 --- /dev/null +++ b/web/src/app/3D_lab/styles.css @@ -0,0 +1,104 @@ +/* 3D Lab 页面样式 */ + +@keyframes fade-in { + from { + opacity: 0; + transform: translateY(-10px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.animate-fade-in { + animation: fade-in 0.3s ease-out; +} + +@keyframes pulse-slow { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.5; + } +} + +.animate-pulse-slow { + animation: pulse-slow 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +/* 延迟动画 */ +.delay-75 { + animation-delay: 75ms; +} + +.delay-150 { + animation-delay: 150ms; +} + +/* 自定义滚动条样式 - 覆盖 Tailwind utility */ +.custom-scrollbar { + /* Firefox */ + scrollbar-width: thin !important; + scrollbar-color: rgba(156, 163, 175, 0.5) rgba(0, 0, 0, 0.05) !important; + /* 强制显示滚动条 */ + overflow-y: auto !important; + overflow-x: hidden !important; +} + +/* Webkit 浏览器(Chrome, Safari, Edge)的滚动条样式 */ +.custom-scrollbar::-webkit-scrollbar { + width: 10px !important; + height: 10px !important; + -webkit-appearance: none; + display: block !important; +} + +.custom-scrollbar::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.05) !important; + border-radius: 5px; + margin: 4px 0; +} + +.dark .custom-scrollbar::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.05) !important; +} + +.custom-scrollbar::-webkit-scrollbar-thumb { + background-color: rgba(156, 163, 175, 0.5) !important; + border-radius: 5px !important; + border: 2px solid transparent; + background-clip: padding-box; + -webkit-appearance: none; + min-height: 20px; +} + +.custom-scrollbar::-webkit-scrollbar-thumb:hover { + background-color: rgba(156, 163, 175, 0.6) !important; +} + +.custom-scrollbar::-webkit-scrollbar-thumb:active { + background-color: rgba(156, 163, 175, 0.8) !important; +} + +/* 深色模式下的滚动条 */ +.dark .custom-scrollbar { + scrollbar-color: rgba(156, 163, 175, 0.5) transparent !important; +} + +.dark .custom-scrollbar::-webkit-scrollbar-thumb { + background-color: rgba(156, 163, 175, 0.5) !important; +} + +.dark .custom-scrollbar::-webkit-scrollbar-thumb:hover { + background-color: rgba(156, 163, 175, 0.7) !important; +} + +.dark .custom-scrollbar::-webkit-scrollbar-thumb:active { + background-color: rgba(156, 163, 175, 0.9) !important; +} diff --git a/web/src/app/navbar/NavbarFullWidth.tsx b/web/src/app/navbar/NavbarFullWidth.tsx index b08aad4..3347edf 100644 --- a/web/src/app/navbar/NavbarFullWidth.tsx +++ b/web/src/app/navbar/NavbarFullWidth.tsx @@ -4,6 +4,7 @@ import clsx from 'clsx'; import { Fragment } from 'react'; import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; import NavbarFullWidthFooter from './NavbarFullWidthFooter'; import type { NavbarFullWidthProps } from './types'; @@ -102,13 +103,25 @@ export default function NavbarFullWidth({ />
- - {item.name} - - + {item.href && item.href.startsWith('http') ? ( + + {item.name} + + + ) : ( + + {item.name} + + + )}

{item.description}

diff --git a/web/src/app/navbar/Projects.tsx b/web/src/app/navbar/Projects.tsx index 6db9cb6..14cdb78 100644 --- a/web/src/app/navbar/Projects.tsx +++ b/web/src/app/navbar/Projects.tsx @@ -134,7 +134,7 @@ import { // RectangleGroupIcon, } from '@heroicons/react/24/outline'; -import { SiUnrealengine,SiUnity,SiProton,SiX,SiStmicroelectronics } from 'react-icons/si'; +import { SiUnrealengine,SiUnity,SiProton,SiX,SiStmicroelectronics,SiBlender } from 'react-icons/si'; import { GitHubIcon } from '@/assets/SocialIcons'; import NavbarFullWidth from './NavbarFullWidth'; import type { NavbarFullWidthProps } from './types'; @@ -165,7 +165,7 @@ const resources = [ { name: 'Anti', description: '用于实验室模拟的3D数字孪生平台', - href: `/deepmd-kit`, + href: `/3D_lab`, icon: SiUnity, color:'text-rose-500', }, @@ -175,7 +175,14 @@ const resources = [ href: `/deepmd-kit`, icon: SiUnrealengine, color:'text-emerald-500', - } + }, + { + name:'3D Lab', + description: '展示3D实验室的实验仪器和场景', + href: `/3D_lab`, + icon: SiBlender, + color:'text-purple-500', + }, ]; const callsToAction = [ diff --git a/web/src/router.tsx b/web/src/router.tsx index 10a1f92..367c438 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -1,22 +1,23 @@ -import { Suspense, lazy } from "react"; -import { BrowserRouter, Route, Routes } from "react-router-dom"; -import App from "./app/App"; -import ProtectedDashboardLayout from "./components/layout/ProtectedDashboardPage"; +import { lazy, Suspense } from 'react'; +import { BrowserRouter, Route, Routes } from 'react-router-dom'; +import App from './app/App'; +import ProtectedDashboardLayout from './components/layout/ProtectedDashboardPage'; // 路由懒加载 -const ChatPage = lazy(() => import("./app/chat/page")); +const ChatPage = lazy(() => import('./app/chat/page')); const EnvironmentPage = lazy(() => - import("./app/dashboard/environment").then((module) => ({ + import('./app/dashboard/environment').then((module) => ({ default: module.EnvironmentPage, })) ); const EnvironmentDetail = lazy( - () => import("./app/dashboard/environment/EnvironmentDetail") + () => import('./app/dashboard/environment/EnvironmentDetail') ); -const DesktopWindow = lazy(() => import("./app/dashboard/Desktop")); -const CallbackPage = lazy(() => import("./app/login/CallbackPage")); -const LoginPage = lazy(() => import("./app/login/LoginPage")); -const UiTestPage = lazy(() => import("./app/ui/page")); +const DashboardHome = lazy(() => import('./app/dashboard/Home')); +const CallbackPage = lazy(() => import('./app/login/CallbackPage')); +const LoginPage = lazy(() => import('./app/login/LoginPage')); +const UiTestPage = lazy(() => import('./app/ui/page')); +const Lab3DPage = lazy(() => import('./app/3D_lab/page')); const LoadingFallback = () => (
@@ -30,18 +31,19 @@ export default function Router() { }> {/* 根路径 - App 组件根据登录状态分流 */} + } /> } /> - {/* 公开路由 */} - } /> - } /> - } /> - } /> + {/* 公开路由 */} + } /> + } /> + } /> + } /> + } /> {/* 所有需要侧边栏和登录保护的页面 */} }> - {/*} />*/} - } /> + } /> } />