fix:解决拖拽问题
This commit is contained in:
1 parent
ce06ad0214
commit
6367014e1f
34 files changed
+1711
-396
No files matched your search
@@ -8,4 +8,4 @@ engine-strict=true
|
|||||||
auto-install-peers=true
|
auto-install-peers=true
|
||||||
|
|
||||||
# 使用国内镜像(可选)
|
# 使用国内镜像(可选)
|
||||||
# registry=https://registry.npmmirror.com
|
registry=https://registry.npmmirror.com
|
||||||
+15
-14
@@ -1,32 +1,33 @@
|
|||||||
import js from '@eslint/js';
|
import js from "@eslint/js";
|
||||||
import globals from 'globals';
|
import globals from "globals";
|
||||||
import reactHooks from 'eslint-plugin-react-hooks';
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
import tseslint from 'typescript-eslint';
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{ ignores: ['dist', 'node_modules'] },
|
{ ignores: ["dist", "node_modules"] },
|
||||||
{
|
{
|
||||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ["**/*.{ts,tsx}"],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
'react-hooks': reactHooks,
|
"react-hooks": reactHooks,
|
||||||
'react-refresh': reactRefresh,
|
"react-refresh": reactRefresh,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
...reactHooks.configs.recommended.rules,
|
...reactHooks.configs.recommended.rules,
|
||||||
'react-refresh/only-export-components': [
|
"react-refresh/only-export-components": [
|
||||||
'warn',
|
"warn",
|
||||||
{ allowConstantExport: true },
|
{ allowConstantExport: true },
|
||||||
],
|
],
|
||||||
'@typescript-eslint/no-unused-vars': [
|
"@typescript-eslint/no-unused-vars": [
|
||||||
'error',
|
"warn",
|
||||||
{ varsIgnorePattern: '^_', argsIgnorePattern: '^_' },
|
{ varsIgnorePattern: "^_", argsIgnorePattern: "^_" },
|
||||||
],
|
],
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -16,9 +16,6 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
|
||||||
"axios": "^1.12.2",
|
"axios": "^1.12.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.545.0",
|
"lucide-react": "^0.545.0",
|
||||||
|
|||||||
+25
-3
@@ -1,8 +1,30 @@
|
|||||||
import { RouterProvider } from 'react-router-dom';
|
import { RouterProvider } from "react-router-dom";
|
||||||
import router from './router/router';
|
import router from "./router";
|
||||||
|
import { usePageScale } from "./hooks/usePageScale";
|
||||||
|
import { ScaleProvider } from "./contexts/ScaleContext";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return <RouterProvider router={router} />;
|
const { scaleStyle, scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } =
|
||||||
|
usePageScale();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScaleProvider
|
||||||
|
value={{ scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
width: "100vw",
|
||||||
|
height: "100vh",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={scaleStyle}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScaleProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 275 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.9 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.9 MiB |
@@ -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<HTMLDivElement>(null);
|
||||||
|
const loadedImagesRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// 预加载图片
|
||||||
|
const preloadImage = (url: string): Promise<void> => {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={clsx("relative overflow-hidden", className)}
|
||||||
|
style={backgroundStyle}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{/* 加载占位符 */}
|
||||||
|
{isLoading && !placeholder && (
|
||||||
|
<div className="absolute inset-0 bg-gray-200 flex items-center justify-center">
|
||||||
|
<div className="animate-pulse bg-gray-300 rounded w-full h-full" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 错误状态 */}
|
||||||
|
{hasError && (
|
||||||
|
<div className="absolute inset-0 bg-gray-100 flex items-center justify-center">
|
||||||
|
<div className="text-gray-400 text-center">
|
||||||
|
<svg
|
||||||
|
className="w-8 h-8 mx-auto mb-2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p className="text-sm">背景图片加载失败</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 遮罩层 */}
|
||||||
|
{overlay && isLoaded && (
|
||||||
|
<div className="absolute inset-0" style={overlayStyle} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 内容 */}
|
||||||
|
{children && (
|
||||||
|
<div className="relative z-10 w-full h-full">{children}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import { ReactNode } from 'react';
|
import { ReactNode } from "react";
|
||||||
import { useSortable } from '@dnd-kit/sortable';
|
import { useSortable } from "@dnd-kit/sortable";
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import { GripVertical } from 'lucide-react';
|
import { GripVertical } from "lucide-react";
|
||||||
|
|
||||||
interface DraggableItemProps {
|
interface DraggableItemProps {
|
||||||
id: string;
|
id: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DraggableItem({ id, children }: DraggableItemProps) {
|
export default function DraggableItem({ id, children }: DraggableItemProps) {
|
||||||
const {
|
const {
|
||||||
attributes,
|
attributes,
|
||||||
listeners,
|
listeners,
|
||||||
@@ -29,7 +29,7 @@ export function DraggableItem({ id, children }: DraggableItemProps) {
|
|||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={style}
|
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 ${
|
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"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { clsx } from "clsx";
|
||||||
|
|
||||||
|
interface ImageProps {
|
||||||
|
src: string;
|
||||||
|
alt?: string;
|
||||||
|
className?: string;
|
||||||
|
width?: number | string;
|
||||||
|
height?: number | string;
|
||||||
|
objectFit?: "cover" | "contain" | "fill" | "none" | "scale-down";
|
||||||
|
objectPosition?: string;
|
||||||
|
loading?: "lazy" | "eager";
|
||||||
|
placeholder?: string;
|
||||||
|
fallback?: string;
|
||||||
|
onLoad?: () => 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<HTMLImageElement>(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 (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"inline-block",
|
||||||
|
button &&
|
||||||
|
"cursor-pointer transition-all duration-300 hover:scale-105 hover:brightness-110",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{ width, height }}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{/* 加载占位符 */}
|
||||||
|
{isLoading && placeholder && (
|
||||||
|
<div
|
||||||
|
className="w-full h-full bg-gray-200 flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${placeholder})`,
|
||||||
|
backgroundSize: "cover",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="animate-pulse bg-gray-300 rounded w-full h-full" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 加载中状态 */}
|
||||||
|
{isLoading && !placeholder && (
|
||||||
|
<div className="w-full h-full bg-gray-200 flex items-center justify-center">
|
||||||
|
<div className="animate-pulse bg-gray-300 rounded w-full h-full" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 错误状态 */}
|
||||||
|
{hasError && (
|
||||||
|
<div className="w-full h-full bg-gray-100 flex items-center justify-center">
|
||||||
|
<div className="text-gray-400 text-center">
|
||||||
|
<svg
|
||||||
|
className="w-8 h-8 mx-auto mb-2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p className="text-sm">图片加载失败</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 实际图片 */}
|
||||||
|
<img
|
||||||
|
ref={imgRef}
|
||||||
|
src={imageSrc}
|
||||||
|
alt={alt}
|
||||||
|
style={imageStyle}
|
||||||
|
loading={loading}
|
||||||
|
draggable={draggable}
|
||||||
|
onLoad={handleLoad}
|
||||||
|
onError={handleError}
|
||||||
|
className={clsx(
|
||||||
|
"transition-opacity duration-300",
|
||||||
|
isLoaded ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { default as Image } from "./Image";
|
||||||
|
export { default as BackgroundImage } from "./BackgroundImage";
|
||||||
|
export { default as DraggableItem } from "./DraggableItem";
|
||||||
@@ -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<ScaleContextValue | null>(null);
|
||||||
|
|
||||||
|
export function ScaleProvider({
|
||||||
|
children,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
value: ScaleContextValue;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ScaleContext.Provider value={value}>{children}</ScaleContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScale() {
|
||||||
|
const context = useContext(ScaleContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useScale must be used within ScaleProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { useScale } from "@/contexts/ScaleContext";
|
||||||
|
|
||||||
|
interface DragState<T = any> {
|
||||||
|
isDragging: boolean;
|
||||||
|
dragData: T | null;
|
||||||
|
mousePosition: { x: number; y: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义拖拽 Hook
|
||||||
|
* 支持页面 scale 缩放的拖拽功能
|
||||||
|
*/
|
||||||
|
export function useDragAndDrop<T = any>() {
|
||||||
|
const { scale, offsetX, offsetY } = useScale();
|
||||||
|
const [state, setState] = useState<DragState<T>>({
|
||||||
|
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<HTMLDivElement>(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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
+19
-3
@@ -1,3 +1,19 @@
|
|||||||
@tailwind base;
|
@import "tailwindcss";
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
/* *{ */
|
||||||
|
/* margin: 0;
|
||||||
|
padding: 0; */
|
||||||
|
/* box-sizing: border-box; */
|
||||||
|
/* } */
|
||||||
|
|
||||||
|
body{
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root{
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
@@ -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 (
|
|
||||||
<div className="max-w-3xl mx-auto">
|
|
||||||
<div className="bg-white rounded-xl shadow-lg p-8 text-center">
|
|
||||||
<h2 className="text-2xl font-bold text-gray-800 mb-4">实验不存在</h2>
|
|
||||||
<p className="text-gray-600 mb-6">抱歉,未找到该实验项目。</p>
|
|
||||||
<Link
|
|
||||||
to="/"
|
|
||||||
className="inline-flex items-center gap-2 px-6 py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
|
||||||
>
|
|
||||||
<ArrowLeft size={20} />
|
|
||||||
返回首页
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const Icon = experiment.icon;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-4xl mx-auto">
|
|
||||||
<button
|
|
||||||
onClick={() => navigate('/')}
|
|
||||||
className="flex items-center gap-2 text-gray-600 hover:text-gray-800 mb-6 transition-colors"
|
|
||||||
>
|
|
||||||
<ArrowLeft size={20} />
|
|
||||||
返回实验列表
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
|
|
||||||
{/* 实验头部 */}
|
|
||||||
<div className="bg-gradient-to-r from-indigo-500 to-purple-600 p-8 text-white">
|
|
||||||
<div className="flex items-center gap-4 mb-4">
|
|
||||||
<div className="w-16 h-16 bg-white/20 backdrop-blur-sm rounded-full flex items-center justify-center">
|
|
||||||
<Icon className="w-8 h-8" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold mb-2">{experiment.name}</h1>
|
|
||||||
<p className="text-indigo-100">{experiment.description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 实验内容 */}
|
|
||||||
<div className="p-8">
|
|
||||||
{/* 实验信息卡片 */}
|
|
||||||
<div className="grid md:grid-cols-3 gap-4 mb-8">
|
|
||||||
<div className="bg-blue-50 rounded-lg p-4">
|
|
||||||
<div className="flex items-center gap-2 text-blue-600 mb-2">
|
|
||||||
<Clock size={20} />
|
|
||||||
<h3 className="font-semibold">实验时长</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-gray-700">约 2-3 小时</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-green-50 rounded-lg p-4">
|
|
||||||
<div className="flex items-center gap-2 text-green-600 mb-2">
|
|
||||||
<Target size={20} />
|
|
||||||
<h3 className="font-semibold">难度等级</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-gray-700">中级</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-purple-50 rounded-lg p-4">
|
|
||||||
<div className="flex items-center gap-2 text-purple-600 mb-2">
|
|
||||||
<BookOpen size={20} />
|
|
||||||
<h3 className="font-semibold">实验类型</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-gray-700">综合实验</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 实验目标 */}
|
|
||||||
<section className="mb-8">
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<Target className="text-indigo-600" size={24} />
|
|
||||||
<h2 className="text-2xl font-bold text-gray-800">实验目标</h2>
|
|
||||||
</div>
|
|
||||||
<ul className="space-y-2 text-gray-700">
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-indigo-600 mt-1">•</span>
|
|
||||||
<span>理解传感器的基本工作原理和测量特性</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-indigo-600 mt-1">•</span>
|
|
||||||
<span>掌握传感器的校准和标定方法</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-indigo-600 mt-1">•</span>
|
|
||||||
<span>学习数据采集和处理的基本技术</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-indigo-600 mt-1">•</span>
|
|
||||||
<span>分析实验结果并撰写实验报告</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 实验步骤 */}
|
|
||||||
<section className="mb-8">
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<FileText className="text-indigo-600" size={24} />
|
|
||||||
<h2 className="text-2xl font-bold text-gray-800">实验步骤</h2>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="border-l-4 border-indigo-600 pl-4 py-2">
|
|
||||||
<h3 className="font-semibold text-gray-800 mb-1">
|
|
||||||
步骤 1: 实验准备
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
检查实验设备和仪器,确保传感器连接正确,熟悉实验操作流程。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-l-4 border-indigo-600 pl-4 py-2">
|
|
||||||
<h3 className="font-semibold text-gray-800 mb-1">
|
|
||||||
步骤 2: 传感器校准
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
使用标准信号源对传感器进行校准,记录校准数据并绘制校准曲线。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-l-4 border-indigo-600 pl-4 py-2">
|
|
||||||
<h3 className="font-semibold text-gray-800 mb-1">
|
|
||||||
步骤 3: 数据采集
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
按照实验要求进行数据采集,注意记录环境条件和测量参数。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-l-4 border-indigo-600 pl-4 py-2">
|
|
||||||
<h3 className="font-semibold text-gray-800 mb-1">
|
|
||||||
步骤 4: 数据分析
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
对采集的数据进行处理和分析,计算传感器的性能参数。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-l-4 border-indigo-600 pl-4 py-2">
|
|
||||||
<h3 className="font-semibold text-gray-800 mb-1">
|
|
||||||
步骤 5: 撰写报告
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
整理实验数据和分析结果,撰写完整的实验报告。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 注意事项 */}
|
|
||||||
<section className="mb-8">
|
|
||||||
<h2 className="text-2xl font-bold text-gray-800 mb-4">注意事项</h2>
|
|
||||||
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4 rounded">
|
|
||||||
<ul className="space-y-2 text-gray-700">
|
|
||||||
<li>• 实验前请仔细阅读实验指导书和安全操作规程</li>
|
|
||||||
<li>• 注意保护传感器,避免过载和损坏</li>
|
|
||||||
<li>• 数据记录要准确完整,便于后续分析</li>
|
|
||||||
<li>• 实验结束后及时整理设备和清理实验台</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<button className="flex-1 px-6 py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors font-semibold">
|
|
||||||
开始实验
|
|
||||||
</button>
|
|
||||||
<button className="px-6 py-3 border-2 border-indigo-600 text-indigo-600 rounded-lg hover:bg-indigo-50 transition-colors font-semibold">
|
|
||||||
下载资料
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+23
-99
@@ -1,108 +1,32 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useNavigate } from 'react-router-dom';
|
import initialPageImage from "@/assets/images/初始页.png";
|
||||||
import {
|
import { BackgroundImage, Image } from "@/components";
|
||||||
DndContext,
|
import { getStaticImage } from "@/utils";
|
||||||
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';
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// 从 localStorage 加载数据,如果没有则使用默认数据
|
const handleStartExperiment = () => {
|
||||||
const [experiments, setExperiments] = useState(() => {
|
console.log("点击开启实验");
|
||||||
return loadExperimentsFromStorage() || defaultExperiments;
|
navigate("/experiment");
|
||||||
});
|
};
|
||||||
|
|
||||||
// 当实验数据变化时,保存到 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}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl mx-auto">
|
<BackgroundImage
|
||||||
<div className="bg-white rounded-xl shadow-lg p-6 mb-6">
|
src={initialPageImage}
|
||||||
<h2 className="text-2xl font-semibold text-gray-800 mb-4">
|
alt="传感器实验首页"
|
||||||
实验项目列表
|
className="h-full w-full relative"
|
||||||
</h2>
|
backgroundSize="cover"
|
||||||
<p className="text-gray-600 mb-6">
|
backgroundPosition="center"
|
||||||
拖动实验项目可以调整顺序,规划您的学习路径。点击项目查看详情。
|
onLoad={() => console.log("背景图片加载完成")}
|
||||||
</p>
|
onError={() => console.log("背景图片加载失败")}
|
||||||
|
|
||||||
<DndContext
|
|
||||||
sensors={sensors}
|
|
||||||
collisionDetection={closestCenter}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
>
|
>
|
||||||
<SortableContext
|
<Image
|
||||||
items={experiments}
|
src={getStaticImage("home/按钮-开启实验.png")}
|
||||||
strategy={verticalListSortingStrategy}
|
alt="开始实验按钮"
|
||||||
>
|
className="absolute bottom-10 left-1/2 transform -translate-x-1/2 -translate-y-1/2"
|
||||||
{experiments.map((experiment, index) => {
|
onClick={handleStartExperiment}
|
||||||
const Icon = experiment.icon;
|
/>
|
||||||
return (
|
</BackgroundImage>
|
||||||
<DraggableItem key={experiment.id} id={experiment.id}>
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-4 cursor-pointer hover:bg-gray-50 rounded transition-colors p-2 -m-2"
|
|
||||||
onClick={() => handleExperimentClick(experiment.id)}
|
|
||||||
>
|
|
||||||
<div className="flex-shrink-0 w-12 h-12 bg-indigo-100 rounded-full flex items-center justify-center">
|
|
||||||
<Icon className="w-6 h-6 text-indigo-600" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="font-semibold text-gray-800">
|
|
||||||
{index + 1}. {experiment.name}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
{experiment.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DraggableItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SortableContext>
|
|
||||||
</DndContext>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
interface ExperimentTitleProps {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ExperimentTitle({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
}: ExperimentTitleProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 标题 */}
|
||||||
|
<div className="w-full relative pb-20">
|
||||||
|
{/* 主标题容器 */}
|
||||||
|
<div className="flex items-start gap-6">
|
||||||
|
{/* 主标题 */}
|
||||||
|
<div className="bg-white border-4 border-blue-500 rounded-2xl px-10 py-3 shadow-md">
|
||||||
|
<h1 className="text-3xl font-bold text-blue-400 italic tracking-wide">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 副标题 */}
|
||||||
|
<div className="flex-1 relative pt-6">
|
||||||
|
<p className="text-gray-600 text-3xl relative z-10">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 使用 SVG 绘制更精确的装饰线 */}
|
||||||
|
<svg
|
||||||
|
className="absolute top-14 w-full h-20"
|
||||||
|
viewBox="0 0 1400 60"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
>
|
||||||
|
{/* 左侧水平线 */}
|
||||||
|
<line
|
||||||
|
x1="10"
|
||||||
|
y1="30"
|
||||||
|
x2="150"
|
||||||
|
y2="30"
|
||||||
|
stroke="#3b82f6"
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
{/* 斜线(从左到右,向下倾斜)*/}
|
||||||
|
<line
|
||||||
|
x1="150"
|
||||||
|
y1="30"
|
||||||
|
x2="200"
|
||||||
|
y2="60"
|
||||||
|
stroke="#3b82f6"
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
{/* 右侧水平线(与左侧在同一高度)*/}
|
||||||
|
<line
|
||||||
|
x1="250"
|
||||||
|
y1="30"
|
||||||
|
x2="1800"
|
||||||
|
y2="30"
|
||||||
|
stroke="#3b82f6"
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 && <Step1IntroPage onNext={() => setStep(2)} />;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
step === 2 && <Step2SelectPage />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 📄 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 <div>第三步内容</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 在 index.tsx 中引入
|
||||||
|
import Step3TestPage from "./Step3TestPage";
|
||||||
|
|
||||||
|
// 3. 添加到渲染逻辑
|
||||||
|
{
|
||||||
|
step === 3 && <Step3TestPage />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 修改工具数据
|
||||||
|
|
||||||
|
直接编辑 `constants.ts` 文件即可,组件会自动更新。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- React 18
|
||||||
|
- TypeScript
|
||||||
|
- Tailwind CSS
|
||||||
|
- 自定义 Image 组件
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<ExperimentTitle
|
||||||
|
title="01实验工具介绍"
|
||||||
|
description="以下是实验所需要的所有工具,让我们来看看具体有哪些吧"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* img列表 */}
|
||||||
|
<div className="grid grid-cols-4 gap-y-20 gap-x-20 mt-10">
|
||||||
|
{sensorImages.map((image, index) => (
|
||||||
|
<div key={index} className="flex flex-col items-center">
|
||||||
|
<Image
|
||||||
|
src={image.src}
|
||||||
|
className="w-80 h-50 rounded-lg shadow-md mb-4 object-cover"
|
||||||
|
/>
|
||||||
|
<div className="text-center text-xl">
|
||||||
|
<h3 className=" font-bold text-gray-800 mb-1">{image.name}</h3>
|
||||||
|
<p className=" text-gray-600">{image.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div onClick={onNext}>
|
||||||
|
<Image
|
||||||
|
className="absolute bottom-4 right-6 cursor-pointer"
|
||||||
|
src={getStaticImage("components/按钮-下一步.png")}
|
||||||
|
button={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<SelectedTool[]>([]);
|
||||||
|
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||||
|
const [isCorrect, setIsCorrect] = useState(false);
|
||||||
|
const {
|
||||||
|
isDragging,
|
||||||
|
dragData,
|
||||||
|
mousePosition,
|
||||||
|
startDrag,
|
||||||
|
endDrag,
|
||||||
|
isPointInElement,
|
||||||
|
} = useDragAndDrop<Tool>();
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<ExperimentTitle
|
||||||
|
title="02实验工具选择"
|
||||||
|
description="我们刚刚学习了实验所需要的所有工具,现在来手动选择一下哪些工具是需要应用在本次实验中的。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 主要内容区域 */}
|
||||||
|
<div className="flex gap-8 mt-10">
|
||||||
|
{/* 左侧:可拖拽选项 */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="bg-[#3d98cd] text-white text-center py-3 rounded-t-lg">
|
||||||
|
<h2 className="text-2xl font-bold">可拖拽选项</h2>
|
||||||
|
</div>
|
||||||
|
<div className="backdrop-blur-sm border-2 border-[#3d98cd] rounded-b-lg p-6 min-h-[500px]">
|
||||||
|
{availableTools.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-full text-gray-400 text-xl">
|
||||||
|
所有工具已选择完毕
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-3 gap-6">
|
||||||
|
{availableTools.map((tool, index) => {
|
||||||
|
const isBeingDragged =
|
||||||
|
isDragging && dragData?.name === tool.name;
|
||||||
|
const isDragDisabled = isSubmitted && isCorrect;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
onMouseDown={(e) => 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" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="rounded-lg p-4 w-full">
|
||||||
|
<Image
|
||||||
|
src={tool.src}
|
||||||
|
className="w-full h-32 object-contain mb-2 pointer-events-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-center text-lg font-semibold text-gray-700 mt-2">
|
||||||
|
{tool.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧:实验工具区域 */}
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<div className="bg-[#3d98cd] text-white text-center py-3 rounded-t-lg">
|
||||||
|
<h2 className="text-2xl font-bold">实验工具区域</h2>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={dropZoneRef}
|
||||||
|
className={`backdrop-blur-sm border-2 border-[#3d98cd] rounded-b-lg p-6 min-h-[500px] transition-colors ${
|
||||||
|
isOver ? "bg-blue-50 border-blue-400" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* 提交后的结果显示 */}
|
||||||
|
{isSubmitted ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full">
|
||||||
|
{isCorrect ? (
|
||||||
|
<>
|
||||||
|
<p className="text-green-500 text-3xl font-bold mb-4">
|
||||||
|
选择正确,继续实验吧
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-red-500 text-3xl font-bold mb-8">
|
||||||
|
选择错误,请重新选择
|
||||||
|
</p>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-blue-400 text-2xl font-semibold mb-4">
|
||||||
|
答案提示
|
||||||
|
</p>
|
||||||
|
<p className="text-blue-400 text-xl">
|
||||||
|
{correctAnswers.join("、")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : selectedTools.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-full text-gray-400 text-xl">
|
||||||
|
拖拽工具到这里
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-3 gap-6">
|
||||||
|
{selectedTools.map((tool) => (
|
||||||
|
<div
|
||||||
|
key={tool.id}
|
||||||
|
className="flex flex-col items-center relative group"
|
||||||
|
>
|
||||||
|
<div className="rounded-lg p-4 w-full">
|
||||||
|
<Image
|
||||||
|
src={tool.src}
|
||||||
|
className="w-full h-32 object-contain mb-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-center text-lg font-semibold text-gray-700 mt-2">
|
||||||
|
{tool.name}
|
||||||
|
</p>
|
||||||
|
{/* 删除按钮 - 答案正确后隐藏 */}
|
||||||
|
{!(isSubmitted && isCorrect) && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveTool(tool.id)}
|
||||||
|
className="absolute top-2 right-2 bg-red-500 hover:bg-red-600 text-white rounded-full w-8 h-8 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-lg"
|
||||||
|
title="删除"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部按钮 */}
|
||||||
|
{!isSubmitted || !isCorrect ? (
|
||||||
|
<div className="absolute bottom-0 left-1/2 translate-y-1/2 -translate-x-1/2 flex justify-center gap-8 mt-8">
|
||||||
|
<button
|
||||||
|
onClick={handleClear}
|
||||||
|
className="bg-gray-400 hover:bg-gray-500 text-white px-12 py-4 rounded-full text-2xl font-bold shadow-lg transition-colors"
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className="bg-[#3d98cd] hover:bg-[#2d7aad] text-white px-12 py-4 rounded-full text-2xl font-bold shadow-lg transition-colors"
|
||||||
|
>
|
||||||
|
提交
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部提示文字或下一步按钮 */}
|
||||||
|
<div className="text-center mt-20">
|
||||||
|
{isSubmitted && isCorrect ? (
|
||||||
|
<Image
|
||||||
|
onClick={onNext}
|
||||||
|
className="absolute bottom-4 right-6 cursor-pointer"
|
||||||
|
src={getStaticImage("components/按钮-下一步.png")}
|
||||||
|
button={true}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
// <button
|
||||||
|
// className="bg-blue-400 hover:bg-blue-500 text-white px-20 py-5 rounded-full text-3xl font-bold shadow-2xl transition-all hover:scale-105"
|
||||||
|
// >
|
||||||
|
// 下一步
|
||||||
|
// </button>
|
||||||
|
<p className="text-blue-400 text-3xl font-semibold">
|
||||||
|
通过拖拽的方式,将正确选择拖拽到实验工具区域内吧。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 拖拽预览 - 使用 Portal 渲染到 body,避免受 scale 影响 */}
|
||||||
|
{isDragging &&
|
||||||
|
dragData &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed pointer-events-none z-[9999]"
|
||||||
|
style={{
|
||||||
|
left: mousePosition.x,
|
||||||
|
top: mousePosition.y,
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center opacity-80 bg-white rounded-lg shadow-xl p-4">
|
||||||
|
<Image src={dragData.src} className="w-24 h-24 object-contain" />
|
||||||
|
<p className="text-sm font-semibold text-gray-700 mt-2">
|
||||||
|
{dragData.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<ExperimentTitle
|
||||||
|
title="03实验环境配置"
|
||||||
|
description="配置实验所需的软件和硬件环境,确保实验能够顺利进行。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 主要内容区域 */}
|
||||||
|
<div className="mt-10">
|
||||||
|
<div className="bg-white/80 backdrop-blur-sm rounded-lg p-8 shadow-lg">
|
||||||
|
<h3 className="text-2xl font-bold text-gray-800 mb-6">
|
||||||
|
环境配置步骤
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex-shrink-0 w-10 h-10 bg-[#3d98cd] text-white rounded-full flex items-center justify-center font-bold text-lg">
|
||||||
|
1
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="text-xl font-semibold text-gray-800 mb-2">
|
||||||
|
安装 Keil 软件
|
||||||
|
</h4>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
下载并安装 Keil μVision IDE,用于编写和编译 STM32 程序。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex-shrink-0 w-10 h-10 bg-[#3d98cd] text-white rounded-full flex items-center justify-center font-bold text-lg">
|
||||||
|
2
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="text-xl font-semibold text-gray-800 mb-2">
|
||||||
|
连接 STM32 主控板
|
||||||
|
</h4>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
使用烧录线将 STM32 主控板连接到电脑,确保驱动正常安装。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex-shrink-0 w-10 h-10 bg-[#3d98cd] text-white rounded-full flex items-center justify-center font-bold text-lg">
|
||||||
|
3
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="text-xl font-semibold text-gray-800 mb-2">
|
||||||
|
配置传感器连接
|
||||||
|
</h4>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
将力敏传感器正确连接到 STM32 主控板的指定引脚。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex-shrink-0 w-10 h-10 bg-[#3d98cd] text-white rounded-full flex items-center justify-center font-bold text-lg">
|
||||||
|
4
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="text-xl font-semibold text-gray-800 mb-2">
|
||||||
|
烧录程序
|
||||||
|
</h4>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
在 Keil 中编译程序,并通过烧录线将程序下载到 STM32 主控板。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部提示和下一步按钮 */}
|
||||||
|
<div className="text-center mt-10">
|
||||||
|
<p className="text-blue-400 text-2xl font-semibold mb-6">
|
||||||
|
完成以上步骤后,即可进入下一阶段的实验操作。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{onNext && (
|
||||||
|
<div onClick={onNext}>
|
||||||
|
<Image
|
||||||
|
className="absolute bottom-4 right-6 cursor-pointer"
|
||||||
|
src={getStaticImage("components/按钮-下一步.png")}
|
||||||
|
button={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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主控板",
|
||||||
|
"力敏传感器",
|
||||||
|
"烧录线",
|
||||||
|
];
|
||||||
@@ -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 (
|
||||||
|
<BackgroundImage
|
||||||
|
src={getStaticImage("experiment/内容背景页.png")}
|
||||||
|
alt="传感器实验首页"
|
||||||
|
className="h-full w-full "
|
||||||
|
>
|
||||||
|
<div className="h-full relative py-20 px-10">
|
||||||
|
{step === 1 && <Step1IntroPage onNext={() => setStep(2)} />}
|
||||||
|
{step === 2 && <Step2SelectPage onNext={() => setStep(3)} />}
|
||||||
|
{step === 3 && <Step3ConfigPage onNext={() => setStep(4)} />}
|
||||||
|
</div>
|
||||||
|
</BackgroundImage>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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: <Layout />,
|
||||||
|
// errorElement: <NotFound />,
|
||||||
|
// children: [
|
||||||
|
// {
|
||||||
|
// index: true,
|
||||||
|
// element: <Home />,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// path: 'experiment/:id',
|
||||||
|
// element: <ExperimentDetail />,
|
||||||
|
// },
|
||||||
|
// ],
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
element: <Navigate to="/home" replace />,
|
||||||
|
//重定向
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/home",
|
||||||
|
element: <Home />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/experiment",
|
||||||
|
element: <Experiment />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "*",
|
||||||
|
element: <NotFound />,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -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: <Layout />,
|
|
||||||
errorElement: <NotFound />,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
index: true,
|
|
||||||
element: <Home />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "experiment/:id",
|
|
||||||
element: <ExperimentDetail />,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "*",
|
|
||||||
element: <NotFound />,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -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: <Layout />,
|
|
||||||
errorElement: <NotFound />,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
index: true,
|
|
||||||
element: <Home />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'experiment/:id',
|
|
||||||
element: <ExperimentDetail />,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '*',
|
|
||||||
element: <NotFound />,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
Vendored
+42
@@ -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<string, any>;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
};
|
||||||
+4
-6
@@ -1,11 +1,9 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
content: [
|
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||||
"./index.html",
|
|
||||||
"./src/**/*.{js,ts,jsx,tsx}",
|
|
||||||
],
|
|
||||||
theme: {
|
theme: {
|
||||||
extend: {},
|
extend: {},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
}
|
} satisfies Config;
|
||||||
+6
-2
@@ -14,14 +14,18 @@
|
|||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
// JSX 支持
|
// JSX 支持
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
// 类型检查
|
// 类型检查
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": false,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": false,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
|
|
||||||
|
|||||||
+9
-3
@@ -1,7 +1,13 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from "vite";
|
||||||
import react from '@vitejs/plugin-react'
|
import react from "@vitejs/plugin-react";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
})
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in new issue
Block a user