<template>
|
<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="库存状态筛选" 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>
|
</template>
|
|
<script setup>
|
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 = {
|
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: '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' }
|
]
|
|
const canvasContainer = ref(null)
|
const activeWarehouse = ref(null)
|
const warehouseList = ref([])
|
const filterStockStatus = ref(null)
|
const filterMaterielCode = ref(null)
|
const filterBatchNo = ref(null)
|
const materielCodeList = ref([])
|
const batchNoList = ref([])
|
const detailDialogVisible = ref(false)
|
const selectedLocation = ref(null)
|
const refreshing = ref(false)
|
|
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.stockStatus === 22) {
|
return COLOR_MAP.STOCK_EMPTY_TRAY
|
}
|
return getLocationColorByStatus(location.locationStatus)
|
}
|
|
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
|
}
|
if (hasCargo(location)) {
|
return true
|
}
|
return Boolean(location.palletCode)
|
}
|
|
function getShelfAccentColor(location) {
|
return BASE_COLOR
|
}
|
|
function getPalletColor(location) {
|
return PALLET_COLOR
|
}
|
|
function getCargoColor(location) {
|
const cargoColor = new THREE.Color(getLocationColor(location))
|
return cargoColor.offsetHSL(0, 0.04, 0.08).getHex()
|
}
|
|
function getCargoLidColor(location) {
|
const lidColor = new THREE.Color(getCargoColor(location))
|
return lidColor.offsetHSL(0, -0.02, 0.06).getHex()
|
}
|
|
function getFilteredLocations() {
|
let filteredData = [...originalLocationData]
|
|
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)
|
}
|
|
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 { 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(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)
|
}
|
|
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)
|
}
|
|
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('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;
|
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.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%;
|
overflow: hidden;
|
border-radius: 12px;
|
}
|
|
.legend {
|
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: 6px;
|
font-size: 12px;
|
}
|
|
.legend-item--shape {
|
color: rgba(255, 255, 255, 0.92);
|
}
|
|
.color-box {
|
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;
|
}
|
|
.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>
|