From b698a2085fd090e90abedb1e91266ec496574b29 Mon Sep 17 00:00:00 2001
From: wanshenmean <cathay_xy@163.com>
Date: 星期四, 16 四月 2026 23:31:35 +0800
Subject: [PATCH] 1

---
 Code/WMS/WIDESEA_WMSClient/src/views/stock/stockChat.vue | 1672 +++++++++++++++++++++++++++++++++++++++++++++--------------
 1 files changed, 1,275 insertions(+), 397 deletions(-)

diff --git a/Code/WMS/WIDESEA_WMSClient/src/views/stock/stockChat.vue b/Code/WMS/WIDESEA_WMSClient/src/views/stock/stockChat.vue
index 9cc6436..6337909 100644
--- a/Code/WMS/WIDESEA_WMSClient/src/views/stock/stockChat.vue
+++ b/Code/WMS/WIDESEA_WMSClient/src/views/stock/stockChat.vue
@@ -1,95 +1,116 @@
 <template>
-  <div class="stock-chat-container">
-    <!-- 浠撳簱 Tabs -->
-    <el-tabs v-model="activeWarehouse" @tab-change="onWarehouseChange">
-      <el-tab-pane
-        v-for="wh in warehouseList"
-        :key="wh.warehouseId || wh.id"
-        :label="wh.warehouseName"
-        :name="wh.warehouseId || wh.id"
-      />
-    </el-tabs>
+    <div class="stock-chat-container">
+        <el-tabs v-model="activeWarehouse" @tab-change="onWarehouseChange">
+            <el-tab-pane
+                v-for="wh in warehouseList"
+                :key="wh.warehouseId || wh.id"
+                :label="wh.warehouseName"
+                :name="wh.warehouseId || wh.id"
+            />
+        </el-tabs>
 
-    <!-- 宸ュ叿鏍� -->
-    <div class="toolbar">
-      <el-select v-model="filterStockStatus" placeholder="搴撳瓨鐘舵�佺瓫閫�" clearable>
-        <el-option label="鏈夎揣" :value="1" />
-        <el-option label="搴撳瓨绱у紶" :value="2" />
-        <el-option label="宸叉弧" :value="3" />
-      </el-select>
-      <el-select v-model="filterMaterielCode" placeholder="鐗╂枡绛涢��" clearable>
-        <el-option v-for="code in materielCodeList" :key="code" :label="code" :value="code" />
-      </el-select>
-      <el-select v-model="filterBatchNo" placeholder="鎵规绛涢��" clearable>
-        <el-option v-for="batch in batchNoList" :key="batch" :label="batch" :value="batch" />
-      </el-select>
-      <el-button @click="resetCamera">閲嶇疆瑙嗚</el-button>
+        <div class="toolbar">
+            <el-select v-model="filterStockStatus" placeholder="搴撳瓨鐘舵�佺瓫閫�" style="width: 160px" clearable>
+                <el-option label="缁勭洏鏆傚瓨(1)" :value="1" />
+                <el-option label="鍏ュ簱纭(3)" :value="3" />
+                <el-option label="鍏ュ簱瀹屾垚(6)" :value="6" />
+                <el-option label="鍑哄簱閿佸畾(7)" :value="7" />
+                <el-option label="鍑哄簱瀹屾垚(8)" :value="8" />
+                <el-option label="绌烘墭鐩�(22)" :value="22" />
+            </el-select>
+            <el-select v-model="filterMaterielCode" placeholder="鐗╂枡缂栫爜绛涢��" style="width: 140px" clearable>
+                <el-option v-for="code in materielCodeList" :key="code" :label="code" :value="code" />
+            </el-select>
+            <el-select v-model="filterBatchNo" placeholder="鎵规绛涢��" style="width: 140px" clearable>
+                <el-option v-for="batch in batchNoList" :key="batch" :label="batch" :value="batch" />
+            </el-select>
+            <el-button @click="resetCamera">閲嶇疆瑙嗚</el-button>
+            <el-button type="primary" @click="refreshData" :loading="refreshing">鍒锋柊鏁版嵁</el-button>
+        </div>
+
+        <div ref="canvasContainer" class="canvas-container" />
+
+        <div class="legend">
+            <div class="legend-title">璐т綅鐘舵��</div>
+            <div class="legend-subtitle">褰㈡�佽鏄�</div>
+            <div class="legend-item legend-item--shape">
+                <span class="shape-pill shape-pill--empty" />
+                <span>绌鸿揣浣�</span>
+            </div>
+            <div class="legend-item legend-item--shape">
+                <span class="shape-pill shape-pill--pallet" />
+                <span>绌烘墭鐩樹綅</span>
+            </div>
+            <div class="legend-item legend-item--shape">
+                <span class="shape-pill shape-pill--cargo" />
+                <span>鏈夎揣浣�</span>
+            </div>
+            <div class="legend-divider" />
+            <div class="legend-subtitle">鐘舵�侀鑹�</div>
+            <div v-for="item in legendItems" :key="item.status" class="legend-item">
+                <span class="color-box" :style="{ background: item.color }" />
+                <span>{{ item.label }}</span>
+            </div>
+        </div>
+
+        <el-drawer v-model="detailDialogVisible" title="搴撳瓨璇︽儏" direction="rtl" size="500px">
+            <div v-if="selectedLocation" class="detail-content">
+                <el-descriptions :column="2" border>
+                    <el-descriptions-item label="璐т綅缂栧彿">{{ selectedLocation.locationCode }}</el-descriptions-item>
+                    <el-descriptions-item label="璐т綅鐘舵��">{{ getLocationStatusText(selectedLocation.locationStatus) }}</el-descriptions-item>
+                    <el-descriptions-item label="鎵樼洏缂栧彿">{{ selectedLocation.palletCode || '鏃�' }}</el-descriptions-item>
+                    <el-descriptions-item label="搴撳瓨鐘舵��">{{ getStockStatusText(selectedLocation.stockStatus) }}</el-descriptions-item>
+                    <el-descriptions-item label="鎬诲簱瀛�">{{ selectedLocation.stockQuantity }}{{ selectedLocation.unit || '' }}</el-descriptions-item>
+                    <el-descriptions-item label="鍑哄簱鏃ユ湡">{{ selectedLocation.outboundDate }}{{ selectedLocation.unit || '' }}</el-descriptions-item>
+                </el-descriptions>
+
+                <div v-if="selectedLocation.details && selectedLocation.details.length > 0" class="detail-table">
+                    <h4>搴撳瓨鏄庣粏</h4>
+                    <el-table :data="selectedLocation.details" border size="small" max-height="400">
+                        <el-table-column prop="materielName" label="鐗╂枡鍚嶇О" min-width="140" show-overflow-tooltip />
+                        <el-table-column prop="serialNumber" label="鐢佃姱鏉$爜" min-width="160" show-overflow-tooltip />
+                        <el-table-column prop="inboundOrderRowNo" label="閫氶亾鍙�" min-width="100" show-overflow-tooltip />
+                    </el-table>
+                </div>
+                <div v-else class="no-detail">
+                    <el-empty description="鏆傛棤搴撳瓨鏄庣粏" />
+                </div>
+            </div>
+        </el-drawer>
     </div>
-
-    <!-- 3D Canvas -->
-    <div ref="canvasContainer" class="canvas-container" />
-
-    <!-- 鐘舵�佸浘渚� -->
-    <div class="legend">
-      <div v-for="item in legendItems" :key="item.status" class="legend-item">
-        <span class="color-box" :style="{ background: item.color }" />
-        <span>{{ item.label }}</span>
-      </div>
-    </div>
-
-    <!-- 璇︽儏寮圭獥 -->
-    <el-dialog v-model="detailDialogVisible" title="搴撳瓨璇︽儏" fullscreen>
-      <div v-if="selectedLocation" class="detail-content">
-        <el-descriptions :column="2" border>
-          <el-descriptions-item label="璐т綅缂栧彿">{{ selectedLocation.locationCode }}</el-descriptions-item>
-          <el-descriptions-item label="璐т綅鐘舵��">{{ getLocationStatusText(selectedLocation.locationStatus) }}</el-descriptions-item>
-          <el-descriptions-item label="鎵樼洏缂栧彿">{{ selectedLocation.palletCode || '鏃�' }}</el-descriptions-item>
-          <el-descriptions-item label="搴撳瓨鐘舵��">{{ getStockStatusText(selectedLocation.stockStatus) }}</el-descriptions-item>
-          <el-descriptions-item label="褰撳墠搴撳瓨">{{ selectedLocation.stockQuantity }}</el-descriptions-item>
-          <el-descriptions-item label="鐗╂枡缂栧彿">{{ selectedLocation.materielCode || '鏃�' }}</el-descriptions-item>
-          <el-descriptions-item label="鐗╂枡鍚嶇О">{{ selectedLocation.materielName || '鏃�' }}</el-descriptions-item>
-          <el-descriptions-item label="鎵规鍙�">{{ selectedLocation.batchNo || '鏃�' }}</el-descriptions-item>
-        </el-descriptions>
-      </div>
-    </el-dialog>
-  </div>
 </template>
 
 <script setup>
-import { ref, reactive, onMounted, onUnmounted, watch, getCurrentInstance } from 'vue'
+import { ref, onMounted, onUnmounted, watch, nextTick, getCurrentInstance } from 'vue'
 import * as THREE from 'three'
 import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
 import * as signalR from '@microsoft/signalr'
 
 const { proxy } = getCurrentInstance()
 
-// SignalR 杩炴帴
 let connection = null
 
-// 棰滆壊甯搁噺
 const COLOR_MAP = {
-  DISABLED: 0x2d2d2d,    // 绂佺敤 - 娣辩伆
-  LOCKED: 0xF56C6C,      // 閿佸畾 - 绾㈣壊
-  EMPTY: 0x4a4a4a,       // 绌鸿揣浣� - 鏆楃伆
-  HAS_STOCK: 0x409EFF,   // 鏈夎揣 - 钃濊壊
-  LOW_STOCK: 0xE6A23C,   // 搴撳瓨绱у紶 - 姗欒壊
-  FULL: 0x67C23A,        // 宸叉弧 - 缁胯壊
+    LOC_FREE: 0x90EE90,
+    LOC_LOCK: 0xFF6B6B,
+    LOC_INSTOCK_LOCK: 0xFFA500,
+    LOC_FREE_LOCK: 0xFFD700,
+    LOC_PALLET_LOCK: 0x9370DB,
+    LOC_INSTOCK: 0x409EFF,
+    STOCK_EMPTY_TRAY: 0xDDA0DD
 }
 
-// 鍥句緥椤�
 const legendItems = [
-  { status: 'disabled', label: '绂佺敤', color: '#2d2d2d' },
-  { status: 'locked', label: '閿佸畾', color: '#F56C6C' },
-  { status: 'empty', label: '绌鸿揣浣�', color: '#4a4a4a' },
-  { status: 'hasStock', label: '鏈夎揣', color: '#409EFF' },
-  { status: 'lowStock', label: '搴撳瓨绱у紶', color: '#E6A23C' },
-  { status: 'full', label: '宸叉弧', color: '#67C23A' },
+    { status: 'loc_free', label: '绌洪棽(0)', color: '#90EE90' },
+    { status: 'loc_lock', label: '閿佸畾(1)', color: '#FF6B6B' },
+    { status: 'loc_instock_lock', label: '鏈夎揣閿佸畾(10)', color: '#FFA500' },
+    { status: 'loc_free_lock', label: '绌洪棽閿佸畾(20)', color: '#FFD700' },
+    { status: 'loc_pallet_lock', label: '鎵樼洏閿佸畾(99)', color: '#9370DB' },
+    { status: 'loc_instock', label: '鏈夎揣(100)', color: '#409EFF' },
+    { status: 'stock_empty_tray', label: '绌烘墭鐩�(22)', color: '#DDA0DD' }
 ]
 
-// Refs
 const canvasContainer = ref(null)
-
-// 鐘舵��
 const activeWarehouse = ref(null)
 const warehouseList = ref([])
 const filterStockStatus = ref(null)
@@ -99,395 +120,1252 @@
 const batchNoList = ref([])
 const detailDialogVisible = ref(false)
 const selectedLocation = ref(null)
+const refreshing = ref(false)
 
-// Three.js 鐩稿叧
-let scene, camera, renderer, controls, raycaster, mouse
-let locationMesh = null
-let locationData = []
+let scene = null
+let camera = null
+let renderer = null
+let controls = null
+let raycaster = null
+let mouse = null
 let animationId = null
-
-// SignalR 鍒濆鍖�
-function initSignalR() {
-  proxy.http.post('api/User/GetCurrentUserInfo').then((result) => {
-    connection = new signalR.HubConnectionBuilder()
-      .withAutomaticReconnect()
-      .withUrl(`${proxy.http.ipAddress}stockHub?userName=${result.data.userName}`)
-      .build();
-
-    connection.start().catch((err) => console.log('SignalR杩炴帴澶辫触:', err));
-
-    connection.on('StockUpdated', (update) => {
-      // 鏇存柊瀵瑰簲璐т綅鐨勬暟鎹�
-      const idx = locationData.findIndex(x => x.locationId === update.locationId);
-      if (idx !== -1) {
-        locationData[idx].stockQuantity = update.stockQuantity;
-        locationData[idx].stockStatus = update.stockStatus;
-        // 閲嶆柊娓叉煋鍗曚釜璐т綅棰滆壊
-        updateInstanceColor(idx, update.stockStatus);
-      }
-    });
-  });
+let locationMeshes = null
+let pickableMeshes = []
+let selectionOutline = null
+let pointerDownHit = null
+let pointerDownPosition = null
+let aisleModels = []
+let isShiftPressed = false
+const preventCanvasContextMenu = (event) => event.preventDefault()
+const preventRightMouseDefault = (event) => {
+    if (event.button === 2) {
+        event.preventDefault()
+    }
 }
 
-// 鏇存柊鍗曚釜璐т綅棰滆壊
-function updateInstanceColor(instanceId, stockStatus) {
-  if (!locationMesh) return;
-  const loc = locationData[instanceId];
-  if (!loc) return;
-  const color = getLocationColor(loc);
-  locationMesh.setColorAt(instanceId, new THREE.Color(color));
-  locationMesh.instanceColor.needsUpdate = true;
+function updateControlMouseBinding(forceShiftPressed = isShiftPressed) {
+    if (!controls) {
+        return
+    }
+
+    controls.mouseButtons = {
+        LEFT: forceShiftPressed ? THREE.MOUSE.ROTATE : THREE.MOUSE.PAN,
+        MIDDLE: THREE.MOUSE.DOLLY,
+        RIGHT: null
+    }
 }
 
-// 鑾峰彇璐т綅棰滆壊
+function onKeyDown(event) {
+    if (event.key !== 'Shift' || isShiftPressed) {
+        return
+    }
+
+    isShiftPressed = true
+    updateControlMouseBinding()
+}
+
+function onKeyUp(event) {
+    if (event.key !== 'Shift') {
+        return
+    }
+
+    isShiftPressed = false
+    updateControlMouseBinding()
+}
+
+function onWindowBlur() {
+    isShiftPressed = false
+    updateControlMouseBinding()
+}
+
+let locationData = []
+let originalLocationData = []
+let renderedLocations = []
+const locationIdToInstanceId = new Map()
+const CELL_SPACING_X = 2
+const CELL_SPACING_Z = 2.2
+const AISLE_GAP_Z = 4.8
+const BASE_COLOR = 0x748294
+const PALLET_COLOR = 0xb89c74
+const POST_COLOR = 0x75859d
+
+function getLocationColorByStatus(locStatus) {
+    switch (locStatus) {
+        case 1:
+            return COLOR_MAP.LOC_LOCK
+        case 10:
+            return COLOR_MAP.LOC_INSTOCK_LOCK
+        case 20:
+            return COLOR_MAP.LOC_FREE_LOCK
+        case 22:
+            return COLOR_MAP.STOCK_EMPTY_TRAY
+        case 99:
+            return COLOR_MAP.LOC_PALLET_LOCK
+        case 100:
+            return COLOR_MAP.LOC_INSTOCK
+        case 0:
+        default:
+            return COLOR_MAP.LOC_FREE
+    }
+}
+
 function getLocationColor(location) {
-  if (location.locationStatus === 3) return COLOR_MAP.DISABLED  // 绂佺敤
-  if (location.locationStatus === 2) return COLOR_MAP.LOCKED     // 閿佸畾
-  if (location.locationStatus === 1) {
-    if (location.stockStatus === 0) return COLOR_MAP.EMPTY       // 鏃犺揣
-    if (location.stockStatus === 1) return COLOR_MAP.HAS_STOCK    // 鏈夎揣
-    if (location.stockStatus === 2) return COLOR_MAP.LOW_STOCK   // 搴撳瓨绱у紶
-    if (location.stockStatus === 3) return COLOR_MAP.FULL        // 宸叉弧
-  }
-  return COLOR_MAP.EMPTY // 榛樿绌�
-}
-
-// 鑾峰彇璐т綅鐘舵�佹枃鏈�
-function getLocationStatusText(status) {
-  const map = { 0: '姝e父', 1: '姝e父', 2: '閿佸畾', 3: '绂佺敤' }
-  return map[status] || '鏈煡'
-}
-
-// 鑾峰彇搴撳瓨鐘舵�佹枃鏈�
-function getStockStatusText(status) {
-  const map = { 0: '鏃犺揣', 1: '鏈夎揣', 2: '搴撳瓨绱у紶', 3: '宸叉弧' }
-  return map[status] || '鏈煡'
-}
-
-// 鍔犺浇浠撳簱鍒楄〃
-async function loadWarehouseList() {
-  try {
-    const res = await proxy.http.get('/api/Warehouse/GetAll')
-    if (res.Status && res.Data) {
-      warehouseList.value = res.Data
-      if (res.Data.length > 0) {
-        activeWarehouse.value = res.Data[0].warehouseId || res.Data[0].id
-        await loadWarehouseData(activeWarehouse.value)
-      }
+    if (location.stockStatus === 22) {
+        return COLOR_MAP.STOCK_EMPTY_TRAY
     }
-  } catch (e) {
-    console.error('鍔犺浇浠撳簱鍒楄〃澶辫触', e)
-  }
+    return getLocationColorByStatus(location.locationStatus)
 }
 
-// 鍔犺浇浠撳簱璐т綅鏁版嵁
-async function loadWarehouseData(warehouseId) {
-  try {
-    const res = await proxy.http.get(`/api/StockInfo/Get3DLayout?warehouseId=${warehouseId}`)
-    if (res.Status && res.Data) {
-      locationData = res.Data
-      // 鎻愬彇鐗╂枡缂栧彿鍜屾壒娆″垪琛�
-      const codes = new Set()
-      const batches = new Set()
-      res.Data.forEach(loc => {
-        if (loc.materielCode) codes.add(loc.materielCode)
-        if (loc.batchNo) batches.add(loc.batchNo)
-      })
-      materielCodeList.value = Array.from(codes)
-      batchNoList.value = Array.from(batches)
-      // 娓叉煋璐т綅
-      renderLocations()
+function hasCargo(location) {
+    return Number(location.stockQuantity || 0) > 0 || ((location.details && location.details.length > 0) || false)
+}
+
+function hasPallet(location) {
+    if (location.stockStatus === 22 || location.locationStatus === 99) {
+        return true
     }
-  } catch (e) {
-    console.error('鍔犺浇璐т綅鏁版嵁澶辫触', e)
-  }
+    if (hasCargo(location)) {
+        return true
+    }
+    return Boolean(location.palletCode)
 }
 
-// 鍒濆鍖� Three.js 鍦烘櫙
-function initThreeJS() {
-  if (!canvasContainer.value) return
-
-  const width = canvasContainer.value.clientWidth
-  const height = canvasContainer.value.clientHeight
-
-  // 鍒涘缓鍦烘櫙
-  scene = new THREE.Scene()
-  scene.background = new THREE.Color(0x1a1a2e)
-
-  // 鍒涘缓鐩告満
-  camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000)
-  camera.position.set(20, 20, 20)
-
-  // 鍒涘缓娓叉煋鍣�
-  renderer = new THREE.WebGLRenderer({ antialias: true })
-  renderer.setPixelRatio(window.devicePixelRatio)
-  renderer.setSize(width, height)
-  canvasContainer.value.appendChild(renderer.domElement)
-
-  // 鍒涘缓杞ㄩ亾鎺у埗鍣�
-  controls = new OrbitControls(camera, renderer.domElement)
-  controls.enableDamping = true
-  controls.dampingFactor = 0.05
-
-  // 鍒涘缓鐜鍏�
-  const ambientLight = new THREE.AmbientLight(0xffffff, 0.6)
-  scene.add(ambientLight)
-
-  // 鍒涘缓瀹氬悜鍏�
-  const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8)
-  directionalLight.position.set(10, 20, 10)
-  scene.add(directionalLight)
-
-  // 鍒涘缓鍦伴潰
-  const groundGeometry = new THREE.PlaneGeometry(100, 100)
-  const groundMaterial = new THREE.MeshStandardMaterial({ color: 0x2d2d2d })
-  const ground = new THREE.Mesh(groundGeometry, groundMaterial)
-  ground.rotation.x = -Math.PI / 2
-  ground.position.y = -0.5
-  scene.add(ground)
-
-  // 鍒涘缓缃戞牸
-  const gridHelper = new THREE.GridHelper(100, 50)
-  scene.add(gridHelper)
-
-  // 鍒涘缓灏勭嚎妫�娴嬪櫒
-  raycaster = new THREE.Raycaster()
-  mouse = new THREE.Vector2()
-
-  // 娣诲姞鐐瑰嚮浜嬩欢
-  canvasContainer.value.addEventListener('click', onCanvasClick)
-
-  // 寮�濮嬫覆鏌撳惊鐜�
-  animate()
+function getShelfAccentColor(location) {
+    return BASE_COLOR
 }
 
-// 娓叉煋璐т綅
-function renderLocations() {
-  if (!scene) return
-
-  // 绉婚櫎鏃х殑璐т綅缃戞牸
-  if (locationMesh) {
-    scene.remove(locationMesh)
-    locationMesh.geometry.dispose()
-    locationMesh.material.forEach(m => m.dispose())
-    locationMesh = null
-  }
-
-  // 杩囨护鏁版嵁
-  let filteredData = locationData
-  if (filterStockStatus.value !== null) {
-    filteredData = filteredData.filter(loc => loc.stockStatus === filterStockStatus.value)
-  }
-  if (filterMaterielCode.value) {
-    filteredData = filteredData.filter(loc => loc.materielCode === filterMaterielCode.value)
-  }
-  if (filterBatchNo.value) {
-    filteredData = filteredData.filter(loc => loc.batchNo === filterBatchNo.value)
-  }
-
-  if (filteredData.length === 0) return
-
-  // 鍒涘缓 InstancedMesh
-  const geometry = new THREE.BoxGeometry(1.5, 1, 1.5)
-  const material = new THREE.MeshStandardMaterial({ color: 0xffffff })
-
-  locationMesh = new THREE.InstancedMesh(geometry, [material], filteredData.length)
-
-  const dummy = new THREE.Object3D()
-  const color = new THREE.Color()
-
-  filteredData.forEach((location, i) => {
-    const x = (location.column - 1) * 2
-    const y = location.layer * 1.5
-    const z = (location.row - 1) * 2
-
-    dummy.position.set(x, y, z)
-    dummy.updateMatrix()
-    locationMesh.setMatrixAt(i, dummy.matrix)
-
-    // 璁剧疆棰滆壊
-    color.setHex(getLocationColor(location))
-    locationMesh.setColorAt(i, color)
-  })
-
-  locationMesh.instanceMatrix.needsUpdate = true
-  if (locationMesh.instanceColor) locationMesh.instanceColor.needsUpdate = true
-
-  scene.add(locationMesh)
+function getPalletColor(location) {
+    return PALLET_COLOR
 }
 
-// 鍔ㄧ敾寰幆
-function animate() {
-  animationId = requestAnimationFrame(animate)
-  controls.update()
-  renderer.render(scene, camera)
+function getCargoColor(location) {
+    const cargoColor = new THREE.Color(getLocationColor(location))
+    return cargoColor.offsetHSL(0, 0.04, 0.08).getHex()
 }
 
-// 鐐瑰嚮鐢诲竷
-function onCanvasClick(event) {
-  if (!canvasContainer.value || !locationMesh) return
+function getCargoLidColor(location) {
+    const lidColor = new THREE.Color(getCargoColor(location))
+    return lidColor.offsetHSL(0, -0.02, 0.06).getHex()
+}
 
-  const rect = canvasContainer.value.getBoundingClientRect()
-  mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
-  mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
+function getFilteredLocations() {
+    let filteredData = [...originalLocationData]
 
-  raycaster.setFromCamera(mouse, camera)
-  const intersects = raycaster.intersectObject(locationMesh)
-
-  if (intersects.length > 0) {
-    const instanceId = intersects[0].instanceId
-    // 鑾峰彇鍘熷鏁版嵁绱㈠紩锛堣�冭檻杩囨护鍚庣殑鏁版嵁锛�
-    let filteredData = locationData
     if (filterStockStatus.value !== null) {
-      filteredData = filteredData.filter(loc => loc.stockStatus === filterStockStatus.value)
+        filteredData = filteredData.filter((loc) => loc.stockStatus === filterStockStatus.value)
     }
     if (filterMaterielCode.value) {
-      filteredData = filteredData.filter(loc => loc.materielCode === filterMaterielCode.value)
+        filteredData = filteredData.filter((loc) => loc.materielCode === filterMaterielCode.value)
     }
     if (filterBatchNo.value) {
-      filteredData = filteredData.filter(loc => loc.batchNo === filterBatchNo.value)
+        filteredData = filteredData.filter((loc) => loc.batchNo === filterBatchNo.value)
     }
 
-    if (instanceId < filteredData.length) {
-      selectedLocation.value = filteredData[instanceId]
-      detailDialogVisible.value = true
-      // 鑱氱劍鐩告満
-      focusCamera(selectedLocation.value)
-    }
-  }
+    return filteredData
 }
 
-// 鑱氱劍鐩告満鍒拌揣浣�
+function matchesCurrentFilters(location) {
+    if (filterStockStatus.value !== null && location.stockStatus !== filterStockStatus.value) {
+        return false
+    }
+    if (filterMaterielCode.value && location.materielCode !== filterMaterielCode.value) {
+        return false
+    }
+    if (filterBatchNo.value && location.batchNo !== filterBatchNo.value) {
+        return false
+    }
+    return true
+}
+
+function getLocationStatusText(status) {
+    const map = {
+        0: '绌洪棽',
+        1: '閿佸畾',
+        10: '鏈夎揣閿佸畾',
+        20: '绌洪棽閿佸畾',
+        99: '鎵樼洏閿佸畾',
+        100: '鏈夎揣'
+    }
+    return map[status] || `鏈煡(${status})`
+}
+
+function getStockStatusText(status) {
+    const map = {
+        0: '鏃犲簱瀛�',
+        1: '缁勭洏鏆傚瓨',
+        3: '鍏ュ簱纭',
+        6: '鍏ュ簱瀹屾垚',
+        7: '鍑哄簱閿佸畾',
+        8: '鍑哄簱瀹屾垚',
+        9: '绉诲簱閿佸畾',
+        10: '鍏ュ簱瀹屾垚鏈缓鍑哄簱鍗�',
+        11: '閫�搴�',
+        12: '鎵嬪姩缁勭洏鏆傚瓨',
+        13: '鎵嬪姩缁勭洏鍏ュ簱纭',
+        14: '鎷i�夊畬鎴�',
+        21: 'MES閫�搴�',
+        22: '绌烘墭鐩�',
+        99: '缁勭洏鎾ら攢',
+        199: '鍏ュ簱鎾ら攢'
+    }
+    return map[status] || `鏈煡(${status})`
+}
+
+function createFloorTexture() {
+    const canvas = document.createElement('canvas')
+    canvas.width = 512
+    canvas.height = 512
+    const context = canvas.getContext('2d')
+
+    const gradient = context.createLinearGradient(0, 0, 512, 512)
+    gradient.addColorStop(0, '#f7f9fc')
+    gradient.addColorStop(1, '#dde5ef')
+    context.fillStyle = gradient
+    context.fillRect(0, 0, 512, 512)
+
+    context.strokeStyle = 'rgba(122, 142, 168, 0.16)'
+    context.lineWidth = 2
+    for (let index = 0; index <= 8; index++) {
+        const offset = index * 64
+        context.beginPath()
+        context.moveTo(offset, 0)
+        context.lineTo(offset, 512)
+        context.stroke()
+
+        context.beginPath()
+        context.moveTo(0, offset)
+        context.lineTo(512, offset)
+        context.stroke()
+    }
+
+    context.fillStyle = 'rgba(255, 255, 255, 0.3)'
+    context.fillRect(0, 242, 512, 28)
+
+    const texture = new THREE.CanvasTexture(canvas)
+    texture.wrapS = THREE.RepeatWrapping
+    texture.wrapT = THREE.RepeatWrapping
+    texture.repeat.set(6, 6)
+    return texture
+}
+
+function createBackgroundTexture() {
+    const canvas = document.createElement('canvas')
+    canvas.width = 16
+    canvas.height = 512
+    const context = canvas.getContext('2d')
+    const gradient = context.createLinearGradient(0, 0, 0, 512)
+    gradient.addColorStop(0, '#eef4fb')
+    gradient.addColorStop(0.45, '#dbe6f3')
+    gradient.addColorStop(1, '#c5d1df')
+    context.fillStyle = gradient
+    context.fillRect(0, 0, 16, 512)
+    return new THREE.CanvasTexture(canvas)
+}
+
+function getLayoutInfo() {
+    const rowValues = [...new Set(renderedLocations.map((item) => Number(item.row || 0)).filter((value) => value > 0))].sort((a, b) => a - b)
+    const columnValues = [...new Set(renderedLocations.map((item) => Number(item.column || 0)).filter((value) => value > 0))].sort((a, b) => a - b)
+    const layerValues = [...new Set(renderedLocations.map((item) => Number(item.layer || 0)).filter((value) => value >= 0))].sort((a, b) => a - b)
+    const rowCount = rowValues.length
+    const splitAfterRow = rowCount > 2 ? 2 : 1
+
+    return {
+        rowValues,
+        columnValues,
+        layerValues,
+        rowCount,
+        splitAfterRow,
+        minColumn: columnValues.length > 0 ? columnValues[0] : 1,
+        maxColumn: columnValues.length > 0 ? columnValues[columnValues.length - 1] : 1,
+        maxLayer: layerValues.length > 0 ? layerValues[layerValues.length - 1] : 1
+    }
+}
+
+function getLocationWorldPosition(location, layoutInfo = getLayoutInfo()) {
+    const x = (Number(location.column || 1) - 1) * CELL_SPACING_X
+    const row = Number(location.row || 1)
+    const zBase = (row - 1) * CELL_SPACING_Z
+    const z = row > layoutInfo.splitAfterRow ? zBase + AISLE_GAP_Z : zBase
+    const y = Number(location.layer || 0) * 1.5
+
+    return { x, y, z }
+}
+
+function clearAisleModels() {
+    if (!scene || aisleModels.length === 0) {
+        aisleModels = []
+        return
+    }
+
+    aisleModels.forEach((mesh) => {
+        scene.remove(mesh)
+        if (mesh.geometry) {
+            mesh.geometry.dispose()
+        }
+        if (mesh.material) {
+            if (Array.isArray(mesh.material)) {
+                mesh.material.forEach((item) => item.dispose())
+            } else {
+                mesh.material.dispose()
+            }
+        }
+    })
+
+    aisleModels = []
+}
+
+function renderAisleEquipment(layoutInfo) {
+    clearAisleModels()
+
+    if (!scene || layoutInfo.rowCount < 2) {
+        return
+    }
+
+    const leftRowZ = (layoutInfo.splitAfterRow - 1) * CELL_SPACING_Z
+    const rightRowZ = layoutInfo.splitAfterRow * CELL_SPACING_Z + AISLE_GAP_Z
+    const aisleCenterZ = (leftRowZ + rightRowZ) / 2
+    const minX = (layoutInfo.minColumn - 1) * CELL_SPACING_X - 1.6
+    const maxX = (layoutInfo.maxColumn - 1) * CELL_SPACING_X + 1.6
+    const railLength = Math.max(maxX - minX, 4)
+    const craneHeight = Math.max(layoutInfo.maxLayer * 1.5 + 1.9, 4)
+    const craneX = minX + railLength / 2
+    const craneBaseY = -0.5
+    const mastSpacing = 0.92
+    const mastThickness = 0.16
+    const carriageY = Math.min(craneHeight * 0.48, craneHeight - 0.78)
+    const leftDepth = layoutInfo.splitAfterRow
+    const rightDepth = Math.max(layoutInfo.rowCount - layoutInfo.splitAfterRow, 1)
+    const leftForkLength = leftDepth > 1 ? 1.72 : 1.02
+    const rightForkLength = rightDepth > 1 ? 1.72 : 1.02
+    const forwardSign = rightDepth > leftDepth ? 1 : -1
+
+    function addAisleMesh(geometry, material, position, rotation = null, castShadow = true) {
+        const mesh = new THREE.Mesh(geometry, material)
+        mesh.position.copy(position)
+        if (rotation) {
+            mesh.rotation.set(rotation.x, rotation.y, rotation.z)
+        }
+        mesh.castShadow = castShadow
+        mesh.receiveShadow = true
+        scene.add(mesh)
+        aisleModels.push(mesh)
+        return mesh
+    }
+
+    const railGeometry = new THREE.BoxGeometry(railLength, 0.08, 0.14)
+    const railMaterial = new THREE.MeshStandardMaterial({
+        color: 0x58677b,
+        roughness: 0.45,
+        metalness: 0.55
+    })
+
+    const railOffsets = [-0.36, 0.36]
+    railOffsets.forEach((offset) => {
+        const rail = new THREE.Mesh(railGeometry, railMaterial.clone())
+        rail.position.set(craneX, -0.42, aisleCenterZ + offset)
+        rail.castShadow = true
+        rail.receiveShadow = true
+        scene.add(rail)
+        aisleModels.push(rail)
+    })
+
+    const sleeperGeometry = new THREE.BoxGeometry(0.18, 0.05, 0.94)
+    for (let x = minX; x <= maxX; x += 1.2) {
+        const sleeper = new THREE.Mesh(sleeperGeometry, new THREE.MeshStandardMaterial({
+            color: 0x8f6a49,
+            roughness: 0.88,
+            metalness: 0.03
+        }))
+        sleeper.position.set(x, -0.46, aisleCenterZ)
+        sleeper.receiveShadow = true
+        scene.add(sleeper)
+        aisleModels.push(sleeper)
+    }
+
+    const machineYellow = new THREE.MeshStandardMaterial({
+        color: 0xe9a63d,
+        roughness: 0.42,
+        metalness: 0.32
+    })
+    const machineDark = new THREE.MeshStandardMaterial({
+        color: 0x334155,
+        roughness: 0.54,
+        metalness: 0.26
+    })
+    const machineSteel = new THREE.MeshStandardMaterial({
+        color: 0x7b8aa0,
+        roughness: 0.42,
+        metalness: 0.58
+    })
+    const guardRed = new THREE.MeshStandardMaterial({
+        color: 0xd05b45,
+        roughness: 0.5,
+        metalness: 0.18
+    })
+
+    addAisleMesh(
+        new THREE.BoxGeometry(1.34, 0.22, 1.12),
+        machineDark.clone(),
+        new THREE.Vector3(craneX, craneBaseY + 0.11, aisleCenterZ)
+    )
+
+    addAisleMesh(
+        new THREE.BoxGeometry(1.08, 0.18, 0.92),
+        machineYellow.clone(),
+        new THREE.Vector3(craneX, craneBaseY + 0.3, aisleCenterZ)
+    )
+
+    const wheelOffsets = [
+        [-0.42, -0.3],
+        [-0.42, 0.3],
+        [0.42, -0.3],
+        [0.42, 0.3]
+    ]
+    wheelOffsets.forEach((offset) => {
+        addAisleMesh(
+            new THREE.CylinderGeometry(0.11, 0.11, 0.12, 16),
+            machineSteel.clone(),
+            new THREE.Vector3(craneX + offset[0], craneBaseY + 0.09, aisleCenterZ + offset[1]),
+            new THREE.Vector3(Math.PI / 2, 0, 0)
+        )
+    })
+
+    const mastPositions = [-mastSpacing / 2, mastSpacing / 2]
+    mastPositions.forEach((offsetX) => {
+        addAisleMesh(
+            new THREE.BoxGeometry(mastThickness, craneHeight, mastThickness),
+            machineYellow.clone(),
+            new THREE.Vector3(craneX + offsetX, craneBaseY + craneHeight / 2, aisleCenterZ)
+        )
+    })
+
+    addAisleMesh(
+        new THREE.BoxGeometry(mastSpacing + 0.34, 0.18, 0.38),
+        machineDark.clone(),
+        new THREE.Vector3(craneX, craneBaseY + craneHeight - 0.12, aisleCenterZ)
+    )
+
+    addAisleMesh(
+        new THREE.BoxGeometry(mastSpacing + 0.18, 0.1, 0.28),
+        machineSteel.clone(),
+        new THREE.Vector3(craneX, craneBaseY + 0.92, aisleCenterZ)
+    )
+
+    addAisleMesh(
+        new THREE.BoxGeometry(mastSpacing + 0.14, 0.44, 0.72),
+        machineDark.clone(),
+        new THREE.Vector3(craneX, carriageY, aisleCenterZ)
+    )
+
+    addAisleMesh(
+        new THREE.BoxGeometry(mastSpacing + 0.28, 0.08, 0.82),
+        machineYellow.clone(),
+        new THREE.Vector3(craneX, carriageY + 0.22, aisleCenterZ)
+    )
+
+    addAisleMesh(
+        new THREE.BoxGeometry(0.26, 0.07, 0.46),
+        machineSteel.clone(),
+        new THREE.Vector3(craneX, carriageY - 0.04, aisleCenterZ)
+    )
+
+    addAisleMesh(
+        new THREE.BoxGeometry(0.12, 0.06, leftForkLength),
+        machineSteel.clone(),
+        new THREE.Vector3(craneX - 0.12, carriageY - 0.04, aisleCenterZ - (0.23 + leftForkLength / 2))
+    )
+
+    addAisleMesh(
+        new THREE.BoxGeometry(0.12, 0.06, rightForkLength),
+        machineSteel.clone(),
+        new THREE.Vector3(craneX + 0.12, carriageY - 0.04, aisleCenterZ + (0.23 + rightForkLength / 2))
+    )
+
+    addAisleMesh(
+        new THREE.BoxGeometry(0.4, 0.54, 0.26),
+        guardRed.clone(),
+        new THREE.Vector3(craneX, carriageY + 0.04, aisleCenterZ - (0.38 * forwardSign))
+    )
+
+    addAisleMesh(
+        new THREE.BoxGeometry(0.78, 0.05, 0.28),
+        machineDark.clone(),
+        new THREE.Vector3(craneX, craneBaseY + 0.66, aisleCenterZ - (0.22 * forwardSign))
+    )
+
+    addAisleMesh(
+        new THREE.BoxGeometry(0.72, 0.38, 0.04),
+        machineSteel.clone(),
+        new THREE.Vector3(craneX, craneBaseY + 0.86, aisleCenterZ - (0.36 * forwardSign))
+    )
+
+    const noseGeometry = new THREE.BoxGeometry(0.52, 0.28, 0.22)
+    addAisleMesh(
+        noseGeometry,
+        machineYellow.clone(),
+        new THREE.Vector3(craneX, carriageY + 0.02, aisleCenterZ + (0.48 * forwardSign))
+    )
+}
+
+function clearLocationMeshes() {
+    pickableMeshes = []
+    pointerDownHit = null
+
+    if (!locationMeshes) {
+        return
+    }
+
+    Object.values(locationMeshes).forEach((mesh) => {
+        scene.remove(mesh)
+        mesh.geometry.dispose()
+        mesh.material.dispose()
+    })
+
+    locationMeshes = null
+}
+
+function updateInstanceVisual(instanceId, location) {
+    if (!locationMeshes) {
+        return
+    }
+
+    const layoutInfo = getLayoutInfo()
+    const { x, y, z } = getLocationWorldPosition(location, layoutInfo)
+    const postTopY = y + 1.54
+    const postBottomY = -0.5
+    const postHeight = postTopY - postBottomY
+    const postCenterY = postBottomY + (postHeight / 2)
+    const showPallet = hasPallet(location)
+    const showCargo = hasCargo(location)
+    const dummy = new THREE.Object3D()
+    const shelfColor = new THREE.Color(getShelfAccentColor(location))
+    const palletColor = new THREE.Color(getPalletColor(location))
+    const cargoColor = new THREE.Color(getCargoColor(location))
+    const cargoLidColor = new THREE.Color(getCargoLidColor(location))
+    const postOffsets = [
+        [-0.76, -0.76],
+        [-0.76, 0.76],
+        [0.76, -0.76],
+        [0.76, 0.76]
+    ]
+
+    dummy.position.set(x, y + 0.12, z)
+    dummy.rotation.set(0, 0, 0)
+    dummy.scale.set(1, 1, 1)
+    dummy.updateMatrix()
+    locationMeshes.base.setMatrixAt(instanceId, dummy.matrix)
+
+    locationMeshes.base.setColorAt(instanceId, shelfColor)
+
+    dummy.position.set(x, y + 0.34, z)
+    dummy.scale.set(showPallet ? 1 : 0.001, showPallet ? 1 : 0.001, showPallet ? 1 : 0.001)
+    dummy.updateMatrix()
+    locationMeshes.pallet.setMatrixAt(instanceId, dummy.matrix)
+    locationMeshes.pallet.setColorAt(instanceId, palletColor)
+
+    dummy.position.set(x, y + 0.86, z)
+    dummy.scale.set(showCargo ? 1 : 0.001, showCargo ? 1 : 0.001, showCargo ? 1 : 0.001)
+    dummy.updateMatrix()
+    locationMeshes.cargoBody.setMatrixAt(instanceId, dummy.matrix)
+    locationMeshes.cargoBody.setColorAt(instanceId, cargoColor)
+
+    dummy.position.set(x, y + 1.25, z)
+    dummy.scale.set(showCargo ? 1 : 0.001, showCargo ? 1 : 0.001, showCargo ? 1 : 0.001)
+    dummy.updateMatrix()
+    locationMeshes.cargoLid.setMatrixAt(instanceId, dummy.matrix)
+    locationMeshes.cargoLid.setColorAt(instanceId, cargoLidColor)
+
+    postOffsets.forEach((offset, postIndex) => {
+        dummy.position.set(x + offset[0], postCenterY, z + offset[1])
+        dummy.rotation.set(0, 0, 0)
+        dummy.scale.set(1, postHeight, 1)
+        dummy.updateMatrix()
+        locationMeshes.posts.setMatrixAt(instanceId * 4 + postIndex, dummy.matrix)
+    })
+
+    locationMeshes.base.instanceMatrix.needsUpdate = true
+    locationMeshes.pallet.instanceMatrix.needsUpdate = true
+    locationMeshes.cargoBody.instanceMatrix.needsUpdate = true
+    locationMeshes.cargoLid.instanceMatrix.needsUpdate = true
+    locationMeshes.posts.instanceMatrix.needsUpdate = true
+    locationMeshes.base.instanceColor.needsUpdate = true
+    locationMeshes.pallet.instanceColor.needsUpdate = true
+    locationMeshes.cargoBody.instanceColor.needsUpdate = true
+    locationMeshes.cargoLid.instanceColor.needsUpdate = true
+}
+
+function syncLocationUpdate(list, update) {
+    const index = list.findIndex((item) => item.locationId === update.locationId)
+    if (index === -1) {
+        return
+    }
+
+    list[index] = {
+        ...list[index],
+        stockQuantity: update.stockQuantity,
+        stockStatus: update.stockStatus,
+        palletCode: update.palletCode,
+        locationStatus: update.locationStatus,
+        details: update.details && update.details.length > 0 ? update.details : list[index].details
+    }
+}
+
+function initSignalR() {
+    proxy.http.post('api/User/GetCurrentUserInfo').then((result) => {
+        connection = new signalR.HubConnectionBuilder()
+            .withAutomaticReconnect()
+            .withUrl(`${proxy.http.ipAddress}stockHub?userName=${result.data.userName}`)
+            .build()
+
+        connection.start().catch((error) => console.log('SignalR杩炴帴澶辫触:', error))
+
+        connection.on('StockUpdated', (update) => {
+            console.log("馃殌 ~ initSignalR ~ update:", update)
+            syncLocationUpdate(locationData, update)
+            syncLocationUpdate(originalLocationData, update)
+            const nextLocation = originalLocationData.find((item) => item.locationId === update.locationId)
+            const instanceId = locationIdToInstanceId.get(update.locationId)
+            const shouldRender = nextLocation && matchesCurrentFilters(nextLocation)
+
+            if (!nextLocation) {
+                return
+            }
+
+            if (instanceId !== undefined && !shouldRender) {
+                renderLocations()
+                return
+            }
+
+            if (instanceId === undefined && shouldRender) {
+                renderLocations()
+                return
+            }
+
+            if (instanceId !== undefined && shouldRender) {
+                renderedLocations[instanceId] = nextLocation
+                updateInstanceVisual(instanceId, nextLocation)
+            }
+        })
+    })
+}
+
+async function loadWarehouseList() {
+    try {
+        const res = await proxy.http.get('/api/Warehouse/GetAll')
+        if (res.status && res.data) {
+            warehouseList.value = res.data
+            if (res.data.length > 0) {
+                activeWarehouse.value = res.data[0].warehouseId || res.data[0].id
+                await loadWarehouseData(activeWarehouse.value)
+            }
+        }
+    } catch (error) {
+        console.error('鍔犺浇浠撳簱鍒楄〃澶辫触', error)
+    }
+}
+
+async function loadWarehouseData(warehouseId) {
+    try {
+        const res = await proxy.http.get(`/api/StockInfo/Get3DLayout?warehouseId=${warehouseId}`)
+        if (res.status && res.data) {
+            const data = res.data
+            originalLocationData = data.locations || []
+            locationData = [...originalLocationData]
+            materielCodeList.value = data.materielCodeList || []
+            batchNoList.value = data.batchNoList || []
+            renderLocations()
+        }
+    } catch (error) {
+        console.error('鍔犺浇璐т綅鏁版嵁澶辫触', error)
+    }
+}
+
+function initThreeJS() {
+    if (!canvasContainer.value) {
+        return
+    }
+
+    const width = canvasContainer.value.clientWidth
+    const height = canvasContainer.value.clientHeight
+
+    scene = new THREE.Scene()
+    scene.background = createBackgroundTexture()
+    scene.fog = new THREE.Fog(0xdeE7f1, 36, 92)
+
+    camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 1000)
+    camera.position.set(22, 20, 22)
+    camera.lookAt(0, 0, 0)
+
+    renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
+    renderer.setPixelRatio(window.devicePixelRatio)
+    renderer.setSize(width, height)
+    renderer.shadowMap.enabled = true
+    renderer.shadowMap.type = THREE.PCFSoftShadowMap
+    renderer.outputColorSpace = THREE.SRGBColorSpace
+    canvasContainer.value.appendChild(renderer.domElement)
+    canvasContainer.value.addEventListener('contextmenu', preventCanvasContextMenu)
+    canvasContainer.value.addEventListener('mousedown', preventRightMouseDefault)
+    renderer.domElement.addEventListener('contextmenu', preventCanvasContextMenu)
+    renderer.domElement.addEventListener('mousedown', preventRightMouseDefault)
+
+    controls = new OrbitControls(camera, renderer.domElement)
+    controls.enableDamping = true
+    controls.dampingFactor = 0.06
+    controls.enablePan = true
+    controls.screenSpacePanning = true
+    controls.zoomToCursor = true
+    controls.panSpeed = 1.15
+    controls.rotateSpeed = 0.75
+    controls.minDistance = 2
+    controls.maxDistance = 160
+    controls.maxPolarAngle = Math.PI
+    controls.touches = {
+        ONE: THREE.TOUCH.PAN,
+        TWO: THREE.TOUCH.DOLLY_ROTATE
+    }
+    updateControlMouseBinding()
+
+    const hemisphereLight = new THREE.HemisphereLight(0xf8fbff, 0xc8d0d9, 1.15)
+    scene.add(hemisphereLight)
+
+    const keyLight = new THREE.DirectionalLight(0xffffff, 1.25)
+    keyLight.position.set(24, 36, 18)
+    keyLight.castShadow = true
+    keyLight.shadow.mapSize.width = 2048
+    keyLight.shadow.mapSize.height = 2048
+    keyLight.shadow.camera.near = 1
+    keyLight.shadow.camera.far = 120
+    keyLight.shadow.camera.left = -44
+    keyLight.shadow.camera.right = 44
+    keyLight.shadow.camera.top = 44
+    keyLight.shadow.camera.bottom = -44
+    scene.add(keyLight)
+
+    const fillLight = new THREE.DirectionalLight(0xd9ebff, 0.55)
+    fillLight.position.set(-18, 18, -24)
+    scene.add(fillLight)
+
+    const rimLight = new THREE.DirectionalLight(0xfff3d6, 0.35)
+    rimLight.position.set(0, 16, 30)
+    scene.add(rimLight)
+
+    const groundGeometry = new THREE.PlaneGeometry(120, 120)
+    const groundMaterial = new THREE.MeshStandardMaterial({
+        color: 0xe4eaf1,
+        map: createFloorTexture(),
+        roughness: 0.94,
+        metalness: 0.02
+    })
+    const ground = new THREE.Mesh(groundGeometry, groundMaterial)
+    ground.rotation.x = -Math.PI / 2
+    ground.position.y = -0.5
+    ground.receiveShadow = true
+    scene.add(ground)
+
+    const gridHelper = new THREE.GridHelper(100, 20, 0x8da0b8, 0xc8d2de)
+    gridHelper.position.y = -0.49
+    gridHelper.material.transparent = true
+    gridHelper.material.opacity = 0.18
+    scene.add(gridHelper)
+
+    raycaster = new THREE.Raycaster()
+    mouse = new THREE.Vector2()
+
+    const outlineGeometry = new THREE.BoxGeometry(2.3, 2.1, 2.3)
+    const outlineMaterial = new THREE.MeshBasicMaterial({
+        color: 0xffc857,
+        wireframe: true,
+        transparent: true,
+        opacity: 0.92
+    })
+    selectionOutline = new THREE.Mesh(outlineGeometry, outlineMaterial)
+    selectionOutline.visible = false
+    scene.add(selectionOutline)
+
+    canvasContainer.value.addEventListener('pointerdown', onCanvasPointerDown)
+    canvasContainer.value.addEventListener('pointerup', onCanvasPointerUp)
+
+    animate()
+}
+
+function renderLocations() {
+    if (!scene) {
+        return
+    }
+
+    if (originalLocationData.length === 0) {
+        clearLocationMeshes()
+        clearAisleModels()
+        renderedLocations = []
+        if (selectionOutline) {
+            selectionOutline.visible = false
+        }
+        return
+    }
+
+    clearLocationMeshes()
+    renderedLocations = getFilteredLocations()
+    locationIdToInstanceId.clear()
+
+    if (renderedLocations.length === 0) {
+        clearAisleModels()
+        if (selectionOutline) {
+            selectionOutline.visible = false
+        }
+        return
+    }
+
+    const layoutInfo = getLayoutInfo()
+    renderAisleEquipment(layoutInfo)
+
+    const baseGeometry = new THREE.BoxGeometry(1.76, 0.24, 1.76)
+    const palletGeometry = new THREE.BoxGeometry(1.42, 0.16, 1.42)
+    const cargoBodyGeometry = new THREE.BoxGeometry(1.08, 0.7, 1.08)
+    const cargoLidGeometry = new THREE.BoxGeometry(1.16, 0.08, 1.16)
+    const postGeometry = new THREE.BoxGeometry(0.1, 1, 0.1)
+    locationMeshes = {
+        base: new THREE.InstancedMesh(baseGeometry, new THREE.MeshPhysicalMaterial({
+            color: 0xffffff,
+            roughness: 0.46,
+            metalness: 0.28,
+            clearcoat: 0.18
+        }), renderedLocations.length),
+        pallet: new THREE.InstancedMesh(palletGeometry, new THREE.MeshStandardMaterial({
+            color: 0xffffff,
+            roughness: 0.84,
+            metalness: 0.04
+        }), renderedLocations.length),
+        cargoBody: new THREE.InstancedMesh(cargoBodyGeometry, new THREE.MeshPhysicalMaterial({
+            color: 0xffffff,
+            roughness: 0.5,
+            metalness: 0.05,
+            clearcoat: 0.22,
+            clearcoatRoughness: 0.28
+        }), renderedLocations.length),
+        cargoLid: new THREE.InstancedMesh(cargoLidGeometry, new THREE.MeshStandardMaterial({
+            color: 0xffffff,
+            roughness: 0.44,
+            metalness: 0.08
+        }), renderedLocations.length),
+        posts: new THREE.InstancedMesh(postGeometry, new THREE.MeshStandardMaterial({
+            color: POST_COLOR,
+            roughness: 0.48,
+            metalness: 0.24
+        }), renderedLocations.length * 4)
+    }
+
+    Object.values(locationMeshes).forEach((mesh) => {
+        mesh.castShadow = true
+        mesh.receiveShadow = true
+    })
+
+    pickableMeshes = [locationMeshes.base, locationMeshes.pallet, locationMeshes.cargoBody, locationMeshes.cargoLid]
+
+    renderedLocations.forEach((location, index) => {
+        updateInstanceVisual(index, location)
+        locationIdToInstanceId.set(location.locationId, index)
+    })
+
+    Object.values(locationMeshes).forEach((mesh) => {
+        mesh.instanceMatrix.needsUpdate = true
+        if (mesh.instanceColor) {
+            mesh.instanceColor.needsUpdate = true
+        }
+        scene.add(mesh)
+    })
+}
+
+function animate() {
+    animationId = requestAnimationFrame(animate)
+    if (controls) {
+        controls.update()
+    }
+    if (selectionOutline && selectionOutline.visible) {
+        selectionOutline.material.opacity = 0.74 + Math.sin(Date.now() * 0.006) * 0.15
+    }
+    if (renderer && scene && camera) {
+        renderer.render(scene, camera)
+    }
+}
+
 function focusCamera(location) {
-  if (!camera || !controls) return
-
-  const targetX = (location.column - 1) * 2
-  const targetY = location.layer * 1.5
-  const targetZ = (location.row - 1) * 2
-
-  const offsetX = 5
-  const offsetY = 5
-  const offsetZ = 5
-
-  const targetPosition = new THREE.Vector3(targetX + offsetX, targetY + offsetY, targetZ + offsetZ)
-
-  // 浣跨敤 lerp 骞虫粦绉诲姩
-  const startPosition = camera.position.clone()
-  const duration = 500
-  const startTime = Date.now()
-
-  function lerpMove() {
-    const elapsed = Date.now() - startTime
-    const t = Math.min(elapsed / duration, 1)
-    const easeT = 1 - Math.pow(1 - t, 3) // easeOutCubic
-
-    camera.position.lerpVectors(startPosition, targetPosition, easeT)
-    controls.target.set(targetX, targetY, targetZ)
-
-    if (t < 1) {
-      requestAnimationFrame(lerpMove)
+    if (!camera || !controls) {
+        return
     }
-  }
 
-  lerpMove()
+    const { x: targetX, y, z: targetZ } = getLocationWorldPosition(location)
+    const targetY = y + 0.9
+    const currentDirection = camera.position.clone().sub(controls.target).normalize()
+    const fallbackDirection = new THREE.Vector3(1, 0.85, 1).normalize()
+    const focusDirection = currentDirection.lengthSq() > 0 ? currentDirection : fallbackDirection
+    const targetPosition = new THREE.Vector3(targetX, targetY, targetZ).add(focusDirection.multiplyScalar(8.5))
+    const startPosition = camera.position.clone()
+    const duration = 500
+    const startTime = Date.now()
+
+    if (selectionOutline) {
+        selectionOutline.position.set(targetX, location.layer * 1.5 + 0.9, targetZ)
+        selectionOutline.visible = true
+    }
+
+    function lerpMove() {
+        const elapsed = Date.now() - startTime
+        const progress = Math.min(elapsed / duration, 1)
+        const easedProgress = 1 - Math.pow(1 - progress, 3)
+
+        camera.position.lerpVectors(startPosition, targetPosition, easedProgress)
+        controls.target.set(targetX, targetY, targetZ)
+
+        if (progress < 1) {
+            requestAnimationFrame(lerpMove)
+        }
+    }
+
+    lerpMove()
 }
 
-// 閲嶇疆鐩告満
+function getCanvasHit(event) {
+    if (!canvasContainer.value || pickableMeshes.length === 0) {
+        return null
+    }
+
+    const rect = canvasContainer.value.getBoundingClientRect()
+    mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
+    mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
+
+    raycaster.setFromCamera(mouse, camera)
+    const intersects = raycaster.intersectObjects(pickableMeshes, false)
+
+    if (intersects.length === 0) {
+        return null
+    }
+
+    const instanceId = intersects[0].instanceId
+    if (instanceId === undefined || instanceId >= renderedLocations.length) {
+        return null
+    }
+
+    return {
+        instanceId,
+        location: renderedLocations[instanceId]
+    }
+}
+
+function onCanvasPointerDown(event) {
+    updateControlMouseBinding(event.shiftKey)
+
+    if (event.button !== 0) {
+        pointerDownHit = null
+        pointerDownPosition = null
+        return
+    }
+
+    if (event.shiftKey) {
+        pointerDownHit = null
+        pointerDownPosition = null
+        return
+    }
+
+    const hit = getCanvasHit(event)
+    pointerDownHit = hit
+    pointerDownPosition = {
+        x: event.clientX,
+        y: event.clientY
+    }
+}
+
+function onCanvasPointerUp(event) {
+    updateControlMouseBinding(event.shiftKey)
+
+    if (event.button !== 0) {
+        pointerDownHit = null
+        pointerDownPosition = null
+        return
+    }
+
+    if (event.shiftKey) {
+        pointerDownHit = null
+        pointerDownPosition = null
+        return
+    }
+
+    const hit = getCanvasHit(event)
+    if (!pointerDownHit || !hit || !pointerDownPosition) {
+        pointerDownHit = null
+        pointerDownPosition = null
+        return
+    }
+
+    const moveDistance = Math.hypot(event.clientX - pointerDownPosition.x, event.clientY - pointerDownPosition.y)
+    const isSameLocation = pointerDownHit.instanceId === hit.instanceId
+
+    if (!isSameLocation || moveDistance > 6) {
+        pointerDownHit = null
+        pointerDownPosition = null
+        return
+    }
+
+    selectedLocation.value = hit.location
+    detailDialogVisible.value = true
+    focusCamera(selectedLocation.value)
+
+    pointerDownHit = null
+    pointerDownPosition = null
+}
+
 function resetCamera() {
-  if (!camera || !controls) return
-  camera.position.set(20, 20, 20)
-  controls.target.set(0, 0, 0)
+    if (!camera || !controls) {
+        return
+    }
+    camera.position.set(22, 20, 22)
+    controls.target.set(0, 0, 0)
+    if (selectionOutline) {
+        selectionOutline.visible = false
+    }
 }
 
-// 浠撳簱鍒囨崲
+async function refreshData() {
+    refreshing.value = true
+    try {
+        filterStockStatus.value = null
+        filterMaterielCode.value = null
+        filterBatchNo.value = null
+        if (activeWarehouse.value) {
+            await loadWarehouseData(activeWarehouse.value)
+        }
+    } finally {
+        refreshing.value = false
+    }
+}
+
 async function onWarehouseChange(warehouseId) {
-  await loadWarehouseData(warehouseId)
+    await loadWarehouseData(warehouseId)
 }
 
-// 鐩戝惉绛涢�夊彉鍖�
-watch(filterStockStatus, () => renderLocations())
-watch(filterMaterielCode, () => renderLocations())
-watch(filterBatchNo, () => renderLocations())
-
-// 绐楀彛澶у皬鍙樺寲
 function onWindowResize() {
-  if (!canvasContainer.value || !camera || !renderer) return
-  const width = canvasContainer.value.clientWidth
-  const height = canvasContainer.value.clientHeight
-  camera.aspect = width / height
-  camera.updateProjectionMatrix()
-  renderer.setSize(width, height)
+    if (!canvasContainer.value || !camera || !renderer) {
+        return
+    }
+
+    const width = canvasContainer.value.clientWidth
+    const height = canvasContainer.value.clientHeight
+    camera.aspect = width / height
+    camera.updateProjectionMatrix()
+    renderer.setSize(width, height)
 }
 
-// 缁勪欢鎸傝浇
-onMounted(() => {
-  initThreeJS()
-  loadWarehouseList()
-  initSignalR()
-  window.addEventListener('resize', onWindowResize)
+watch(filterStockStatus, async () => {
+    await nextTick()
+    renderLocations()
 })
 
-// 缁勪欢鍗歌浇
+watch(filterMaterielCode, async () => {
+    await nextTick()
+    renderLocations()
+})
+
+watch(filterBatchNo, async () => {
+    await nextTick()
+    renderLocations()
+})
+
+onMounted(() => {
+    initThreeJS()
+    loadWarehouseList()
+    initSignalR()
+    window.addEventListener('keydown', onKeyDown)
+    window.addEventListener('keyup', onKeyUp)
+    window.addEventListener('blur', onWindowBlur)
+    window.addEventListener('resize', onWindowResize)
+})
+
 onUnmounted(() => {
-  if (animationId) {
-    cancelAnimationFrame(animationId)
-  }
-  if (canvasContainer.value) {
-    canvasContainer.value.removeEventListener('click', onCanvasClick)
-  }
-  window.removeEventListener('resize', onWindowResize)
-  if (renderer) {
-    renderer.dispose()
-  }
-  if (connection) {
-    connection.stop()
-  }
+    if (animationId) {
+        cancelAnimationFrame(animationId)
+    }
+    if (canvasContainer.value) {
+        canvasContainer.value.removeEventListener('contextmenu', preventCanvasContextMenu)
+        canvasContainer.value.removeEventListener('mousedown', preventRightMouseDefault)
+        canvasContainer.value.removeEventListener('pointerdown', onCanvasPointerDown)
+        canvasContainer.value.removeEventListener('pointerup', onCanvasPointerUp)
+    }
+    window.removeEventListener('keydown', onKeyDown)
+    window.removeEventListener('keyup', onKeyUp)
+    window.removeEventListener('blur', onWindowBlur)
+    window.removeEventListener('resize', onWindowResize)
+    clearLocationMeshes()
+    clearAisleModels()
+    if (selectionOutline) {
+        scene.remove(selectionOutline)
+        selectionOutline.geometry.dispose()
+        selectionOutline.material.dispose()
+    }
+    if (renderer) {
+        renderer.domElement.removeEventListener('contextmenu', preventCanvasContextMenu)
+        renderer.domElement.removeEventListener('mousedown', preventRightMouseDefault)
+        renderer.dispose()
+    }
+    if (connection) {
+        connection.stop()
+    }
 })
 </script>
 
 <style scoped>
 .stock-chat-container {
-  width: 100%;
-  height: calc(100vh - 120px);
-  position: relative;
+    width: 100%;
+    height: calc(100vh - 120px);
+    position: relative;
+    overflow: visible;
+    background: linear-gradient(180deg, #f6f9fc 0%, #edf2f7 100%);
+    border-radius: 12px;
 }
+
 .toolbar {
-  position: absolute;
-  top: 60px;
-  left: 20px;
-  z-index: 10;
-  display: flex;
-  gap: 10px;
-  background: rgba(255, 255, 255, 0.9);
-  padding: 10px;
-  border-radius: 4px;
+    position: absolute;
+    top: 60px;
+    left: 20px;
+    z-index: 10;
+    display: flex;
+    gap: 10px;
+    background: rgba(255, 255, 255, 0.82);
+    padding: 12px;
+    border-radius: 12px;
+    border: 1px solid rgba(148, 163, 184, 0.18);
+    box-shadow: 0 12px 32px rgba(15, 23, 42, 0.12);
+    backdrop-filter: blur(14px);
 }
+
 .canvas-container {
-  width: 100%;
-  height: 100%;
+    width: 100%;
+    height: 100%;
+    overflow: hidden;
+    border-radius: 12px;
 }
+
 .legend {
-  position: absolute;
-  bottom: 20px;
-  right: 20px;
-  background: rgba(0, 0, 0, 0.7);
-  padding: 10px;
-  border-radius: 4px;
-  color: white;
-  z-index: 10;
+    position: absolute;
+    bottom: 20px;
+    right: 20px;
+    z-index: 10;
+    width: 220px;
+    background: rgba(15, 23, 42, 0.78);
+    padding: 14px 16px;
+    border-radius: 14px;
+    color: #fff;
+    box-shadow: 0 16px 36px rgba(15, 23, 42, 0.24);
+    backdrop-filter: blur(10px);
+    max-height: 460px;
+    overflow-y: auto;
 }
+
+.legend-title {
+    font-weight: 700;
+    font-size: 14px;
+    margin-bottom: 8px;
+}
+
+.legend-subtitle {
+    font-size: 12px;
+    color: rgba(226, 232, 240, 0.8);
+    margin-bottom: 6px;
+}
+
+.legend-divider {
+    height: 1px;
+    background: rgba(255, 255, 255, 0.16);
+    margin: 10px 0;
+}
+
 .legend-item {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  margin-bottom: 4px;
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    margin-bottom: 6px;
+    font-size: 12px;
 }
+
+.legend-item--shape {
+    color: rgba(255, 255, 255, 0.92);
+}
+
 .color-box {
-  width: 16px;
-  height: 16px;
-  border-radius: 2px;
+    width: 16px;
+    height: 16px;
+    border-radius: 5px;
+    flex-shrink: 0;
+    box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18);
 }
+
+.shape-pill {
+    display: inline-block;
+    width: 24px;
+    height: 12px;
+    border-radius: 6px;
+    position: relative;
+    flex-shrink: 0;
+}
+
+.shape-pill::after {
+    content: '';
+    position: absolute;
+    left: 2px;
+    right: 2px;
+    bottom: -4px;
+    height: 4px;
+    border-radius: 4px;
+}
+
+.shape-pill--empty {
+    width: 26px;
+    height: 6px;
+    border-radius: 4px;
+    background: linear-gradient(180deg, #b8c6d8 0%, #7f90a8 100%);
+}
+
+.shape-pill--empty::after {
+    display: none;
+}
+
+.shape-pill--pallet {
+    background: linear-gradient(180deg, #e7c997 0%, #bf8851 100%);
+}
+
+.shape-pill--pallet::after {
+    background: #7f8fa5;
+}
+
+.shape-pill--cargo {
+    background: linear-gradient(180deg, #7ab6ff 0%, #2b7fff 100%);
+    height: 14px;
+}
+
+.shape-pill--cargo::after {
+    background: #bf8851;
+}
+
 .detail-content {
-  padding: 20px;
+    padding: 20px;
+}
+
+.detail-table {
+    margin-top: 20px;
+}
+
+.detail-table h4 {
+    margin-bottom: 10px;
+    color: #303133;
+}
+
+.no-detail {
+    margin-top: 20px;
+}
+
+:deep(.el-tabs__nav-wrap::after) {
+    background-color: rgba(148, 163, 184, 0.22);
+}
+
+:deep(.el-drawer__body) {
+    padding: 20px;
 }
 </style>

--
Gitblit v1.9.3