wanshenmean
2026-03-30 14980b36f342dfcac9629891ca4019dc52480433
feat(stockChat): create 3D warehouse visualization component

- Implement Three.js based 3D warehouse view with InstancedMesh rendering
- Add warehouse tab switching and data filtering (stock status, materiel, batch)
- Implement Raycaster for location click detection and detail dialog
- Add camera focus animation with lerp smoothing
- Include status legend and toolbar with filter controls

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
已添加1个文件
452 ■■■■■ 文件已修改
Code/WMS/WIDESEA_WMSClient/src/views/stock/stockChat.vue 452 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Code/WMS/WIDESEA_WMSClient/src/views/stock/stockChat.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,452 @@
<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="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>
    <!-- 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 * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
const { proxy } = getCurrentInstance()
// é¢œè‰²å¸¸é‡
const COLOR_MAP = {
  DISABLED: 0x2d2d2d,    // ç¦ç”¨ - æ·±ç°
  LOCKED: 0xF56C6C,      // é”å®š - çº¢è‰²
  EMPTY: 0x4a4a4a,       // ç©ºè´§ä½ - æš—灰
  HAS_STOCK: 0x409EFF,   // æœ‰è´§ - è“è‰²
  LOW_STOCK: 0xE6A23C,   // åº“存紧张 - æ©™è‰²
  FULL: 0x67C23A,        // å·²æ»¡ - ç»¿è‰²
}
// å›¾ä¾‹é¡¹
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' },
]
// Refs
const canvasContainer = ref(null)
// çŠ¶æ€
const activeWarehouse = ref(null)
const warehouseList = ref([])
const filterStockStatus = ref(null)
const filterMaterielCode = ref(null)
const filterBatchNo = ref(null)
const materielCodeList = ref([])
const batchNoList = ref([])
const detailDialogVisible = ref(false)
const selectedLocation = ref(null)
// Three.js ç›¸å…³
let scene, camera, renderer, controls, raycaster, mouse
let locationMesh = null
let locationData = []
let animationId = null
// èŽ·å–è´§ä½é¢œè‰²
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)
      }
    }
  } catch (e) {
    console.error('加载仓库列表失败', e)
  }
}
// åŠ è½½ä»“åº“è´§ä½æ•°æ®
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()
    }
  } 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() {
  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 animate() {
  animationId = requestAnimationFrame(animate)
  controls.update()
  renderer.render(scene, camera)
}
// ç‚¹å‡»ç”»å¸ƒ
function onCanvasClick(event) {
  if (!canvasContainer.value || !locationMesh) return
  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.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)
    }
    if (filterMaterielCode.value) {
      filteredData = filteredData.filter(loc => loc.materielCode === filterMaterielCode.value)
    }
    if (filterBatchNo.value) {
      filteredData = filteredData.filter(loc => loc.batchNo === filterBatchNo.value)
    }
    if (instanceId < filteredData.length) {
      selectedLocation.value = filteredData[instanceId]
      detailDialogVisible.value = true
      // èšç„¦ç›¸æœº
      focusCamera(selectedLocation.value)
    }
  }
}
// èšç„¦ç›¸æœºåˆ°è´§ä½
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)
    }
  }
  lerpMove()
}
// é‡ç½®ç›¸æœº
function resetCamera() {
  if (!camera || !controls) return
  camera.position.set(20, 20, 20)
  controls.target.set(0, 0, 0)
}
// ä»“库切换
async function onWarehouseChange(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)
}
// ç»„件挂载
onMounted(() => {
  initThreeJS()
  loadWarehouseList()
  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()
  }
})
</script>
<style scoped>
.stock-chat-container {
  width: 100%;
  height: calc(100vh - 120px);
  position: relative;
}
.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;
}
.canvas-container {
  width: 100%;
  height: 100%;
}
.legend {
  position: absolute;
  bottom: 20px;
  right: 20px;
  background: rgba(0, 0, 0, 0.7);
  padding: 10px;
  border-radius: 4px;
  color: white;
  z-index: 10;
}
.legend-item {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 4px;
}
.color-box {
  width: 16px;
  height: 16px;
  border-radius: 2px;
}
.detail-content {
  padding: 20px;
}
</style>