| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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' |
| | | |
| | | const { proxy } = getCurrentInstance() |
| | | |
| | | // é¢è²å¸¸é |
| | | 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 |
| | | |
| | | // è·åè´§ä½é¢è² |
| | | 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}`) |
| | | 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() |
| | | } |
| | | } 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() { |
| | | 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 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() |
| | | 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() |
| | | } |
| | | }) |
| | | </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> |