wanshenmean
4 天以前 ce1292c9cf37195b6abd2699dfc5d6cb3e143c9b
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,400 +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: '正常', 1: '正常', 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}`)
    console.log('Get3DLayout response:', res)
    if (res.status && res.data) {
      const data = res.data
      console.log('data.locations:', data.locations)
      locationData = data.locations || []
      // 使用后端返回的筛选列表
      materielCodeList.value = data.materielCodeList || []
      batchNoList.value = data.batchNoList || []
      console.log('locationData set:', locationData.length, 'items')
      // 渲染货位
      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)
  }
}
// 初始化 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 renderLocations() {
  console.log('renderLocations called', { scene: !!scene, locationDataLength: locationData.length })
  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)
  }
  console.log('filteredData length:', filteredData.length)
  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)
    if (i === 0) {
      console.log('First location:', location, { x, y, z })
    if (hasCargo(location)) {
        return true
    }
  })
  locationMesh.instanceMatrix.needsUpdate = true
  if (locationMesh.instanceColor) locationMesh.instanceColor.needsUpdate = true
  scene.add(locationMesh)
  console.log('locationMesh added to scene')
    return Boolean(location.palletCode)
}
// 动画循环
function animate() {
  animationId = requestAnimationFrame(animate)
  controls.update()
  renderer.render(scene, camera)
function getShelfAccentColor(location) {
    return BASE_COLOR
}
// 点击画布
function onCanvasClick(event) {
  if (!canvasContainer.value || !locationMesh) return
function getPalletColor(location) {
    return PALLET_COLOR
}
  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 getCargoColor(location) {
    const cargoColor = new THREE.Color(getLocationColor(location))
    return cargoColor.offsetHSL(0, 0.04, 0.08).getHex()
}
  raycaster.setFromCamera(mouse, camera)
  const intersects = raycaster.intersectObject(locationMesh)
function getCargoLidColor(location) {
    const lidColor = new THREE.Color(getCargoColor(location))
    return lidColor.offsetHSL(0, -0.02, 0.06).getHex()
}
  if (intersects.length > 0) {
    const instanceId = intersects[0].instanceId
    // 获取原始数据索引(考虑过滤后的数据)
    let filteredData = locationData
function getFilteredLocations() {
    let filteredData = [...originalLocationData]
    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: '拣选完成',
        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>