| | |
| | | <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() |
| | | |
| | | // 颜色常量 |
| | | 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) |
| | |
| | | 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 |
| | | 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 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}`) |
| | | if (res.Status && res.Data) { |
| | | locationData = res.Data |
| | | // 提取物料编号和批次列表 |
| | | const codes = new Set() |
| | | const batches = new Set() |
| | | res.Data.forEach(loc => { |
| | | if (loc.materielCode) codes.add(loc.materielCode) |
| | | if (loc.batchNo) batches.add(loc.batchNo) |
| | | }) |
| | | materielCodeList.value = Array.from(codes) |
| | | batchNoList.value = Array.from(batches) |
| | | // 渲染货位 |
| | | renderLocations() |
| | | function hasCargo(location) { |
| | | return Number(location.stockQuantity || 0) > 0 || ((location.details && location.details.length > 0) || false) |
| | | } |
| | | |
| | | function hasPallet(location) { |
| | | if (location.stockStatus === 22 || location.locationStatus === 99) { |
| | | return true |
| | | } |
| | | } catch (e) { |
| | | console.error('加载货位数据失败', e) |
| | | } |
| | | if (hasCargo(location)) { |
| | | return true |
| | | } |
| | | return Boolean(location.palletCode) |
| | | } |
| | | |
| | | // 初始化 Three.js 场景 |
| | | function initThreeJS() { |
| | | if (!canvasContainer.value) return |
| | | |
| | | const width = canvasContainer.value.clientWidth |
| | | const height = canvasContainer.value.clientHeight |
| | | |
| | | // 创建场景 |
| | | scene = new THREE.Scene() |
| | | scene.background = new THREE.Color(0x1a1a2e) |
| | | |
| | | // 创建相机 |
| | | camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000) |
| | | camera.position.set(20, 20, 20) |
| | | |
| | | // 创建渲染器 |
| | | renderer = new THREE.WebGLRenderer({ antialias: true }) |
| | | renderer.setPixelRatio(window.devicePixelRatio) |
| | | renderer.setSize(width, height) |
| | | canvasContainer.value.appendChild(renderer.domElement) |
| | | |
| | | // 创建轨道控制器 |
| | | controls = new OrbitControls(camera, renderer.domElement) |
| | | controls.enableDamping = true |
| | | controls.dampingFactor = 0.05 |
| | | |
| | | // 创建环境光 |
| | | const ambientLight = new THREE.AmbientLight(0xffffff, 0.6) |
| | | scene.add(ambientLight) |
| | | |
| | | // 创建定向光 |
| | | const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8) |
| | | directionalLight.position.set(10, 20, 10) |
| | | scene.add(directionalLight) |
| | | |
| | | // 创建地面 |
| | | const groundGeometry = new THREE.PlaneGeometry(100, 100) |
| | | const groundMaterial = new THREE.MeshStandardMaterial({ color: 0x2d2d2d }) |
| | | const ground = new THREE.Mesh(groundGeometry, groundMaterial) |
| | | ground.rotation.x = -Math.PI / 2 |
| | | ground.position.y = -0.5 |
| | | scene.add(ground) |
| | | |
| | | // 创建网格 |
| | | const gridHelper = new THREE.GridHelper(100, 50) |
| | | scene.add(gridHelper) |
| | | |
| | | // 创建射线检测器 |
| | | raycaster = new THREE.Raycaster() |
| | | mouse = new THREE.Vector2() |
| | | |
| | | // 添加点击事件 |
| | | canvasContainer.value.addEventListener('click', onCanvasClick) |
| | | |
| | | // 开始渲染循环 |
| | | animate() |
| | | function getShelfAccentColor(location) { |
| | | return BASE_COLOR |
| | | } |
| | | |
| | | // 渲染货位 |
| | | function renderLocations() { |
| | | if (!scene) return |
| | | |
| | | // 移除旧的货位网格 |
| | | if (locationMesh) { |
| | | scene.remove(locationMesh) |
| | | locationMesh.geometry.dispose() |
| | | locationMesh.material.forEach(m => m.dispose()) |
| | | locationMesh = null |
| | | } |
| | | |
| | | // 过滤数据 |
| | | let filteredData = locationData |
| | | if (filterStockStatus.value !== null) { |
| | | filteredData = filteredData.filter(loc => loc.stockStatus === filterStockStatus.value) |
| | | } |
| | | if (filterMaterielCode.value) { |
| | | filteredData = filteredData.filter(loc => loc.materielCode === filterMaterielCode.value) |
| | | } |
| | | if (filterBatchNo.value) { |
| | | filteredData = filteredData.filter(loc => loc.batchNo === filterBatchNo.value) |
| | | } |
| | | |
| | | if (filteredData.length === 0) return |
| | | |
| | | // 创建 InstancedMesh |
| | | const geometry = new THREE.BoxGeometry(1.5, 1, 1.5) |
| | | const material = new THREE.MeshStandardMaterial({ color: 0xffffff }) |
| | | |
| | | locationMesh = new THREE.InstancedMesh(geometry, [material], filteredData.length) |
| | | |
| | | const dummy = new THREE.Object3D() |
| | | const color = new THREE.Color() |
| | | |
| | | filteredData.forEach((location, i) => { |
| | | const x = (location.column - 1) * 2 |
| | | const y = location.layer * 1.5 |
| | | const z = (location.row - 1) * 2 |
| | | |
| | | dummy.position.set(x, y, z) |
| | | dummy.updateMatrix() |
| | | locationMesh.setMatrixAt(i, dummy.matrix) |
| | | |
| | | // 设置颜色 |
| | | color.setHex(getLocationColor(location)) |
| | | locationMesh.setColorAt(i, color) |
| | | }) |
| | | |
| | | locationMesh.instanceMatrix.needsUpdate = true |
| | | if (locationMesh.instanceColor) locationMesh.instanceColor.needsUpdate = true |
| | | |
| | | scene.add(locationMesh) |
| | | function getPalletColor(location) { |
| | | return PALLET_COLOR |
| | | } |
| | | |
| | | // 动画循环 |
| | | function animate() { |
| | | animationId = requestAnimationFrame(animate) |
| | | controls.update() |
| | | renderer.render(scene, camera) |
| | | function getCargoColor(location) { |
| | | const cargoColor = new THREE.Color(getLocationColor(location)) |
| | | return cargoColor.offsetHSL(0, 0.04, 0.08).getHex() |
| | | } |
| | | |
| | | // 点击画布 |
| | | function onCanvasClick(event) { |
| | | if (!canvasContainer.value || !locationMesh) return |
| | | function getCargoLidColor(location) { |
| | | const lidColor = new THREE.Color(getCargoColor(location)) |
| | | return lidColor.offsetHSL(0, -0.02, 0.06).getHex() |
| | | } |
| | | |
| | | const rect = canvasContainer.value.getBoundingClientRect() |
| | | mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1 |
| | | mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1 |
| | | function getFilteredLocations() { |
| | | let filteredData = [...originalLocationData] |
| | | |
| | | raycaster.setFromCamera(mouse, camera) |
| | | const intersects = raycaster.intersectObject(locationMesh) |
| | | |
| | | if (intersects.length > 0) { |
| | | const instanceId = intersects[0].instanceId |
| | | // 获取原始数据索引(考虑过滤后的数据) |
| | | let filteredData = locationData |
| | | if (filterStockStatus.value !== null) { |
| | | filteredData = filteredData.filter(loc => loc.stockStatus === filterStockStatus.value) |
| | | filteredData = filteredData.filter((loc) => loc.stockStatus === filterStockStatus.value) |
| | | } |
| | | if (filterMaterielCode.value) { |
| | | filteredData = filteredData.filter(loc => loc.materielCode === filterMaterielCode.value) |
| | | filteredData = filteredData.filter((loc) => loc.materielCode === filterMaterielCode.value) |
| | | } |
| | | if (filterBatchNo.value) { |
| | | filteredData = filteredData.filter(loc => loc.batchNo === filterBatchNo.value) |
| | | filteredData = filteredData.filter((loc) => loc.batchNo === filterBatchNo.value) |
| | | } |
| | | |
| | | if (instanceId < filteredData.length) { |
| | | selectedLocation.value = filteredData[instanceId] |
| | | detailDialogVisible.value = true |
| | | // 聚焦相机 |
| | | focusCamera(selectedLocation.value) |
| | | } |
| | | } |
| | | return filteredData |
| | | } |
| | | |
| | | // 聚焦相机到货位 |
| | | function matchesCurrentFilters(location) { |
| | | if (filterStockStatus.value !== null && location.stockStatus !== filterStockStatus.value) { |
| | | return false |
| | | } |
| | | if (filterMaterielCode.value && location.materielCode !== filterMaterielCode.value) { |
| | | return false |
| | | } |
| | | if (filterBatchNo.value && location.batchNo !== filterBatchNo.value) { |
| | | return false |
| | | } |
| | | return true |
| | | } |
| | | |
| | | function getLocationStatusText(status) { |
| | | const map = { |
| | | 0: '空闲', |
| | | 1: '锁定', |
| | | 10: '有货锁定', |
| | | 20: '空闲锁定', |
| | | 99: '托盘锁定', |
| | | 100: '有货' |
| | | } |
| | | return map[status] || `未知(${status})` |
| | | } |
| | | |
| | | function getStockStatusText(status) { |
| | | const map = { |
| | | 0: '无库存', |
| | | 1: '组盘暂存', |
| | | 3: '入库确认', |
| | | 6: '入库完成', |
| | | 7: '出库锁定', |
| | | 8: '出库完成', |
| | | 9: '移库锁定', |
| | | 10: '入库完成未建出库单', |
| | | 11: '退库', |
| | | 12: '手动组盘暂存', |
| | | 13: '手动组盘入库确认', |
| | | 14: '拣选完成', |
| | | 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() |
| | | 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 (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> |