From 8b900ed7a1137e731cea43e425a76ae95efe9b24 Mon Sep 17 00:00:00 2001 From: jhd <1684074631@qq.com> Date: Wed, 27 May 2026 17:05:49 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=A7=E5=B1=8F=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screens/warehouse-overview/index.vue | 834 +++++++++++++++--- 1 file changed, 723 insertions(+), 111 deletions(-) diff --git a/src/views/screens/warehouse-overview/index.vue b/src/views/screens/warehouse-overview/index.vue index a5bea07..068a683 100644 --- a/src/views/screens/warehouse-overview/index.vue +++ b/src/views/screens/warehouse-overview/index.vue @@ -23,54 +23,79 @@
- 🏭 +

库区总览大屏

Warehouse Overview Dashboard + {{ currentDate }}
-
@@ -95,14 +207,28 @@
- 📋 + + + + + + + + + + + + + + + {{ selectedNodeName || '库位列表' }} 共 {{ warehouseList.length }} 条
📭 -
点击左侧分区查看库位
+
请从左侧选择仓库分区
@@ -221,10 +358,16 @@ export default { // 树形数据 const warehouseTree = ref([]) - const treeProps = { - label: 'actualWarehouseName', - children: 'children' - } + // 下拉选项(树形结构展平为分组) + const warehouseOptions = computed(() => { + return (warehouseTree.value || []).map(parent => ({ + label: parent.actualWarehouseName, + options: (parent.children || []).map(child => ({ + id: child.actualWarehouseId, + name: child.actualWarehouseName + })) + })) + }) // 选中状态 const selectedNodeId = ref('') @@ -242,6 +385,33 @@ export default { utilization: 0 }) + // 拆分状态统计 + const splitStats = computed(() => { + const total = warehouseList.value.length + const split = warehouseList.value.filter(w => w.splitStatus === 1).length + const unsplit = total - split + return { + total, + split, + unsplit, + splitPct: total > 0 ? Math.round((split / total) * 100) : 0, + unsplitPct: total > 0 ? Math.round((unsplit / total) * 100) : 0 + } + }) + + // 层列信息统计 + const columnStats = computed(() => { + const keys = Object.keys(gridData.value).sort((a, b) => Number(a) - Number(b)) + const totalLayer1 = keys.reduce((sum, k) => sum + (gridData.value[k]?.layer1?.length || 0), 0) + const totalLayer2 = keys.reduce((sum, k) => sum + (gridData.value[k]?.layer2?.length || 0), 0) + return { + totalColumns: keys.length, + totalLayer1, + totalLayer2, + avgPerColumn: keys.length > 0 ? Math.round((totalLayer1 + totalLayer2) / keys.length) : 0 + } + }) + // 动画数字 const animatedTotal = ref(0) const animatedOccupied = ref(0) @@ -292,10 +462,27 @@ export default { // 高度约束:容器高 - 图例 - 列头 - 标题栏 - 分隔线 - 间距 - 内边距 const byHeight = (ch - 120 - (maxRows - 1) * 4) / maxRows + // 居中时(列数≤15):尽量填满容器宽度 + if (colCount <= 15) { + // 目标:网格总宽占容器宽的 85% + const fillSize = (cw * 0.85 - 30 - (colCount - 1) * 8) / colCount + let size = Math.max(byHeight, fillSize) + size = Math.min(size, byWidth) + // 列数越少,可放大的上限越高 + const maxSize = colCount <= 2 ? 180 : colCount <= 4 ? 140 : 110 + return Math.max(44, Math.min(maxSize, Math.floor(size))) + } + + // 列数多时:维持原逻辑(高度约束为主) let size = Math.min(byWidth, byHeight) return Math.max(44, Math.min(80, Math.floor(size))) }) + // 网格居中(列数少于15时居中更美观) + const isGridCentered = computed(() => { + return sortedColumnKeys.value.length <= 15 + }) + const cellStyle = computed(() => { const s = cellSize.value const fs = Math.max(9, Math.min(14, Math.floor(s * 0.16))) @@ -333,39 +520,46 @@ export default { const gridInnerRef = ref(null) let scrollTimer = null let pauseTimer = null + let stayTimer = null let isScrollPaused = false + // 滚动共享状态(自动轮播 + 拖拽共用) + const scrollPos = { x: 0, y: 0 } + const overflowDist = { x: 0, y: 0 } + const dragState = { active: false, maybeClick: false, startX: 0, startY: 0, startScrollX: 0, startScrollY: 0 } + + const applyTransform = () => { + const inner = gridInnerRef.value + if (!inner) return + inner.style.transform = `translate(${-scrollPos.x}px, ${-scrollPos.y}px)` + } + const startAutoScroll = () => { stopAutoScroll() const container = gridContainerRef.value const inner = gridInnerRef.value if (!container || !inner) return - // 测量实际溢出距离 - const overflowX = Math.max(inner.scrollWidth - container.clientWidth, 0) - const overflowY = Math.max(inner.scrollHeight - container.clientHeight, 0) - if (overflowX <= 0 && overflowY <= 0) return // 无需滚动 - - let hPos = 0 - let vPos = 0 + // 使用预测量的溢出距离 + if (overflowDist.x <= 0 && overflowDist.y <= 0) return const step = () => { if (isScrollPaused) return let changed = false - if (overflowX > 0) { - hPos += 1 - if (hPos >= overflowX + 40) { // 多滚 40px 再回退 - hPos = 0 + if (overflowDist.x > 0) { + scrollPos.x += 1 + if (scrollPos.x >= overflowDist.x + 40) { + scrollPos.x = 0 changed = true } } - if (overflowY > 0) { - vPos += 0.5 - if (vPos >= overflowY + 30) { - vPos = 0 + if (overflowDist.y > 0) { + scrollPos.y += 0.5 + if (scrollPos.y >= overflowDist.y + 30) { + scrollPos.y = 0 changed = true } } @@ -376,7 +570,7 @@ export default { pauseTimer = setTimeout(() => { isScrollPaused = false }, 2000) } - inner.style.transform = `translate(${-hPos}px, ${-vPos}px)` + applyTransform() } scrollTimer = setInterval(step, 30) @@ -391,13 +585,16 @@ export default { clearTimeout(pauseTimer) pauseTimer = null } + if (stayTimer) { + clearTimeout(stayTimer) + stayTimer = null + } } const resetScroll = () => { - const inner = gridInnerRef.value - if (inner) { - inner.style.transform = 'translate(0, 0)' - } + scrollPos.x = 0 + scrollPos.y = 0 + applyTransform() } const checkOverflow = () => { @@ -409,6 +606,8 @@ export default { // 等待一帧让布局稳定再测量溢出 requestAnimationFrame(() => { requestAnimationFrame(() => { + overflowDist.x = Math.max(inner.scrollWidth - el.clientWidth, 0) + overflowDist.y = Math.max(inner.scrollHeight - el.clientHeight, 0) startAutoScroll() }) }) @@ -417,6 +616,59 @@ export default { const onGridMouseEnter = () => { isScrollPaused = true } const onGridMouseLeave = () => { isScrollPaused = false } + // --- 鼠标拖拽滑动网格(带阈值 + 停留窗口) --- + const onGridDragStart = (e) => { + if (e.button !== 0) return + e.preventDefault() + dragState.maybeClick = true + dragState.startX = e.clientX + dragState.startY = e.clientY + dragState.startScrollX = scrollPos.x + dragState.startScrollY = scrollPos.y + isScrollPaused = true + clearTimeout(stayTimer) + document.addEventListener('mousemove', onGridDragMove) + document.addEventListener('mouseup', onGridDragEnd) + } + + const onGridDragMove = (e) => { + const dx = e.clientX - dragState.startX + const dy = e.clientY - dragState.startY + + // 阈值判断:移动超过 8px 才真正进入拖拽模式 + if (dragState.maybeClick) { + if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return + dragState.maybeClick = false + dragState.active = true + const inner = gridInnerRef.value + if (inner) inner.style.cursor = 'grabbing' + } + + if (!dragState.active) return + + scrollPos.x = Math.max(0, Math.min(overflowDist.x + 40, dragState.startScrollX - dx)) + scrollPos.y = Math.max(0, Math.min(overflowDist.y + 30, dragState.startScrollY - dy * 0.5)) + applyTransform() + } + + const onGridDragEnd = () => { + document.removeEventListener('mousemove', onGridDragMove) + document.removeEventListener('mouseup', onGridDragEnd) + + // 未超过阈值,视为点击,不干涉 + if (dragState.maybeClick) return + + dragState.active = false + const inner = gridInnerRef.value + if (inner) inner.style.cursor = '' + + // 停留窗口:4 秒后恢复自动轮播,让用户有足够时间点击 + clearTimeout(stayTimer) + stayTimer = setTimeout(() => { + isScrollPaused = false + }, 4000) + } + // 监听仓库列表变化,数据就绪后启动滚动 const stopWatchList = watch(warehouseList, () => { nextTick(() => checkOverflow()) @@ -442,6 +694,11 @@ export default { animationDuration: `${6 + Math.random() * 4}s` }) + const getEqBarStyle = (index) => ({ + animationDelay: `${index * 0.06}s`, + animationDuration: `${1.8 + (index % 7) * 0.25}s` + }) + // 数字动画 const animateValue = (start, end, callback, duration = 1000) => { const startTime = performance.now() @@ -497,6 +754,7 @@ export default { }], graphic: [ { + id: 'util-number', type: 'text', left: 'center', top: '36%', @@ -510,6 +768,7 @@ export default { } }, { + id: 'util-label', type: 'text', left: 'center', top: '52%', @@ -542,13 +801,25 @@ export default { } } - // 树节点点击 - const handleNodeClick = (node) => { - selectedNodeId.value = node.actualWarehouseId - selectedNodeName.value = node.actualWarehouseName - - if (!node.children || node.children.length === 0) { - getWarehouseList(node.actualWarehouseId) + // 下拉选择变更 + const handleSelectChange = (value) => { + if (!value) { + selectedNodeId.value = '' + selectedNodeName.value = '' + warehouseList.value = [] + gridData.value = {} + return + } + // 找到选中节点名称 + for (const parent of warehouseTree.value) { + if (parent.children) { + const child = parent.children.find(c => c.actualWarehouseId === value) + if (child) { + selectedNodeName.value = child.actualWarehouseName + getWarehouseList(value) + return + } + } } } @@ -678,6 +949,11 @@ export default { detailDialogVisible.value = true } + // 全屏切换 → 刷新 popper 位置 + const handleFullscreenChange = () => { + setTimeout(() => window.dispatchEvent(new Event('resize')), 100) + } + // 初始化 onMounted(() => { updateTime() @@ -687,6 +963,8 @@ export default { // 初始化环图 initChart() window.addEventListener('resize', handleChartResize) + document.addEventListener('fullscreenchange', handleFullscreenChange) + document.addEventListener('webkitfullscreenchange', handleFullscreenChange) }) onBeforeUnmount(() => { @@ -695,8 +973,12 @@ export default { if (resizeObserver) resizeObserver.disconnect() stopAutoScroll() resetScroll() + document.removeEventListener('mousemove', onGridDragMove) + document.removeEventListener('mouseup', onGridDragEnd) if (chartInitTimer) clearTimeout(chartInitTimer) window.removeEventListener('resize', handleChartResize) + document.removeEventListener('fullscreenchange', handleFullscreenChange) + document.removeEventListener('webkitfullscreenchange', handleFullscreenChange) if (chartInstance) { chartInstance.dispose() chartInstance = null @@ -706,17 +988,20 @@ export default { return { currentDate, warehouseTree, - treeProps, + warehouseOptions, warehouseList, selectedNodeName, statistics, + splitStats, + columnStats, animatedTotal, animatedOccupied, animatedError, animatedAvailable, animatedUtilization, getParticleStyle, - handleNodeClick, + getEqBarStyle, + handleSelectChange, getStatusText, getStatusClass, // 网格 @@ -727,8 +1012,10 @@ export default { showDetail, cellStyle, columnWidth, + isGridCentered, onGridMouseEnter, onGridMouseLeave, + onGridDragStart, chartRef, gridContainerRef, gridInnerRef @@ -742,7 +1029,7 @@ export default { width: 100%; min-height: 100vh; background: linear-gradient(135deg, #050a15 0%, #0a1428 50%, #0d1b34 100%); - padding: 20px; + padding: 20px 20px 56px; box-sizing: border-box; color: #fff; position: relative; @@ -951,7 +1238,32 @@ export default { } .title-icon { - color: #00d4ff; + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; +} + +.diamond { + width: 14px; + height: 14px; + background: #00d4ff; + transform: rotate(45deg); + border-radius: 2px; + box-shadow: 0 0 8px #00d4ff, 0 0 20px rgba(0, 212, 255, 0.4); + animation: diamondPulse 2s ease-in-out infinite; +} + +@keyframes diamondPulse { + 0%, 100% { + box-shadow: 0 0 8px #00d4ff, 0 0 20px rgba(0, 212, 255, 0.4); + transform: rotate(45deg) scale(1); + } + 50% { + box-shadow: 0 0 16px #00d4ff, 0 0 40px rgba(0, 212, 255, 0.6), 0 0 60px rgba(0, 212, 255, 0.3); + transform: rotate(45deg) scale(1.1); + } } .screen-title { @@ -977,6 +1289,15 @@ export default { background: rgba(10, 20, 40, 0.8); } +/* 时钟 */ +.clock-text { + margin-left: auto; + font-size: 14px; + color: #00d4ff; + font-family: 'Courier New', monospace; + letter-spacing: 1px; +} + /* 主内容 */ .screen-body { display: flex; @@ -984,7 +1305,7 @@ export default { height: calc(100vh - 180px); position: relative; z-index: 10; - overflow: hidden; + overflow: visible; } .sidebar { @@ -996,16 +1317,43 @@ export default { min-height: 0; } -.panel-border { - width: 100%; - padding: 0; - background: transparent; - border-radius: 0; +.tree-panel { + flex-shrink: 0; + position: relative; + background: rgba(10, 20, 40, 0.85); + border-radius: 4px; } -.tree-panel { - flex: 1; - min-height: 250px; +.tree-panel .header-compact { + padding: 8px 10px 0; + border-bottom: none; + background: none; +} + +.tree-panel .header-compact .header-icon { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; +} + +.header-icon-svg { + display: block; +} + +.radar-beam { + transform-origin: 12px 12px; + animation: radarSpin 3s linear infinite; +} + +@keyframes radarSpin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.tree-panel .header-compact .panel-title { + font-size: 15px; } .panel { @@ -1050,40 +1398,18 @@ export default { min-height: 0; } -/* 树形样式 */ -:deep(.el-tree) { - background: transparent; - color: #a0c4e8; +/* 下拉选择器样式 */ +.select-body { + padding: 2px 10px 10px; } -:deep(.el-tree-node__content) { - padding: 10px 12px; - cursor: pointer; - border-radius: 6px; - transition: all 0.3s ease; +.warehouse-select { + width: 100%; } -:deep(.el-tree-node__content:hover) { - background: rgba(0, 212, 255, 0.15); - transform: translateX(5px); -} - -:deep(.el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content) { - background: linear-gradient(90deg, rgba(0, 212, 255, 0.3) 0%, rgba(0, 212, 255, 0.1) 100%); - color: #00d4ff; - box-shadow: 0 0 15px rgba(0, 212, 255, 0.3); -} - -.tree-label { - display: flex; - align-items: center; - gap: 8px; -} - -/* 统计卡片包装 - 防止被 flex 压缩 */ +/* 统计卡片包装 */ .stats-wrapper { flex-shrink: 0; - min-height: 260px; } /* 侧边栏统计 - 环图 */ @@ -1093,6 +1419,8 @@ export default { align-items: center; padding: 6px 8px; min-height: 240px; + background: rgba(10, 20, 40, 0.85); + border-radius: 4px; } .total-row { @@ -1120,6 +1448,40 @@ export default { width: 100%; height: 150px; flex-shrink: 0; + position: relative; + z-index: 1; +} + +/* 环图外圈旋转流光 */ +.ring-chart-wrapper { + position: relative; + width: 100%; + height: 150px; +} + +.ring-chart-wrapper::after { + content: ''; + position: absolute; + inset: 0; + border-radius: 50%; + background: conic-gradient( + transparent 0deg, + transparent 300deg, + rgba(0, 212, 255, 0.15) 315deg, + rgba(0, 212, 255, 0.8) 335deg, + rgba(0, 212, 255, 0.9) 345deg, + rgba(0, 212, 255, 0.4) 355deg, + transparent 360deg + ); + -webkit-mask: radial-gradient(circle, transparent 44%, #fff 47%, #fff 70%, transparent 72%); + mask: radial-gradient(circle, transparent 44%, #fff 47%, #fff 70%, transparent 72%); + animation: ringGlowSpin 2s linear infinite; + pointer-events: none; + z-index: 2; +} + +@keyframes ringGlowSpin { + 100% { transform: rotate(360deg); } } .chart-legend { @@ -1156,14 +1518,6 @@ export default { .chart-dot.error { background: #ff6b6b; } .chart-dot.available { background: #00ff88; } -.header-badge { - background: rgba(0, 212, 255, 0.2); - color: #00d4ff; - font-size: 12px; - padding: 2px 8px; - border-radius: 10px; -} - /* 中间主内容区 */ .main-content { flex: 1; @@ -1264,6 +1618,11 @@ export default { gap: 8px; padding-bottom: 8px; will-change: transform; + cursor: grab; +} + +.grid-inner.grid-centered { + justify-content: center; } .grid-column { @@ -1369,10 +1728,263 @@ export default { flex-shrink: 0; } +/* 拆分状态卡片 */ +.split-wrapper { + flex-shrink: 0; +} + +.split-stats { + padding: 8px 12px; + display: flex; + flex-direction: column; + gap: 8px; + background: rgba(10, 20, 40, 0.85); + border-radius: 4px; +} + +.split-header { + display: flex; + align-items: center; + gap: 6px; +} + +.split-title-icon { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + color: #00d4ff; +} + +.flow-icon-svg { + display: block; +} + +.split-title-text { + font-size: 13px; + color: #00d4ff; + font-weight: 600; +} + +.split-body { + display: flex; + align-items: center; + justify-content: space-around; + gap: 8px; +} + +.split-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} + +.split-num { + font-size: 24px; + font-weight: bold; + line-height: 1.2; +} + +.split-num.unsplit { + color: #ffa726; + text-shadow: 0 0 10px rgba(255, 167, 38, 0.5); +} + +.split-num.split { + color: #42a5f5; + text-shadow: 0 0 10px rgba(66, 165, 245, 0.5); +} + +.split-lbl { + font-size: 11px; + color: #6a8cb5; +} + +.split-pct { + font-size: 10px; + color: #6a8cb5; +} + +.split-vr { + width: 1px; + height: 40px; + flex-shrink: 0; + background: linear-gradient(to bottom, transparent, rgba(0, 212, 255, 0.3), transparent); +} + +/* 层列信息卡片 */ +.layer-wrapper { + flex-shrink: 0; +} + +.layer-stats { + background: rgba(10, 20, 40, 0.85); + border-radius: 4px; +} + +.layer-header { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px 0; +} + +.layer-title-icon { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + color: #00d4ff; +} + +.layer-title-text { + font-size: 13px; + color: #00d4ff; + font-weight: 600; +} + +.layer-body { + padding: 6px 12px 10px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.layer-row { + display: flex; + justify-content: space-between; + align-items: center; +} + +.layer-lbl { + font-size: 11px; + color: #6a8cb5; +} + +.layer-val { + font-size: 12px; + color: #fff; + font-weight: 500; +} + +/* ===== 底部跃动方块 ===== */ +.eq-bar-container { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 32px; + display: flex; + align-items: flex-end; + justify-content: center; + gap: 3px; + padding: 0 20px; + box-sizing: border-box; + z-index: 20; + pointer-events: none; +} + +.eq-bar { + width: 3px; + min-height: 2px; + border-radius: 2px; + background: linear-gradient(to top, rgba(0, 212, 255, 0.1), #00d4ff); + box-shadow: 0 0 6px rgba(0, 212, 255, 0.5); + animation: eqBounce 1s ease-in-out infinite alternate; +} + +@keyframes eqBounce { + 0% { height: 3px; opacity: 0.2; } + 20% { height: 22px; opacity: 0.9; } + 40% { height: 8px; opacity: 0.4; } + 60% { height: 30px; opacity: 1; } + 80% { height: 14px; opacity: 0.6; } + 100% { height: 2px; opacity: 0.15; } +} + - +