Initial commit

This commit is contained in:
2025-12-09 16:07:11 +08:00
commit 547a79ddfd
49 changed files with 7752 additions and 0 deletions

14
.gitignore vendored Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

43
package.json Normal file
View 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
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

1
public/vite.svg Normal file
View 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
View 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

View 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>
)
}

View 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'

View File

@@ -0,0 +1,2 @@
export * from './Button'
export * from './Input'

View 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>
)
}

View File

@@ -0,0 +1 @@
export * from './Header'

View 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}
/>
</>
)
}

View 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>
)
}

View File

@@ -0,0 +1,2 @@
export * from './StoreCanvas'
export * from './SceneLighting'

View File

@@ -0,0 +1,2 @@
export * from './canvas'
export * from './objects'

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -0,0 +1,3 @@
export * from './Floor'
export * from './Aisle'
export * from './StoreGroup'

2
src/data/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from './sampleCategories'
export * from './sampleProducts'

View 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 ?? '未分類'
}

View 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
View File

@@ -0,0 +1 @@
export * from './useSearch'

47
src/hooks/useSearch.ts Normal file
View 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
View 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
View 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>,
)

View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,4 @@
export * from './WizardPage'
export * from './EditorPage'
export * from './DistributionPage'
export * from './SearchPage'

3
src/store/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './storeConfigStore'
export * from './mapStore'
export * from './uiStore'

132
src/store/mapStore.ts Normal file
View 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)
}

View 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
View 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 }),
}))

View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,2 @@
export * from './mapGenerator'
export * from './searchEngine'

149
src/utils/mapGenerator.ts Normal file
View 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
View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

97
start-server.sh Executable file
View 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
View 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
View 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
View 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
View 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'),
},
},
})