wanshenmean
2026-03-30 8482760e3db0581ee34d79424e73fed69e7948d9
feat(stockChat): 库存3D查看器完整实现

功能特性:
- 3D仓库货位可视化 (Three.js + WebGL)
- 多仓库Tab切换
- 货位状态颜色渲染 (空闲/锁定/有货锁定/空闲锁定/大托盘锁定/有货)
- 货位点击显示库存详情侧边栏
- 库存状态和明细数据展示
- 筛选功能 (库存状态/物料/批次)
- 刷新数据按钮
- SignalR实时推送更新

后端改动:
- StockMonitorBackgroundService: 新增后台服务,实时监控货位和库存数据变化
- StockHub: 扩展StockUpdateDTO,新增LocationStatus和Details字段
- StockInfoService: 修复空托盘处理逻辑,直接使用后端StockStatusEmun枚举
- Program.cs: 注册StockMonitorBackgroundService后台服务
- Stock3DLayoutDTO: 新增StockDetailItemDTO用于库存明细传输

前端改动:
- stockChat.vue: 完整3D视图组件,支持实时更新
- 使用InstancedMesh批量渲染货位
- locationIdToInstanceId映射支持快速更新单个货位颜色
- SignalR监听StockUpdated事件自动刷新货位颜色

技术细节:
- 货位颜色仅根据locationStatus枚举 (0/1/10/20/99/100)
- 后台服务同时监听货位表和库存表变化
- 变化检测: 货位状态/库存状态/托盘编号/库存数量/库存明细

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
已添加2个文件
已修改5个文件
947 ■■■■■ 文件已修改
Code/WMS/WIDESEA_WMSClient/src/views/stock/stockChat.vue 328 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Code/WMS/WIDESEA_WMSServer/WIDESEA_DTO/Stock/Stock3DLayoutDTO.cs 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Code/WMS/WIDESEA_WMSServer/WIDESEA_StockService/StockInfoService.cs 58 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Code/WMS/WIDESEA_WMSServer/WIDESEA_WMSServer/BackgroundServices/StockMonitorBackgroundService.cs 199 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Code/WMS/WIDESEA_WMSServer/WIDESEA_WMSServer/Hubs/StockHub.cs 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Code/WMS/WIDESEA_WMSServer/WIDESEA_WMSServer/Program.cs 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Code/WMS/docs/superpowers/plans/2026-03-30-stock-chat-implementation-plan.md 314 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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>
Code/WMS/WIDESEA_WMSServer/WIDESEA_DTO/Stock/Stock3DLayoutDTO.cs
@@ -77,12 +77,12 @@
        public int Layer { get; set; }
        /// <summary>
        /// è´§ä½çŠ¶æ€ (0=空, 1=占用, 2=锁定, 3=禁用)
        /// è´§ä½çŠ¶æ€
        /// </summary>
        public int LocationStatus { get; set; }
        /// <summary>
        /// åº“存状态 (0=无货, 1=有货, 2=库存紧张, 3=已满)
        /// åº“存状态
        /// </summary>
        public int StockStatus { get; set; }
@@ -115,5 +115,27 @@
        /// æ‰¹æ¬¡å·
        /// </summary>
        public string? BatchNo { get; set; }
        /// <summary>
        /// åº“存明细列表
        /// </summary>
        public List<StockDetailItemDTO> Details { get; set; } = new();
    }
    /// <summary>
    /// åº“存明细项DTO
    /// </summary>
    public class StockDetailItemDTO
    {
        public int Id { get; set; }
        public string? MaterielCode { get; set; }
        public string? MaterielName { get; set; }
        public string? BatchNo { get; set; }
        public float StockQuantity { get; set; }
        public string? Unit { get; set; }
        public string? ProductionDate { get; set; }
        public string? EffectiveDate { get; set; }
        public string? OrderNo { get; set; }
        public int Status { get; set; }
    }
}
Code/WMS/WIDESEA_WMSServer/WIDESEA_StockService/StockInfoService.cs
@@ -107,7 +107,7 @@
            var locations = await _locationInfoService.Repository.QueryDataAsync(x => x.WarehouseId == warehouseId);
            // 3. æŸ¥è¯¢è¯¥ä»“库所有库存信息(包含Details导航属性)
            var stockInfos = await Repository.QueryDataNavAsync(x => x.WarehouseId == warehouseId);
            var stockInfos = await Repository.QueryDataNavAsync(x => x.WarehouseId == warehouseId && x.LocationId != 0);
            // 4. æå–物料编号和批次号列表(去重)
            var materielCodeList = stockInfos
@@ -145,34 +145,52 @@
                };
                // å°è¯•从库存字典中获取库存信息
                if (stockDict.TryGetValue(loc.Id, out var stockInfo) && stockInfo.Details != null)
                if (stockDict.TryGetValue(loc.Id, out var stockInfo))
                {
                    // ç©ºæ‰˜ç›˜ä¹Ÿæœ‰åº“存记录,只是不包含明细
                    item.PalletCode = stockInfo.PalletCode;
                    item.StockQuantity = stockInfo.Details.Sum(d => d.StockQuantity);
                    item.StockStatus = stockInfo.StockStatus; // ç›´æŽ¥ä½¿ç”¨åŽç«¯åº“存状态
                    // èŽ·å–ç¬¬ä¸€ä¸ªæ˜Žç»†çš„ç‰©æ–™ä¿¡æ¯ï¼ˆå¦‚æžœå­˜åœ¨ï¼‰
                    var firstDetail = stockInfo.Details.FirstOrDefault();
                    if (firstDetail != null)
                    // åªæœ‰å½“Details不为null且有数据时才处理库存明细
                    if (stockInfo.Details != null && stockInfo.Details.Any())
                    {
                        item.MaterielCode = firstDetail.MaterielCode;
                        item.MaterielName = firstDetail.MaterielName;
                        item.BatchNo = firstDetail.BatchNo;
                    }
                        item.StockQuantity = stockInfo.Details.Sum(d => d.StockQuantity);
                    // è®¡ç®—库存状态
                    var ratio = item.MaxCapacity > 0 ? item.StockQuantity / item.MaxCapacity : 0;
                    if (ratio >= 0.9f)
                        item.StockStatus = 3; // å·²æ»¡ (FULL)
                    else if (ratio >= 0.1f)
                        item.StockStatus = 1; // æœ‰è´§ (HAS_STOCK)
                    else if (ratio > 0)
                        item.StockStatus = 2; // åº“存紧张 (LOW_STOCK)
                        // èŽ·å–ç¬¬ä¸€ä¸ªæ˜Žç»†çš„ç‰©æ–™ä¿¡æ¯ï¼ˆå¦‚æžœå­˜åœ¨ï¼‰
                        var firstDetail = stockInfo.Details.FirstOrDefault();
                        if (firstDetail != null)
                        {
                            item.MaterielCode = firstDetail.MaterielCode;
                            item.MaterielName = firstDetail.MaterielName;
                            item.BatchNo = firstDetail.BatchNo;
                        }
                        // å¡«å……库存明细列表
                        item.Details = stockInfo.Details.Select(d => new StockDetailItemDTO
                        {
                            Id = d.Id,
                            MaterielCode = d.MaterielCode,
                            MaterielName = d.MaterielName,
                            BatchNo = d.BatchNo,
                            StockQuantity = d.StockQuantity,
                            Unit = d.Unit,
                            ProductionDate = d.ProductionDate,
                            EffectiveDate = d.EffectiveDate,
                            OrderNo = d.OrderNo,
                            Status = d.Status
                        }).ToList();
                    }
                    else
                        item.StockStatus = 0; // æ— è´§ (EMPTY)
                    {
                        // ç©ºæ‰˜ç›˜ï¼ˆæ— æ˜Žç»†ï¼‰
                        item.StockQuantity = 0;
                        item.Details = new List<StockDetailItemDTO>(); // ç¡®ä¿æ˜¯ç©ºåˆ—表而非null
                    }
                }
                else
                {
                    item.StockStatus = 0; // æ— è´§ (EMPTY)
                    // æ— åº“存记录,货位为空
                    item.StockStatus = 0; // ç©ºé—²
                    item.StockQuantity = 0;
                }
Code/WMS/WIDESEA_WMSServer/WIDESEA_WMSServer/BackgroundServices/StockMonitorBackgroundService.cs
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,199 @@
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Concurrent;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using WIDESEA_Core.BaseRepository;
using WIDESEA_IStockService;
using WIDESEA_Model.Models;
using WIDESEA_WMSServer.Hubs;
namespace WIDESEA_WMSServer.BackgroundServices
{
    /// <summary>
    /// åº“存监控后台服务
    /// å®šæœŸæ£€æŸ¥åº“存和货位数据变化并通过SignalR推送到前端
    /// </summary>
    public class StockMonitorBackgroundService : BackgroundService
    {
        private readonly ILogger<StockMonitorBackgroundService> _logger;
        private readonly IHubContext<StockHub> _hubContext;
        private readonly IServiceProvider _serviceProvider;
        // è´§ä½çŠ¶æ€å¿«ç…§ï¼škey = LocationId
        private ConcurrentDictionary<int, LocationSnapshot> _lastLocationSnapshots = new();
        // ç›‘控间隔(毫秒)
        private const int MonitorIntervalMs = 3000;
        public StockMonitorBackgroundService(
            ILogger<StockMonitorBackgroundService> logger,
            IHubContext<StockHub> hubContext,
            IServiceProvider serviceProvider)
        {
            _logger = logger;
            _hubContext = hubContext;
            _serviceProvider = serviceProvider;
        }
        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            _logger.LogInformation("库存监控后台服务已启动");
            // ç­‰å¾…应用完全启动
            await Task.Delay(5000, stoppingToken);
            while (!stoppingToken.IsCancellationRequested)
            {
                try
                {
                    await CheckChangesAsync();
                }
                catch (Exception ex)
                {
                    _logger.LogError(ex, "检查数据变化时发生错误");
                }
                await Task.Delay(MonitorIntervalMs, stoppingToken);
            }
            _logger.LogInformation("库存监控后台服务已停止");
        }
        /// <summary>
        /// æ£€æŸ¥è´§ä½å’Œåº“存变化
        /// </summary>
        private async Task CheckChangesAsync()
        {
            using var scope = _serviceProvider.CreateScope();
            var stockService = scope.ServiceProvider.GetRequiredService<IStockInfoService>();
            var locationRepo = scope.ServiceProvider.GetRequiredService<IRepository<Dt_LocationInfo>>();
            // 1. èŽ·å–æ‰€æœ‰è´§ä½æ•°æ®
            var allLocations = await locationRepo.QueryDataAsync(x => x.LocationStatus != 99); // æŽ’除禁用的货位
            // 2. èŽ·å–æ‰€æœ‰åº“å­˜æ•°æ®ï¼ˆåŒ…å«æ˜Žç»†ï¼‰
            var allStockData = await stockService.Repository.Db.Queryable<Dt_StockInfo>()
                .Includes(x => x.Details)
                .ToListAsync();
            // æž„建库存字典:LocationId -> StockInfo
            var stockDict = allStockData
                .Where(s => s.LocationId > 0)
                .ToDictionary(s => s.LocationId, s => s);
            // æž„建当前货位快照字典
            var currentSnapshots = new ConcurrentDictionary<int, LocationSnapshot>();
            foreach (var location in allLocations)
            {
                // èŽ·å–è¯¥è´§ä½çš„åº“å­˜ä¿¡æ¯
                stockDict.TryGetValue(location.Id, out var stock);
                // è®¡ç®—库存数量
                float totalQuantity = 0;
                string detailsHash = string.Empty;
                if (stock?.Details != null && stock.Details.Any())
                {
                    totalQuantity = stock.Details.Sum(d => d.StockQuantity);
                    detailsHash = GenerateDetailsHash(stock.Details.ToList());
                }
                var snapshot = new LocationSnapshot
                {
                    LocationId = location.Id,
                    WarehouseId = location.WarehouseId,
                    LocationCode = location.LocationCode,
                    LocationStatus = location.LocationStatus,
                    PalletCode = stock?.PalletCode,
                    StockStatus = stock?.StockStatus ?? 0,
                    StockQuantity = totalQuantity,
                    DetailsHash = detailsHash
                };
                currentSnapshots.TryAdd(location.Id, snapshot);
                // æ£€æŸ¥æ˜¯å¦æœ‰å˜åŒ–
                if (_lastLocationSnapshots.TryGetValue(location.Id, out var lastSnapshot))
                {
                    // æ£€æµ‹å˜åŒ–:货位状态、库存状态、数量、明细变化
                    if (lastSnapshot.LocationStatus != snapshot.LocationStatus ||
                        lastSnapshot.StockStatus != snapshot.StockStatus ||
                        lastSnapshot.PalletCode != snapshot.PalletCode ||
                        Math.Abs(lastSnapshot.StockQuantity - snapshot.StockQuantity) > 0.001f ||
                        lastSnapshot.DetailsHash != snapshot.DetailsHash)
                    {
                        // æž„建更新DTO
                        var update = new StockUpdateDTO
                        {
                            LocationId = snapshot.LocationId,
                            WarehouseId = snapshot.WarehouseId,
                            PalletCode = snapshot.PalletCode,
                            StockQuantity = snapshot.StockQuantity,
                            StockStatus = snapshot.StockStatus,
                            LocationStatus = snapshot.LocationStatus,
                            Details = BuildDetailDtos(stock?.Details?.ToList())
                        };
                        await _hubContext.Clients.All.SendAsync("StockUpdated", update);
                        _logger.LogDebug("数据变化推送: LocationId={LocationId}, LocStatus={LocStatus}, StockStatus={StockStatus}, Quantity={Quantity}",
                            snapshot.LocationId, snapshot.LocationStatus, snapshot.StockStatus, snapshot.StockQuantity);
                    }
                }
            }
            // æ›´æ–°å¿«ç…§æ•°æ®
            _lastLocationSnapshots = currentSnapshots;
        }
        /// <summary>
        /// ç”Ÿæˆæ˜Žç»†æ•°æ®å“ˆå¸Œ
        /// </summary>
        private string GenerateDetailsHash(List<Dt_StockInfoDetail> details)
        {
            if (details == null || !details.Any()) return string.Empty;
            var hashString = string.Join("|", details
                .OrderBy(d => d.Id)
                .Select(d => $"{d.Id}:{d.MaterielCode}:{d.BatchNo}:{d.StockQuantity}"));
            return hashString.GetHashCode().ToString();
        }
        /// <summary>
        /// æž„建明细DTO列表
        /// </summary>
        private List<StockDetailUpdateDTO> BuildDetailDtos(List<Dt_StockInfoDetail> details)
        {
            if (details == null || !details.Any()) return new List<StockDetailUpdateDTO>();
            return details.Select(d => new StockDetailUpdateDTO
            {
                Id = d.Id,
                MaterielCode = d.MaterielCode,
                MaterielName = d.MaterielName,
                BatchNo = d.BatchNo,
                StockQuantity = d.StockQuantity,
                Unit = d.Unit,
                Status = d.Status
            }).ToList();
        }
        /// <summary>
        /// è´§ä½å¿«ç…§
        /// </summary>
        private class LocationSnapshot
        {
            public int LocationId { get; set; }
            public int WarehouseId { get; set; }
            public string LocationCode { get; set; }
            public int LocationStatus { get; set; }
            public string PalletCode { get; set; }
            public int StockStatus { get; set; }
            public float StockQuantity { get; set; }
            public string DetailsHash { get; set; }
        }
    }
}
Code/WMS/WIDESEA_WMSServer/WIDESEA_WMSServer/Hubs/StockHub.cs
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.SignalR;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace WIDESEA_WMSServer.Hubs
@@ -14,11 +15,31 @@
        }
    }
    /// <summary>
    /// åº“存更新DTO(SignalR推送用)
    /// </summary>
    public class StockUpdateDTO
    {
        public int LocationId { get; set; }
        public int WarehouseId { get; set; }
        public string PalletCode { get; set; }
        public float StockQuantity { get; set; }
        public int StockStatus { get; set; }
        public int LocationStatus { get; set; }
        public List<StockDetailUpdateDTO> Details { get; set; } = new();
    }
    /// <summary>
    /// åº“存明细更新DTO
    /// </summary>
    public class StockDetailUpdateDTO
    {
        public int Id { get; set; }
        public string MaterielCode { get; set; }
        public string MaterielName { get; set; }
        public string BatchNo { get; set; }
        public float StockQuantity { get; set; }
        public string Unit { get; set; }
        public int Status { get; set; }
    }
}
Code/WMS/WIDESEA_WMSServer/WIDESEA_WMSServer/Program.cs
@@ -81,6 +81,7 @@
builder.Services.AddDbSetup(); // Db æ•°æ®åº“配置
builder.Services.AddInitializationHostServiceSetup(); // åº”用程序初始化服务注册
builder.Services.AddHostedService<AutoOutboundTaskBackgroundService>();  // å¯åŠ¨è‡ªåŠ¨å‡ºåº“ä»»åŠ¡åŽå°æœåŠ¡
builder.Services.AddHostedService<StockMonitorBackgroundService>();  // å¯åŠ¨åº“å­˜ç›‘æŽ§åŽå°æœåŠ¡
// builder.Services.AddHostedService<PermissionDataHostService>(); // æƒé™æ•°æ®æœåŠ¡
builder.Services.AddAutoMapperSetup();
Code/WMS/docs/superpowers/plans/2026-03-30-stock-chat-implementation-plan.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,314 @@
# åº“å­˜3D查看器 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
**Goal:** å®žçŽ°åº“å­˜3D查看器,用户可在 Three.js 3D åœºæ™¯ä¸­å·¡è§†ä»“库、点击货位查看库存详情
**Architecture:** å‰ç«¯ Vue 3 + Element Plus + Three.js,后端 ASP.NET Core 6 Web API + SignalR å®žæ—¶æŽ¨é€
**Tech Stack:** Three.js, @microsoft/signalr, Element Plus, Vue 3 Composition API
---
## æ–‡ä»¶ç»“æž„
```
后端 (WIDESEA_WMSServer)
├── WIDESEA_DTO/Stock/Stock3DLayoutDTO.cs      # 3D布局响应DTO [新建]
├── WIDESEA_IStockService/IStockInfoService.cs  # æ·»åŠ Get3DLayoutAsync方法签名 [修改]
├── WIDESEA_StockService/StockInfoService.cs    # å®žçްGet3DLayoutAsync [修改]
├── WIDESEA_WMSServer/Controllers/Stock/StockInfoController.cs  # æ·»åŠ Get3DLayout端点 [修改]
└── WIDESEA_WMSServer/Hubs/StockHub.cs         # SignalR Hub [新建]
前端 (WIDESEA_WMSClient)
├── package.json                                # æ·»åŠ three依赖 [修改]
├── src/router/viewGird.js                      # æ³¨å†Œè·¯ç”± [修改]
├── src/views/stock/stockChat.vue               # ä¸»é¡µé¢ç»„ä»¶ [新建]
└── src/extension/stock/stockChat.js            # æ‰©å±•配置 [新建]
```
---
## å®žçŽ°ä»»åŠ¡
### Task 1: åŽç«¯ - åˆ›å»º Stock3DLayoutDTO
**Files:**
- Create: `WIDESEA_WMSServer/WIDESEA_DTO/Stock/Stock3DLayoutDTO.cs`
**详细规范:**
创建两个 DTO ç±»ï¼š
1. `Stock3DLayoutDTO` - åŒ…含仓库基本信息、尺寸、筛选列表、货位数组
2. `Location3DItemDTO` - åŒ…含单个货位的所有3D渲染所需数据
**验收标准:**
- DTO åŒ…含所有 spec ä¸­å®šä¹‰çš„字段
- å‘½åç©ºé—´æ­£ç¡®
- å¯ä»¥è¢« Service å±‚正确引用
```csharp
namespace WIDESEA_DTO.Stock
{
    /// <summary>
    /// ä»“库3D布局响应DTO
    /// </summary>
    public class Stock3DLayoutDTO
    {
        public int WarehouseId { get; set; }
        public string WarehouseName { get; set; }
        public int MaxRow { get; set; }
        public int MaxColumn { get; set; }
        public int MaxLayer { get; set; }
        public List<string> MaterielCodeList { get; set; } = new();
        public List<string> BatchNoList { get; set; } = new();
        public List<Location3DItemDTO> Locations { get; set; } = new();
    }
    /// <summary>
    /// è´§ä½3D数据项
    /// </summary>
    public class Location3DItemDTO
    {
        public int LocationId { get; set; }
        public string LocationCode { get; set; }
        public int Row { get; set; }
        public int Column { get; set; }
        public int Layer { get; set; }
        public int LocationStatus { get; set; } // 0=空, 1=占用, 2=锁定, 3=禁用
        public int StockStatus { get; set; } // 0=无货, 1=有货, 2=库存紧张, 3=已满
        public float StockQuantity { get; set; }
        public float MaxCapacity { get; set; }
        public string? PalletCode { get; set; }
        public string? MaterielCode { get; set; }
        public string? MaterielName { get; set; }
        public string? BatchNo { get; set; }
    }
}
```
---
### Task 2: åŽç«¯ - æ›´æ–° IStockInfoService æŽ¥å£
**Files:**
- Modify: `WIDESEA_WMSServer/WIDESEA_IStockService/IStockInfoService.cs`
**详细规范:**
在接口中添加方法签名:
```csharp
/// <summary>
/// èŽ·å–ä»“åº“3D布局数据
/// </summary>
/// <param name="warehouseId">仓库ID</param>
/// <returns>3D布局DTO</returns>
Task<Stock3DLayoutDTO> Get3DLayoutAsync(int warehouseId);
```
**验收标准:**
- æ–¹æ³•签名正确
- æ·»åŠ äº†æ–‡æ¡£æ³¨é‡Š
- å¼•用了 Stock3DLayoutDTO
---
### Task 3: åŽç«¯ - å®žçް Get3DLayoutAsync
**Files:**
- Modify: `WIDESEA_WMSServer/WIDESEA_StockService/StockInfoService.cs`
**详细规范:**
实现 Get3DLayoutAsync æ–¹æ³•:
1. æŸ¥è¯¢ä»“库信息
2. æŸ¥è¯¢è¯¥ä»“库所有货位
3. æŸ¥è¯¢åº“存信息(包含明细)
4. æå–物料编号和批次号列表
5. æ˜ å°„到 Location3DItemDTO
6. è®¡ç®—仓库尺寸
**验收标准:**
- æ–¹æ³•能正确返回 Stock3DLayoutDTO
- æ‰€æœ‰ locationStatus å’Œ stockStatus å€¼æ­£ç¡®æ˜ å°„
- æ€§èƒ½é€‚合中型仓库(1000-5000货位)
---
### Task 4: åŽç«¯ - æ·»åŠ  API ç«¯ç‚¹
**Files:**
- Modify: `WIDESEA_WMSServer/WIDESEA_WMSServer/Controllers/Stock/StockInfoController.cs`
**详细规范:**
添加端点:
```csharp
/// <summary>
/// èŽ·å–ä»“åº“3D布局
/// </summary>
/// <param name="warehouseId">仓库ID</param>
/// <returns>3D布局数据</returns>
[HttpGet("Get3DLayout")]
public async Task<WebResponseContent> Get3DLayout(int warehouseId)
{
    var result = await Service.Get3DLayoutAsync(warehouseId);
    return WebResponseContent.Instance.OK(result);
}
```
**验收标准:**
- è·¯ç”±æ­£ç¡®ï¼šGET /api/StockInfo/Get3DLayout?warehouseId={id}
- è¿”回格式符合 WebResponseContent è§„范
---
### Task 5: åŽç«¯ - åˆ›å»º SignalR Hub
**Files:**
- Create: `WIDESEA_WMSServer/WIDESEA_WMSServer/Hubs/StockHub.cs`
- Modify: `WIDESEA_WMSServer/WIDESEA_WMSServer/Program.cs`
**详细规范:**
1. åˆ›å»º StockHub ç±»ï¼Œç»§æ‰¿ Microsoft.AspNetCore.SignalR.Hub
2. æ·»åŠ  SendStockUpdate æ–¹æ³•供外部调用
3. åœ¨ Program.cs ä¸­æ³¨å†Œ SignalR æœåŠ¡å¹¶æ˜ å°„ Hub
**验收标准:**
- Hub å¯è¢«å‰ç«¯è¿žæŽ¥
- SendStockUpdate æ–¹æ³•存在且可被调用
---
### Task 6: å‰ç«¯ - å®‰è£… Three.js ä¾èµ–
**Files:**
- Modify: `WIDESEA_WMSClient/package.json`
**详细规范:**
添加 three ä¾èµ–到 package.json:
```json
"three": "^0.160.0"
```
**验收标准:**
- package.json åŒ…含 three ä¾èµ–
- ç‰ˆæœ¬å·åˆç†ï¼ˆ^0.160.0 æˆ–更新稳定版)
---
### Task 7: å‰ç«¯ - æ³¨å†Œè·¯ç”±
**Files:**
- Modify: `WIDESEA_WMSClient/src/router/viewGird.js`
**详细规范:**
在 stockView è·¯ç”±åŽæ·»åŠ ï¼š
```javascript
{
  path: '/stockChat',
  name: 'stockChat',
  component: () => import('@/views/stock/stockChat.vue')
}
```
**验收标准:**
- è·¯ç”±æ³¨å†Œæ­£ç¡®
- ä¸Žå…¶ä»–路由格式一致
---
### Task 8: å‰ç«¯ - åˆ›å»º stockChat.vue ä¸»ç»„ä»¶
**Files:**
- Create: `WIDESEA_WMSClient/src/views/stock/stockChat.vue`
**详细规范:**
组件必须包含:
1. ä»“库 Tabs(el-tabs)
2. å·¥å…·æ ï¼ˆç­›é€‰ + é‡ç½®è§†è§’按钮)
3. 3D Canvas å®¹å™¨
4. çŠ¶æ€å›¾ä¾‹
5. è¯¦æƒ…弹窗(el-dialog fullscreen)
Three.js åœºæ™¯ï¼š
1. åœºæ™¯åˆå§‹åŒ–(背景色 0x1a1a2e)
2. é€è§†ç›¸æœº
3. WebGLRenderer
4. OrbitControls(阻尼启用的轨道控制器)
5. çŽ¯å¢ƒå…‰ + å®šå‘å…‰
6. åœ°é¢ï¼ˆPlaneGeometry,网格)
7. InstancedMesh æ‰¹é‡æ¸²æŸ“货位
8. Raycaster ç‚¹å‡»æ‹¾å–
9. ç›¸æœº lerp èšç„¦åŠ¨ç”»
颜色编码(前端实现):
- DISABLED(3): 0x2d2d2d
- LOCKED(2): 0xF56C6C
- EMPTY(0/无货): 0x4a4a4a
- HAS_STOCK(1): 0x409EFF
- LOW_STOCK(2): 0xE6A23C
- FULL(3): 0x67C23A
**验收标准:**
- é¡µé¢å¯ä»¥æ­£å¸¸åŠ è½½
- Three.js åœºæ™¯æ­£ç¡®åˆå§‹åŒ–
- ç‚¹å‡»è´§ä½èƒ½æ˜¾ç¤ºè¯¦æƒ…弹窗
- é¢œè‰²ç¼–码正确
---
### Task 9: å‰ç«¯ - åˆ›å»ºæ‰©å±•配置文件
**Files:**
- Create: `WIDESEA_WMSClient/src/extension/stock/stockChat.js`
**详细规范:**
创建标准扩展文件格式:
```javascript
let extension = {
  components: {
    gridHeader: '',
    gridBody: '',
    gridFooter: '',
    modelHeader: '',
    modelBody: '',
    modelFooter: ''
  },
  tableAction: '',
  buttons: { view: [], box: [], detail: [] },
  methods: {
    onInit() {},
    onInited() {}
  }
};
export default extension;
```
**验收标准:**
- ç¬¦åˆé¡¹ç›®çŽ°æœ‰æ‰©å±•æ–‡ä»¶æ¨¡å¼
---
### Task 10: å‰ç«¯ - é›†æˆ SignalR å®žæ—¶æ›´æ–°
**Files:**
- Modify: `WIDESEA_WMSClient/src/views/stock/stockChat.vue`
**详细规范:**
1. åœ¨ onMounted ä¸­åˆå§‹åŒ– SignalR è¿žæŽ¥
2. è¿žæŽ¥ /stockHub
3. ç›‘听 StockUpdated äº‹ä»¶
4. æ›´æ–°å¯¹åº”货位的 stockQuantity å’Œ stockStatus
5. åŠ¨æ€æ›´æ–°è´§ä½é¢œè‰²
6. åœ¨ onUnmounted ä¸­æ–­å¼€è¿žæŽ¥
**验收标准:**
- SignalR è¿žæŽ¥æ­£å¸¸å»ºç«‹
- æ”¶åˆ°æ›´æ–°æ—¶è´§ä½é¢œè‰²èƒ½åŠ¨æ€å˜åŒ–