diff --git a/.npmrc b/.npmrc index 21a2bd0..4eee7e8 100644 --- a/.npmrc +++ b/.npmrc @@ -8,4 +8,4 @@ engine-strict=true auto-install-peers=true # 使用国内镜像(可选) -# registry=https://registry.npmmirror.com +registry=https://registry.npmmirror.com diff --git a/eslint.config.ts b/eslint.config.ts index ef1cf6c..ff03589 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -1,32 +1,33 @@ -import js from '@eslint/js'; -import globals from 'globals'; -import reactHooks from 'eslint-plugin-react-hooks'; -import reactRefresh from 'eslint-plugin-react-refresh'; -import tseslint from 'typescript-eslint'; +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; export default tseslint.config( - { ignores: ['dist', 'node_modules'] }, + { ignores: ["dist", "node_modules"] }, { extends: [js.configs.recommended, ...tseslint.configs.recommended], - files: ['**/*.{ts,tsx}'], + files: ["**/*.{ts,tsx}"], languageOptions: { ecmaVersion: 2020, globals: globals.browser, }, plugins: { - 'react-hooks': reactHooks, - 'react-refresh': reactRefresh, + "react-hooks": reactHooks, + "react-refresh": reactRefresh, }, rules: { ...reactHooks.configs.recommended.rules, - 'react-refresh/only-export-components': [ - 'warn', + "react-refresh/only-export-components": [ + "warn", { allowConstantExport: true }, ], - '@typescript-eslint/no-unused-vars': [ - 'error', - { varsIgnorePattern: '^_', argsIgnorePattern: '^_' }, + "@typescript-eslint/no-unused-vars": [ + "warn", + { varsIgnorePattern: "^_", argsIgnorePattern: "^_" }, ], + "@typescript-eslint/no-explicit-any": "off", }, } ); diff --git a/package.json b/package.json index e7589ea..845aeec 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,6 @@ "preview": "vite preview" }, "dependencies": { - "@dnd-kit/core": "^6.3.1", - "@dnd-kit/sortable": "^10.0.0", - "@dnd-kit/utilities": "^3.2.2", "axios": "^1.12.2", "clsx": "^2.1.1", "lucide-react": "^0.545.0", diff --git a/src/App.tsx b/src/App.tsx index 5c690b2..fffd19c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,30 @@ -import { RouterProvider } from 'react-router-dom'; -import router from './router/router'; +import { RouterProvider } from "react-router-dom"; +import router from "./router"; +import { usePageScale } from "./hooks/usePageScale"; +import { ScaleProvider } from "./contexts/ScaleContext"; function App() { - return ; + const { scaleStyle, scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = + usePageScale(); + + return ( + +
+
+ +
+
+
+ ); } export default App; diff --git a/src/assets/images/components/按钮-下一步.png b/src/assets/images/components/按钮-下一步.png new file mode 100644 index 0000000..e6573d4 Binary files /dev/null and b/src/assets/images/components/按钮-下一步.png differ diff --git a/src/assets/images/experiment/内容背景页.png b/src/assets/images/experiment/内容背景页.png new file mode 100644 index 0000000..606a50e Binary files /dev/null and b/src/assets/images/experiment/内容背景页.png differ diff --git a/src/assets/images/home/按钮-开启实验.png b/src/assets/images/home/按钮-开启实验.png new file mode 100644 index 0000000..499ac44 Binary files /dev/null and b/src/assets/images/home/按钮-开启实验.png differ diff --git a/src/assets/images/初始页.png b/src/assets/images/初始页.png new file mode 100644 index 0000000..319d60d Binary files /dev/null and b/src/assets/images/初始页.png differ diff --git a/src/assets/images/结束页.png b/src/assets/images/结束页.png new file mode 100644 index 0000000..63cdb7f Binary files /dev/null and b/src/assets/images/结束页.png differ diff --git a/src/components/BackgroundImage.tsx b/src/components/BackgroundImage.tsx new file mode 100644 index 0000000..a5f3df5 --- /dev/null +++ b/src/components/BackgroundImage.tsx @@ -0,0 +1,180 @@ +import { useState, useRef, useEffect } from "react"; +import { clsx } from "clsx"; + +interface BackgroundImageProps { + src: string; + alt?: string; + className?: string; + width?: number | string; + height?: number | string; + backgroundSize?: "cover" | "contain" | "auto" | string; + backgroundPosition?: string; + backgroundRepeat?: "no-repeat" | "repeat" | "repeat-x" | "repeat-y"; + placeholder?: string; + fallback?: string; + onLoad?: () => void; + onError?: () => void; + onClick?: () => void; + children?: React.ReactNode; + overlay?: boolean; + overlayColor?: string; + overlayOpacity?: number; +} + +export default function BackgroundImage({ + src, + alt, + className, + width, + height, + backgroundSize = "cover", + backgroundPosition = "center", + backgroundRepeat = "no-repeat", + placeholder, + fallback, + onLoad, + onError, + onClick, + children, + overlay = false, + overlayColor = "rgba(0, 0, 0, 0.5)", + overlayOpacity = 0.5, +}: BackgroundImageProps) { + const [imageSrc, setImageSrc] = useState(src); + const [isLoading, setIsLoading] = useState(true); + const [hasError, setHasError] = useState(false); + const [isLoaded, setIsLoaded] = useState(false); + const containerRef = useRef(null); + const loadedImagesRef = useRef>(new Set()); + + // 预加载图片 + const preloadImage = (url: string): Promise => { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(); + img.onerror = () => reject(); + img.src = url; + }); + }; + + // 统一的加载逻辑 + useEffect(() => { + let isMounted = true; + + const loadImage = async () => { + if (!imageSrc || !isMounted) return; + + // 检查是否已经加载过这个图片 + if (loadedImagesRef.current.has(imageSrc)) { + setIsLoading(false); + setIsLoaded(true); + setHasError(false); + return; + } + + try { + await preloadImage(imageSrc); + if (isMounted) { + loadedImagesRef.current.add(imageSrc); + setIsLoading(false); + setIsLoaded(true); + setHasError(false); + onLoad?.(); + } + } catch (error) { + if (isMounted) { + setIsLoading(false); + setHasError(true); + if (fallback && imageSrc !== fallback) { + setImageSrc(fallback); + } else { + onError?.(); + } + } + } + }; + + loadImage(); + + return () => { + isMounted = false; + }; + }, [imageSrc]); // 只在imageSrc改变时执行 + + // 当src改变时更新imageSrc + useEffect(() => { + if (src !== imageSrc) { + setImageSrc(src); + setIsLoading(true); + setHasError(false); + setIsLoaded(false); + } + }, [src]); + + const backgroundStyle = { + width, + height, + backgroundImage: isLoaded + ? `url(${imageSrc})` + : placeholder + ? `url(${placeholder})` + : undefined, + backgroundSize, + backgroundPosition, + backgroundRepeat, + position: "relative" as const, + }; + + const overlayStyle = { + backgroundColor: overlayColor, + opacity: overlayOpacity, + }; + + return ( +
+ {/* 加载占位符 */} + {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 && ( +
+
+ + + +

图片加载失败

+
+
+ )} + + {/* 实际图片 */} + {alt} +
+ ); +} 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} +

+
+ + {/* 副标题 */} +
+

+ {description} +

+
+
+ + {/* 使用 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"), + }, + }, +});