wanshenmean
2026-03-30 8482760e3db0581ee34d79424e73fed69e7948d9
Code/WMS/WIDESEA_WMSClient/src/views/stock/stockChat.vue
@@ -12,18 +12,22 @@
    <!-- 工具栏 -->
    <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 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="物料筛选" clearable>
      <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="批次筛选" clearable>
      <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>
    <!-- 3D Canvas -->
@@ -31,32 +35,46 @@
    <!-- 状态图例 -->
    <div class="legend">
      <div class="legend-title">货位状态</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-dialog v-model="detailDialogVisible" title="库存详情" fullscreen>
    <!-- 详情侧边面板 -->
    <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 }}</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-item label="总库存">{{ selectedLocation.stockQuantity }}{{ 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="materielCode" label="物料编码" width="100" />
            <el-table-column prop="materielName" label="物料名称" min-width="120" show-overflow-tooltip />
            <el-table-column prop="batchNo" label="批次号" width="100" show-overflow-tooltip />
            <el-table-column prop="stockQuantity" label="数量" width="70" align="right" />
            <el-table-column prop="unit" label="单位" width="50" align="center" />
            <el-table-column prop="effectiveDate" label="有效期" width="100" />
          </el-table>
        </div>
        <div v-else class="no-detail">
          <el-empty description="暂无库存明细" />
        </div>
      </div>
    </el-dialog>
    </el-drawer>
  </div>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted, watch, getCurrentInstance } from 'vue'
import { ref, reactive, 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'
@@ -66,24 +84,44 @@
// SignalR 连接
let connection = null
// 颜色常量
// 颜色常量 - 基于实际枚举值
// 货位状态(locationStatus): 0=空闲, 1=锁定, 10=有货锁定, 20=空闲锁定, 99=大托盘锁定, 100=有货
// 库存状态(stockStatus): 1=组盘暂存, 3=入库确认, 6=入库完成, 7=出库锁定, 8=出库完成, 9=移库锁定等
const COLOR_MAP = {
  DISABLED: 0x2d2d2d,    // 禁用 - 深灰
  LOCKED: 0xF56C6C,      // 锁定 - 红色
  EMPTY: 0x4a4a4a,       // 空货位 - 暗灰
  HAS_STOCK: 0x409EFF,   // 有货 - 蓝色
  LOW_STOCK: 0xE6A23C,   // 库存紧张 - 橙色
  FULL: 0x67C23A,        // 已满 - 绿色
  // 货位状态颜色
  LOC_FREE: 0x90EE90,         // 0=空闲 - 浅绿色
  LOC_LOCK: 0xFF6B6B,        // 1=锁定 - 红色
  LOC_INSTOCK_LOCK: 0xFFA500, // 10=有货锁定 - 橙色
  LOC_FREE_LOCK: 0xFFD700,   // 20=空闲锁定 - 金色
  LOC_PALLET_LOCK: 0x9370DB,  // 99=大托盘锁定 - 紫色
  LOC_INSTOCK: 0x409EFF,     // 100=有货 - 蓝色
  // 库存状态颜色
  STOCK_PENDING: 0x00CED1,      // 1=组盘暂存 - 深青色
  STOCK_CONFIRMED: 0x87CEEB,     // 3=入库确认 - 天蓝色
  STOCK_COMPLETED: 0x32CD32,     // 6=入库完成 - 亮绿色
  STOCK_OUT_LOCK: 0xFF6347,     // 7=出库锁定 - 番茄红
  STOCK_OUT_COMPLETED: 0x228B22, // 8=出库完成 - 森林绿
  STOCK_TRANSFER_LOCK: 0xFF8C00, // 9=移库锁定 - 深橙色
  STOCK_COMPLETED_NO_ORDER: 0x20B2AA, // 10=入库完成未建出库单
  STOCK_RETURN: 0xFF4500,       // 11=退库
  STOCK_MANUAL_PENDING: 0x48D1CC, // 12=手动组盘暂存
  STOCK_MANUAL_CONFIRMED: 0x7FFFD4, // 13=手动组盘入库确认
  STOCK_PICK_COMPLETED: 0x6B8E23, // 14=拣选完成
  STOCK_MES_RETURN: 0xDC143C,   // 21=MES退库
  STOCK_EMPTY_TRAY: 0xDDA0DD,   // 22=空托盘库存 - 梅红色
  STOCK_GROUP_CANCEL: 0xDEB887, // 99=组盘撤销 - 暗金色
  STOCK_IN_CANCEL: 0xA0522D,    // 199=入库撤销 - 赭色
}
// 图例项
// 图例项 - 货位状态
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' },
]
// Refs
@@ -99,12 +137,15 @@
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 originalLocationData = [] // 保存原始完整数据,用于筛选恢复
let animationId = null
let locationIdToInstanceId = new Map() // locationId -> instanceId 映射
// SignalR 初始化
function initSignalR() {
@@ -117,51 +158,100 @@
    connection.start().catch((err) => console.log('SignalR连接失败:', err));
    connection.on('StockUpdated', (update) => {
      console.log('收到库存更新:', 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);
        locationData[idx].palletCode = update.palletCode;
        locationData[idx].locationStatus = update.locationStatus;
        // 更新库存明细
        if (update.details && update.details.length > 0) {
          locationData[idx].details = update.details;
        }
        // 通过映射找到实例ID,更新颜色
        const instanceId = locationIdToInstanceId.get(update.locationId);
        if (instanceId !== undefined) {
          updateInstanceColor(instanceId, update.locationStatus);
        }
      }
    });
  });
}
// 更新单个货位颜色
function updateInstanceColor(instanceId, stockStatus) {
function updateInstanceColor(instanceId, locationStatus) {
  if (!locationMesh) return;
  const loc = locationData[instanceId];
  if (!loc) return;
  const color = getLocationColor(loc);
  // 根据货位状态获取颜色
  const color = getLocationColorByStatus(locationStatus);
  locationMesh.setColorAt(instanceId, new THREE.Color(color));
  locationMesh.instanceColor.needsUpdate = true;
}
// 获取货位颜色
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        // 已满
// 根据货位状态获取颜色
function getLocationColorByStatus(locStatus) {
  switch (locStatus) {
    case 0: return COLOR_MAP.LOC_FREE           // 空闲
    case 1: return COLOR_MAP.LOC_LOCK            // 锁定
    case 10: return COLOR_MAP.LOC_INSTOCK_LOCK   // 有货锁定
    case 20: return COLOR_MAP.LOC_FREE_LOCK      // 空闲锁定
    case 99: return COLOR_MAP.LOC_PALLET_LOCK    // 大托盘锁定
    case 100: return COLOR_MAP.LOC_INSTOCK      // 有货
    default: return COLOR_MAP.LOC_FREE // 默认空闲色
  }
  return COLOR_MAP.EMPTY // 默认空
}
// 获取货位颜色 - 只根据货位状态
function getLocationColor(location) {
  const locStatus = location.locationStatus
  // 根据货位状态判断颜色
  switch (locStatus) {
    case 0: return COLOR_MAP.LOC_FREE           // 空闲
    case 1: return COLOR_MAP.LOC_LOCK            // 锁定
    case 10: return COLOR_MAP.LOC_INSTOCK_LOCK   // 有货锁定
    case 20: return COLOR_MAP.LOC_FREE_LOCK      // 空闲锁定
    case 99: return COLOR_MAP.LOC_PALLET_LOCK    // 大托盘锁定
    case 100: return COLOR_MAP.LOC_INSTOCK      // 有货
    default: return COLOR_MAP.LOC_FREE // 默认空闲色
  }
}
// 获取货位状态文本
function getLocationStatusText(status) {
  const map = { 0: '正常', 1: '正常', 2: '锁定', 3: '禁用' }
  return map[status] || '未知'
  const map = {
    0: '空闲',
    1: '锁定',
    10: '有货锁定',
    20: '空闲锁定',
    99: '大托盘锁定',
    100: '有货'
  }
  return map[status] || '未知(' + status + ')'
}
// 获取库存状态文本
function getStockStatusText(status) {
  const map = { 0: '无货', 1: '有货', 2: '库存紧张', 3: '已满' }
  return map[status] || '未知'
  const map = {
    0: '无库存',
    1: '组盘暂存',
    3: '入库确认',
    6: '入库完成',
    7: '出库锁定',
    8: '出库完成',
    9: '移库锁定',
    10: '入库完成未建出库单',
    11: '退库',
    12: '手动组盘暂存',
    13: '手动组盘入库确认',
    14: '拣选完成',
    21: 'MES退库',
    22: '空托盘库存',
    99: '组盘撤销',
    199: '入库撤销'
  }
  return map[status] || '未知(' + status + ')'
}
// 加载仓库列表
@@ -184,15 +274,13 @@
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 || []
      originalLocationData = data.locations || [] // 保存原始完整数据
      locationData = [...originalLocationData] // 当前显示数据
      // 使用后端返回的筛选列表
      materielCodeList.value = data.materielCodeList || []
      batchNoList.value = data.batchNoList || []
      console.log('locationData set:', locationData.length, 'items')
      // 渲染货位
      renderLocations()
    }
@@ -215,6 +303,7 @@
  // 创建相机
  camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000)
  camera.position.set(20, 20, 20)
  camera.lookAt(0, 0, 0)
  // 创建渲染器
  renderer = new THREE.WebGLRenderer({ antialias: true })
@@ -228,13 +317,18 @@
  controls.dampingFactor = 0.05
  // 创建环境光
  const ambientLight = new THREE.AmbientLight(0xffffff, 0.6)
  const ambientLight = new THREE.AmbientLight(0xffffff, 0.5)
  scene.add(ambientLight)
  // 创建定向光
  const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8)
  directionalLight.position.set(10, 20, 10)
  // 创建主定向光
  const directionalLight = new THREE.DirectionalLight(0xffffff, 1.0)
  directionalLight.position.set(50, 100, 50)
  scene.add(directionalLight)
  // 创建补光
  const fillLight = new THREE.DirectionalLight(0xffffff, 0.3)
  fillLight.position.set(-50, 50, -50)
  scene.add(fillLight)
  // 创建地面
  const groundGeometry = new THREE.PlaneGeometry(100, 100)
@@ -253,7 +347,7 @@
  mouse = new THREE.Vector2()
  // 添加点击事件
  canvasContainer.value.addEventListener('click', onCanvasClick)
  canvasContainer.value.addEventListener('mousedown', onCanvasClick)
  // 开始渲染循环
  animate()
@@ -261,19 +355,32 @@
// 渲染货位
function renderLocations() {
  console.log('renderLocations called', { scene: !!scene, locationDataLength: locationData.length })
  if (!scene) return
  console.log('渲染货位,原始数据总数:', originalLocationData.length)
  // 如果原始数据为空,尝试重新加载
  if (originalLocationData.length === 0) {
    console.warn('原始数据为空,重新加载...')
    if (activeWarehouse.value) {
      loadWarehouseData(activeWarehouse.value)
    }
    return
  }
  // 移除旧的货位网格
  console.log("🚀 ~ renderLocations ~ locationMesh:", locationMesh)
  if (locationMesh) {
    scene.remove(locationMesh)
    locationMesh.geometry.dispose()
    locationMesh.material.forEach(m => m.dispose())
    if (Array.isArray(locationMesh.material)) {
      locationMesh.material.forEach(m => m.dispose())
    } else {
      locationMesh.material.dispose()
    }
    locationMesh = null
  }
  // 过滤数据
  let filteredData = [...locationData]
  // 过滤数据 - 始终从原始完整数据过滤
  let filteredData = [...originalLocationData]
  if (filterStockStatus.value !== null) {
    filteredData = filteredData.filter(loc => loc.stockStatus === filterStockStatus.value)
  }
@@ -284,14 +391,27 @@
    filteredData = filteredData.filter(loc => loc.batchNo === filterBatchNo.value)
  }
  console.log('filteredData length:', filteredData.length)
  if (filteredData.length === 0) return
  console.log('过滤后数据数量:', filteredData.length, '筛选条件:', filterStockStatus.value, filterMaterielCode.value, filterBatchNo.value)
  // 创建 InstancedMesh
  const geometry = new THREE.BoxGeometry(1.5, 1, 1.5)
  const material = new THREE.MeshStandardMaterial({ color: 0xffffff })
  const material = new THREE.MeshStandardMaterial({
    color: 0xffffff,
    roughness: 0.5,
    metalness: 0.1
  })
  locationMesh = new THREE.InstancedMesh(geometry, [material], filteredData.length)
  // 如果过滤后无数据,创建空的 InstancedMesh
  if (filteredData.length === 0) {
    locationMesh = new THREE.InstancedMesh(geometry, material, 0)
    scene.add(locationMesh)
    return
  }
  locationMesh = new THREE.InstancedMesh(geometry, material, filteredData.length)
  // 清空并重建映射
  locationIdToInstanceId.clear()
  const dummy = new THREE.Object3D()
  const color = new THREE.Color()
@@ -309,6 +429,9 @@
    color.setHex(getLocationColor(location))
    locationMesh.setColorAt(i, color)
    // 建立映射: locationId -> instanceId (i)
    locationIdToInstanceId.set(location.locationId, i)
    if (i === 0) {
      console.log('First location:', location, { x, y, z })
    }
@@ -318,7 +441,6 @@
  if (locationMesh.instanceColor) locationMesh.instanceColor.needsUpdate = true
  scene.add(locationMesh)
  console.log('locationMesh added to scene')
}
// 动画循环
@@ -342,7 +464,7 @@
  if (intersects.length > 0) {
    const instanceId = intersects[0].instanceId
    // 获取原始数据索引(考虑过滤后的数据)
    let filteredData = locationData
    let filteredData = [...originalLocationData]
    if (filterStockStatus.value !== null) {
      filteredData = filteredData.filter(loc => loc.stockStatus === filterStockStatus.value)
    }
@@ -404,15 +526,47 @@
  controls.target.set(0, 0, 0)
}
// 刷新数据
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)
}
// 监听筛选变化
watch(filterStockStatus, () => renderLocations())
watch(filterMaterielCode, () => renderLocations())
watch(filterBatchNo, () => renderLocations())
watch(filterStockStatus, async () => {
  await nextTick()
  if (originalLocationData.length > 0) {
    renderLocations()
  }
})
watch(filterMaterielCode, async () => {
  await nextTick()
  if (originalLocationData.length > 0) {
    renderLocations()
  }
})
watch(filterBatchNo, async () => {
  await nextTick()
  if (originalLocationData.length > 0) {
    renderLocations()
  }
})
// 窗口大小变化
function onWindowResize() {
@@ -438,7 +592,7 @@
    cancelAnimationFrame(animationId)
  }
  if (canvasContainer.value) {
    canvasContainer.value.removeEventListener('click', onCanvasClick)
    canvasContainer.value.removeEventListener('mousedown', onCanvasClick)
  }
  window.removeEventListener('resize', onWindowResize)
  if (renderer) {
@@ -455,6 +609,7 @@
  width: 100%;
  height: calc(100vh - 120px);
  position: relative;
  overflow: visible;
}
.toolbar {
  position: absolute;
@@ -466,10 +621,12 @@
  background: rgba(255, 255, 255, 0.9);
  padding: 10px;
  border-radius: 4px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.canvas-container {
  width: 100%;
  height: 100%;
  overflow: hidden;
}
.legend {
  position: absolute;
@@ -480,19 +637,48 @@
  border-radius: 4px;
  color: white;
  z-index: 10;
  max-height: 400px;
  overflow-y: auto;
}
.legend-title {
  font-weight: bold;
  font-size: 13px;
  margin-bottom: 6px;
  color: #fff;
}
.legend-divider {
  height: 1px;
  background: rgba(255, 255, 255, 0.3);
  margin: 8px 0;
}
.legend-item {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 4px;
  font-size: 12px;
}
.color-box {
  width: 16px;
  height: 16px;
  border-radius: 2px;
  flex-shrink: 0;
}
.detail-content {
  padding: 20px;
}
.detail-table {
  margin-top: 20px;
}
.detail-table h4 {
  margin-bottom: 10px;
  color: #303133;
}
.no-detail {
  margin-top: 20px;
}
:deep(.el-drawer__body) {
  padding: 20px;
}
</style>