<template>
|
<div class="stock-chat-container">
|
<!-- 仓库 Tabs -->
|
<el-tabs v-model="activeWarehouse" @tab-change="onWarehouseChange">
|
<el-tab-pane
|
v-for="wh in warehouseList"
|
:key="wh.warehouseId || wh.id"
|
:label="wh.warehouseName"
|
:name="wh.warehouseId || wh.id"
|
/>
|
</el-tabs>
|
|
<!-- 工具栏 -->
|
<div class="toolbar">
|
<el-select v-model="filterStockStatus" placeholder="库存状态筛选" clearable>
|
<el-option label="有货" :value="1" />
|
<el-option label="库存紧张" :value="2" />
|
<el-option label="已满" :value="3" />
|
</el-select>
|
<el-select v-model="filterMaterielCode" placeholder="物料筛选" clearable>
|
<el-option v-for="code in materielCodeList" :key="code" :label="code" :value="code" />
|
</el-select>
|
<el-select v-model="filterBatchNo" placeholder="批次筛选" clearable>
|
<el-option v-for="batch in batchNoList" :key="batch" :label="batch" :value="batch" />
|
</el-select>
|
<el-button @click="resetCamera">重置视角</el-button>
|
</div>
|
|
<!-- 3D Canvas -->
|
<div ref="canvasContainer" class="canvas-container" />
|
|
<!-- 状态图例 -->
|
<div class="legend">
|
<div v-for="item in legendItems" :key="item.status" class="legend-item">
|
<span class="color-box" :style="{ background: item.color }" />
|
<span>{{ item.label }}</span>
|
</div>
|
</div>
|
|
<!-- 详情弹窗 -->
|
<el-dialog v-model="detailDialogVisible" title="库存详情" fullscreen>
|
<div v-if="selectedLocation" class="detail-content">
|
<el-descriptions :column="2" border>
|
<el-descriptions-item label="货位编号">{{ selectedLocation.locationCode }}</el-descriptions-item>
|
<el-descriptions-item label="货位状态">{{ getLocationStatusText(selectedLocation.locationStatus) }}</el-descriptions-item>
|
<el-descriptions-item label="托盘编号">{{ selectedLocation.palletCode || '无' }}</el-descriptions-item>
|
<el-descriptions-item label="库存状态">{{ getStockStatusText(selectedLocation.stockStatus) }}</el-descriptions-item>
|
<el-descriptions-item label="当前库存">{{ selectedLocation.stockQuantity }}</el-descriptions-item>
|
<el-descriptions-item label="物料编号">{{ selectedLocation.materielCode || '无' }}</el-descriptions-item>
|
<el-descriptions-item label="物料名称">{{ selectedLocation.materielName || '无' }}</el-descriptions-item>
|
<el-descriptions-item label="批次号">{{ selectedLocation.batchNo || '无' }}</el-descriptions-item>
|
</el-descriptions>
|
</div>
|
</el-dialog>
|
</div>
|
</template>
|
|
<script setup>
|
import { ref, reactive, onMounted, onUnmounted, watch, getCurrentInstance } from 'vue'
|
import * as THREE from 'three'
|
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
|
import * as signalR from '@microsoft/signalr'
|
|
const { proxy } = getCurrentInstance()
|
|
// SignalR 连接
|
let connection = null
|
|
// 颜色常量
|
const COLOR_MAP = {
|
DISABLED: 0x2d2d2d, // 禁用 - 深灰
|
LOCKED: 0xF56C6C, // 锁定 - 红色
|
EMPTY: 0x4a4a4a, // 空货位 - 暗灰
|
HAS_STOCK: 0x409EFF, // 有货 - 蓝色
|
LOW_STOCK: 0xE6A23C, // 库存紧张 - 橙色
|
FULL: 0x67C23A, // 已满 - 绿色
|
}
|
|
// 图例项
|
const legendItems = [
|
{ status: 'disabled', label: '禁用', color: '#2d2d2d' },
|
{ status: 'locked', label: '锁定', color: '#F56C6C' },
|
{ status: 'empty', label: '空货位', color: '#4a4a4a' },
|
{ status: 'hasStock', label: '有货', color: '#409EFF' },
|
{ status: 'lowStock', label: '库存紧张', color: '#E6A23C' },
|
{ status: 'full', label: '已满', color: '#67C23A' },
|
]
|
|
// Refs
|
const canvasContainer = ref(null)
|
|
// 状态
|
const activeWarehouse = ref(null)
|
const warehouseList = ref([])
|
const filterStockStatus = ref(null)
|
const filterMaterielCode = ref(null)
|
const filterBatchNo = ref(null)
|
const materielCodeList = ref([])
|
const batchNoList = ref([])
|
const detailDialogVisible = ref(false)
|
const selectedLocation = ref(null)
|
|
// Three.js 相关
|
let scene, camera, renderer, controls, raycaster, mouse
|
let locationMesh = null
|
let locationData = []
|
let animationId = null
|
|
// SignalR 初始化
|
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((err) => console.log('SignalR连接失败:', err));
|
|
connection.on('StockUpdated', (update) => {
|
// 更新对应货位的数据
|
const idx = locationData.findIndex(x => x.locationId === update.locationId);
|
if (idx !== -1) {
|
locationData[idx].stockQuantity = update.stockQuantity;
|
locationData[idx].stockStatus = update.stockStatus;
|
// 重新渲染单个货位颜色
|
updateInstanceColor(idx, update.stockStatus);
|
}
|
});
|
});
|
}
|
|
// 更新单个货位颜色
|
function updateInstanceColor(instanceId, stockStatus) {
|
if (!locationMesh) return;
|
const loc = locationData[instanceId];
|
if (!loc) return;
|
const color = getLocationColor(loc);
|
locationMesh.setColorAt(instanceId, new THREE.Color(color));
|
locationMesh.instanceColor.needsUpdate = true;
|
}
|
|
// 获取货位颜色
|
function getLocationColor(location) {
|
if (location.locationStatus === 3) return COLOR_MAP.DISABLED // 禁用
|
if (location.locationStatus === 2) return COLOR_MAP.LOCKED // 锁定
|
if (location.locationStatus === 1) {
|
if (location.stockStatus === 0) return COLOR_MAP.EMPTY // 无货
|
if (location.stockStatus === 1) return COLOR_MAP.HAS_STOCK // 有货
|
if (location.stockStatus === 2) return COLOR_MAP.LOW_STOCK // 库存紧张
|
if (location.stockStatus === 3) return COLOR_MAP.FULL // 已满
|
}
|
return COLOR_MAP.EMPTY // 默认空
|
}
|
|
// 获取货位状态文本
|
function getLocationStatusText(status) {
|
const map = { 0: '正常', 1: '正常', 2: '锁定', 3: '禁用' }
|
return map[status] || '未知'
|
}
|
|
// 获取库存状态文本
|
function getStockStatusText(status) {
|
const map = { 0: '无货', 1: '有货', 2: '库存紧张', 3: '已满' }
|
return map[status] || '未知'
|
}
|
|
// 加载仓库列表
|
async function loadWarehouseList() {
|
try {
|
const res = await proxy.http.get('/api/Warehouse/GetAll')
|
if (res.status && res.data) {
|
warehouseList.value = res.data
|
if (res.data.length > 0) {
|
activeWarehouse.value = res.data[0].warehouseId || res.data[0].id
|
await loadWarehouseData(activeWarehouse.value)
|
}
|
}
|
} catch (e) {
|
console.error('加载仓库列表失败', e)
|
}
|
}
|
|
// 加载仓库货位数据
|
async function loadWarehouseData(warehouseId) {
|
try {
|
const res = await proxy.http.get(`/api/StockInfo/Get3DLayout?warehouseId=${warehouseId}`)
|
console.log('Get3DLayout response:', res)
|
if (res.status && res.data) {
|
const data = res.data
|
console.log('data.locations:', data.locations)
|
locationData = data.locations || []
|
// 使用后端返回的筛选列表
|
materielCodeList.value = data.materielCodeList || []
|
batchNoList.value = data.batchNoList || []
|
console.log('locationData set:', locationData.length, 'items')
|
// 渲染货位
|
renderLocations()
|
}
|
} catch (e) {
|
console.error('加载货位数据失败', e)
|
}
|
}
|
|
// 初始化 Three.js 场景
|
function initThreeJS() {
|
if (!canvasContainer.value) return
|
|
const width = canvasContainer.value.clientWidth
|
const height = canvasContainer.value.clientHeight
|
|
// 创建场景
|
scene = new THREE.Scene()
|
scene.background = new THREE.Color(0x1a1a2e)
|
|
// 创建相机
|
camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000)
|
camera.position.set(20, 20, 20)
|
|
// 创建渲染器
|
renderer = new THREE.WebGLRenderer({ antialias: true })
|
renderer.setPixelRatio(window.devicePixelRatio)
|
renderer.setSize(width, height)
|
canvasContainer.value.appendChild(renderer.domElement)
|
|
// 创建轨道控制器
|
controls = new OrbitControls(camera, renderer.domElement)
|
controls.enableDamping = true
|
controls.dampingFactor = 0.05
|
|
// 创建环境光
|
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6)
|
scene.add(ambientLight)
|
|
// 创建定向光
|
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8)
|
directionalLight.position.set(10, 20, 10)
|
scene.add(directionalLight)
|
|
// 创建地面
|
const groundGeometry = new THREE.PlaneGeometry(100, 100)
|
const groundMaterial = new THREE.MeshStandardMaterial({ color: 0x2d2d2d })
|
const ground = new THREE.Mesh(groundGeometry, groundMaterial)
|
ground.rotation.x = -Math.PI / 2
|
ground.position.y = -0.5
|
scene.add(ground)
|
|
// 创建网格
|
const gridHelper = new THREE.GridHelper(100, 50)
|
scene.add(gridHelper)
|
|
// 创建射线检测器
|
raycaster = new THREE.Raycaster()
|
mouse = new THREE.Vector2()
|
|
// 添加点击事件
|
canvasContainer.value.addEventListener('click', onCanvasClick)
|
|
// 开始渲染循环
|
animate()
|
}
|
|
// 渲染货位
|
function renderLocations() {
|
console.log('renderLocations called', { scene: !!scene, locationDataLength: locationData.length })
|
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)
|
}
|
|
console.log('filteredData length:', filteredData.length)
|
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)
|
|
if (i === 0) {
|
console.log('First location:', location, { x, y, z })
|
}
|
})
|
|
locationMesh.instanceMatrix.needsUpdate = true
|
if (locationMesh.instanceColor) locationMesh.instanceColor.needsUpdate = true
|
|
scene.add(locationMesh)
|
console.log('locationMesh added to scene')
|
}
|
|
// 动画循环
|
function animate() {
|
animationId = requestAnimationFrame(animate)
|
controls.update()
|
renderer.render(scene, camera)
|
}
|
|
// 点击画布
|
function onCanvasClick(event) {
|
if (!canvasContainer.value || !locationMesh) return
|
|
const rect = canvasContainer.value.getBoundingClientRect()
|
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
|
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
|
|
raycaster.setFromCamera(mouse, camera)
|
const intersects = raycaster.intersectObject(locationMesh)
|
|
if (intersects.length > 0) {
|
const instanceId = intersects[0].instanceId
|
// 获取原始数据索引(考虑过滤后的数据)
|
let filteredData = locationData
|
if (filterStockStatus.value !== null) {
|
filteredData = filteredData.filter(loc => loc.stockStatus === filterStockStatus.value)
|
}
|
if (filterMaterielCode.value) {
|
filteredData = filteredData.filter(loc => loc.materielCode === filterMaterielCode.value)
|
}
|
if (filterBatchNo.value) {
|
filteredData = filteredData.filter(loc => loc.batchNo === filterBatchNo.value)
|
}
|
|
if (instanceId < filteredData.length) {
|
selectedLocation.value = filteredData[instanceId]
|
detailDialogVisible.value = true
|
// 聚焦相机
|
focusCamera(selectedLocation.value)
|
}
|
}
|
}
|
|
// 聚焦相机到货位
|
function focusCamera(location) {
|
if (!camera || !controls) return
|
|
const targetX = (location.column - 1) * 2
|
const targetY = location.layer * 1.5
|
const targetZ = (location.row - 1) * 2
|
|
const offsetX = 5
|
const offsetY = 5
|
const offsetZ = 5
|
|
const targetPosition = new THREE.Vector3(targetX + offsetX, targetY + offsetY, targetZ + offsetZ)
|
|
// 使用 lerp 平滑移动
|
const startPosition = camera.position.clone()
|
const duration = 500
|
const startTime = Date.now()
|
|
function lerpMove() {
|
const elapsed = Date.now() - startTime
|
const t = Math.min(elapsed / duration, 1)
|
const easeT = 1 - Math.pow(1 - t, 3) // easeOutCubic
|
|
camera.position.lerpVectors(startPosition, targetPosition, easeT)
|
controls.target.set(targetX, targetY, targetZ)
|
|
if (t < 1) {
|
requestAnimationFrame(lerpMove)
|
}
|
}
|
|
lerpMove()
|
}
|
|
// 重置相机
|
function resetCamera() {
|
if (!camera || !controls) return
|
camera.position.set(20, 20, 20)
|
controls.target.set(0, 0, 0)
|
}
|
|
// 仓库切换
|
async function onWarehouseChange(warehouseId) {
|
await loadWarehouseData(warehouseId)
|
}
|
|
// 监听筛选变化
|
watch(filterStockStatus, () => renderLocations())
|
watch(filterMaterielCode, () => renderLocations())
|
watch(filterBatchNo, () => renderLocations())
|
|
// 窗口大小变化
|
function onWindowResize() {
|
if (!canvasContainer.value || !camera || !renderer) return
|
const width = canvasContainer.value.clientWidth
|
const height = canvasContainer.value.clientHeight
|
camera.aspect = width / height
|
camera.updateProjectionMatrix()
|
renderer.setSize(width, height)
|
}
|
|
// 组件挂载
|
onMounted(() => {
|
initThreeJS()
|
loadWarehouseList()
|
initSignalR()
|
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 (connection) {
|
connection.stop()
|
}
|
})
|
</script>
|
|
<style scoped>
|
.stock-chat-container {
|
width: 100%;
|
height: calc(100vh - 120px);
|
position: relative;
|
}
|
.toolbar {
|
position: absolute;
|
top: 60px;
|
left: 20px;
|
z-index: 10;
|
display: flex;
|
gap: 10px;
|
background: rgba(255, 255, 255, 0.9);
|
padding: 10px;
|
border-radius: 4px;
|
}
|
.canvas-container {
|
width: 100%;
|
height: 100%;
|
}
|
.legend {
|
position: absolute;
|
bottom: 20px;
|
right: 20px;
|
background: rgba(0, 0, 0, 0.7);
|
padding: 10px;
|
border-radius: 4px;
|
color: white;
|
z-index: 10;
|
}
|
.legend-item {
|
display: flex;
|
align-items: center;
|
gap: 8px;
|
margin-bottom: 4px;
|
}
|
.color-box {
|
width: 16px;
|
height: 16px;
|
border-radius: 2px;
|
}
|
.detail-content {
|
padding: 20px;
|
}
|
</style>
|