+ {/* 加载占位符 */}
+ {isLoading && !placeholder && (
+
+ )}
+
+ {/* 错误状态 */}
+ {hasError && (
+
+ )}
+
+ {/* 遮罩层 */}
+ {overlay && isLoaded && (
+
+ )}
+
+ {/* 内容 */}
+ {children && (
+
{children}
+ )}
+
+ );
+}
diff --git a/src/components/DraggableItem.tsx b/src/components/DraggableItem.tsx
index f08c299..0e69493 100644
--- a/src/components/DraggableItem.tsx
+++ b/src/components/DraggableItem.tsx
@@ -1,14 +1,14 @@
-import { ReactNode } from 'react';
-import { useSortable } from '@dnd-kit/sortable';
-import { CSS } from '@dnd-kit/utilities';
-import { GripVertical } from 'lucide-react';
+import { ReactNode } from "react";
+import { useSortable } from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
+import { GripVertical } from "lucide-react";
interface DraggableItemProps {
id: string;
children: ReactNode;
}
-export function DraggableItem({ id, children }: DraggableItemProps) {
+export default function DraggableItem({ id, children }: DraggableItemProps) {
const {
attributes,
listeners,
@@ -29,7 +29,7 @@ export function DraggableItem({ id, children }: DraggableItemProps) {
ref={setNodeRef}
style={style}
className={`bg-white border border-gray-200 rounded-lg p-4 mb-3 flex items-center gap-3 shadow-sm hover:shadow-md transition-shadow ${
- isDragging ? 'cursor-grabbing' : 'cursor-grab'
+ isDragging ? "cursor-grabbing" : "cursor-grab"
}`}
>
void;
+ onError?: () => void;
+ onClick?: () => void;
+ draggable?: boolean;
+ quality?: number;
+ blur?: boolean;
+ button?: boolean;
+}
+
+export default function Image({
+ src,
+ alt,
+ className,
+ width,
+ height,
+ objectFit = "cover",
+ objectPosition = "center",
+ loading = "lazy",
+ placeholder,
+ fallback,
+ onLoad,
+ onError,
+ onClick,
+ draggable = false,
+ quality = 80,
+ blur = false,
+ button = false,
+}: ImageProps) {
+ const [imageSrc, setImageSrc] = useState(src);
+ const [isLoading, setIsLoading] = useState(true);
+ const [hasError, setHasError] = useState(false);
+ const [isLoaded, setIsLoaded] = useState(false);
+ const imgRef = useRef
(null);
+
+ // 处理图片加载
+ const handleLoad = () => {
+ setIsLoading(false);
+ setIsLoaded(true);
+ setHasError(false);
+ onLoad?.();
+ };
+
+ // 处理图片错误
+ const handleError = () => {
+ setIsLoading(false);
+ setHasError(true);
+ if (fallback && imageSrc !== fallback) {
+ setImageSrc(fallback);
+ }
+ onError?.();
+ };
+
+ // 重置状态当src改变时
+ useEffect(() => {
+ if (src !== imageSrc) {
+ setImageSrc(src);
+ setIsLoading(true);
+ setHasError(false);
+ setIsLoaded(false);
+ }
+ }, [src, imageSrc]);
+
+ // 懒加载处理
+ useEffect(() => {
+ if (loading === "lazy" && imgRef.current) {
+ const observer = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ if (entry.isIntersecting) {
+ setImageSrc(src);
+ observer.disconnect();
+ }
+ });
+ },
+ { threshold: 0.1 }
+ );
+
+ observer.observe(imgRef.current);
+ return () => observer.disconnect();
+ }
+ }, [loading, src]);
+
+ const imageStyle = {
+ width: width,
+ height: height,
+ objectFit,
+ objectPosition,
+ filter: blur ? "blur(4px)" : "none",
+ transition: "filter 0.3s ease",
+ };
+
+ return (
+
+ {/* 加载占位符 */}
+ {isLoading && placeholder && (
+
+ )}
+
+ {/* 加载中状态 */}
+ {isLoading && !placeholder && (
+
+ )}
+
+ {/* 错误状态 */}
+ {hasError && (
+
+ )}
+
+ {/* 实际图片 */}
+

+
+ );
+}
diff --git a/src/components/index.ts b/src/components/index.ts
new file mode 100644
index 0000000..1baccda
--- /dev/null
+++ b/src/components/index.ts
@@ -0,0 +1,3 @@
+export { default as Image } from "./Image";
+export { default as BackgroundImage } from "./BackgroundImage";
+export { default as DraggableItem } from "./DraggableItem";
diff --git a/src/contexts/ScaleContext.tsx b/src/contexts/ScaleContext.tsx
new file mode 100644
index 0000000..94c2902
--- /dev/null
+++ b/src/contexts/ScaleContext.tsx
@@ -0,0 +1,31 @@
+import { createContext, useContext, ReactNode } from "react";
+
+export interface ScaleContextValue {
+ scale: number;
+ offsetX: number;
+ offsetY: number;
+ DESIGN_WIDTH: number;
+ DESIGN_HEIGHT: number;
+}
+
+const ScaleContext = createContext(null);
+
+export function ScaleProvider({
+ children,
+ value,
+}: {
+ children: ReactNode;
+ value: ScaleContextValue;
+}) {
+ return (
+ {children}
+ );
+}
+
+export function useScale() {
+ const context = useContext(ScaleContext);
+ if (!context) {
+ throw new Error("useScale must be used within ScaleProvider");
+ }
+ return context;
+}
diff --git a/src/hooks/useDragAndDrop.ts b/src/hooks/useDragAndDrop.ts
new file mode 100644
index 0000000..a0f4d82
--- /dev/null
+++ b/src/hooks/useDragAndDrop.ts
@@ -0,0 +1,143 @@
+import { useState, useEffect, useCallback, useRef } from "react";
+import { useScale } from "@/contexts/ScaleContext";
+
+interface DragState {
+ isDragging: boolean;
+ dragData: T | null;
+ mousePosition: { x: number; y: number };
+}
+
+/**
+ * 自定义拖拽 Hook
+ * 支持页面 scale 缩放的拖拽功能
+ */
+export function useDragAndDrop() {
+ const { scale, offsetX, offsetY } = useScale();
+ const [state, setState] = useState>({
+ isDragging: false,
+ dragData: null,
+ mousePosition: { x: 0, y: 0 },
+ });
+
+ // 开始拖拽
+ const startDrag = useCallback((data: T, event?: React.MouseEvent) => {
+ if (event) {
+ event.preventDefault();
+ event.stopPropagation();
+
+ // 立即获取鼠标初始位置
+ setState({
+ isDragging: true,
+ dragData: data,
+ mousePosition: { x: event.clientX, y: event.clientY },
+ });
+ } else {
+ setState({
+ isDragging: true,
+ dragData: data,
+ mousePosition: { x: 0, y: 0 },
+ });
+ }
+ }, []);
+
+ // 结束拖拽
+ const endDrag = useCallback(() => {
+ setState({
+ isDragging: false,
+ dragData: null,
+ mousePosition: { x: 0, y: 0 },
+ });
+ }, []);
+
+ // 监听鼠标移动
+ useEffect(() => {
+ if (!state.isDragging) return;
+
+ const handleMouseMove = (e: MouseEvent) => {
+ setState((prev) => ({
+ ...prev,
+ mousePosition: { x: e.clientX, y: e.clientY },
+ }));
+ };
+
+ const handleMouseUp = () => {
+ // 拖拽结束的逻辑由外部处理
+ // 这里只是提供一个安全机制
+ };
+
+ window.addEventListener("mousemove", handleMouseMove);
+ window.addEventListener("mouseup", handleMouseUp);
+
+ return () => {
+ window.removeEventListener("mousemove", handleMouseMove);
+ window.removeEventListener("mouseup", handleMouseUp);
+ };
+ }, [state.isDragging]);
+
+ // 将屏幕坐标转换为缩放后的页面坐标
+ const screenToScaledCoords = useCallback(
+ (screenX: number, screenY: number) => {
+ return {
+ x: (screenX - offsetX) / scale,
+ y: (screenY - offsetY) / scale,
+ };
+ },
+ [scale, offsetX, offsetY]
+ );
+
+ // 检查点是否在元素内
+ const isPointInElement = useCallback(
+ (pointX: number, pointY: number, element: HTMLElement | null): boolean => {
+ if (!element) return false;
+
+ const rect = element.getBoundingClientRect();
+ return (
+ pointX >= rect.left &&
+ pointX <= rect.right &&
+ pointY >= rect.top &&
+ pointY <= rect.bottom
+ );
+ },
+ []
+ );
+
+ return {
+ isDragging: state.isDragging,
+ dragData: state.dragData,
+ mousePosition: state.mousePosition,
+ startDrag,
+ endDrag,
+ screenToScaledCoords,
+ isPointInElement,
+ };
+}
+
+/**
+ * 放置区域 Hook
+ * 用于检测拖拽元素是否悬停在放置区域上
+ */
+export function useDropZone() {
+ const dropZoneRef = useRef(null);
+ const [isOver, setIsOver] = useState(false);
+
+ // 检查鼠标是否在放置区域内
+ const checkIsOver = useCallback((mouseX: number, mouseY: number) => {
+ if (!dropZoneRef.current) return false;
+
+ const rect = dropZoneRef.current.getBoundingClientRect();
+ const over =
+ mouseX >= rect.left &&
+ mouseX <= rect.right &&
+ mouseY >= rect.top &&
+ mouseY <= rect.bottom;
+
+ setIsOver(over);
+ return over;
+ }, []);
+
+ return {
+ dropZoneRef,
+ isOver,
+ checkIsOver,
+ };
+}
diff --git a/src/hooks/usePageScale.ts b/src/hooks/usePageScale.ts
new file mode 100644
index 0000000..26973d1
--- /dev/null
+++ b/src/hooks/usePageScale.ts
@@ -0,0 +1,86 @@
+import { useEffect, useState, useMemo } from "react";
+
+/**
+ * 等比例缩放Hook
+ * 基于1920*1080设计稿进行等比例缩放
+ */
+export function usePageScale() {
+ // 设计稿尺寸
+ const DESIGN_WIDTH = 1920;
+ const DESIGN_HEIGHT = 1080;
+
+ // 当前窗口尺寸
+ const [windowWidth, setWindowWidth] = useState(window.innerWidth);
+ const [windowHeight, setWindowHeight] = useState(window.innerHeight);
+
+ // 缩放比例
+ const scale = useMemo(() => {
+ const scaleX = windowWidth / DESIGN_WIDTH;
+ const scaleY = windowHeight / DESIGN_HEIGHT;
+
+ // 取较小的缩放比例,确保内容完全显示在视窗内
+ return Math.min(scaleX, scaleY);
+ }, [windowWidth, windowHeight]);
+
+ // 缩放后的实际尺寸
+ const scaledWidth = useMemo(() => DESIGN_WIDTH * scale, [scale]);
+ const scaledHeight = useMemo(() => DESIGN_HEIGHT * scale, [scale]);
+
+ // 居中偏移量
+ const offsetX = useMemo(
+ () => (windowWidth - scaledWidth) / 2,
+ [windowWidth, scaledWidth]
+ );
+ const offsetY = useMemo(
+ () => (windowHeight - scaledHeight) / 2,
+ [windowHeight, scaledHeight]
+ );
+
+ // 缩放样式对象
+ const scaleStyle = useMemo(
+ () => ({
+ transform: `scale(${scale})`,
+ transformOrigin: "top left",
+ width: `${DESIGN_WIDTH}px`,
+ height: `${DESIGN_HEIGHT}px`,
+ position: "absolute" as const,
+ left: `${offsetX}px`,
+ top: `${offsetY}px`,
+ }),
+ [scale, offsetX, offsetY]
+ );
+
+ // 更新窗口尺寸
+ const updateWindowSize = () => {
+ setWindowWidth(window.innerWidth);
+ setWindowHeight(window.innerHeight);
+ };
+
+ // 监听窗口大小变化
+ useEffect(() => {
+ window.addEventListener("resize", updateWindowSize);
+ updateWindowSize();
+
+ return () => {
+ window.removeEventListener("resize", updateWindowSize);
+ };
+ }, []);
+
+ return {
+ // 响应式数据
+ windowWidth,
+ windowHeight,
+ scale,
+ scaledWidth,
+ scaledHeight,
+ offsetX,
+ offsetY,
+
+ // 样式对象
+ scaleStyle,
+
+ // 常量
+ DESIGN_WIDTH,
+ DESIGN_HEIGHT,
+ };
+}
diff --git a/src/index.css b/src/index.css
index b5c61c9..1479c45 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,3 +1,19 @@
-@tailwind base;
-@tailwind components;
-@tailwind utilities;
+@import "tailwindcss";
+
+/* *{ */
+ /* margin: 0;
+ padding: 0; */
+ /* box-sizing: border-box; */
+/* } */
+
+body{
+ width: 100vw;
+ height: 100vh;
+ overflow: hidden;
+ background-color: #000;
+}
+
+#root{
+ width: 100%;
+ height: 100%;
+}
\ No newline at end of file
diff --git a/src/pages/ExperimentDetail.tsx b/src/pages/ExperimentDetail.tsx
deleted file mode 100644
index 3dccd5c..0000000
--- a/src/pages/ExperimentDetail.tsx
+++ /dev/null
@@ -1,190 +0,0 @@
-import { useParams, useNavigate, Link } from 'react-router-dom';
-import { ArrowLeft, BookOpen, Target, Clock, FileText } from 'lucide-react';
-import { defaultExperiments } from '../data/experiments';
-
-export default function ExperimentDetail() {
- const { id } = useParams();
- const navigate = useNavigate();
-
- const experiment = defaultExperiments.find((exp) => exp.id === id);
-
- if (!experiment) {
- return (
-
-
-
实验不存在
-
抱歉,未找到该实验项目。
-
-
- 返回首页
-
-
-
- );
- }
-
- const Icon = experiment.icon;
-
- return (
-
-
-
-
- {/* 实验头部 */}
-
-
-
-
-
-
-
{experiment.name}
-
{experiment.description}
-
-
-
-
- {/* 实验内容 */}
-
- {/* 实验信息卡片 */}
-
-
-
-
-
实验时长
-
-
约 2-3 小时
-
-
-
-
-
-
-
- {/* 实验目标 */}
-
-
-
-
实验目标
-
-
- -
- •
- 理解传感器的基本工作原理和测量特性
-
- -
- •
- 掌握传感器的校准和标定方法
-
- -
- •
- 学习数据采集和处理的基本技术
-
- -
- •
- 分析实验结果并撰写实验报告
-
-
-
-
- {/* 实验步骤 */}
-
-
-
-
实验步骤
-
-
-
-
- 步骤 1: 实验准备
-
-
- 检查实验设备和仪器,确保传感器连接正确,熟悉实验操作流程。
-
-
-
-
-
- 步骤 2: 传感器校准
-
-
- 使用标准信号源对传感器进行校准,记录校准数据并绘制校准曲线。
-
-
-
-
-
- 步骤 3: 数据采集
-
-
- 按照实验要求进行数据采集,注意记录环境条件和测量参数。
-
-
-
-
-
- 步骤 4: 数据分析
-
-
- 对采集的数据进行处理和分析,计算传感器的性能参数。
-
-
-
-
-
- 步骤 5: 撰写报告
-
-
- 整理实验数据和分析结果,撰写完整的实验报告。
-
-
-
-
-
- {/* 注意事项 */}
-
- 注意事项
-
-
- - • 实验前请仔细阅读实验指导书和安全操作规程
- - • 注意保护传感器,避免过载和损坏
- - • 数据记录要准确完整,便于后续分析
- - • 实验结束后及时整理设备和清理实验台
-
-
-
-
- {/* 操作按钮 */}
-
-
-
-
-
-
-
- );
-}
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
index fa77c25..01cdd5a 100644
--- a/src/pages/Home.tsx
+++ b/src/pages/Home.tsx
@@ -1,108 +1,32 @@
-import { useState, useEffect } from 'react';
-import { useNavigate } from 'react-router-dom';
-import {
- DndContext,
- closestCenter,
- KeyboardSensor,
- PointerSensor,
- useSensor,
- useSensors,
- DragEndEvent,
-} from '@dnd-kit/core';
-import {
- arrayMove,
- SortableContext,
- sortableKeyboardCoordinates,
- verticalListSortingStrategy,
-} from '@dnd-kit/sortable';
-import { DraggableItem } from '../components/DraggableItem';
-import {
- defaultExperiments,
- loadExperimentsFromStorage,
- saveExperimentsToStorage,
-} from '../data/experiments';
+import { useNavigate } from "react-router-dom";
+import initialPageImage from "@/assets/images/初始页.png";
+import { BackgroundImage, Image } from "@/components";
+import { getStaticImage } from "@/utils";
export default function Home() {
const navigate = useNavigate();
- // 从 localStorage 加载数据,如果没有则使用默认数据
- const [experiments, setExperiments] = useState(() => {
- return loadExperimentsFromStorage() || defaultExperiments;
- });
-
- // 当实验数据变化时,保存到 localStorage
- useEffect(() => {
- saveExperimentsToStorage(experiments);
- }, [experiments]);
-
- const sensors = useSensors(
- useSensor(PointerSensor),
- useSensor(KeyboardSensor, {
- coordinateGetter: sortableKeyboardCoordinates,
- })
- );
-
- function handleDragEnd(event: DragEndEvent) {
- const { active, over } = event;
-
- if (over && active.id !== over.id) {
- setExperiments((items) => {
- const oldIndex = items.findIndex((item) => item.id === active.id);
- const newIndex = items.findIndex((item) => item.id === over.id);
- return arrayMove(items, oldIndex, newIndex);
- });
- }
- }
-
- function handleExperimentClick(id: string) {
- navigate(`/experiment/${id}`);
- }
+ const handleStartExperiment = () => {
+ console.log("点击开启实验");
+ navigate("/experiment");
+ };
return (
-
-
-
- 实验项目列表
-
-
- 拖动实验项目可以调整顺序,规划您的学习路径。点击项目查看详情。
-
-
-
-
- {experiments.map((experiment, index) => {
- const Icon = experiment.icon;
- return (
-
- handleExperimentClick(experiment.id)}
- >
-
-
-
-
-
- {index + 1}. {experiment.name}
-
-
- {experiment.description}
-
-
-
-
- );
- })}
-
-
-
-
+ console.log("背景图片加载完成")}
+ onError={() => console.log("背景图片加载失败")}
+ >
+
+
);
}
diff --git a/src/pages/experiment/ExperimentTitle.tsx b/src/pages/experiment/ExperimentTitle.tsx
new file mode 100644
index 0000000..61a34eb
--- /dev/null
+++ b/src/pages/experiment/ExperimentTitle.tsx
@@ -0,0 +1,71 @@
+interface ExperimentTitleProps {
+ title: string;
+ description: string;
+}
+
+export default function ExperimentTitle({
+ title,
+ description,
+}: ExperimentTitleProps) {
+ return (
+ <>
+ {/* 标题 */}
+
+ {/* 主标题容器 */}
+
+ {/* 主标题 */}
+
+
+ {title}
+
+
+
+ {/* 副标题 */}
+
+
+
+ {/* 使用 SVG 绘制更精确的装饰线 */}
+
+
+ >
+ );
+}
diff --git a/src/pages/experiment/README.md b/src/pages/experiment/README.md
new file mode 100644
index 0000000..de00aac
--- /dev/null
+++ b/src/pages/experiment/README.md
@@ -0,0 +1,210 @@
+# 实验页面模块结构
+
+## 文件组织
+
+```
+src/pages/experiment/
+├── index.tsx # 主入口文件,管理步骤状态和路由
+├── Step1IntroPage.tsx # 第一步:实验工具介绍页面
+├── Step2SelectPage.tsx # 第二步:实验工具选择页面
+├── ExperimentTitle.tsx # 可复用的标题组件
+├── constants.ts # 常量数据(工具列表)
+└── README.md # 本文档
+```
+
+## 各文件职责
+
+### 📄 index.tsx
+
+**职责**: 主入口文件,管理页面状态和步骤切换
+
+- 管理 `step` 状态(当前在哪一步)
+- 根据 step 渲染对应的页面组件
+- 提供背景图片容器
+
+**关键代码**:
+
+```tsx
+const [step, setStep] = useState(1);
+{
+ step === 1 && setStep(2)} />;
+}
+{
+ step === 2 && ;
+}
+```
+
+---
+
+### 📄 Step1IntroPage.tsx
+
+**职责**: 第一步 - 实验工具介绍页面
+
+- 显示所有 8 个实验工具
+- 展示每个工具的图片、名称和描述
+- 提供"下一步"按钮,点击后触发 `onNext` 回调
+
+**Props**:
+
+- `onNext: () => void` - 点击下一步时的回调函数
+
+**特点**:
+
+- 4 列网格布局
+- 每个工具包含图片和文字说明
+- 使用 `ExperimentTitle` 组件显示标题
+
+---
+
+### 📄 Step2SelectPage.tsx
+
+**职责**: 第二步 - 实验工具选择页面
+
+- 显示左侧"可拖拽选项"区域(6 个工具)
+- 显示右侧"实验工具区域"(拖放目标区)
+- 提供"清空"和"提交"按钮
+- 显示底部提示文字
+
+**布局结构**:
+
+- 左右分栏布局(flex 1:1)
+- 左侧:3 列 2 行网格展示可选工具
+- 右侧:空白拖放区域
+- 底部:操作按钮和提示文字
+
+**待实现功能**:
+
+- [ ] 拖拽功能(从左侧拖到右侧)
+- [ ] 清空按钮功能
+- [ ] 提交按钮功能及验证逻辑
+
+---
+
+### 📄 ExperimentTitle.tsx
+
+**职责**: 可复用的标题组件
+
+- 显示主标题(带蓝色边框的圆角矩形)
+- 显示副标题文字
+- 显示装饰性的 SVG 线条
+
+**Props**:
+
+```tsx
+interface ExperimentTitleProps {
+ title: string; // 主标题文字
+ description: string; // 副标题文字
+}
+```
+
+**样式特点**:
+
+- 主标题:白色背景、蓝色边框、斜体字
+- 副标题:灰色文字
+- 装饰线:左侧短线 + 斜线 + 右侧长线
+
+---
+
+### 📄 constants.ts
+
+**职责**: 存储静态数据常量
+
+**导出内容**:
+
+1. **sensorImages** - 8 个实验工具的完整信息
+
+ ```tsx
+ {
+ src: string; // 图片路径
+ name: string; // 工具名称
+ description: string; // 工具描述
+ }
+ [];
+ ```
+
+2. **selectableTools** - 6 个可选择的工具(用于第二步)
+ ```tsx
+ {
+ src: string; // 图片路径
+ name: string; // 工具名称
+ }
+ [];
+ ```
+
+---
+
+## 设计原则
+
+### 单一职责原则 (SRP)
+
+- 每个文件只负责一个功能模块
+- 组件职责清晰,易于维护
+
+### 可复用性
+
+- `ExperimentTitle` 可在多个步骤中使用
+- 数据与视图分离,便于扩展
+
+### 数据驱动
+
+- 使用 `constants.ts` 统一管理数据
+- 通过 map 渲染列表,避免重复代码
+
+### 可扩展性
+
+- 易于添加新的步骤(Step3、Step4...)
+- 状态管理集中在 `index.tsx`
+
+---
+
+## 后续开发建议
+
+### 短期任务
+
+1. 实现拖拽功能(使用 HTML5 Drag and Drop API 或库)
+2. 添加状态管理(已选中的工具)
+3. 实现"清空"和"提交"按钮功能
+4. 添加正确答案验证逻辑
+
+### 长期优化
+
+1. 添加动画效果(页面切换、拖拽反馈)
+2. 添加错误提示和成功提示
+3. 支持键盘操作(无障碍访问)
+4. 添加进度指示器
+5. 考虑使用状态管理库(如 Zustand)管理复杂状态
+
+---
+
+## 使用示例
+
+### 添加新步骤
+
+```tsx
+// 1. 创建新组件
+// Step3TestPage.tsx
+export default function Step3TestPage() {
+ return 第三步内容
;
+}
+
+// 2. 在 index.tsx 中引入
+import Step3TestPage from "./Step3TestPage";
+
+// 3. 添加到渲染逻辑
+{
+ step === 3 && ;
+}
+```
+
+### 修改工具数据
+
+直接编辑 `constants.ts` 文件即可,组件会自动更新。
+
+---
+
+## 技术栈
+
+- React 18
+- TypeScript
+- Tailwind CSS
+- 自定义 Image 组件
diff --git a/src/pages/experiment/Step1IntroPage.tsx b/src/pages/experiment/Step1IntroPage.tsx
new file mode 100644
index 0000000..5b51d68
--- /dev/null
+++ b/src/pages/experiment/Step1IntroPage.tsx
@@ -0,0 +1,43 @@
+import { Image } from "@/components";
+import { getStaticImage } from "@/utils";
+import ExperimentTitle from "./ExperimentTitle";
+import { sensorImages } from "./constants";
+
+interface Step1IntroPageProps {
+ onNext: () => void;
+}
+
+export default function Step1IntroPage({ onNext }: Step1IntroPageProps) {
+ return (
+ <>
+
+
+ {/* img列表 */}
+
+ {sensorImages.map((image, index) => (
+
+
+
+
{image.name}
+
{image.description}
+
+
+ ))}
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/pages/experiment/Step2SelectPage.tsx b/src/pages/experiment/Step2SelectPage.tsx
new file mode 100644
index 0000000..6d646d6
--- /dev/null
+++ b/src/pages/experiment/Step2SelectPage.tsx
@@ -0,0 +1,325 @@
+import { useState, useEffect } from "react";
+import { createPortal } from "react-dom";
+import { Image } from "@/components";
+import ExperimentTitle from "./ExperimentTitle";
+import { selectableTools, correctAnswers } from "./constants";
+import { useDragAndDrop, useDropZone } from "@/hooks/useDragAndDrop";
+import { getStaticImage } from "@/utils";
+
+interface Tool {
+ src: string;
+ name: string;
+}
+
+interface SelectedTool extends Tool {
+ id: string;
+}
+
+interface Step2SelectPageProps {
+ onNext?: () => void;
+}
+
+export default function Step2SelectPage({ onNext }: Step2SelectPageProps) {
+ const [selectedTools, setSelectedTools] = useState([]);
+ const [isSubmitted, setIsSubmitted] = useState(false);
+ const [isCorrect, setIsCorrect] = useState(false);
+ const {
+ isDragging,
+ dragData,
+ mousePosition,
+ startDrag,
+ endDrag,
+ isPointInElement,
+ } = useDragAndDrop();
+ const { dropZoneRef, isOver, checkIsOver } = useDropZone();
+
+ // 获取可用的工具列表(过滤掉已选择的)
+ const availableTools = selectableTools.filter(
+ (tool) => !selectedTools.some((selected) => selected.name === tool.name)
+ );
+
+ // 开始拖拽工具
+ const handleMouseDown = (tool: Tool, e: React.MouseEvent) => {
+ // 如果已经提交且答案正确,则不允许拖拽
+ if (isSubmitted && isCorrect) {
+ return;
+ }
+ startDrag(tool, e);
+ };
+
+ // 监听鼠标位置,检查是否在放置区域上
+ useEffect(() => {
+ if (isDragging) {
+ checkIsOver(mousePosition.x, mousePosition.y);
+ }
+ }, [isDragging, mousePosition, checkIsOver]);
+
+ // 监听鼠标释放,处理拖拽结束
+ useEffect(() => {
+ if (!isDragging) return;
+
+ const handleMouseUp = () => {
+ if (
+ dragData &&
+ isPointInElement(mousePosition.x, mousePosition.y, dropZoneRef.current)
+ ) {
+ // 检查是否已经添加过这个工具
+ const isAlreadyAdded = selectedTools.some(
+ (tool) => tool.name === dragData.name
+ );
+
+ if (!isAlreadyAdded) {
+ const newTool: SelectedTool = {
+ ...dragData,
+ id: `selected-${Date.now()}`,
+ };
+ setSelectedTools([...selectedTools, newTool]);
+ }
+ }
+ endDrag();
+ };
+
+ window.addEventListener("mouseup", handleMouseUp);
+ return () => window.removeEventListener("mouseup", handleMouseUp);
+ }, [
+ isDragging,
+ dragData,
+ mousePosition,
+ selectedTools,
+ endDrag,
+ isPointInElement,
+ dropZoneRef,
+ ]);
+
+ // 清空选择
+ const handleClear = () => {
+ setSelectedTools([]);
+ setIsSubmitted(false);
+ setIsCorrect(false);
+ };
+
+ // 提交选择
+ const handleSubmit = () => {
+ if (selectedTools.length === 0) {
+ // alert("请至少选择一个工具!");
+ return;
+ }
+
+ // 验证答案
+ const selectedNames = selectedTools.map((tool) => tool.name);
+
+ // 检查数量是否一致
+ if (selectedNames.length !== correctAnswers.length) {
+ setIsSubmitted(true);
+ setIsCorrect(false);
+ return;
+ }
+
+ // 检查每个选择的工具是否都在正确答案中
+ const allCorrect = selectedNames.every((name) =>
+ correctAnswers.includes(name)
+ );
+
+ setIsSubmitted(true);
+ setIsCorrect(allCorrect);
+ };
+
+ // 删除已选工具
+ const handleRemoveTool = (id: string) => {
+ // 如果已经提交且答案正确,则不允许删除
+ if (isSubmitted && isCorrect) {
+ return;
+ }
+ setSelectedTools(selectedTools.filter((tool) => tool.id !== id));
+ };
+
+ return (
+ <>
+
+
+ {/* 主要内容区域 */}
+
+ {/* 左侧:可拖拽选项 */}
+
+
+
可拖拽选项
+
+
+ {availableTools.length === 0 ? (
+
+ 所有工具已选择完毕
+
+ ) : (
+
+ {availableTools.map((tool, index) => {
+ const isBeingDragged =
+ isDragging && dragData?.name === tool.name;
+ const isDragDisabled = isSubmitted && isCorrect;
+
+ return (
+
handleMouseDown(tool, e)}
+ className={`flex flex-col items-center transition-transform select-none ${
+ isDragDisabled
+ ? "cursor-not-allowed opacity-50"
+ : "cursor-move hover:scale-105"
+ } ${isBeingDragged ? "opacity-30" : ""}`}
+ >
+
+
+
+
+ {tool.name}
+
+
+ );
+ })}
+
+ )}
+
+
+
+ {/* 右侧:实验工具区域 */}
+
+
+
实验工具区域
+
+
+ {/* 提交后的结果显示 */}
+ {isSubmitted ? (
+
+ {isCorrect ? (
+ <>
+
+ 选择正确,继续实验吧
+
+ >
+ ) : (
+ <>
+
+ 选择错误,请重新选择
+
+
+
+ 答案提示
+
+
+ {correctAnswers.join("、")}
+
+
+ >
+ )}
+
+ ) : selectedTools.length === 0 ? (
+
+ 拖拽工具到这里
+
+ ) : (
+
+ {selectedTools.map((tool) => (
+
+
+
+
+
+ {tool.name}
+
+ {/* 删除按钮 - 答案正确后隐藏 */}
+ {!(isSubmitted && isCorrect) && (
+
+ )}
+
+ ))}
+
+ )}
+
+
+ {/* 底部按钮 */}
+ {!isSubmitted || !isCorrect ? (
+
+
+
+
+ ) : null}
+
+
+
+ {/* 底部提示文字或下一步按钮 */}
+
+ {isSubmitted && isCorrect ? (
+
+ ) : (
+ //
+
+ 通过拖拽的方式,将正确选择拖拽到实验工具区域内吧。
+
+ )}
+
+
+ {/* 拖拽预览 - 使用 Portal 渲染到 body,避免受 scale 影响 */}
+ {isDragging &&
+ dragData &&
+ createPortal(
+
+
+
+
+ {dragData.name}
+
+
+
,
+ document.body
+ )}
+ >
+ );
+}
diff --git a/src/pages/experiment/Step3ConfigPage.tsx b/src/pages/experiment/Step3ConfigPage.tsx
new file mode 100644
index 0000000..88b32c3
--- /dev/null
+++ b/src/pages/experiment/Step3ConfigPage.tsx
@@ -0,0 +1,102 @@
+import { Image } from "@/components";
+import { getStaticImage } from "@/utils";
+import ExperimentTitle from "./ExperimentTitle";
+
+interface Step3ConfigPageProps {
+ onNext?: () => void;
+}
+
+export default function Step3ConfigPage({ onNext }: Step3ConfigPageProps) {
+ return (
+ <>
+
+
+ {/* 主要内容区域 */}
+
+
+
+ 环境配置步骤
+
+
+
+
+
+ 1
+
+
+
+ 安装 Keil 软件
+
+
+ 下载并安装 Keil μVision IDE,用于编写和编译 STM32 程序。
+
+
+
+
+
+
+ 2
+
+
+
+ 连接 STM32 主控板
+
+
+ 使用烧录线将 STM32 主控板连接到电脑,确保驱动正常安装。
+
+
+
+
+
+
+ 3
+
+
+
+ 配置传感器连接
+
+
+ 将力敏传感器正确连接到 STM32 主控板的指定引脚。
+
+
+
+
+
+
+ 4
+
+
+
+ 烧录程序
+
+
+ 在 Keil 中编译程序,并通过烧录线将程序下载到 STM32 主控板。
+
+
+
+
+
+
+
+ {/* 底部提示和下一步按钮 */}
+
+
+ 完成以上步骤后,即可进入下一阶段的实验操作。
+
+
+
+ {onNext && (
+
+
+
+ )}
+ >
+ );
+}
diff --git a/src/pages/experiment/constants.ts b/src/pages/experiment/constants.ts
new file mode 100644
index 0000000..4ad3091
--- /dev/null
+++ b/src/pages/experiment/constants.ts
@@ -0,0 +1,81 @@
+import { getStaticImage } from "@/utils";
+
+// 传感器图片数据
+export const sensorImages = [
+ {
+ src: getStaticImage("experiment/windows.png"),
+ description: "作为传感器信号的接收端",
+ name: "Windows电脑",
+ },
+ {
+ src: getStaticImage("experiment/stm32.png"),
+ description: "连接传感器与电脑之间的桥梁",
+ name: "STM32主控板",
+ },
+ {
+ src: getStaticImage("experiment/serial-cable.png"),
+ description: "连接STM32主控板与电脑",
+ name: "串口线",
+ },
+ {
+ src: getStaticImage("experiment/serial-assistant.png"),
+ description: "显示传感器传送到接收端的数据",
+ name: "串口助手",
+ },
+ {
+ src: getStaticImage("experiment/sensor.png"),
+ description: "",
+ name: "力敏传感器",
+ },
+ {
+ src: getStaticImage("experiment/flash-cable.png"),
+ description: "将代码烧录至STM32主控板中",
+ name: "烧录线",
+ },
+ {
+ src: getStaticImage("experiment/wireless.png"),
+ description: "无线的方式连接STM32与电脑",
+ name: "无线通信模组",
+ },
+ {
+ src: getStaticImage("experiment/keil.png"),
+ description: "电脑中配置传感器、上传代码",
+ name: "keil软件",
+ },
+];
+
+// 可选择的工具列表(用于第二步)
+export const selectableTools = [
+ {
+ src: getStaticImage("experiment/keil.png"),
+ name: "keil软件",
+ },
+ {
+ src: getStaticImage("experiment/stm32.png"),
+ name: "STM32主控板",
+ },
+ {
+ src: getStaticImage("experiment/serial-cable.png"),
+ name: "TYPE-C",
+ },
+ {
+ src: getStaticImage("experiment/sensor.png"),
+ name: "力敏传感器",
+ },
+ {
+ src: getStaticImage("experiment/flash-cable.png"),
+ name: "烧录线",
+ },
+ {
+ src: getStaticImage("experiment/wireless.png"),
+ name: "Arduino开发板",
+ },
+];
+
+// 正确答案列表
+export const correctAnswers = [
+ "keil软件",
+ "STM32主控板",
+ "力敏传感器",
+ "烧录线",
+];
diff --git a/src/pages/experiment/index.tsx b/src/pages/experiment/index.tsx
new file mode 100644
index 0000000..54b7e48
--- /dev/null
+++ b/src/pages/experiment/index.tsx
@@ -0,0 +1,24 @@
+import { BackgroundImage } from "@/components";
+import { getStaticImage } from "@/utils";
+import { useState } from "react";
+import Step1IntroPage from "./Step1IntroPage";
+import Step2SelectPage from "./Step2SelectPage";
+import Step3ConfigPage from "./Step3ConfigPage";
+
+export default function Experiment() {
+ const [step, setStep] = useState(1); // 1: 工具介绍, 2: 工具选择, 3: 实验环境配置
+
+ return (
+
+
+ {step === 1 && setStep(2)} />}
+ {step === 2 && setStep(3)} />}
+ {step === 3 && setStep(4)} />}
+
+
+ );
+}
diff --git a/src/router/index.tsx b/src/router/index.tsx
new file mode 100644
index 0000000..2123917
--- /dev/null
+++ b/src/router/index.tsx
@@ -0,0 +1,45 @@
+import { createBrowserRouter, Navigate } from "react-router-dom";
+import Home from "@/pages/Home";
+import Experiment from "../pages/experiment";
+import NotFound from "../pages/NotFound";
+
+/**
+ * 应用路由配置
+ * 使用 React Router v7 的 createBrowserRouter
+ */
+const router = createBrowserRouter([
+ // {
+ // path: '/',
+ // element: ,
+ // errorElement: ,
+ // children: [
+ // {
+ // index: true,
+ // element: ,
+ // },
+ // {
+ // path: 'experiment/:id',
+ // element: ,
+ // },
+ // ],
+ // },
+ {
+ path: "/",
+ element: ,
+ //重定向
+ },
+ {
+ path: "/home",
+ element: ,
+ },
+ {
+ path: "/experiment",
+ element: ,
+ },
+ {
+ path: "*",
+ element: ,
+ },
+]);
+
+export default router;
diff --git a/src/router/router.jsx b/src/router/router.jsx
deleted file mode 100644
index 9902284..0000000
--- a/src/router/router.jsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import { createBrowserRouter } from "react-router-dom";
-import Layout from "../pages/Layout";
-import Home from "../pages/Home";
-import ExperimentDetail from "../pages/ExperimentDetail";
-import NotFound from "../pages/NotFound";
-
-/**
- * 应用路由配置
- * 使用 React Router v7 的 createBrowserRouter
- */
-const router = createBrowserRouter([
- {
- path: "/",
- element: ,
- errorElement: ,
- children: [
- {
- index: true,
- element: ,
- },
- {
- path: "experiment/:id",
- element: ,
- },
- ],
- },
- {
- path: "*",
- element: ,
- },
-]);
-
-export default router;
diff --git a/src/router/router.tsx b/src/router/router.tsx
deleted file mode 100644
index b645c86..0000000
--- a/src/router/router.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import { createBrowserRouter } from 'react-router-dom';
-import Layout from '../pages/Layout';
-import Home from '../pages/Home';
-import ExperimentDetail from '../pages/ExperimentDetail';
-import NotFound from '../pages/NotFound';
-
-/**
- * 应用路由配置
- * 使用 React Router v7 的 createBrowserRouter
- */
-const router = createBrowserRouter([
- {
- path: '/',
- element: ,
- errorElement: ,
- children: [
- {
- index: true,
- element: ,
- },
- {
- path: 'experiment/:id',
- element: ,
- },
- ],
- },
- {
- path: '*',
- element: ,
- },
-]);
-
-export default router;
diff --git a/src/types/images.d.ts b/src/types/images.d.ts
new file mode 100644
index 0000000..ced6224
--- /dev/null
+++ b/src/types/images.d.ts
@@ -0,0 +1,42 @@
+declare module "*.png" {
+ const value: string;
+ export default value;
+}
+
+declare module "*.jpg" {
+ const value: string;
+ export default value;
+}
+
+declare module "*.jpeg" {
+ const value: string;
+ export default value;
+}
+
+declare module "*.gif" {
+ const value: string;
+ export default value;
+}
+
+declare module "*.svg" {
+ const value: string;
+ export default value;
+}
+
+declare module "*.webp" {
+ const value: string;
+ export default value;
+}
+
+// Vite 类型声明
+interface ImportMeta {
+ glob: (
+ pattern: string,
+ options?: {
+ eager?: boolean;
+ import?: string;
+ as?: string;
+ }
+ ) => Record;
+ url: string;
+}
diff --git a/src/utils/index.ts b/src/utils/index.ts
new file mode 100644
index 0000000..b5ee1a1
--- /dev/null
+++ b/src/utils/index.ts
@@ -0,0 +1,41 @@
+// 图片资源映射 - 使用静态导入确保构建时包含所有图片
+const imageModules = import.meta.glob(
+ "../assets/images/**/*.{png,jpg,jpeg,gif,svg,webp}",
+ {
+ eager: true,
+ }
+);
+
+//访问静态图片 - 使用静态导入确保构建后路径正确
+export const getStaticImage = (path: string) => {
+ try {
+ // 确保路径以正确的格式结尾
+ const imagePath =
+ path.endsWith(".png") ||
+ path.endsWith(".jpg") ||
+ path.endsWith(".jpeg") ||
+ path.endsWith(".gif") ||
+ path.endsWith(".svg") ||
+ path.endsWith(".webp")
+ ? `../assets/images/${path}`
+ : `../assets/images/${path}.png`;
+
+ // 使用静态导入的模块
+ const module = imageModules[imagePath];
+ if (module && typeof module === "object" && "default" in module) {
+ return module.default as string;
+ }
+
+ // 回退到URL方式
+ return new URL(imagePath, import.meta.url).href;
+ } catch (error) {
+ console.error("Failed to load image:", path, error);
+ return "";
+ }
+};
+
+// 获取背景图片的专用函数
+export const getBackgroundImage = (path: string) => {
+ const imageUrl = getStaticImage(path);
+ return imageUrl ? `url(${imageUrl})` : "none";
+};
diff --git a/tailwind.config.ts b/tailwind.config.ts
index dca8ba0..6642f66 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -1,11 +1,9 @@
-/** @type {import('tailwindcss').Config} */
+import type { Config } from "tailwindcss";
+
export default {
- content: [
- "./index.html",
- "./src/**/*.{js,ts,jsx,tsx}",
- ],
+ content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {},
},
plugins: [],
-}
+} satisfies Config;
diff --git a/tsconfig.json b/tsconfig.json
index 4a5655b..8f64aa4 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -14,14 +14,18 @@
"moduleDetection": "force",
"resolveJsonModule": true,
"noEmit": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ },
// JSX 支持
"jsx": "react-jsx",
// 类型检查
"strict": true,
- "noUnusedLocals": true,
- "noUnusedParameters": true,
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
diff --git a/vite.config.ts b/vite.config.ts
index 8b0f57b..2680889 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,7 +1,13 @@
-import { defineConfig } from 'vite'
-import react from '@vitejs/plugin-react'
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+import path from "path";
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
-})
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
+});