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