Initial commit
This commit is contained in:
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.env.*.local
|
||||||
|
*.local
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
.eslintcache
|
||||||
|
.vscode/
|
||||||
|
.server.log
|
||||||
|
.server.pid
|
||||||
16
index.html
Normal file
16
index.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-TW">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||||
|
<title>3D 店內導引系統</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4908
package-lock.json
generated
Normal file
4908
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
43
package.json
Normal file
43
package.json
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"name": "3d-guiding-demo",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"dev:host": "vite --host 0.0.0.0 --port 3000",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"preview:host": "vite preview --host 0.0.0.0 --port 3000"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@react-three/drei": "^9.117.3",
|
||||||
|
"@react-three/fiber": "^8.17.10",
|
||||||
|
"fuse.js": "^7.0.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.28.0",
|
||||||
|
"three": "^0.170.0",
|
||||||
|
"uuid": "^11.0.3",
|
||||||
|
"zustand": "^5.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.15.0",
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@types/three": "^0.170.0",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"eslint": "^9.15.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.0.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.14",
|
||||||
|
"globals": "^15.12.0",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"tailwindcss": "^3.4.15",
|
||||||
|
"typescript": "~5.6.2",
|
||||||
|
"typescript-eslint": "^8.15.0",
|
||||||
|
"vite": "^6.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
1
public/vite.svg
Normal file
1
public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFBD4F"></stop><stop offset="100%" stop-color="#FF980E"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
25
src/App.tsx
Normal file
25
src/App.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
|
import { Header } from './components/layout/Header'
|
||||||
|
import { WizardPage } from './pages/WizardPage'
|
||||||
|
import { EditorPage } from './pages/EditorPage'
|
||||||
|
import { DistributionPage } from './pages/DistributionPage'
|
||||||
|
import { SearchPage } from './pages/SearchPage'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex flex-col bg-slate-50 overflow-hidden">
|
||||||
|
<Header />
|
||||||
|
<main className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Navigate to="/wizard" replace />} />
|
||||||
|
<Route path="/wizard" element={<WizardPage />} />
|
||||||
|
<Route path="/editor" element={<EditorPage />} />
|
||||||
|
<Route path="/distribution" element={<DistributionPage />} />
|
||||||
|
<Route path="/search" element={<SearchPage />} />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
41
src/components/common/Button.tsx
Normal file
41
src/components/common/Button.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: 'primary' | 'secondary' | 'outline' | 'ghost'
|
||||||
|
size?: 'sm' | 'md' | 'lg'
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
className = '',
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ButtonProps) {
|
||||||
|
const baseStyles =
|
||||||
|
'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed'
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
|
||||||
|
secondary: 'bg-slate-600 text-white hover:bg-slate-700 focus:ring-slate-500',
|
||||||
|
outline:
|
||||||
|
'border-2 border-blue-600 text-blue-600 hover:bg-blue-50 focus:ring-blue-500',
|
||||||
|
ghost: 'text-slate-600 hover:bg-slate-100 focus:ring-slate-500',
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: 'px-3 py-1.5 text-sm',
|
||||||
|
md: 'px-4 py-2 text-base',
|
||||||
|
lg: 'px-6 py-3 text-lg',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
44
src/components/common/Input.tsx
Normal file
44
src/components/common/Input.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { InputHTMLAttributes, forwardRef } from 'react'
|
||||||
|
|
||||||
|
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label?: string
|
||||||
|
error?: string
|
||||||
|
helpText?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ label, error, helpText, className = '', id, ...props }, ref) => {
|
||||||
|
const inputId = id || label?.toLowerCase().replace(/\s/g, '-')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{label && (
|
||||||
|
<label
|
||||||
|
htmlFor={inputId}
|
||||||
|
className="block text-sm font-medium text-slate-700 mb-1"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
id={inputId}
|
||||||
|
className={`
|
||||||
|
w-full px-3 py-2 border rounded-lg shadow-sm
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
|
||||||
|
disabled:bg-slate-100 disabled:cursor-not-allowed
|
||||||
|
${error ? 'border-red-500 focus:ring-red-500' : 'border-slate-300'}
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{helpText && !error && (
|
||||||
|
<p className="mt-1 text-sm text-slate-500">{helpText}</p>
|
||||||
|
)}
|
||||||
|
{error && <p className="mt-1 text-sm text-red-600">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Input.displayName = 'Input'
|
||||||
2
src/components/common/index.ts
Normal file
2
src/components/common/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './Button'
|
||||||
|
export * from './Input'
|
||||||
65
src/components/layout/Header.tsx
Normal file
65
src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
|
const setupRoutes = [
|
||||||
|
{ path: '/wizard', label: '1. 店家資訊' },
|
||||||
|
{ path: '/editor', label: '2. 地圖編輯' },
|
||||||
|
{ path: '/distribution', label: '3. 類別分布' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
const location = useLocation()
|
||||||
|
const isSearchMode = location.pathname === '/search'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="bg-white border-b border-slate-200 shadow-sm">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex items-center justify-between h-16">
|
||||||
|
{/* Logo / Title */}
|
||||||
|
<Link to="/" className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||||
|
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold text-slate-800 text-lg">3D 店內導引</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="flex items-center gap-1">
|
||||||
|
{/* Setup Mode Links */}
|
||||||
|
<div className="flex items-center gap-1 mr-4">
|
||||||
|
{setupRoutes.map((route) => (
|
||||||
|
<Link
|
||||||
|
key={route.path}
|
||||||
|
to={route.path}
|
||||||
|
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
location.pathname === route.path
|
||||||
|
? 'bg-blue-100 text-blue-700'
|
||||||
|
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{route.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="w-px h-6 bg-slate-200 mr-4" />
|
||||||
|
|
||||||
|
{/* Search Mode Link */}
|
||||||
|
<Link
|
||||||
|
to="/search"
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
isSearchMode
|
||||||
|
? 'bg-green-600 text-white'
|
||||||
|
: 'bg-green-50 text-green-700 hover:bg-green-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
顧客搜尋
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
1
src/components/layout/index.ts
Normal file
1
src/components/layout/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './Header'
|
||||||
45
src/components/three/canvas/SceneLighting.tsx
Normal file
45
src/components/three/canvas/SceneLighting.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
export function SceneLighting() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 環境光 - 提供基礎照明 */}
|
||||||
|
<ambientLight intensity={0.6} color="#ffffff" />
|
||||||
|
|
||||||
|
{/* 主光源 - 模擬天花板燈光 */}
|
||||||
|
<directionalLight
|
||||||
|
position={[10, 20, 10]}
|
||||||
|
intensity={1}
|
||||||
|
color="#ffffff"
|
||||||
|
castShadow
|
||||||
|
shadow-mapSize={[2048, 2048]}
|
||||||
|
shadow-camera-far={50}
|
||||||
|
shadow-camera-left={-30}
|
||||||
|
shadow-camera-right={30}
|
||||||
|
shadow-camera-top={30}
|
||||||
|
shadow-camera-bottom={-30}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 補光 - 減少陰影過暗 */}
|
||||||
|
<directionalLight
|
||||||
|
position={[-10, 15, -10]}
|
||||||
|
intensity={0.3}
|
||||||
|
color="#e0e7ff"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 頂光 - 從上方照射 */}
|
||||||
|
<pointLight
|
||||||
|
position={[0, 25, 0]}
|
||||||
|
intensity={0.5}
|
||||||
|
color="#ffffff"
|
||||||
|
distance={50}
|
||||||
|
decay={2}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 半球光 - 模擬天空和地面反射 */}
|
||||||
|
<hemisphereLight
|
||||||
|
color="#87ceeb"
|
||||||
|
groundColor="#f5f5dc"
|
||||||
|
intensity={0.3}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
59
src/components/three/canvas/StoreCanvas.tsx
Normal file
59
src/components/three/canvas/StoreCanvas.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { Canvas } from '@react-three/fiber'
|
||||||
|
import { OrbitControls, PerspectiveCamera } from '@react-three/drei'
|
||||||
|
import { ReactNode, Suspense } from 'react'
|
||||||
|
import { SceneLighting } from './SceneLighting'
|
||||||
|
|
||||||
|
interface StoreCanvasProps {
|
||||||
|
children: ReactNode
|
||||||
|
editable?: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingFallback() {
|
||||||
|
return (
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[1, 1, 1]} />
|
||||||
|
<meshBasicMaterial color="#cccccc" wireframe />
|
||||||
|
</mesh>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StoreCanvas({ children, editable = false, className = '' }: StoreCanvasProps) {
|
||||||
|
return (
|
||||||
|
<div className={`w-full h-full ${className}`}>
|
||||||
|
<Canvas
|
||||||
|
shadows
|
||||||
|
dpr={[1, 2]}
|
||||||
|
gl={{ antialias: true, alpha: true }}
|
||||||
|
style={{ background: 'linear-gradient(180deg, #e2e8f0 0%, #f8fafc 100%)' }}
|
||||||
|
>
|
||||||
|
<PerspectiveCamera
|
||||||
|
makeDefault
|
||||||
|
position={[0, 20, 25]}
|
||||||
|
fov={50}
|
||||||
|
near={0.1}
|
||||||
|
far={1000}
|
||||||
|
/>
|
||||||
|
<SceneLighting />
|
||||||
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
|
{children}
|
||||||
|
</Suspense>
|
||||||
|
<OrbitControls
|
||||||
|
makeDefault
|
||||||
|
enableDamping
|
||||||
|
dampingFactor={0.05}
|
||||||
|
minDistance={5}
|
||||||
|
maxDistance={80}
|
||||||
|
maxPolarAngle={Math.PI / 2.1} // 防止相機穿過地板
|
||||||
|
minPolarAngle={0.1}
|
||||||
|
enablePan={editable}
|
||||||
|
panSpeed={0.5}
|
||||||
|
rotateSpeed={0.5}
|
||||||
|
zoomSpeed={0.8}
|
||||||
|
/>
|
||||||
|
{/* 地面網格輔助線 */}
|
||||||
|
<gridHelper args={[50, 50, '#94a3b8', '#cbd5e1']} position={[0, 0.01, 0]} />
|
||||||
|
</Canvas>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
2
src/components/three/canvas/index.ts
Normal file
2
src/components/three/canvas/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './StoreCanvas'
|
||||||
|
export * from './SceneLighting'
|
||||||
2
src/components/three/index.ts
Normal file
2
src/components/three/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './canvas'
|
||||||
|
export * from './objects'
|
||||||
182
src/components/three/objects/Aisle.tsx
Normal file
182
src/components/three/objects/Aisle.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { useRef, useState, useMemo } from 'react'
|
||||||
|
import { useFrame, ThreeEvent } from '@react-three/fiber'
|
||||||
|
import * as THREE from 'three'
|
||||||
|
import { MapElement } from '../../../types'
|
||||||
|
import { getCategoryColor } from '../../../data'
|
||||||
|
|
||||||
|
// 醒目提示箭頭組件 - 帶動畫的大箭頭
|
||||||
|
function HighlightArrow({ height }: { height: number }) {
|
||||||
|
const groupRef = useRef<THREE.Group>(null)
|
||||||
|
|
||||||
|
// 箭頭上下浮動動畫
|
||||||
|
useFrame((state) => {
|
||||||
|
if (groupRef.current) {
|
||||||
|
const bounce = Math.sin(state.clock.elapsedTime * 3) * 0.3
|
||||||
|
groupRef.current.position.y = height + 4 + bounce
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 創建箭頭形狀
|
||||||
|
const arrowShape = useMemo(() => {
|
||||||
|
const shape = new THREE.Shape()
|
||||||
|
// 箭頭形狀 (向下指)
|
||||||
|
shape.moveTo(0, 0) // 箭頭尖端
|
||||||
|
shape.lineTo(-1, 1.5) // 左翼
|
||||||
|
shape.lineTo(-0.4, 1.5) // 左內
|
||||||
|
shape.lineTo(-0.4, 3) // 左桿
|
||||||
|
shape.lineTo(0.4, 3) // 右桿
|
||||||
|
shape.lineTo(0.4, 1.5) // 右內
|
||||||
|
shape.lineTo(1, 1.5) // 右翼
|
||||||
|
shape.closePath()
|
||||||
|
return shape
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group ref={groupRef} position={[0, height + 4, 0]}>
|
||||||
|
{/* 主箭頭 - 紅色 */}
|
||||||
|
<mesh rotation={[0, 0, 0]}>
|
||||||
|
<extrudeGeometry args={[arrowShape, { depth: 0.5, bevelEnabled: false }]} />
|
||||||
|
<meshStandardMaterial color="#ef4444" emissive="#ef4444" emissiveIntensity={0.5} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* 連接線 - 從箭頭到目標 */}
|
||||||
|
<mesh position={[0, -1.5, 0.25]}>
|
||||||
|
<cylinderGeometry args={[0.1, 0.1, height + 2.5, 8]} />
|
||||||
|
<meshBasicMaterial color="#ef4444" transparent opacity={0.6} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* 目標環 */}
|
||||||
|
<mesh position={[0, -(height + 3), 0.25]} rotation={[Math.PI / 2, 0, 0]}>
|
||||||
|
<torusGeometry args={[1.5, 0.15, 8, 32]} />
|
||||||
|
<meshBasicMaterial color="#ef4444" />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* 內環 */}
|
||||||
|
<mesh position={[0, -(height + 3), 0.25]} rotation={[Math.PI / 2, 0, 0]}>
|
||||||
|
<torusGeometry args={[0.8, 0.1, 8, 32]} />
|
||||||
|
<meshBasicMaterial color="#fbbf24" />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AisleProps {
|
||||||
|
element: MapElement
|
||||||
|
isSelected?: boolean
|
||||||
|
isHighlighted?: boolean
|
||||||
|
onClick?: (id: string, event: ThreeEvent<MouseEvent>) => void
|
||||||
|
showLabel?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Aisle({
|
||||||
|
element,
|
||||||
|
isSelected = false,
|
||||||
|
isHighlighted = false,
|
||||||
|
onClick,
|
||||||
|
showLabel = true,
|
||||||
|
}: AisleProps) {
|
||||||
|
const meshRef = useRef<THREE.Mesh>(null)
|
||||||
|
const [hovered, setHovered] = useState(false)
|
||||||
|
|
||||||
|
const { position, dimensions, rotation, categoryId, type } = element
|
||||||
|
const categoryColor = getCategoryColor(categoryId)
|
||||||
|
|
||||||
|
// 發光動畫效果
|
||||||
|
useFrame((state) => {
|
||||||
|
if (meshRef.current && isHighlighted) {
|
||||||
|
const material = meshRef.current.material as THREE.MeshStandardMaterial
|
||||||
|
// 脈衝發光效果
|
||||||
|
const pulse = Math.sin(state.clock.elapsedTime * 4) * 0.3 + 0.7
|
||||||
|
material.emissiveIntensity = pulse * 0.8
|
||||||
|
} else if (meshRef.current) {
|
||||||
|
const material = meshRef.current.material as THREE.MeshStandardMaterial
|
||||||
|
material.emissiveIntensity = hovered ? 0.2 : 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 根據類型決定顏色
|
||||||
|
const getBaseColor = () => {
|
||||||
|
if (type === 'cashier') return '#5D6D7E'
|
||||||
|
if (type === 'entrance') return '#1ABC9C'
|
||||||
|
if (type === 'storage') return '#7F8C8D'
|
||||||
|
return categoryColor
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseColor = getBaseColor()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group
|
||||||
|
position={[position.x, position.y, position.z]}
|
||||||
|
rotation={[0, rotation, 0]}
|
||||||
|
>
|
||||||
|
{/* 主體方塊 */}
|
||||||
|
<mesh
|
||||||
|
ref={meshRef}
|
||||||
|
position={[0, dimensions.height / 2, 0]}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onClick?.(element.id, e)
|
||||||
|
}}
|
||||||
|
onPointerOver={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setHovered(true)
|
||||||
|
document.body.style.cursor = 'pointer'
|
||||||
|
}}
|
||||||
|
onPointerOut={() => {
|
||||||
|
setHovered(false)
|
||||||
|
document.body.style.cursor = 'default'
|
||||||
|
}}
|
||||||
|
castShadow
|
||||||
|
receiveShadow
|
||||||
|
>
|
||||||
|
<boxGeometry args={[dimensions.width, dimensions.height, dimensions.depth]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={baseColor}
|
||||||
|
emissive={isHighlighted ? baseColor : '#000000'}
|
||||||
|
emissiveIntensity={isHighlighted ? 0.5 : 0}
|
||||||
|
metalness={0.1}
|
||||||
|
roughness={0.7}
|
||||||
|
transparent={hovered || isSelected}
|
||||||
|
opacity={hovered || isSelected ? 0.9 : 1}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* 選取框線 */}
|
||||||
|
{isSelected && (
|
||||||
|
<lineSegments position={[0, dimensions.height / 2, 0]}>
|
||||||
|
<edgesGeometry
|
||||||
|
args={[
|
||||||
|
new THREE.BoxGeometry(
|
||||||
|
dimensions.width * 1.02,
|
||||||
|
dimensions.height * 1.02,
|
||||||
|
dimensions.depth * 1.02
|
||||||
|
),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<lineBasicMaterial color="#3b82f6" linewidth={2} />
|
||||||
|
</lineSegments>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 醒目提示效果 - 大箭頭指示器 */}
|
||||||
|
{isHighlighted && (
|
||||||
|
<HighlightArrow height={dimensions.height} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 標籤 - 使用簡單的顏色標記 */}
|
||||||
|
{showLabel && (
|
||||||
|
<mesh position={[0, dimensions.height + 0.3, 0]}>
|
||||||
|
<boxGeometry args={[Math.min(dimensions.width, 2), 0.15, 0.6]} />
|
||||||
|
<meshBasicMaterial color={baseColor} />
|
||||||
|
</mesh>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 懸浮提示背景 */}
|
||||||
|
{hovered && (
|
||||||
|
<mesh position={[0, dimensions.height + 0.5, 0]}>
|
||||||
|
<planeGeometry args={[3, 0.8]} />
|
||||||
|
<meshBasicMaterial color="#ffffff" transparent opacity={0.9} />
|
||||||
|
</mesh>
|
||||||
|
)}
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
39
src/components/three/objects/Floor.tsx
Normal file
39
src/components/three/objects/Floor.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { useRef } from 'react'
|
||||||
|
import * as THREE from 'three'
|
||||||
|
|
||||||
|
interface FloorProps {
|
||||||
|
width: number
|
||||||
|
length: number
|
||||||
|
floorIndex?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Floor({ width, length, floorIndex = 0 }: FloorProps) {
|
||||||
|
const meshRef = useRef<THREE.Mesh>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group position={[0, floorIndex * 4, 0]}>
|
||||||
|
{/* 地板平面 */}
|
||||||
|
<mesh
|
||||||
|
ref={meshRef}
|
||||||
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
|
position={[0, 0, 0]}
|
||||||
|
receiveShadow
|
||||||
|
>
|
||||||
|
<planeGeometry args={[width, length]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#f1f5f9"
|
||||||
|
roughness={0.8}
|
||||||
|
metalness={0.1}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* 地板邊框 */}
|
||||||
|
<lineSegments position={[0, 0.02, 0]}>
|
||||||
|
<edgesGeometry
|
||||||
|
args={[new THREE.PlaneGeometry(width, length)]}
|
||||||
|
/>
|
||||||
|
<lineBasicMaterial color="#64748b" linewidth={2} />
|
||||||
|
</lineSegments>
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
61
src/components/three/objects/StoreGroup.tsx
Normal file
61
src/components/three/objects/StoreGroup.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { ThreeEvent } from '@react-three/fiber'
|
||||||
|
import { StoreMap } from '../../../types'
|
||||||
|
import { Floor } from './Floor'
|
||||||
|
import { Aisle } from './Aisle'
|
||||||
|
|
||||||
|
interface StoreGroupProps {
|
||||||
|
storeMap: StoreMap | null
|
||||||
|
floorWidth?: number
|
||||||
|
floorLength?: number
|
||||||
|
selectedElementId?: string | null
|
||||||
|
highlightedElementId?: string | null
|
||||||
|
onElementClick?: (id: string, event: ThreeEvent<MouseEvent>) => void
|
||||||
|
showLabels?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StoreGroup({
|
||||||
|
storeMap,
|
||||||
|
floorWidth = 30,
|
||||||
|
floorLength = 25,
|
||||||
|
selectedElementId,
|
||||||
|
highlightedElementId,
|
||||||
|
onElementClick,
|
||||||
|
showLabels = true,
|
||||||
|
}: StoreGroupProps) {
|
||||||
|
if (!storeMap) {
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
<Floor width={floorWidth} length={floorLength} />
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取得所有樓層
|
||||||
|
const floorIndices = [...new Set(storeMap.elements.map((e) => e.floorIndex))]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* 渲染每個樓層的地板 */}
|
||||||
|
{floorIndices.map((floorIndex) => (
|
||||||
|
<Floor
|
||||||
|
key={`floor-${floorIndex}`}
|
||||||
|
width={floorWidth}
|
||||||
|
length={floorLength}
|
||||||
|
floorIndex={floorIndex}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 渲染所有元素 */}
|
||||||
|
{storeMap.elements.map((element) => (
|
||||||
|
<Aisle
|
||||||
|
key={element.id}
|
||||||
|
element={element}
|
||||||
|
isSelected={selectedElementId === element.id}
|
||||||
|
isHighlighted={highlightedElementId === element.id}
|
||||||
|
onClick={onElementClick}
|
||||||
|
showLabel={showLabels}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
3
src/components/three/objects/index.ts
Normal file
3
src/components/three/objects/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './Floor'
|
||||||
|
export * from './Aisle'
|
||||||
|
export * from './StoreGroup'
|
||||||
2
src/data/index.ts
Normal file
2
src/data/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './sampleCategories'
|
||||||
|
export * from './sampleProducts'
|
||||||
30
src/data/sampleCategories.ts
Normal file
30
src/data/sampleCategories.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Category } from '../types'
|
||||||
|
|
||||||
|
export const sampleCategories: Category[] = [
|
||||||
|
{ id: 'cat-1', name: '零食餅乾區', nameEn: 'Snacks', color: '#FF6B6B' },
|
||||||
|
{ id: 'cat-2', name: '飲料區', nameEn: 'Beverages', color: '#4ECDC4' },
|
||||||
|
{ id: 'cat-3', name: '乳製品區', nameEn: 'Dairy', color: '#F7DC6F' },
|
||||||
|
{ id: 'cat-4', name: '生鮮蔬果區', nameEn: 'Fresh Produce', color: '#82E0AA' },
|
||||||
|
{ id: 'cat-5', name: '肉品海鮮區', nameEn: 'Meat & Seafood', color: '#E74C3C' },
|
||||||
|
{ id: 'cat-6', name: '冷凍食品區', nameEn: 'Frozen Foods', color: '#85C1E9' },
|
||||||
|
{ id: 'cat-7', name: '調味料區', nameEn: 'Condiments', color: '#F39C12' },
|
||||||
|
{ id: 'cat-8', name: '泡麵罐頭區', nameEn: 'Instant Noodles & Canned', color: '#D35400' },
|
||||||
|
{ id: 'cat-9', name: '日用品區', nameEn: 'Daily Necessities', color: '#BB8FCE' },
|
||||||
|
{ id: 'cat-10', name: '清潔用品區', nameEn: 'Cleaning Supplies', color: '#F8B500' },
|
||||||
|
{ id: 'cat-11', name: '收銀台', nameEn: 'Cashier', color: '#5D6D7E' },
|
||||||
|
{ id: 'cat-12', name: '入口', nameEn: 'Entrance', color: '#1ABC9C' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 取得類別顏色的輔助函數
|
||||||
|
export function getCategoryColor(categoryId: string | undefined): string {
|
||||||
|
if (!categoryId) return '#888888'
|
||||||
|
const category = sampleCategories.find(c => c.id === categoryId)
|
||||||
|
return category?.color ?? '#888888'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取得類別名稱的輔助函數
|
||||||
|
export function getCategoryName(categoryId: string | undefined): string {
|
||||||
|
if (!categoryId) return '未分類'
|
||||||
|
const category = sampleCategories.find(c => c.id === categoryId)
|
||||||
|
return category?.name ?? '未分類'
|
||||||
|
}
|
||||||
69
src/data/sampleProducts.ts
Normal file
69
src/data/sampleProducts.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { Product } from '../types'
|
||||||
|
|
||||||
|
export const sampleProducts: Product[] = [
|
||||||
|
// 零食餅乾區 (cat-1)
|
||||||
|
{ id: 'p-1', name: '洋芋片', categoryId: 'cat-1', keywords: ['薯片', '零食', '餅乾', '波卡'] },
|
||||||
|
{ id: 'p-2', name: '巧克力', categoryId: 'cat-1', keywords: ['糖果', '甜食', '可可'] },
|
||||||
|
{ id: 'p-3', name: '餅乾', categoryId: 'cat-1', keywords: ['零食', '點心', '蘇打餅'] },
|
||||||
|
{ id: 'p-4', name: '糖果', categoryId: 'cat-1', keywords: ['甜食', '軟糖', '硬糖'] },
|
||||||
|
{ id: 'p-5', name: '堅果', categoryId: 'cat-1', keywords: ['杏仁', '腰果', '核桃', '花生'] },
|
||||||
|
|
||||||
|
// 飲料區 (cat-2)
|
||||||
|
{ id: 'p-6', name: '礦泉水', categoryId: 'cat-2', keywords: ['水', '飲用水', '純水'] },
|
||||||
|
{ id: 'p-7', name: '可樂', categoryId: 'cat-2', keywords: ['汽水', '碳酸飲料', '可口可樂', '百事'] },
|
||||||
|
{ id: 'p-8', name: '果汁', categoryId: 'cat-2', keywords: ['柳橙汁', '蘋果汁', '葡萄汁'] },
|
||||||
|
{ id: 'p-9', name: '茶飲', categoryId: 'cat-2', keywords: ['綠茶', '紅茶', '奶茶', '烏龍茶'] },
|
||||||
|
{ id: 'p-10', name: '咖啡', categoryId: 'cat-2', keywords: ['即溶咖啡', '罐裝咖啡', '拿鐵'] },
|
||||||
|
{ id: 'p-11', name: '運動飲料', categoryId: 'cat-2', keywords: ['寶礦力', '舒跑', '電解質'] },
|
||||||
|
|
||||||
|
// 乳製品區 (cat-3)
|
||||||
|
{ id: 'p-12', name: '鮮奶', categoryId: 'cat-3', keywords: ['牛奶', '牛乳', '鮮乳'] },
|
||||||
|
{ id: 'p-13', name: '優格', categoryId: 'cat-3', keywords: ['酸奶', '優酪乳', '希臘優格'] },
|
||||||
|
{ id: 'p-14', name: '起司', categoryId: 'cat-3', keywords: ['乳酪', '芝士', '起士'] },
|
||||||
|
{ id: 'p-15', name: '奶油', categoryId: 'cat-3', keywords: ['黃油', '牛油', '奶酪'] },
|
||||||
|
|
||||||
|
// 生鮮蔬果區 (cat-4)
|
||||||
|
{ id: 'p-16', name: '蘋果', categoryId: 'cat-4', keywords: ['水果', '新鮮水果', '富士蘋果'] },
|
||||||
|
{ id: 'p-17', name: '香蕉', categoryId: 'cat-4', keywords: ['水果', '黃香蕉'] },
|
||||||
|
{ id: 'p-18', name: '高麗菜', categoryId: 'cat-4', keywords: ['蔬菜', '青菜', '包心菜', '甘藍'] },
|
||||||
|
{ id: 'p-19', name: '番茄', categoryId: 'cat-4', keywords: ['蔬菜', '西紅柿', '小番茄'] },
|
||||||
|
{ id: 'p-20', name: '胡蘿蔔', categoryId: 'cat-4', keywords: ['紅蘿蔔', '蔬菜'] },
|
||||||
|
{ id: 'p-21', name: '洋蔥', categoryId: 'cat-4', keywords: ['蔬菜', '圓蔥'] },
|
||||||
|
|
||||||
|
// 肉品海鮮區 (cat-5)
|
||||||
|
{ id: 'p-22', name: '豬肉', categoryId: 'cat-5', keywords: ['肉品', '豬排', '五花肉', '里肌'] },
|
||||||
|
{ id: 'p-23', name: '雞肉', categoryId: 'cat-5', keywords: ['肉品', '雞腿', '雞胸', '雞翅'] },
|
||||||
|
{ id: 'p-24', name: '牛肉', categoryId: 'cat-5', keywords: ['肉品', '牛排', '牛腩'] },
|
||||||
|
{ id: 'p-25', name: '鮮魚', categoryId: 'cat-5', keywords: ['海鮮', '魚', '鮭魚', '鯛魚'] },
|
||||||
|
{ id: 'p-26', name: '蝦子', categoryId: 'cat-5', keywords: ['海鮮', '蝦仁', '白蝦'] },
|
||||||
|
|
||||||
|
// 冷凍食品區 (cat-6)
|
||||||
|
{ id: 'p-27', name: '冷凍水餃', categoryId: 'cat-6', keywords: ['水餃', '餃子', '冷凍餃'] },
|
||||||
|
{ id: 'p-28', name: '冰淇淋', categoryId: 'cat-6', keywords: ['雪糕', '冰品', '冰棒'] },
|
||||||
|
{ id: 'p-29', name: '冷凍蔬菜', categoryId: 'cat-6', keywords: ['冷凍青菜', '三色豆'] },
|
||||||
|
{ id: 'p-30', name: '冷凍披薩', categoryId: 'cat-6', keywords: ['比薩', 'pizza'] },
|
||||||
|
|
||||||
|
// 調味料區 (cat-7)
|
||||||
|
{ id: 'p-31', name: '醬油', categoryId: 'cat-7', keywords: ['調味料', '豆油', '生抽', '老抽'] },
|
||||||
|
{ id: 'p-32', name: '鹽', categoryId: 'cat-7', keywords: ['調味料', '食鹽', '海鹽'] },
|
||||||
|
{ id: 'p-33', name: '糖', categoryId: 'cat-7', keywords: ['調味料', '白糖', '砂糖', '冰糖'] },
|
||||||
|
{ id: 'p-34', name: '醋', categoryId: 'cat-7', keywords: ['調味料', '白醋', '烏醋', '米醋'] },
|
||||||
|
{ id: 'p-35', name: '油', categoryId: 'cat-7', keywords: ['調味料', '沙拉油', '橄欖油', '麻油'] },
|
||||||
|
|
||||||
|
// 泡麵罐頭區 (cat-8)
|
||||||
|
{ id: 'p-36', name: '泡麵', categoryId: 'cat-8', keywords: ['速食麵', '方便麵', '即食麵', '杯麵'] },
|
||||||
|
{ id: 'p-37', name: '罐頭', categoryId: 'cat-8', keywords: ['鮪魚罐頭', '玉米罐頭', '水果罐頭'] },
|
||||||
|
{ id: 'p-38', name: '即食粥', categoryId: 'cat-8', keywords: ['速食粥', '八寶粥'] },
|
||||||
|
|
||||||
|
// 日用品區 (cat-9)
|
||||||
|
{ id: 'p-39', name: '衛生紙', categoryId: 'cat-9', keywords: ['面紙', '紙巾', '廁紙', '抽取式衛生紙'] },
|
||||||
|
{ id: 'p-40', name: '牙膏', categoryId: 'cat-9', keywords: ['牙刷', '口腔清潔', '牙膏牙刷'] },
|
||||||
|
{ id: 'p-41', name: '洗髮精', categoryId: 'cat-9', keywords: ['洗髮乳', '頭髮', '護髮'] },
|
||||||
|
{ id: 'p-42', name: '沐浴乳', categoryId: 'cat-9', keywords: ['沐浴露', '香皂', '肥皂'] },
|
||||||
|
|
||||||
|
// 清潔用品區 (cat-10)
|
||||||
|
{ id: 'p-43', name: '洗碗精', categoryId: 'cat-10', keywords: ['洗潔精', '碗盤清潔'] },
|
||||||
|
{ id: 'p-44', name: '洗衣精', categoryId: 'cat-10', keywords: ['洗衣液', '衣物清潔', '洗衣粉'] },
|
||||||
|
{ id: 'p-45', name: '拖把', categoryId: 'cat-10', keywords: ['清潔工具', '地板清潔', '掃把'] },
|
||||||
|
{ id: 'p-46', name: '垃圾袋', categoryId: 'cat-10', keywords: ['清潔用品', '塑膠袋'] },
|
||||||
|
]
|
||||||
1
src/hooks/index.ts
Normal file
1
src/hooks/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './useSearch'
|
||||||
47
src/hooks/useSearch.ts
Normal file
47
src/hooks/useSearch.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { useMemo, useState, useCallback } from 'react'
|
||||||
|
import { createSearchEngine } from '../utils/searchEngine'
|
||||||
|
import { sampleProducts, sampleCategories } from '../data'
|
||||||
|
import { SearchResult } from '../types'
|
||||||
|
|
||||||
|
export function useSearch() {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [results, setResults] = useState<SearchResult[]>([])
|
||||||
|
const [selectedResult, setSelectedResult] = useState<SearchResult | null>(null)
|
||||||
|
|
||||||
|
const searchEngine = useMemo(
|
||||||
|
() => createSearchEngine(sampleProducts, sampleCategories),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSearch = useCallback(
|
||||||
|
(searchQuery: string) => {
|
||||||
|
setQuery(searchQuery)
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const searchResults = searchEngine.search(searchQuery)
|
||||||
|
setResults(searchResults)
|
||||||
|
} else {
|
||||||
|
setResults([])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[searchEngine]
|
||||||
|
)
|
||||||
|
|
||||||
|
const clearSearch = useCallback(() => {
|
||||||
|
setQuery('')
|
||||||
|
setResults([])
|
||||||
|
setSelectedResult(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const selectResult = useCallback((result: SearchResult | null) => {
|
||||||
|
setSelectedResult(result)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
results,
|
||||||
|
selectedResult,
|
||||||
|
handleSearch,
|
||||||
|
clearSearch,
|
||||||
|
selectResult,
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/index.css
Normal file
52
src/index.css
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
font-family: 'Noto Sans TC', system-ui, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #213547;
|
||||||
|
background-color: #f8fafc;
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #94a3b8;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 3D Canvas container */
|
||||||
|
.canvas-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
13
src/main.tsx
Normal file
13
src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
279
src/pages/DistributionPage.tsx
Normal file
279
src/pages/DistributionPage.tsx
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import { useEffect, useCallback } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { ThreeEvent } from '@react-three/fiber'
|
||||||
|
import { StoreCanvas, StoreGroup } from '../components/three'
|
||||||
|
import { Button } from '../components/common'
|
||||||
|
import { useStoreConfigStore, useMapStore, useUIStore } from '../store'
|
||||||
|
import { sampleCategories, getCategoryColor } from '../data'
|
||||||
|
import { assignSampleCategories } from '../utils'
|
||||||
|
|
||||||
|
export function DistributionPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { storeConfig, isConfigured } = useStoreConfigStore()
|
||||||
|
const {
|
||||||
|
storeMap,
|
||||||
|
setStoreMap,
|
||||||
|
selectedElementId,
|
||||||
|
selectElement,
|
||||||
|
assignCategory,
|
||||||
|
clearCategoryFromElement,
|
||||||
|
} = useMapStore()
|
||||||
|
const { selectedCategoryId, setSelectedCategoryId } = useUIStore()
|
||||||
|
|
||||||
|
// 如果沒有設定或地圖,導向適當頁面
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isConfigured || !storeConfig) {
|
||||||
|
navigate('/wizard')
|
||||||
|
} else if (!storeMap) {
|
||||||
|
navigate('/editor')
|
||||||
|
}
|
||||||
|
}, [isConfigured, storeConfig, storeMap, navigate])
|
||||||
|
|
||||||
|
// 處理元素點擊
|
||||||
|
const handleElementClick = useCallback(
|
||||||
|
(id: string, _event: ThreeEvent<MouseEvent>) => {
|
||||||
|
selectElement(id)
|
||||||
|
|
||||||
|
// 如果有選取類別,自動指派
|
||||||
|
if (selectedCategoryId) {
|
||||||
|
assignCategory(id, selectedCategoryId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectElement, selectedCategoryId, assignCategory]
|
||||||
|
)
|
||||||
|
|
||||||
|
// 處理類別選取
|
||||||
|
const handleCategorySelect = (categoryId: string) => {
|
||||||
|
if (selectedCategoryId === categoryId) {
|
||||||
|
setSelectedCategoryId(null)
|
||||||
|
} else {
|
||||||
|
setSelectedCategoryId(categoryId)
|
||||||
|
|
||||||
|
// 如果有選取的元素,立即指派類別
|
||||||
|
if (selectedElementId) {
|
||||||
|
assignCategory(selectedElementId, categoryId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除元素的類別
|
||||||
|
const handleClearCategory = () => {
|
||||||
|
if (selectedElementId) {
|
||||||
|
clearCategoryFromElement(selectedElementId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自動分配範例類別
|
||||||
|
const handleAutoAssign = () => {
|
||||||
|
if (storeMap) {
|
||||||
|
const updatedMap = assignSampleCategories(storeMap)
|
||||||
|
setStoreMap(updatedMap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取得選取的元素
|
||||||
|
const selectedElement = storeMap?.elements.find((e) => e.id === selectedElementId)
|
||||||
|
|
||||||
|
// 過濾可指派類別的類別(排除收銀台和入口)
|
||||||
|
const assignableCategories = sampleCategories.filter(
|
||||||
|
(c) => c.id !== 'cat-11' && c.id !== 'cat-12'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!storeConfig || !storeMap) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const floorConfig = storeConfig.floors[0]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex overflow-hidden">
|
||||||
|
{/* 左側類別列表 */}
|
||||||
|
<div className="w-72 bg-white border-r border-slate-200 flex flex-col">
|
||||||
|
<div className="p-4 border-b border-slate-200">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-800">商品類別</h2>
|
||||||
|
<p className="text-sm text-slate-500 mt-1">
|
||||||
|
選取類別,然後點擊地圖中的貨架進行指派
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Demo 快速設定 */}
|
||||||
|
<div className="p-4 border-b border-slate-200 bg-gradient-to-r from-blue-50 to-indigo-50">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-lg">🚀</span>
|
||||||
|
<h3 className="font-semibold text-blue-800">Demo 快速設定</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-blue-600 mb-3">
|
||||||
|
一鍵填入範例類別資料,快速完成展示設定
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
className="w-full bg-gradient-to-r from-blue-500 to-indigo-500 hover:from-blue-600 hover:to-indigo-600"
|
||||||
|
onClick={handleAutoAssign}
|
||||||
|
>
|
||||||
|
一鍵填入範例資料
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 類別列表 */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{assignableCategories.map((category) => (
|
||||||
|
<button
|
||||||
|
key={category.id}
|
||||||
|
onClick={() => handleCategorySelect(category.id)}
|
||||||
|
className={`w-full flex items-center gap-3 p-3 rounded-lg transition-all ${
|
||||||
|
selectedCategoryId === category.id
|
||||||
|
? 'bg-blue-50 border-2 border-blue-500'
|
||||||
|
: 'bg-slate-50 border-2 border-transparent hover:bg-slate-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-6 h-6 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: category.color }}
|
||||||
|
/>
|
||||||
|
<div className="text-left">
|
||||||
|
<p className="font-medium text-slate-800">{category.name}</p>
|
||||||
|
{category.nameEn && (
|
||||||
|
<p className="text-xs text-slate-500">{category.nameEn}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selectedCategoryId === category.id && (
|
||||||
|
<span className="ml-auto text-blue-500 text-sm">選取中</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 快速操作 */}
|
||||||
|
<div className="p-4 border-t border-slate-200">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => setSelectedCategoryId(null)}
|
||||||
|
disabled={!selectedCategoryId}
|
||||||
|
>
|
||||||
|
取消選取類別
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3D 畫布區 */}
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<StoreCanvas className="w-full h-full">
|
||||||
|
<StoreGroup
|
||||||
|
storeMap={storeMap}
|
||||||
|
floorWidth={floorConfig?.floorWidth || 30}
|
||||||
|
floorLength={floorConfig?.floorLength || 25}
|
||||||
|
selectedElementId={selectedElementId}
|
||||||
|
onElementClick={handleElementClick}
|
||||||
|
showLabels={true}
|
||||||
|
/>
|
||||||
|
</StoreCanvas>
|
||||||
|
|
||||||
|
{/* 操作提示 */}
|
||||||
|
<div className="absolute bottom-4 left-4 bg-white/90 rounded-lg px-4 py-2 shadow">
|
||||||
|
<p className="text-sm text-slate-600">
|
||||||
|
{selectedCategoryId
|
||||||
|
? '點擊地圖中的貨架進行類別指派'
|
||||||
|
: '請先從左側選取一個類別'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 圖例 */}
|
||||||
|
<div className="absolute top-4 right-4 bg-white/90 rounded-lg p-4 shadow max-w-xs">
|
||||||
|
<h3 className="font-medium text-slate-800 mb-2 text-sm">類別圖例</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{assignableCategories.slice(0, 8).map((category) => (
|
||||||
|
<div key={category.id} className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: category.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-slate-600 truncate">
|
||||||
|
{category.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右側選取元素資訊 */}
|
||||||
|
<div className="w-64 bg-white border-l border-slate-200 p-4">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-800 mb-4">元素資訊</h2>
|
||||||
|
|
||||||
|
{selectedElement ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 元素類型 */}
|
||||||
|
<div className="bg-slate-50 rounded-lg p-3">
|
||||||
|
<p className="text-sm text-slate-500">類型</p>
|
||||||
|
<p className="font-medium text-slate-800">
|
||||||
|
{selectedElement.type === 'aisle' && '貨架'}
|
||||||
|
{selectedElement.type === 'cashier' && '收銀台'}
|
||||||
|
{selectedElement.type === 'entrance' && '入口'}
|
||||||
|
{selectedElement.type === 'storage' && '倉庫'}
|
||||||
|
{selectedElement.type === 'promo' && '促銷區'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 已指派類別 */}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500 mb-2">已指派類別</p>
|
||||||
|
{selectedElement.categoryId ? (
|
||||||
|
<div className="flex items-center gap-2 p-2 bg-slate-50 rounded-lg">
|
||||||
|
<div
|
||||||
|
className="w-5 h-5 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: getCategoryColor(selectedElement.categoryId),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="font-medium text-slate-800">
|
||||||
|
{sampleCategories.find((c) => c.id === selectedElement.categoryId)?.name || '未知類別'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-slate-400 text-sm">尚未指派</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 清除按鈕 */}
|
||||||
|
{selectedElement.categoryId && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full text-red-600 hover:bg-red-50"
|
||||||
|
onClick={handleClearCategory}
|
||||||
|
>
|
||||||
|
清除類別
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-slate-500">請選取一個元素</p>
|
||||||
|
<p className="text-sm text-slate-400 mt-1">點擊地圖中的物件</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 導覽按鈕 */}
|
||||||
|
<div className="mt-8 pt-4 border-t border-slate-200 space-y-2">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => navigate('/search')}
|
||||||
|
>
|
||||||
|
完成設定,前往搜尋
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => navigate('/editor')}
|
||||||
|
>
|
||||||
|
返回編輯地圖
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
350
src/pages/EditorPage.tsx
Normal file
350
src/pages/EditorPage.tsx
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
import { useEffect, useCallback } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
import { ThreeEvent } from '@react-three/fiber'
|
||||||
|
import { StoreCanvas, StoreGroup } from '../components/three'
|
||||||
|
import { Button } from '../components/common'
|
||||||
|
import { useStoreConfigStore, useMapStore } from '../store'
|
||||||
|
import { generateInitialMap } from '../utils'
|
||||||
|
import { EditorMode, MapElementType } from '../types'
|
||||||
|
|
||||||
|
const editorModes: { mode: EditorMode; label: string; icon: string }[] = [
|
||||||
|
{ mode: 'select', label: '選取', icon: '👆' },
|
||||||
|
{ mode: 'move', label: '移動', icon: '✋' },
|
||||||
|
{ mode: 'scale', label: '縮放', icon: '⤢' },
|
||||||
|
{ mode: 'rotate', label: '旋轉', icon: '🔄' },
|
||||||
|
{ mode: 'add', label: '新增', icon: '➕' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const addElementTypes: { type: MapElementType; label: string }[] = [
|
||||||
|
{ type: 'aisle', label: '貨架' },
|
||||||
|
{ type: 'cashier', label: '收銀台' },
|
||||||
|
{ type: 'entrance', label: '入口' },
|
||||||
|
{ type: 'storage', label: '倉庫' },
|
||||||
|
{ type: 'promo', label: '促銷區' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function EditorPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { storeConfig, isConfigured } = useStoreConfigStore()
|
||||||
|
const {
|
||||||
|
storeMap,
|
||||||
|
setStoreMap,
|
||||||
|
selectedElementId,
|
||||||
|
selectElement,
|
||||||
|
editorMode,
|
||||||
|
setEditorMode,
|
||||||
|
updateElement,
|
||||||
|
addElement,
|
||||||
|
removeElement,
|
||||||
|
} = useMapStore()
|
||||||
|
|
||||||
|
// 如果沒有設定,導向精靈頁面
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isConfigured || !storeConfig) {
|
||||||
|
navigate('/wizard')
|
||||||
|
}
|
||||||
|
}, [isConfigured, storeConfig, navigate])
|
||||||
|
|
||||||
|
// 生成初始地圖
|
||||||
|
useEffect(() => {
|
||||||
|
if (storeConfig) {
|
||||||
|
// 如果地圖不存在,或地圖的 storeId 與設定不符,或地圖元素為空,則重新生成
|
||||||
|
const shouldRegenerate =
|
||||||
|
!storeMap ||
|
||||||
|
storeMap.storeId !== storeConfig.id ||
|
||||||
|
storeMap.elements.length === 0
|
||||||
|
|
||||||
|
if (shouldRegenerate) {
|
||||||
|
console.log('生成新地圖,設定ID:', storeConfig.id)
|
||||||
|
const initialMap = generateInitialMap(storeConfig)
|
||||||
|
console.log('生成的地圖元素數量:', initialMap.elements.length)
|
||||||
|
setStoreMap(initialMap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [storeConfig, storeMap, setStoreMap])
|
||||||
|
|
||||||
|
// 處理元素點擊
|
||||||
|
const handleElementClick = useCallback(
|
||||||
|
(id: string, _event: ThreeEvent<MouseEvent>) => {
|
||||||
|
if (editorMode === 'select' || editorMode === 'move' || editorMode === 'scale' || editorMode === 'rotate') {
|
||||||
|
selectElement(id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[editorMode, selectElement]
|
||||||
|
)
|
||||||
|
|
||||||
|
// 處理新增元素
|
||||||
|
const handleAddElement = useCallback(
|
||||||
|
(type: MapElementType) => {
|
||||||
|
if (!storeMap || !storeConfig) return
|
||||||
|
|
||||||
|
const newElement = {
|
||||||
|
id: uuidv4(),
|
||||||
|
type,
|
||||||
|
floorIndex: 0,
|
||||||
|
position: { x: 0, y: 0, z: 0 },
|
||||||
|
rotation: 0,
|
||||||
|
dimensions: {
|
||||||
|
width: type === 'aisle' ? 1.2 : 2,
|
||||||
|
height: type === 'aisle' ? 2.2 : 1.2,
|
||||||
|
depth: type === 'aisle' ? 8 : 2,
|
||||||
|
},
|
||||||
|
label: type === 'aisle' ? '新貨架' : undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
addElement(newElement)
|
||||||
|
selectElement(newElement.id)
|
||||||
|
setEditorMode('select')
|
||||||
|
},
|
||||||
|
[storeMap, storeConfig, addElement, selectElement, setEditorMode]
|
||||||
|
)
|
||||||
|
|
||||||
|
// 處理刪除元素
|
||||||
|
const handleDeleteElement = useCallback(() => {
|
||||||
|
if (selectedElementId) {
|
||||||
|
removeElement(selectedElementId)
|
||||||
|
}
|
||||||
|
}, [selectedElementId, removeElement])
|
||||||
|
|
||||||
|
// 取得選取的元素
|
||||||
|
const selectedElement = storeMap?.elements.find((e) => e.id === selectedElementId)
|
||||||
|
|
||||||
|
// 處理屬性變更
|
||||||
|
const handlePropertyChange = (key: string, value: number) => {
|
||||||
|
if (!selectedElementId || !selectedElement) return
|
||||||
|
|
||||||
|
if (key.startsWith('position.')) {
|
||||||
|
const axis = key.split('.')[1] as 'x' | 'y' | 'z'
|
||||||
|
updateElement(selectedElementId, {
|
||||||
|
position: { ...selectedElement.position, [axis]: value },
|
||||||
|
})
|
||||||
|
} else if (key.startsWith('dimensions.')) {
|
||||||
|
const dim = key.split('.')[1] as 'width' | 'height' | 'depth'
|
||||||
|
updateElement(selectedElementId, {
|
||||||
|
dimensions: { ...selectedElement.dimensions, [dim]: value },
|
||||||
|
})
|
||||||
|
} else if (key === 'rotation') {
|
||||||
|
updateElement(selectedElementId, { rotation: (value * Math.PI) / 180 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!storeConfig) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const floorConfig = storeConfig.floors[0]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex overflow-hidden">
|
||||||
|
{/* 左側工具列 */}
|
||||||
|
<div className="w-16 bg-white border-r border-slate-200 flex flex-col items-center py-4 gap-2">
|
||||||
|
{editorModes.map(({ mode, label, icon }) => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
onClick={() => setEditorMode(mode)}
|
||||||
|
className={`w-12 h-12 rounded-lg flex flex-col items-center justify-center text-xs transition-colors ${
|
||||||
|
editorMode === mode
|
||||||
|
? 'bg-blue-100 text-blue-700'
|
||||||
|
: 'hover:bg-slate-100 text-slate-600'
|
||||||
|
}`}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
<span className="text-lg">{icon}</span>
|
||||||
|
<span className="mt-0.5">{label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="border-t border-slate-200 w-10 my-2" />
|
||||||
|
|
||||||
|
{/* 刪除按鈕 */}
|
||||||
|
<button
|
||||||
|
onClick={handleDeleteElement}
|
||||||
|
disabled={!selectedElementId}
|
||||||
|
className="w-12 h-12 rounded-lg flex flex-col items-center justify-center text-xs hover:bg-red-100 text-red-600 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
title="刪除"
|
||||||
|
>
|
||||||
|
<span className="text-lg">🗑️</span>
|
||||||
|
<span className="mt-0.5">刪除</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-200 w-10 my-2" />
|
||||||
|
|
||||||
|
{/* 重新生成按鈕 */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (storeConfig && confirm('確定要重新生成地圖嗎?目前的編輯將會遺失。')) {
|
||||||
|
const newMap = generateInitialMap(storeConfig)
|
||||||
|
setStoreMap(newMap)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-12 h-12 rounded-lg flex flex-col items-center justify-center text-xs hover:bg-orange-100 text-orange-600"
|
||||||
|
title="重新生成"
|
||||||
|
>
|
||||||
|
<span className="text-lg">🔄</span>
|
||||||
|
<span className="mt-0.5">重生</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3D 畫布區 */}
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<StoreCanvas editable className="w-full h-full">
|
||||||
|
<StoreGroup
|
||||||
|
storeMap={storeMap}
|
||||||
|
floorWidth={floorConfig?.floorWidth || 30}
|
||||||
|
floorLength={floorConfig?.floorLength || 25}
|
||||||
|
selectedElementId={selectedElementId}
|
||||||
|
onElementClick={handleElementClick}
|
||||||
|
showLabels={true}
|
||||||
|
/>
|
||||||
|
</StoreCanvas>
|
||||||
|
|
||||||
|
{/* 新增元素面板 */}
|
||||||
|
{editorMode === 'add' && (
|
||||||
|
<div className="absolute top-4 left-4 bg-white rounded-lg shadow-lg p-4">
|
||||||
|
<h3 className="font-medium text-slate-800 mb-3">新增元素</h3>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{addElementTypes.map(({ type, label }) => (
|
||||||
|
<Button
|
||||||
|
key={type}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleAddElement(type)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 提示訊息 */}
|
||||||
|
<div className="absolute bottom-4 left-4 bg-white/90 rounded-lg px-4 py-2 shadow">
|
||||||
|
<p className="text-sm text-slate-600">
|
||||||
|
{editorMode === 'select' && '點擊元素選取,或拖曳旋轉視角'}
|
||||||
|
{editorMode === 'move' && '選取元素後,可移動位置'}
|
||||||
|
{editorMode === 'scale' && '選取元素後,可調整大小'}
|
||||||
|
{editorMode === 'rotate' && '選取元素後,可旋轉方向'}
|
||||||
|
{editorMode === 'add' && '選擇要新增的元素類型'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
地圖元素數量: {storeMap?.elements?.length ?? 0} |
|
||||||
|
設定ID: {storeConfig?.id?.substring(0, 8) ?? '無'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右側屬性面板 */}
|
||||||
|
<div className="w-72 bg-white border-l border-slate-200 p-4 overflow-y-auto">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-800 mb-4">屬性面板</h2>
|
||||||
|
|
||||||
|
{selectedElement ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 元素資訊 */}
|
||||||
|
<div className="bg-slate-50 rounded-lg p-3">
|
||||||
|
<p className="text-sm text-slate-500">類型</p>
|
||||||
|
<p className="font-medium text-slate-800">
|
||||||
|
{selectedElement.type === 'aisle' && '貨架'}
|
||||||
|
{selectedElement.type === 'cashier' && '收銀台'}
|
||||||
|
{selectedElement.type === 'entrance' && '入口'}
|
||||||
|
{selectedElement.type === 'storage' && '倉庫'}
|
||||||
|
{selectedElement.type === 'promo' && '促銷區'}
|
||||||
|
</p>
|
||||||
|
{selectedElement.label && (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-slate-500 mt-2">標籤</p>
|
||||||
|
<p className="font-medium text-slate-800">{selectedElement.label}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 位置 */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-slate-700 mb-2">位置</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{['x', 'y', 'z'].map((axis) => (
|
||||||
|
<div key={axis}>
|
||||||
|
<label className="text-xs text-slate-500 uppercase">{axis}</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step={0.5}
|
||||||
|
value={selectedElement.position[axis as 'x' | 'y' | 'z'].toFixed(1)}
|
||||||
|
onChange={(e) =>
|
||||||
|
handlePropertyChange(`position.${axis}`, parseFloat(e.target.value) || 0)
|
||||||
|
}
|
||||||
|
className="w-full px-2 py-1 border rounded text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 尺寸 */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-slate-700 mb-2">尺寸</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{[
|
||||||
|
{ key: 'width', label: '寬' },
|
||||||
|
{ key: 'height', label: '高' },
|
||||||
|
{ key: 'depth', label: '深' },
|
||||||
|
].map(({ key, label }) => (
|
||||||
|
<div key={key}>
|
||||||
|
<label className="text-xs text-slate-500">{label}</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step={0.1}
|
||||||
|
min={0.1}
|
||||||
|
value={selectedElement.dimensions[key as 'width' | 'height' | 'depth'].toFixed(1)}
|
||||||
|
onChange={(e) =>
|
||||||
|
handlePropertyChange(`dimensions.${key}`, parseFloat(e.target.value) || 0.1)
|
||||||
|
}
|
||||||
|
className="w-full px-2 py-1 border rounded text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 旋轉 */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-slate-700 mb-2">旋轉角度</h3>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step={15}
|
||||||
|
value={Math.round((selectedElement.rotation * 180) / Math.PI)}
|
||||||
|
onChange={(e) =>
|
||||||
|
handlePropertyChange('rotation', parseFloat(e.target.value) || 0)
|
||||||
|
}
|
||||||
|
className="w-full px-2 py-1 border rounded text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">單位:度</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-slate-500">請選取一個元素</p>
|
||||||
|
<p className="text-sm text-slate-400 mt-1">點擊畫布中的物件</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 導覽按鈕 */}
|
||||||
|
<div className="mt-8 pt-4 border-t border-slate-200 space-y-2">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => navigate('/distribution')}
|
||||||
|
>
|
||||||
|
下一步:設定類別分布
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => navigate('/wizard')}
|
||||||
|
>
|
||||||
|
返回設定
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
267
src/pages/SearchPage.tsx
Normal file
267
src/pages/SearchPage.tsx
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
import { useEffect, useMemo, useCallback } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { StoreCanvas, StoreGroup } from '../components/three'
|
||||||
|
import { useStoreConfigStore, useMapStore } from '../store'
|
||||||
|
import { useSearch } from '../hooks'
|
||||||
|
import { sampleCategories, sampleProducts } from '../data'
|
||||||
|
import { getProductsByCategory } from '../utils'
|
||||||
|
|
||||||
|
export function SearchPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { storeConfig, isConfigured } = useStoreConfigStore()
|
||||||
|
const { storeMap, highlightElement, highlightedElementId } = useMapStore()
|
||||||
|
const { query, results, selectedResult, handleSearch, clearSearch, selectResult } = useSearch()
|
||||||
|
|
||||||
|
// 如果沒有設定或地圖,導向適當頁面
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isConfigured || !storeConfig) {
|
||||||
|
navigate('/wizard')
|
||||||
|
} else if (!storeMap) {
|
||||||
|
navigate('/editor')
|
||||||
|
}
|
||||||
|
}, [isConfigured, storeConfig, storeMap, navigate])
|
||||||
|
|
||||||
|
// 根據選取的結果找到對應的元素並醒目提示
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedResult?.category) {
|
||||||
|
// 找到該類別的第一個元素
|
||||||
|
const element = storeMap?.elements.find(
|
||||||
|
(e) => e.categoryId === selectedResult.category?.id
|
||||||
|
)
|
||||||
|
console.log('搜尋結果類別:', selectedResult.category?.id, selectedResult.category?.name)
|
||||||
|
console.log('地圖中有分類的元素:', storeMap?.elements.filter(e => e.categoryId).map(e => ({ id: e.id.slice(0, 8), type: e.type, categoryId: e.categoryId })))
|
||||||
|
console.log('找到的元素:', element?.id, element?.type)
|
||||||
|
if (element) {
|
||||||
|
highlightElement(element.id)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
highlightElement(null)
|
||||||
|
}
|
||||||
|
}, [selectedResult, storeMap, highlightElement])
|
||||||
|
|
||||||
|
// 處理搜尋輸入
|
||||||
|
const handleInputChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
handleSearch(e.target.value)
|
||||||
|
selectResult(null)
|
||||||
|
},
|
||||||
|
[handleSearch, selectResult]
|
||||||
|
)
|
||||||
|
|
||||||
|
// 處理結果選取
|
||||||
|
const handleResultClick = useCallback(
|
||||||
|
(result: typeof results[0]) => {
|
||||||
|
selectResult(result)
|
||||||
|
},
|
||||||
|
[selectResult]
|
||||||
|
)
|
||||||
|
|
||||||
|
// 取得選取類別的相關商品
|
||||||
|
const relatedProducts = useMemo(() => {
|
||||||
|
if (!selectedResult?.category) return []
|
||||||
|
return getProductsByCategory(sampleProducts, selectedResult.category.id).slice(0, 5)
|
||||||
|
}, [selectedResult])
|
||||||
|
|
||||||
|
// 處理元素點擊
|
||||||
|
const handleElementClick = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
const element = storeMap?.elements.find((e) => e.id === id)
|
||||||
|
if (element?.categoryId) {
|
||||||
|
const category = sampleCategories.find((c) => c.id === element.categoryId)
|
||||||
|
if (category) {
|
||||||
|
highlightElement(id)
|
||||||
|
// 建立一個虛擬的搜尋結果
|
||||||
|
selectResult({
|
||||||
|
product: {
|
||||||
|
id: 'click-' + id,
|
||||||
|
name: category.name,
|
||||||
|
categoryId: category.id,
|
||||||
|
keywords: [],
|
||||||
|
},
|
||||||
|
category,
|
||||||
|
score: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[storeMap, highlightElement, selectResult]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!storeConfig || !storeMap) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const floorConfig = storeConfig.floors[0]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col relative overflow-hidden">
|
||||||
|
{/* 搜尋列 */}
|
||||||
|
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-10 w-full max-w-xl px-4">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜尋商品名稱(例如:牛奶、洋芋片、洗衣精)"
|
||||||
|
value={query}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="w-full px-5 py-3 pl-12 bg-white rounded-xl shadow-lg border-2 border-transparent focus:border-blue-500 focus:outline-none text-lg"
|
||||||
|
/>
|
||||||
|
{/* 搜尋圖示 */}
|
||||||
|
<svg
|
||||||
|
className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/* 清除按鈕 */}
|
||||||
|
{query && (
|
||||||
|
<button
|
||||||
|
onClick={clearSearch}
|
||||||
|
className="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 搜尋結果下拉選單 */}
|
||||||
|
{results.length > 0 && !selectedResult && (
|
||||||
|
<div className="absolute top-full left-0 right-0 mt-2 bg-white rounded-xl shadow-lg overflow-hidden max-h-80 overflow-y-auto">
|
||||||
|
{results.map((result, index) => (
|
||||||
|
<button
|
||||||
|
key={`${result.product.id}-${index}`}
|
||||||
|
onClick={() => handleResultClick(result)}
|
||||||
|
className="w-full px-4 py-3 flex items-center gap-3 hover:bg-slate-50 transition-colors text-left border-b border-slate-100 last:border-none"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-4 h-4 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: result.category?.color || '#888' }}
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium text-slate-800">{result.product.name}</p>
|
||||||
|
<p className="text-sm text-slate-500">{result.category?.name || '未分類'}</p>
|
||||||
|
</div>
|
||||||
|
<svg className="w-5 h-5 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3D 畫布 */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<StoreCanvas className="w-full h-full">
|
||||||
|
<StoreGroup
|
||||||
|
storeMap={storeMap}
|
||||||
|
floorWidth={floorConfig?.floorWidth || 30}
|
||||||
|
floorLength={floorConfig?.floorLength || 25}
|
||||||
|
highlightedElementId={highlightedElementId}
|
||||||
|
onElementClick={handleElementClick}
|
||||||
|
showLabels={true}
|
||||||
|
/>
|
||||||
|
</StoreCanvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部資訊面板 */}
|
||||||
|
{selectedResult && (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 bg-white border-t border-slate-200 shadow-lg">
|
||||||
|
<div className="max-w-4xl mx-auto p-4">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
{/* 類別顏色標示 */}
|
||||||
|
<div
|
||||||
|
className="w-16 h-16 rounded-xl flex-shrink-0 flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: selectedResult.category?.color || '#888' }}
|
||||||
|
>
|
||||||
|
<span className="text-white text-2xl font-bold">
|
||||||
|
{selectedResult.category?.name.charAt(0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 資訊 */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h3 className="text-xl font-bold text-slate-800">
|
||||||
|
{selectedResult.product.name}
|
||||||
|
</h3>
|
||||||
|
<span className="px-2 py-0.5 bg-green-100 text-green-700 text-sm rounded-full">
|
||||||
|
已找到位置
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-slate-600 mb-2">
|
||||||
|
位於 <span className="font-semibold" style={{ color: selectedResult.category?.color }}>
|
||||||
|
{selectedResult.category?.name}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 相關商品 */}
|
||||||
|
{relatedProducts.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500 mb-1">同區域還有:</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{relatedProducts.map((product) => (
|
||||||
|
<span
|
||||||
|
key={product.id}
|
||||||
|
className="px-2 py-1 bg-slate-100 text-slate-600 text-sm rounded-lg"
|
||||||
|
>
|
||||||
|
{product.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 關閉按鈕 */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
selectResult(null)
|
||||||
|
highlightElement(null)
|
||||||
|
}}
|
||||||
|
className="text-slate-400 hover:text-slate-600"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 操作提示 */}
|
||||||
|
{!selectedResult && !query && (
|
||||||
|
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 bg-white/90 rounded-lg px-6 py-3 shadow">
|
||||||
|
<p className="text-slate-600 text-center">
|
||||||
|
輸入商品名稱進行搜尋,或點擊地圖中的區域查看類別資訊
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 圖例 */}
|
||||||
|
<div className="absolute bottom-4 right-4 bg-white/90 rounded-lg p-4 shadow">
|
||||||
|
<h3 className="font-medium text-slate-800 mb-2 text-sm">區域圖例</h3>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{sampleCategories.slice(0, 6).map((category) => (
|
||||||
|
<div key={category.id} className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: category.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-slate-600">{category.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
265
src/pages/WizardPage.tsx
Normal file
265
src/pages/WizardPage.tsx
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
import { Button } from '../components/common/Button'
|
||||||
|
import { Input } from '../components/common/Input'
|
||||||
|
import { useStoreConfigStore, useMapStore } from '../store'
|
||||||
|
import { FloorConfig } from '../types'
|
||||||
|
|
||||||
|
export function WizardPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { storeConfig, setStoreConfig, loadSampleConfig, isConfigured, resetConfig } = useStoreConfigStore()
|
||||||
|
const { resetMap } = useMapStore()
|
||||||
|
|
||||||
|
// 表單狀態
|
||||||
|
const [storeName, setStoreName] = useState(storeConfig?.name || '')
|
||||||
|
const [floorCount, setFloorCount] = useState(storeConfig?.floorCount || 1)
|
||||||
|
const [aisleCount, setAisleCount] = useState(
|
||||||
|
storeConfig?.floors[0]?.aisleCount || 14
|
||||||
|
)
|
||||||
|
const [aisleWidth, setAisleWidth] = useState(
|
||||||
|
storeConfig?.floors[0]?.defaultAisleWidth || 1.2
|
||||||
|
)
|
||||||
|
const [aisleLength, setAisleLength] = useState(
|
||||||
|
storeConfig?.floors[0]?.defaultAisleLength || 8
|
||||||
|
)
|
||||||
|
const [floorWidth, setFloorWidth] = useState(
|
||||||
|
storeConfig?.floors[0]?.floorWidth || 30
|
||||||
|
)
|
||||||
|
const [floorLength, setFloorLength] = useState(
|
||||||
|
storeConfig?.floors[0]?.floorLength || 25
|
||||||
|
)
|
||||||
|
|
||||||
|
// 錯誤狀態
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
|
// 驗證表單
|
||||||
|
const validateForm = (): boolean => {
|
||||||
|
const newErrors: Record<string, string> = {}
|
||||||
|
|
||||||
|
if (!storeName.trim()) {
|
||||||
|
newErrors.storeName = '請輸入店名'
|
||||||
|
}
|
||||||
|
if (floorCount < 1 || floorCount > 5) {
|
||||||
|
newErrors.floorCount = '樓層數必須介於 1-5 之間'
|
||||||
|
}
|
||||||
|
if (aisleCount < 1 || aisleCount > 50) {
|
||||||
|
newErrors.aisleCount = '走道數必須介於 1-50 之間'
|
||||||
|
}
|
||||||
|
if (aisleWidth < 0.5 || aisleWidth > 5) {
|
||||||
|
newErrors.aisleWidth = '走道寬度必須介於 0.5-5 公尺'
|
||||||
|
}
|
||||||
|
if (aisleLength < 1 || aisleLength > 20) {
|
||||||
|
newErrors.aisleLength = '走道長度必須介於 1-20 公尺'
|
||||||
|
}
|
||||||
|
if (floorWidth < 10 || floorWidth > 100) {
|
||||||
|
newErrors.floorWidth = '樓層寬度必須介於 10-100 公尺'
|
||||||
|
}
|
||||||
|
if (floorLength < 10 || floorLength > 100) {
|
||||||
|
newErrors.floorLength = '樓層長度必須介於 10-100 公尺'
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors)
|
||||||
|
return Object.keys(newErrors).length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 載入範例資料
|
||||||
|
const handleLoadSample = () => {
|
||||||
|
loadSampleConfig()
|
||||||
|
setStoreName('範例超市')
|
||||||
|
setFloorCount(1)
|
||||||
|
setAisleCount(14)
|
||||||
|
setAisleWidth(1.2)
|
||||||
|
setAisleLength(8)
|
||||||
|
setFloorWidth(30)
|
||||||
|
setFloorLength(25)
|
||||||
|
setErrors({})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除所有資料
|
||||||
|
const handleResetAll = () => {
|
||||||
|
if (confirm('確定要清除所有資料嗎?這將重置所有設定和地圖。')) {
|
||||||
|
resetConfig()
|
||||||
|
resetMap()
|
||||||
|
setStoreName('')
|
||||||
|
setFloorCount(1)
|
||||||
|
setAisleCount(14)
|
||||||
|
setAisleWidth(1.2)
|
||||||
|
setAisleLength(8)
|
||||||
|
setFloorWidth(30)
|
||||||
|
setFloorLength(25)
|
||||||
|
setErrors({})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交表單
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!validateForm()) return
|
||||||
|
|
||||||
|
// 建立樓層設定
|
||||||
|
const floors: FloorConfig[] = []
|
||||||
|
for (let i = 0; i < floorCount; i++) {
|
||||||
|
floors.push({
|
||||||
|
floorIndex: i,
|
||||||
|
aisleCount,
|
||||||
|
defaultAisleWidth: aisleWidth,
|
||||||
|
defaultAisleLength: aisleLength,
|
||||||
|
floorWidth,
|
||||||
|
floorLength,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每次提交都生成新的 ID,確保地圖會重新生成
|
||||||
|
const now = new Date()
|
||||||
|
setStoreConfig({
|
||||||
|
id: uuidv4(),
|
||||||
|
name: storeName,
|
||||||
|
floorCount,
|
||||||
|
floors,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
navigate('/editor')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-center justify-center p-8 overflow-auto">
|
||||||
|
<div className="w-full max-w-2xl">
|
||||||
|
{/* 標題區 */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-slate-800 mb-2">
|
||||||
|
店家資訊設定
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-600">
|
||||||
|
請填寫以下資訊,系統將根據這些資料生成初始 3D 地圖
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 快速載入區 */}
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-blue-800">快速開始</h3>
|
||||||
|
<p className="text-sm text-blue-600">
|
||||||
|
載入超市範例資料,快速體驗功能
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={handleLoadSample}>
|
||||||
|
載入範例資料
|
||||||
|
</Button>
|
||||||
|
{isConfigured && (
|
||||||
|
<Button variant="ghost" onClick={handleResetAll} className="text-red-600 hover:bg-red-50">
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 表單 */}
|
||||||
|
<div className="bg-white rounded-xl shadow-lg p-6 space-y-6">
|
||||||
|
{/* 店名 */}
|
||||||
|
<Input
|
||||||
|
label="店名"
|
||||||
|
placeholder="請輸入店名"
|
||||||
|
value={storeName}
|
||||||
|
onChange={(e) => setStoreName(e.target.value)}
|
||||||
|
error={errors.storeName}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 樓層數 */}
|
||||||
|
<Input
|
||||||
|
label="樓層數"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={5}
|
||||||
|
value={floorCount}
|
||||||
|
onChange={(e) => setFloorCount(parseInt(e.target.value) || 1)}
|
||||||
|
error={errors.floorCount}
|
||||||
|
helpText="支援 1-5 層樓"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 分隔線 */}
|
||||||
|
<div className="border-t border-slate-200 pt-4">
|
||||||
|
<h3 className="text-lg font-medium text-slate-800 mb-4">
|
||||||
|
樓層配置(以第一層為基準)
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* 走道數 */}
|
||||||
|
<Input
|
||||||
|
label="走道/貨架數量"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={50}
|
||||||
|
value={aisleCount}
|
||||||
|
onChange={(e) => setAisleCount(parseInt(e.target.value) || 1)}
|
||||||
|
error={errors.aisleCount}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 走道寬度 */}
|
||||||
|
<Input
|
||||||
|
label="走道寬度(公尺)"
|
||||||
|
type="number"
|
||||||
|
min={0.5}
|
||||||
|
max={5}
|
||||||
|
step={0.1}
|
||||||
|
value={aisleWidth}
|
||||||
|
onChange={(e) => setAisleWidth(parseFloat(e.target.value) || 1)}
|
||||||
|
error={errors.aisleWidth}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 走道長度 */}
|
||||||
|
<Input
|
||||||
|
label="走道長度(公尺)"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={20}
|
||||||
|
step={0.5}
|
||||||
|
value={aisleLength}
|
||||||
|
onChange={(e) => setAisleLength(parseFloat(e.target.value) || 1)}
|
||||||
|
error={errors.aisleLength}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 樓層寬度 */}
|
||||||
|
<Input
|
||||||
|
label="樓層寬度(公尺)"
|
||||||
|
type="number"
|
||||||
|
min={10}
|
||||||
|
max={100}
|
||||||
|
value={floorWidth}
|
||||||
|
onChange={(e) => setFloorWidth(parseInt(e.target.value) || 10)}
|
||||||
|
error={errors.floorWidth}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 樓層長度 */}
|
||||||
|
<Input
|
||||||
|
label="樓層長度(公尺)"
|
||||||
|
type="number"
|
||||||
|
min={10}
|
||||||
|
max={100}
|
||||||
|
value={floorLength}
|
||||||
|
onChange={(e) => setFloorLength(parseInt(e.target.value) || 10)}
|
||||||
|
error={errors.floorLength}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 提交按鈕 */}
|
||||||
|
<div className="flex justify-end gap-4 pt-4">
|
||||||
|
{isConfigured && (
|
||||||
|
<Button variant="ghost" onClick={() => navigate('/editor')}>
|
||||||
|
跳過,使用現有設定
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={handleSubmit} size="lg">
|
||||||
|
下一步:編輯地圖
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
4
src/pages/index.ts
Normal file
4
src/pages/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './WizardPage'
|
||||||
|
export * from './EditorPage'
|
||||||
|
export * from './DistributionPage'
|
||||||
|
export * from './SearchPage'
|
||||||
3
src/store/index.ts
Normal file
3
src/store/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './storeConfigStore'
|
||||||
|
export * from './mapStore'
|
||||||
|
export * from './uiStore'
|
||||||
132
src/store/mapStore.ts
Normal file
132
src/store/mapStore.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import { persist, createJSONStorage } from 'zustand/middleware'
|
||||||
|
import { MapElement, StoreMap, EditorMode } from '../types'
|
||||||
|
|
||||||
|
interface MapState {
|
||||||
|
storeMap: StoreMap | null
|
||||||
|
selectedElementId: string | null
|
||||||
|
highlightedElementId: string | null
|
||||||
|
editorMode: EditorMode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MapActions {
|
||||||
|
setStoreMap: (map: StoreMap) => void
|
||||||
|
updateElement: (id: string, updates: Partial<MapElement>) => void
|
||||||
|
addElement: (element: MapElement) => void
|
||||||
|
removeElement: (id: string) => void
|
||||||
|
selectElement: (id: string | null) => void
|
||||||
|
highlightElement: (id: string | null) => void
|
||||||
|
setEditorMode: (mode: EditorMode) => void
|
||||||
|
assignCategory: (elementId: string, categoryId: string) => void
|
||||||
|
clearCategoryFromElement: (elementId: string) => void
|
||||||
|
resetMap: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type MapStore = MapState & MapActions
|
||||||
|
|
||||||
|
export const useMapStore = create<MapStore>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
// 狀態
|
||||||
|
storeMap: null,
|
||||||
|
selectedElementId: null,
|
||||||
|
highlightedElementId: null,
|
||||||
|
editorMode: 'select',
|
||||||
|
|
||||||
|
// 動作
|
||||||
|
setStoreMap: (map) => set({ storeMap: map }),
|
||||||
|
|
||||||
|
updateElement: (id, updates) => {
|
||||||
|
const storeMap = get().storeMap
|
||||||
|
if (!storeMap) return
|
||||||
|
|
||||||
|
set({
|
||||||
|
storeMap: {
|
||||||
|
...storeMap,
|
||||||
|
elements: storeMap.elements.map((el) =>
|
||||||
|
el.id === id ? { ...el, ...updates } : el
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
addElement: (element) => {
|
||||||
|
const storeMap = get().storeMap
|
||||||
|
if (!storeMap) return
|
||||||
|
|
||||||
|
set({
|
||||||
|
storeMap: {
|
||||||
|
...storeMap,
|
||||||
|
elements: [...storeMap.elements, element],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
removeElement: (id) => {
|
||||||
|
const storeMap = get().storeMap
|
||||||
|
if (!storeMap) return
|
||||||
|
|
||||||
|
set({
|
||||||
|
storeMap: {
|
||||||
|
...storeMap,
|
||||||
|
elements: storeMap.elements.filter((el) => el.id !== id),
|
||||||
|
},
|
||||||
|
selectedElementId: get().selectedElementId === id ? null : get().selectedElementId,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
selectElement: (id) => set({ selectedElementId: id }),
|
||||||
|
|
||||||
|
highlightElement: (id) => set({ highlightedElementId: id }),
|
||||||
|
|
||||||
|
setEditorMode: (mode) => set({ editorMode: mode }),
|
||||||
|
|
||||||
|
assignCategory: (elementId, categoryId) => {
|
||||||
|
const storeMap = get().storeMap
|
||||||
|
if (!storeMap) return
|
||||||
|
|
||||||
|
set({
|
||||||
|
storeMap: {
|
||||||
|
...storeMap,
|
||||||
|
elements: storeMap.elements.map((el) =>
|
||||||
|
el.id === elementId ? { ...el, categoryId } : el
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
clearCategoryFromElement: (elementId) => {
|
||||||
|
const storeMap = get().storeMap
|
||||||
|
if (!storeMap) return
|
||||||
|
|
||||||
|
set({
|
||||||
|
storeMap: {
|
||||||
|
...storeMap,
|
||||||
|
elements: storeMap.elements.map((el) =>
|
||||||
|
el.id === elementId ? { ...el, categoryId: undefined } : el
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
resetMap: () => {
|
||||||
|
set({
|
||||||
|
storeMap: null,
|
||||||
|
selectedElementId: null,
|
||||||
|
highlightedElementId: null,
|
||||||
|
editorMode: 'select',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'store-map-storage',
|
||||||
|
storage: createJSONStorage(() => localStorage),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 取得指定類別的所有元素
|
||||||
|
export function getElementsByCategory(storeMap: StoreMap | null, categoryId: string): MapElement[] {
|
||||||
|
if (!storeMap) return []
|
||||||
|
return storeMap.elements.filter((el) => el.categoryId === categoryId)
|
||||||
|
}
|
||||||
99
src/store/storeConfigStore.ts
Normal file
99
src/store/storeConfigStore.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import { persist, createJSONStorage } from 'zustand/middleware'
|
||||||
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
import { StoreConfig, FloorConfig, DEFAULT_SUPERMARKET_CONFIG } from '../types'
|
||||||
|
|
||||||
|
interface StoreConfigState {
|
||||||
|
storeConfig: StoreConfig | null
|
||||||
|
isConfigured: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoreConfigActions {
|
||||||
|
setStoreConfig: (config: Partial<StoreConfig>) => void
|
||||||
|
updateFloor: (floorIndex: number, updates: Partial<FloorConfig>) => void
|
||||||
|
loadSampleConfig: () => void
|
||||||
|
resetConfig: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type StoreConfigStore = StoreConfigState & StoreConfigActions
|
||||||
|
|
||||||
|
export const useStoreConfigStore = create<StoreConfigStore>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
// 狀態
|
||||||
|
storeConfig: null,
|
||||||
|
isConfigured: false,
|
||||||
|
|
||||||
|
// 動作
|
||||||
|
setStoreConfig: (config) => {
|
||||||
|
const current = get().storeConfig
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
if (current) {
|
||||||
|
set({
|
||||||
|
storeConfig: {
|
||||||
|
...current,
|
||||||
|
...config,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
isConfigured: true,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
set({
|
||||||
|
storeConfig: {
|
||||||
|
id: uuidv4(),
|
||||||
|
name: '',
|
||||||
|
floorCount: 1,
|
||||||
|
floors: [],
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
...config,
|
||||||
|
},
|
||||||
|
isConfigured: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateFloor: (floorIndex, updates) => {
|
||||||
|
const config = get().storeConfig
|
||||||
|
if (!config) return
|
||||||
|
|
||||||
|
set({
|
||||||
|
storeConfig: {
|
||||||
|
...config,
|
||||||
|
floors: config.floors.map((floor) =>
|
||||||
|
floor.floorIndex === floorIndex
|
||||||
|
? { ...floor, ...updates }
|
||||||
|
: floor
|
||||||
|
),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
loadSampleConfig: () => {
|
||||||
|
const now = new Date()
|
||||||
|
set({
|
||||||
|
storeConfig: {
|
||||||
|
...DEFAULT_SUPERMARKET_CONFIG,
|
||||||
|
id: uuidv4(),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
isConfigured: true,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
resetConfig: () => {
|
||||||
|
set({
|
||||||
|
storeConfig: null,
|
||||||
|
isConfigured: false,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'store-config-storage',
|
||||||
|
storage: createJSONStorage(() => localStorage),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
42
src/store/uiStore.ts
Normal file
42
src/store/uiStore.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
interface UIState {
|
||||||
|
// 當前設定步驟 (1-3)
|
||||||
|
currentStep: number
|
||||||
|
// 選取的類別 ID(用於分布頁面)
|
||||||
|
selectedCategoryId: string | null
|
||||||
|
// 搜尋關鍵字
|
||||||
|
searchQuery: string
|
||||||
|
// 是否顯示側邊欄
|
||||||
|
showSidebar: boolean
|
||||||
|
// 載入狀態
|
||||||
|
isLoading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UIActions {
|
||||||
|
setCurrentStep: (step: number) => void
|
||||||
|
setSelectedCategoryId: (id: string | null) => void
|
||||||
|
setSearchQuery: (query: string) => void
|
||||||
|
toggleSidebar: () => void
|
||||||
|
setShowSidebar: (show: boolean) => void
|
||||||
|
setIsLoading: (loading: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type UIStore = UIState & UIActions
|
||||||
|
|
||||||
|
export const useUIStore = create<UIStore>((set) => ({
|
||||||
|
// 狀態
|
||||||
|
currentStep: 1,
|
||||||
|
selectedCategoryId: null,
|
||||||
|
searchQuery: '',
|
||||||
|
showSidebar: true,
|
||||||
|
isLoading: false,
|
||||||
|
|
||||||
|
// 動作
|
||||||
|
setCurrentStep: (step) => set({ currentStep: step }),
|
||||||
|
setSelectedCategoryId: (id) => set({ selectedCategoryId: id }),
|
||||||
|
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||||
|
toggleSidebar: () => set((state) => ({ showSidebar: !state.showSidebar })),
|
||||||
|
setShowSidebar: (show) => set({ showSidebar: show }),
|
||||||
|
setIsLoading: (loading) => set({ isLoading: loading }),
|
||||||
|
}))
|
||||||
29
src/types/category.types.ts
Normal file
29
src/types/category.types.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// 類別與商品相關型別
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
id: string
|
||||||
|
name: string // 繁體中文名稱
|
||||||
|
nameEn?: string // 英文名稱(可選)
|
||||||
|
color: string // 視覺化顏色(Hex)
|
||||||
|
icon?: string // 圖示識別碼(可選)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Product {
|
||||||
|
id: string
|
||||||
|
name: string // 繁體中文名稱
|
||||||
|
categoryId: string // 所屬類別 ID
|
||||||
|
keywords: string[] // 搜尋關鍵字(同義詞、別名)
|
||||||
|
brand?: string // 品牌(可選)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategoryAssignment {
|
||||||
|
elementId: string // MapElement id
|
||||||
|
categoryId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜尋結果
|
||||||
|
export interface SearchResult {
|
||||||
|
product: Product
|
||||||
|
category: Category | undefined
|
||||||
|
score: number | undefined
|
||||||
|
}
|
||||||
3
src/types/index.ts
Normal file
3
src/types/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './store.types'
|
||||||
|
export * from './map.types'
|
||||||
|
export * from './category.types'
|
||||||
44
src/types/map.types.ts
Normal file
44
src/types/map.types.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// 地圖元素相關型別
|
||||||
|
|
||||||
|
export type MapElementType = 'aisle' | 'cashier' | 'entrance' | 'storage' | 'curved' | 'promo'
|
||||||
|
|
||||||
|
export interface Position3D {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
z: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Dimensions3D {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
depth: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapElement {
|
||||||
|
id: string
|
||||||
|
type: MapElementType
|
||||||
|
floorIndex: number
|
||||||
|
position: Position3D
|
||||||
|
rotation: number // Y 軸旋轉角度(弧度)
|
||||||
|
dimensions: Dimensions3D
|
||||||
|
categoryId?: string // 指派的類別 ID
|
||||||
|
label?: string // 顯示標籤
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CurvedElement extends MapElement {
|
||||||
|
type: 'curved'
|
||||||
|
curvePoints: Position3D[] // 曲線控制點
|
||||||
|
curveType: 'arc' | 'bezier'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoreMap {
|
||||||
|
storeId: string
|
||||||
|
elements: MapElement[]
|
||||||
|
curvedElements: CurvedElement[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 編輯器模式
|
||||||
|
export type EditorMode = 'select' | 'move' | 'scale' | 'rotate' | 'add'
|
||||||
|
|
||||||
|
// 新增元素類型
|
||||||
|
export type AddElementType = MapElementType
|
||||||
35
src/types/store.types.ts
Normal file
35
src/types/store.types.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// 店家設定相關型別
|
||||||
|
|
||||||
|
export interface StoreConfig {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
floorCount: number
|
||||||
|
floors: FloorConfig[]
|
||||||
|
createdAt: Date
|
||||||
|
updatedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FloorConfig {
|
||||||
|
floorIndex: number
|
||||||
|
aisleCount: number
|
||||||
|
defaultAisleWidth: number // 單位:公尺
|
||||||
|
defaultAisleLength: number // 單位:公尺
|
||||||
|
floorWidth: number // 樓層總寬度
|
||||||
|
floorLength: number // 樓層總長度
|
||||||
|
}
|
||||||
|
|
||||||
|
// 預設超市配置
|
||||||
|
export const DEFAULT_SUPERMARKET_CONFIG: Omit<StoreConfig, 'id' | 'createdAt' | 'updatedAt'> = {
|
||||||
|
name: '範例超市',
|
||||||
|
floorCount: 1,
|
||||||
|
floors: [
|
||||||
|
{
|
||||||
|
floorIndex: 0,
|
||||||
|
aisleCount: 14,
|
||||||
|
defaultAisleWidth: 1.2,
|
||||||
|
defaultAisleLength: 8,
|
||||||
|
floorWidth: 30,
|
||||||
|
floorLength: 25,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
2
src/utils/index.ts
Normal file
2
src/utils/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './mapGenerator'
|
||||||
|
export * from './searchEngine'
|
||||||
149
src/utils/mapGenerator.ts
Normal file
149
src/utils/mapGenerator.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
import { StoreConfig, StoreMap, MapElement } from '../types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根據店家設定生成初始地圖
|
||||||
|
*/
|
||||||
|
export function generateInitialMap(config: StoreConfig): StoreMap {
|
||||||
|
const elements: MapElement[] = []
|
||||||
|
|
||||||
|
config.floors.forEach((floor) => {
|
||||||
|
const {
|
||||||
|
floorIndex,
|
||||||
|
aisleCount,
|
||||||
|
defaultAisleWidth,
|
||||||
|
defaultAisleLength,
|
||||||
|
floorWidth,
|
||||||
|
floorLength,
|
||||||
|
} = floor
|
||||||
|
|
||||||
|
// 計算走道間距
|
||||||
|
// 將走道分成兩排
|
||||||
|
const aislesPerRow = Math.ceil(aisleCount / 2)
|
||||||
|
const rowSpacing = floorLength / 3 // 兩排之間的間距
|
||||||
|
|
||||||
|
// 計算每排走道的間距
|
||||||
|
const aisleSpacing = (floorWidth - aislesPerRow * defaultAisleWidth) / (aislesPerRow + 1)
|
||||||
|
|
||||||
|
// 生成走道
|
||||||
|
for (let i = 0; i < aisleCount; i++) {
|
||||||
|
const row = Math.floor(i / aislesPerRow)
|
||||||
|
const col = i % aislesPerRow
|
||||||
|
|
||||||
|
const xPos = aisleSpacing + col * (defaultAisleWidth + aisleSpacing) - floorWidth / 2 + defaultAisleWidth / 2
|
||||||
|
const zPos = row === 0 ? -rowSpacing / 2 : rowSpacing / 2
|
||||||
|
|
||||||
|
elements.push({
|
||||||
|
id: uuidv4(),
|
||||||
|
type: 'aisle',
|
||||||
|
floorIndex,
|
||||||
|
position: { x: xPos, y: floorIndex * 4, z: zPos },
|
||||||
|
rotation: 0,
|
||||||
|
dimensions: {
|
||||||
|
width: defaultAisleWidth,
|
||||||
|
height: 2.2,
|
||||||
|
depth: defaultAisleLength,
|
||||||
|
},
|
||||||
|
label: `走道 ${i + 1}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增入口
|
||||||
|
elements.push({
|
||||||
|
id: uuidv4(),
|
||||||
|
type: 'entrance',
|
||||||
|
floorIndex,
|
||||||
|
position: { x: 0, y: floorIndex * 4, z: floorLength / 2 - 1.5 },
|
||||||
|
rotation: 0,
|
||||||
|
dimensions: { width: 4, height: 0.3, depth: 2 },
|
||||||
|
label: '入口',
|
||||||
|
categoryId: 'cat-12',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 新增收銀台區域(三個收銀台)
|
||||||
|
const cashierWidth = 2
|
||||||
|
const cashierSpacing = 3
|
||||||
|
const cashierStartX = -(cashierWidth + cashierSpacing)
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
elements.push({
|
||||||
|
id: uuidv4(),
|
||||||
|
type: 'cashier',
|
||||||
|
floorIndex,
|
||||||
|
position: {
|
||||||
|
x: cashierStartX + i * (cashierWidth + cashierSpacing),
|
||||||
|
y: floorIndex * 4,
|
||||||
|
z: floorLength / 2 - 4,
|
||||||
|
},
|
||||||
|
rotation: 0,
|
||||||
|
dimensions: { width: cashierWidth, height: 1.2, depth: 1.5 },
|
||||||
|
label: `收銀台 ${i + 1}`,
|
||||||
|
categoryId: 'cat-11',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增生鮮區(左側)
|
||||||
|
elements.push({
|
||||||
|
id: uuidv4(),
|
||||||
|
type: 'aisle',
|
||||||
|
floorIndex,
|
||||||
|
position: { x: -floorWidth / 2 + 2, y: floorIndex * 4, z: 0 },
|
||||||
|
rotation: Math.PI / 2,
|
||||||
|
dimensions: { width: 1.5, height: 1.8, depth: floorLength - 8 },
|
||||||
|
label: '生鮮區',
|
||||||
|
categoryId: 'cat-4',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 新增冷凍區(右側)
|
||||||
|
elements.push({
|
||||||
|
id: uuidv4(),
|
||||||
|
type: 'aisle',
|
||||||
|
floorIndex,
|
||||||
|
position: { x: floorWidth / 2 - 2, y: floorIndex * 4, z: 0 },
|
||||||
|
rotation: Math.PI / 2,
|
||||||
|
dimensions: { width: 1.5, height: 2, depth: floorLength - 8 },
|
||||||
|
label: '冷凍區',
|
||||||
|
categoryId: 'cat-6',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
storeId: config.id,
|
||||||
|
elements,
|
||||||
|
curvedElements: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 為走道自動分配類別(範例用)
|
||||||
|
*/
|
||||||
|
export function assignSampleCategories(storeMap: StoreMap): StoreMap {
|
||||||
|
// 商品類別 ID(排除收銀台和入口)
|
||||||
|
const productCategories = [
|
||||||
|
'cat-1', // 零食餅乾
|
||||||
|
'cat-2', // 飲料
|
||||||
|
'cat-3', // 乳製品
|
||||||
|
'cat-5', // 肉品海鮮
|
||||||
|
'cat-7', // 調味料
|
||||||
|
'cat-8', // 泡麵罐頭
|
||||||
|
'cat-9', // 日用品
|
||||||
|
'cat-10', // 清潔用品
|
||||||
|
]
|
||||||
|
|
||||||
|
let categoryIndex = 0
|
||||||
|
|
||||||
|
const updatedElements = storeMap.elements.map((element) => {
|
||||||
|
// 只為未指派類別的走道分配
|
||||||
|
if (element.type === 'aisle' && !element.categoryId) {
|
||||||
|
const categoryId = productCategories[categoryIndex % productCategories.length]
|
||||||
|
categoryIndex++
|
||||||
|
return { ...element, categoryId }
|
||||||
|
}
|
||||||
|
return element
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
...storeMap,
|
||||||
|
elements: updatedElements,
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/utils/searchEngine.ts
Normal file
51
src/utils/searchEngine.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import Fuse, { IFuseOptions } from 'fuse.js'
|
||||||
|
import { Product, Category, SearchResult } from '../types'
|
||||||
|
|
||||||
|
const fuseOptions: IFuseOptions<Product> = {
|
||||||
|
keys: [
|
||||||
|
{ name: 'name', weight: 2 },
|
||||||
|
{ name: 'keywords', weight: 1.5 },
|
||||||
|
{ name: 'brand', weight: 0.5 },
|
||||||
|
],
|
||||||
|
threshold: 0.4, // 0 = 完全匹配, 1 = 匹配所有
|
||||||
|
includeScore: true,
|
||||||
|
includeMatches: true,
|
||||||
|
minMatchCharLength: 1,
|
||||||
|
ignoreLocation: true, // 忽略匹配位置
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 建立搜尋引擎
|
||||||
|
*/
|
||||||
|
export function createSearchEngine(products: Product[], categories: Category[]) {
|
||||||
|
const fuse = new Fuse(products, fuseOptions)
|
||||||
|
|
||||||
|
const search = (query: string): SearchResult[] => {
|
||||||
|
if (!query.trim()) return []
|
||||||
|
|
||||||
|
const results = fuse.search(query)
|
||||||
|
|
||||||
|
// 加入類別資訊
|
||||||
|
return results.map((result) => ({
|
||||||
|
product: result.item,
|
||||||
|
score: result.score,
|
||||||
|
category: categories.find((c) => c.id === result.item.categoryId),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchByCategory = (categoryId: string): Product[] => {
|
||||||
|
return products.filter((p) => p.categoryId === categoryId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { search, searchByCategory }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得類別的所有商品
|
||||||
|
*/
|
||||||
|
export function getProductsByCategory(
|
||||||
|
products: Product[],
|
||||||
|
categoryId: string
|
||||||
|
): Product[] {
|
||||||
|
return products.filter((p) => p.categoryId === categoryId)
|
||||||
|
}
|
||||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
97
start-server.sh
Executable file
97
start-server.sh
Executable file
@@ -0,0 +1,97 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# 3D 店內導引 Demo - 啟動伺服器腳本
|
||||||
|
# 使用方式: ./start-server.sh [dev|prod]
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
# PID 檔案位置
|
||||||
|
PID_FILE="$SCRIPT_DIR/.server.pid"
|
||||||
|
LOG_FILE="$SCRIPT_DIR/.server.log"
|
||||||
|
|
||||||
|
# 檢查是否已經在執行
|
||||||
|
if [ -f "$PID_FILE" ]; then
|
||||||
|
PID=$(cat "$PID_FILE")
|
||||||
|
if ps -p "$PID" > /dev/null 2>&1; then
|
||||||
|
echo "伺服器已在執行中 (PID: $PID)"
|
||||||
|
echo "請先執行 ./stop-server.sh 關閉伺服器"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
# 清理過期的 PID 檔案
|
||||||
|
rm -f "$PID_FILE"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 取得本機 IP
|
||||||
|
get_local_ip() {
|
||||||
|
# 嘗試不同方式取得 IP
|
||||||
|
ip route get 1 2>/dev/null | awk '{print $7; exit}' || \
|
||||||
|
hostname -I 2>/dev/null | awk '{print $1}' || \
|
||||||
|
ifconfig 2>/dev/null | grep -Eo 'inet (addr:)?([0-9]*\.){3}[0-9]*' | grep -Eo '([0-9]*\.){3}[0-9]*' | grep -v '127.0.0.1' | head -1 || \
|
||||||
|
echo "無法取得"
|
||||||
|
}
|
||||||
|
|
||||||
|
LOCAL_IP=$(get_local_ip)
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# 判斷模式
|
||||||
|
MODE=${1:-dev}
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo " 3D 店內導引 Demo 伺服器"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ "$MODE" = "prod" ]; then
|
||||||
|
echo "模式: 生產環境 (使用建構後的靜態檔案)"
|
||||||
|
echo ""
|
||||||
|
echo "正在建構專案..."
|
||||||
|
npm run build > /dev/null 2>&1
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "建構失敗!請檢查錯誤訊息。"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "建構完成,啟動預覽伺服器..."
|
||||||
|
nohup npm run preview:host > "$LOG_FILE" 2>&1 &
|
||||||
|
else
|
||||||
|
echo "模式: 開發環境 (支援熱更新)"
|
||||||
|
echo ""
|
||||||
|
echo "正在啟動開發伺服器..."
|
||||||
|
nohup npm run dev:host > "$LOG_FILE" 2>&1 &
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 儲存 PID
|
||||||
|
SERVER_PID=$!
|
||||||
|
echo $SERVER_PID > "$PID_FILE"
|
||||||
|
|
||||||
|
# 等待伺服器啟動
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# 檢查是否成功啟動
|
||||||
|
if ps -p "$SERVER_PID" > /dev/null 2>&1; then
|
||||||
|
echo ""
|
||||||
|
echo "伺服器啟動成功!"
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo " 存取網址"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
echo " 本機存取: http://localhost:$PORT"
|
||||||
|
echo " 遠端存取: http://$LOCAL_IP:$PORT"
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
echo "PID: $SERVER_PID"
|
||||||
|
echo "日誌檔案: $LOG_FILE"
|
||||||
|
echo ""
|
||||||
|
echo "關閉伺服器: ./stop-server.sh"
|
||||||
|
echo ""
|
||||||
|
else
|
||||||
|
echo "伺服器啟動失敗!"
|
||||||
|
echo "請檢查日誌: cat $LOG_FILE"
|
||||||
|
rm -f "$PID_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
73
stop-server.sh
Executable file
73
stop-server.sh
Executable file
@@ -0,0 +1,73 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# 3D 店內導引 Demo - 關閉伺服器腳本
|
||||||
|
# 使用方式: ./stop-server.sh
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
PID_FILE="$SCRIPT_DIR/.server.pid"
|
||||||
|
LOG_FILE="$SCRIPT_DIR/.server.log"
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo " 關閉 3D 店內導引 Demo 伺服器"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 檢查 PID 檔案是否存在
|
||||||
|
if [ ! -f "$PID_FILE" ]; then
|
||||||
|
echo "找不到執行中的伺服器"
|
||||||
|
|
||||||
|
# 嘗試找到並關閉可能的遺留程序
|
||||||
|
VITE_PIDS=$(pgrep -f "vite.*--host" 2>/dev/null)
|
||||||
|
if [ -n "$VITE_PIDS" ]; then
|
||||||
|
echo ""
|
||||||
|
echo "發現可能的遺留程序: $VITE_PIDS"
|
||||||
|
read -p "是否要關閉這些程序? (y/n) " -n 1 -r
|
||||||
|
echo ""
|
||||||
|
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
kill $VITE_PIDS 2>/dev/null
|
||||||
|
echo "已關閉遺留程序"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 讀取 PID
|
||||||
|
PID=$(cat "$PID_FILE")
|
||||||
|
|
||||||
|
# 檢查程序是否存在
|
||||||
|
if ps -p "$PID" > /dev/null 2>&1; then
|
||||||
|
echo "正在關閉伺服器 (PID: $PID)..."
|
||||||
|
|
||||||
|
# 先嘗試優雅關閉
|
||||||
|
kill "$PID" 2>/dev/null
|
||||||
|
|
||||||
|
# 等待程序結束
|
||||||
|
for i in {1..5}; do
|
||||||
|
if ! ps -p "$PID" > /dev/null 2>&1; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
# 如果還在執行,強制關閉
|
||||||
|
if ps -p "$PID" > /dev/null 2>&1; then
|
||||||
|
echo "程序未響應,強制關閉..."
|
||||||
|
kill -9 "$PID" 2>/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 同時關閉可能的子程序
|
||||||
|
pkill -f "vite.*--host.*3000" 2>/dev/null
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "伺服器已關閉"
|
||||||
|
else
|
||||||
|
echo "伺服器程序已不存在 (PID: $PID)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 清理檔案
|
||||||
|
rm -f "$PID_FILE"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "完成!"
|
||||||
15
tailwind.config.js
Normal file
15
tailwind.config.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Noto Sans TC', 'sans-serif'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
26
tsconfig.json
Normal file
26
tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
13
vite.config.ts
Normal file
13
vite.config.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
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'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user