Files
screen/src/views/screens/warehouse-overview/index.vue
2026-05-28 14:23:14 +08:00

2547 lines
78 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="warehouse-screen">
<div class="scale-wrapper" :style="scaleWrapperStyle">
<!-- 动态背景 -->
<div class="dynamic-bg">
<!-- 网格线 -->
<div class="grid-container">
<div class="grid-lines horizontal"></div>
<div class="grid-lines vertical"></div>
</div>
<!-- 粒子 -->
<div class="particles-container">
<div v-for="i in 30" :key="i" class="particle" :style="getParticleStyle(i)"></div>
</div>
<!-- 扫描线 -->
<div class="scan-line"></div>
<!-- 光晕效果 -->
<div class="glow-globe globe-1"></div>
<div class="glow-globe globe-2"></div>
<div class="glow-globe globe-3"></div>
</div>
<!-- 头部 -->
<header class="screen-header">
<dv-border-box-1 class="title-border">
<div class="title-box">
<span class="title-icon"><span class="diamond"></span></span>
<h1 class="screen-title">库区总览大屏</h1>
<span class="subtitle">Warehouse Overview Dashboard</span>
<span class="clock-text">{{ currentDate }}</span>
</div>
</dv-border-box-1>
</header>
<!-- 主内容 -->
<main class="screen-body">
<!-- 左侧边栏 -->
<aside class="sidebar">
<!-- 仓库结构下拉 -->
<div class="tree-panel">
<dv-border-box-8 :reverse="true">
<div class="panel-header header-compact">
<span class="header-icon">
<svg class="header-icon-svg" viewBox="0 0 24 24" width="18" height="18" color="#00d4ff">
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1" opacity="0.35"/>
<circle cx="12" cy="12" r="5" fill="none" stroke="currentColor" stroke-width="0.5" opacity="0.2"/>
<line x1="2" y1="12" x2="22" y2="12" stroke="currentColor" stroke-width="0.5" opacity="0.2"/>
<line x1="12" y1="2" x2="12" y2="22" stroke="currentColor" stroke-width="0.5" opacity="0.2"/>
<g class="radar-beam">
<path d="M12,12 L12,3 A9,9 0 0,1 21,12 Z" fill="currentColor" opacity="0.25"/>
</g>
<circle cx="12" cy="12" r="1.5" fill="#00d4ff" opacity="0.9"/>
</svg>
</span>
<span class="panel-title">仓库结构</span>
</div>
<div class="select-body">
<el-select
v-model="selectedNodeId"
placeholder="请选择仓库分区"
filterable
clearable
:teleported="false"
@change="handleSelectChange"
class="warehouse-select"
popper-class="warehouse-select-popper"
>
<el-option-group
v-for="group in warehouseOptions"
:key="group.label"
:label="group.label"
>
<el-option
v-for="item in group.options"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-option-group>
</el-select>
</div>
</dv-border-box-8>
</div>
<!-- 统计卡片 -->
<div class="stats-wrapper">
<dv-border-box-8 :reverse="true">
<div class="stats-sidebar">
<div class="total-row">
<span class="total-num">{{ animatedTotal }}</span>
<span class="total-lbl">总库位数</span>
</div>
<div class="ring-chart-wrapper">
<div ref="chartRef" class="ring-chart"></div>
</div>
<div class="util-text">
<span class="util-number">{{ statistics.utilization }}%</span>
<span class="util-label">利用率</span>
</div>
<div class="chart-legend">
<div class="legend-item">
<span class="chart-dot occupied"></span>
已占用 <span class="legend-val">{{ statistics.occupied }}</span>
</div>
<div class="legend-item">
<span class="chart-dot error"></span>
异常 <span class="legend-val">{{ statistics.error }}</span>
</div>
<div class="legend-item">
<span class="chart-dot available"></span>
空闲 <span class="legend-val">{{ statistics.available }}</span>
</div>
</div>
<div class="status-bar">
<span v-for="item in statusBarData" :key="item.label"
class="status-bar-fill"
:style="{ width: item.pct + '%', background: item.color }"></span>
</div>
<div class="status-bar-labels">
<div class="status-bar-label" v-for="item in statusBarData" :key="item.label">
<span class="sbl-dot" :style="{ background: item.color }"></span>
<span class="sbl-text">{{ item.label }} {{ item.pct }}%</span>
</div>
</div>
<div class="breath-indicators">
<div v-for="item in statusBarData" :key="'b'+item.label"
class="breath-dot"
:style="{
background: item.color,
animationDuration: Math.max(1.5, 4 - item.pct * 0.03) + 's',
boxShadow: '0 0 ' + Math.round(3 + item.pct * 0.12) + 'px ' + item.color
}"
:title="item.label + ' ' + item.pct + '%'">
</div>
</div>
<div class="micro-eq">
<span v-for="i in 18" :key="i" class="micro-eq-bar"
:style="{
animationDelay: (i * 0.08) + 's',
animationDuration: (1.2 + (i % 5) * 0.15) + 's'
}"></span>
</div>
<div class="scan-line-red"><div class="scan-beam"></div></div>
<div class="mini-chart">
<div class="mini-chart-bars">
<div v-for="i in 10" :key="i" class="mini-bar"
:style="{
height: (18 + (i * 7) % 52) + '%',
animationDelay: i * 0.12 + 's',
animationDuration: (1.8 + (i % 3) * 0.4) + 's'
}"></div>
</div>
<div class="mini-chart-axis"></div>
</div>
</div>
</dv-border-box-8>
</div>
<div class="split-wrapper">
<dv-border-box-8 :reverse="true">
<div class="split-stats">
<div class="split-header">
<span class="split-title-icon">
<svg class="flow-icon-svg" viewBox="0 0 24 24" width="16" height="16" color="#00d4ff">
<line x1="5" y1="7" x2="15" y2="7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" opacity="0.25"/>
<line x1="2" y1="12" x2="20" y2="12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" opacity="0.4"/>
<line x1="5" y1="17" x2="15" y2="17" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" opacity="0.25"/>
<circle r="2" fill="#00d4ff" opacity="0.9">
<animate attributeName="cx" values="2;20;2" dur="2s" repeatCount="indefinite"/>
<animate attributeName="cy" values="12;12;12" dur="2s" repeatCount="indefinite"/>
</circle>
<circle r="4" fill="none" stroke="#00d4ff" stroke-width="0.8" opacity="0.2">
<animate attributeName="cx" values="2;20;2" dur="2s" repeatCount="indefinite"/>
<animate attributeName="cy" values="12;12;12" dur="2s" repeatCount="indefinite"/>
</circle>
</svg>
</span>
<span class="split-title-text">库位拆分状态</span>
</div>
<div class="split-body">
<div class="split-item">
<span class="split-num unsplit">{{ splitStats.unsplit }}</span>
<span class="split-lbl">大库位</span>
<span class="split-pct">({{ splitStats.unsplitPct }}%)</span>
</div>
<div class="split-vr"></div>
<div class="split-item">
<span class="split-num split">{{ splitStats.split }}</span>
<span class="split-lbl">小库位</span>
<span class="split-pct">({{ splitStats.splitPct }}%)</span>
</div>
</div>
<div class="split-bar">
<div class="split-bar-fill unsplit" :style="{ width: splitStats.unsplitPct + '%' }"></div>
<div class="split-bar-fill split" :style="{ width: splitStats.splitPct + '%' }"></div>
</div>
<div class="split-bar-labels">
<span class="sbl-item">
<span class="sbl-dot unsplit-dot"></span>大库位 {{ splitStats.unsplitPct }}%
</span>
<span class="sbl-item">
<span class="sbl-dot split-dot"></span>小库位 {{ splitStats.splitPct }}%
</span>
</div>
</div>
</dv-border-box-8>
</div>
<!-- 层列信息 -->
<div class="layer-wrapper">
<div class="layer-stats">
<dv-border-box-8 :reverse="true">
<div class="layer-header">
<span class="layer-title-icon">
<svg class="flow-icon-svg" viewBox="0 0 24 24" width="16" height="16" color="#00d4ff">
<line x1="5" y1="7" x2="15" y2="7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" opacity="0.25"/>
<line x1="2" y1="12" x2="20" y2="12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" opacity="0.4"/>
<line x1="5" y1="17" x2="15" y2="17" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" opacity="0.25"/>
<circle r="2" fill="#00d4ff" opacity="0.9">
<animate attributeName="cx" values="2;20;2" dur="2.5s" repeatCount="indefinite"/>
<animate attributeName="cy" values="12;12;12" dur="2.5s" repeatCount="indefinite"/>
</circle>
<circle r="4" fill="none" stroke="#00d4ff" stroke-width="0.8" opacity="0.2">
<animate attributeName="cx" values="2;20;2" dur="2.5s" repeatCount="indefinite"/>
<animate attributeName="cy" values="12;12;12" dur="2.5s" repeatCount="indefinite"/>
</circle>
</svg>
</span>
<span class="layer-title-text">层列信息</span>
</div>
<div class="layer-body">
<div class="layer-row">
<span class="layer-lbl">总列数</span>
<span class="layer-val">{{ columnStats.totalColumns }}</span>
</div>
<div class="layer-row">
<span class="layer-lbl">1层库位</span>
<span class="layer-val">{{ columnStats.totalLayer1 }} </span>
</div>
<div class="layer-row">
<span class="layer-lbl">2层库位</span>
<span class="layer-val">{{ columnStats.totalLayer2 }} </span>
</div>
<div class="layer-row">
<span class="layer-lbl">每列平均</span>
<span class="layer-val">{{ columnStats.avgPerColumn }} </span>
</div>
</div>
</dv-border-box-8>
</div>
</div>
</aside>
<!-- 中间大框库位列表 -->
<section class="main-content">
<dv-border-box1 ref="borderRef">
<div class="panel">
<div class="panel-header">
<span class="header-icon">
<svg class="header-icon-svg" viewBox="0 0 24 24" width="18" height="18" color="#00d4ff">
<line x1="4" y1="8" x2="18" y2="8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" opacity="0.25"/>
<line x1="4" y1="12" x2="18" y2="12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" opacity="0.4"/>
<line x1="4" y1="16" x2="18" y2="16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" opacity="0.25"/>
<circle r="2" fill="#00d4ff" opacity="0.9">
<animate attributeName="cx" values="4;18;4" dur="2s" repeatCount="indefinite"/>
<animate attributeName="cy" values="12;12;12" dur="2s" repeatCount="indefinite"/>
</circle>
<circle r="4" fill="none" stroke="#00d4ff" stroke-width="0.8" opacity="0.2">
<animate attributeName="cx" values="4;18;4" dur="2s" repeatCount="indefinite"/>
<animate attributeName="cy" values="12;12;12" dur="2s" repeatCount="indefinite"/>
</circle>
</svg>
</span>
<span class="panel-title">{{ selectedNodeName || '库位列表' }}</span>
<span class="panel-count"> {{ warehouseList.length }} </span>
</div>
<div class="panel-body table-container">
<div v-if="warehouseList.length === 0" class="empty-tip">
<span class="empty-icon">📭</span>
<div class="empty-text">请从左侧选择仓库分区</div>
</div>
<template v-else>
<!-- 图例 -->
<div class="grid-legend">
<div class="legend-item">
<span class="legend-dot available"></span>
<span>空闲</span>
</div>
<div class="legend-item">
<span class="legend-dot occupied"></span>
<span>已占用</span>
</div>
<div class="legend-item">
<span class="legend-dot error"></span>
<span>异常</span>
</div>
<div class="legend-item">
<span class="legend-dot has-coil-icon"></span>
<span>有钢卷</span>
</div>
</div>
<!-- 库位网格外层容器负责裁剪内层负责 flex 布局 + transform 移动 -->
<div ref="gridContainerRef" class="warehouse-grid" @mouseenter="onGridMouseEnter" @mouseleave="onGridMouseLeave">
<div class="grid-inner" :class="{ 'grid-centered': isGridCentered }" ref="gridInnerRef" @mousedown="onGridDragStart">
<div
v-for="colNum in sortedColumnKeys"
:key="colNum"
class="grid-column"
:style="{ minWidth: columnWidth + 'px' }"
>
<div class="column-header">{{ colNum }}</div>
<div class="column-pair">
<div class="layer-cells layer1-cells">
<div
v-for="wh in gridData[colNum].layer1"
:key="wh.actualWarehouseId"
class="grid-cell"
:class="[getStatusClass(wh), 'cell-layer-1', { 'has-coil': !!wh.currentCoilNo }]"
:style="cellStyle"
@click="showDetail(wh, $event)"
>
<span class="cell-code">{{ wh.actualWarehouseCode }}</span>
<span v-if="wh.currentCoilNo" class="cell-coil">{{ wh.currentCoilNo }}</span>
</div>
</div>
<div class="layer-cells layer2-cells" :style="{ marginTop: cellMarginOffset + 'px' }">
<div
v-for="wh in gridData[colNum].layer2"
:key="wh.actualWarehouseId"
class="grid-cell"
:class="[getStatusClass(wh), 'cell-layer-2', { 'has-coil': !!wh.currentCoilNo }]"
:style="cellStyle"
@click="showDetail(wh, $event)"
>
<span class="cell-code">{{ wh.actualWarehouseCode }}</span>
<span v-if="wh.currentCoilNo" class="cell-coil">{{ wh.currentCoilNo }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</dv-border-box1>
</section>
</main>
<!-- 底部跃动方块 -->
<div class="eq-bar-container">
<div
v-for="i in 40"
:key="i"
class="eq-bar"
:style="getEqBarStyle(i)"
></div>
</div>
</div><!-- /scale-wrapper -->
<!-- 库位详情浮层 -->
<div
v-show="!!detailWarehouse"
ref="tooltipRef"
class="warehouse-tooltip"
:style="tooltipStyle"
@mousedown.stop
>
<template v-if="detailWarehouse">
<div class="tooltip-header">
<span class="tooltip-code">{{ detailWarehouse.actualWarehouseCode }}</span>
<span class="tooltip-status" :class="getStatusClass(detailWarehouse)">{{ getStatusText(detailWarehouse) }}</span>
</div>
<div class="tooltip-divider"></div>
<div class="tooltip-body">
<div class="tooltip-row">
<span class="tooltip-label">库位名称</span>
<span class="tooltip-value">{{ detailWarehouse.actualWarehouseName }}</span>
</div>
<div class="tooltip-row">
<span class="tooltip-label">钢卷编号</span>
<span class="tooltip-value highlight" v-if="detailWarehouse.currentCoilNo">{{ detailWarehouse.currentCoilNo }}</span>
<span class="tooltip-value dim" v-else></span>
</div>
<div class="tooltip-row">
<span class="tooltip-label">数量</span>
<span class="tooltip-value">{{ detailWarehouse.coilQuantity || 0 }}</span>
</div>
<div class="tooltip-row" v-if="detailWarehouse.parsedInfo">
<span class="tooltip-label">位置</span>
<span class="tooltip-value">{{ detailWarehouse.parsedInfo.column }} {{ detailWarehouse.parsedInfo.row }} {{ detailWarehouse.parsedInfo.layer }}</span>
</div>
</div>
</template>
</div>
</div><!-- /warehouse-screen -->
</template>
<script>
import { ref, reactive, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import * as echarts from 'echarts'
import { treeActualWarehouseTwoLevel, listActualWarehouse } from '@/api/wms/actualWarehouse'
export default {
name: 'WarehouseOverview',
setup() {
// 时间显示
const currentDate = ref('')
let timeInterval = null
// 树形数据
const warehouseTree = ref([])
// 下拉选项(树形结构展平为分组)
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('')
const selectedNodeName = ref('')
// 库位列表Mock数据
const warehouseList = ref([])
// 统计数据
const statistics = reactive({
total: 0,
error: 0,
occupied: 0,
available: 0,
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 statusBarData = computed(() => {
const total = statistics.total || 1
return [
{ label: '已占用', pct: Math.round(statistics.occupied / total * 100), color: '#7c63ff' },
{ label: '异常', pct: Math.round(statistics.error / total * 100), color: '#ff6b6b' },
{ label: '空闲', pct: Math.round(statistics.available / total * 100), color: '#00ff88' }
]
})
// 动画数字
const animatedTotal = ref(0)
const animatedOccupied = ref(0)
const animatedError = ref(0)
const animatedAvailable = ref(0)
const animatedUtilization = ref(0)
// 加载状态
const loading = ref(false)
// 缩放比例transform scale 适配超大屏)
const scaleRatio = ref(1)
const scaleWrapperStyle = computed(() => ({
transform: `scale(${scaleRatio.value})`
}))
const updateScale = () => {
scaleRatio.value = Math.min(window.innerWidth / 1920, window.innerHeight / 1080)
}
// 网格数据
const gridData = ref({})
// 排序的列键
const sortedColumnKeys = computed(() => {
return Object.keys(gridData.value).sort((a, b) => Number(a) - Number(b))
})
// 详情浮层
const detailWarehouse = ref(null)
const tooltipRef = ref(null)
const tooltipPos = reactive({ x: 0, y: 0 })
const tooltipStyle = computed(() => ({
left: tooltipPos.x + 'px',
top: tooltipPos.y + 'px'
}))
// 容器尺寸观测(自适应网格)
const gridContainerRef = ref(null)
const containerRect = reactive({ width: 800, height: 600 })
let resizeObserver = null
let chartResizeObserver = null
// 自适应格子尺寸(错层布局:高宽独立)
const maxLayerRows = computed(() => {
const keys = sortedColumnKeys.value
let m = 0
keys.forEach(k => {
const col = gridData.value[k]
if (col) {
m = Math.max(m, col.layer1.length, col.layer2.length)
}
})
return Math.max(m, 1)
})
const cellHeight = computed(() => {
const keys = sortedColumnKeys.value
const colCount = keys.length
if (!colCount) return 60
const mRows = maxLayerRows.value
const ch = containerRect.height
const h = (ch - 120 - (mRows - 1) * 4) / mRows
// 大屏下按容器高度比例放大,不再固定封顶 120
const maxH = Math.floor(ch * 0.15)
return Math.max(48, Math.min(Math.max(maxH, 120), Math.floor(h)))
})
const cellWidth = computed(() => {
const keys = sortedColumnKeys.value
const colCount = keys.length
if (!colCount) return 60
const cw = containerRect.width
const byWidth = (cw - 30 - (colCount - 1) * 8) / colCount
let w = (byWidth - 6) / 2
if (colCount <= 15) {
w = Math.max(w, cellHeight.value)
}
// 大屏下按列宽比例放大,不再固定封顶 140
const maxW = Math.floor(byWidth * 0.45)
return Math.max(30, Math.min(Math.max(maxW, 140), Math.floor(w)))
})
// 错层偏移量(二层下移半格)
const cellMarginOffset = computed(() => Math.round(cellHeight.value / 2))
// 网格居中列数少于15时居中更美观
const isGridCentered = computed(() => {
return sortedColumnKeys.value.length <= 15
})
const cellStyle = computed(() => {
const h = cellHeight.value
const w = cellWidth.value
const fs = Math.max(9, Math.min(11, Math.floor(Math.min(w, h) * 0.14)))
return { width: w + 'px', height: h + 'px', fontSize: fs + 'px' }
})
const columnWidth = computed(() => cellWidth.value * 2 + 14)
// 测量网格容器尺寸(始终使用 pre-scale 尺寸,避免缩放反馈循环)
const measureGridContainer = () => {
nextTick(() => {
const el = gridContainerRef.value
if (!el) return
containerRect.width = el.clientWidth
containerRect.height = el.clientHeight
// 持久化 ResizeObserver监听容器尺寸变化
if (!resizeObserver) {
resizeObserver = new ResizeObserver(entries => {
for (const entry of entries) {
containerRect.width = entry.contentRect.width
containerRect.height = entry.contentRect.height
}
nextTick(() => checkOverflow())
})
resizeObserver.observe(el)
}
// 检查是否需要自动滚动(立即 + 延迟,确保布局完成)
checkOverflow()
setTimeout(() => checkOverflow(), 400)
})
}
// --- 自动轮播滚动transform 驱动,不依赖 overflow ---
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
// 使用预测量的溢出距离
if (overflowDist.x <= 0 && overflowDist.y <= 0) return
const step = () => {
if (isScrollPaused) return
let changed = false
const inner = gridInnerRef.value
if (overflowDist.x > 0) {
// 渐进减速:距终点 100px 内线性降低速度
const endX = overflowDist.x + 40
const remainingX = endX - scrollPos.x
const decelDist = 100
const speedX = remainingX < decelDist
? Math.max(0.2, remainingX / decelDist)
: 1
scrollPos.x += speedX
if (scrollPos.x >= endX) {
scrollPos.x = 0
changed = true
// CSS 过渡实现平滑回弹
if (inner) {
inner.style.transition = 'transform 0.35s ease'
setTimeout(() => {
if (inner) inner.style.transition = ''
}, 400)
}
}
}
if (overflowDist.y > 0) {
const endY = overflowDist.y + 30
const remainingY = endY - scrollPos.y
const speedY = remainingY < 100
? Math.max(0.1, remainingY / 100)
: 0.5
scrollPos.y += speedY
if (scrollPos.y >= endY) {
scrollPos.y = 0
changed = true
if (inner) {
inner.style.transition = 'transform 0.35s ease'
setTimeout(() => {
if (inner) inner.style.transition = ''
}, 400)
}
}
}
if (changed) {
isScrollPaused = true
clearTimeout(pauseTimer)
pauseTimer = setTimeout(() => { isScrollPaused = false }, 2000)
}
applyTransform()
}
scrollTimer = setInterval(step, 30)
}
const stopAutoScroll = () => {
if (scrollTimer) {
clearInterval(scrollTimer)
scrollTimer = null
}
if (pauseTimer) {
clearTimeout(pauseTimer)
pauseTimer = null
}
if (stayTimer) {
clearTimeout(stayTimer)
stayTimer = null
}
}
const resetScroll = () => {
scrollPos.x = 0
scrollPos.y = 0
applyTransform()
}
const checkOverflow = () => {
const el = gridContainerRef.value
const inner = gridInnerRef.value
if (!el || !inner) return
stopAutoScroll()
resetScroll()
// 等待一帧让布局稳定再测量溢出
requestAnimationFrame(() => {
requestAnimationFrame(() => {
overflowDist.x = Math.max(inner.scrollWidth - el.clientWidth, 0)
overflowDist.y = Math.max(inner.scrollHeight - el.clientHeight, 0)
startAutoScroll()
})
})
}
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
const s = scaleRatio.value
scrollPos.x = Math.max(0, Math.min(overflowDist.x + 40, dragState.startScrollX - dx / s))
scrollPos.y = Math.max(0, Math.min(overflowDist.y + 30, dragState.startScrollY - dy * 0.5 / s))
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())
})
// 更新时间
const updateTime = () => {
const now = new Date()
currentDate.value = now.toLocaleString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
// 获取粒子样式
const getParticleStyle = (index) => ({
left: `${Math.random() * 100}%`,
animationDelay: `${index * 0.3}s`,
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()
const animate = (currentTime) => {
const elapsed = currentTime - startTime
const progress = Math.min(elapsed / duration, 1)
const easeOut = 1 - Math.pow(1 - progress, 4)
const currentValue = Math.round(start + (end - start) * easeOut)
callback(currentValue)
if (progress < 1) {
requestAnimationFrame(animate)
}
}
requestAnimationFrame(animate)
}
// ===== 环图 =====
const chartRef = ref(null)
let chartInstance = null
let chartInitTimer = null
const initChart = () => {
// dv-border-box8 渲染较慢,需要延迟以确保容器有尺寸
if (chartInitTimer) clearTimeout(chartInitTimer)
chartInitTimer = setTimeout(() => {
const el = chartRef.value
if (!el || chartInstance) return
chartInstance = echarts.init(el, null, { renderer: 'canvas' })
updateChart()
// 监听 chart 容器尺寸变化,大屏缩放时实时重绘
const parent = el.parentElement
if (parent && !chartResizeObserver) {
chartResizeObserver = new ResizeObserver(() => {
if (chartInstance) chartInstance.resize()
})
chartResizeObserver.observe(parent)
}
}, 300)
}
const updateChart = () => {
if (!chartInstance) return
chartInstance.setOption({
backgroundColor: 'transparent',
tooltip: { trigger: 'item', formatter: '{b}: {c} 个 ({d}%)' },
series: [{
type: 'pie',
radius: ['50%', '72%'],
center: ['50%', '50%'],
avoidLabelOverlap: false,
label: { show: false },
emphasis: {
label: { show: true, fontSize: 14, fontWeight: 'bold', color: '#fff' }
},
animationDuration: 1200,
data: [
{ value: statistics.occupied || 0, name: '已占用', itemStyle: { color: '#7c63ff' } },
{ value: statistics.error || 0, name: '异常', itemStyle: { color: '#ff6b6b' } },
{ value: statistics.available || 0, name: '空闲', itemStyle: { color: '#00ff88' } }
]
}],
})
}
const handleChartResize = () => {
if (chartInstance) chartInstance.resize()
}
// 获取仓库树形结构(两级)
const fetchWarehouseTree = async () => {
try {
loading.value = true
const data = await treeActualWarehouseTwoLevel()
warehouseTree.value = data || []
} catch (error) {
console.error('获取仓库树形结构失败:', error)
} finally {
loading.value = false
}
}
// 下拉选择变更
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
}
}
}
}
// 获取库位列表
const getWarehouseList = async (parentId) => {
try {
loading.value = true
const data = await listActualWarehouse({ parentId })
warehouseList.value = data || []
buildGridData()
measureGridContainer()
// 确保环图已初始化(数据加载时 DOM 必定就绪)
nextTick(() => {
if (!chartInstance) initChart()
else updateChart()
})
// 统计数据 — 与 getStatusClass 逻辑一一对应
const total = warehouseList.value.length
const occupied = warehouseList.value.filter(w =>
w.isEnabled === 0 && w.currentCoilNo
).length
const available = warehouseList.value.filter(w =>
w.isEnabled === 1 && !w.currentCoilNo
).length
const error = total - occupied - available
const usable = occupied + available
const utilization = usable > 0 ? Math.round((occupied / usable) * 100) : 0
// 触发动画
animateValue(animatedTotal.value, total, (val) => { animatedTotal.value = val })
animateValue(animatedOccupied.value, occupied, (val) => { animatedOccupied.value = val })
animateValue(animatedError.value, error, (val) => { animatedError.value = val })
animateValue(animatedAvailable.value, available, (val) => { animatedAvailable.value = val })
animateValue(animatedUtilization.value, utilization, (val) => { animatedUtilization.value = val })
statistics.total = total
statistics.occupied = occupied
statistics.error = error
statistics.available = available
statistics.utilization = utilization
updateChart()
} catch (error) {
console.error('获取库位列表失败:', error)
} finally {
loading.value = false
}
}
// 获取状态文本
const getStatusText = (row) => {
if (row.isEnabled === 0 && row.currentCoilNo) {
return '已占用'
} else if (row.isEnabled === 1 && !row.currentCoilNo) {
return '空闲'
} else {
return '异常'
}
}
// 获取状态样式类
const getStatusClass = (row) => {
if (row.isEnabled === 0 && row.currentCoilNo) {
return 'occupied'
} else if (row.isEnabled === 1 && !row.currentCoilNo) {
return 'available'
} else {
return 'error'
}
}
// 解析库位编码:支持三级编码 F2B101-01-1 和四级编码 F2B3-X38-2
const parseWarehouseCode = (code) => {
if (!code) return null
// 先尝试四级编码 F2B3-X38-2
const reg4 = /^([A-Za-z0-9]{3})([^-]+)-X(\d{2})-(\d+)$/
const match4 = code.match(reg4)
if (match4) {
return {
level: 4,
column: Number(match4[2]),
row: Number(match4[3]),
layer: match4[4]
}
}
// 再尝试三级编码 F2B101-01-1
const reg3 = /^([A-Za-z0-9]{3})([^-]+)-(\d{2})-(\d+)$/
const match3 = code.match(reg3)
if (match3) {
return {
level: 3,
column: Number(match3[2]),
row: Number(match3[3]),
layer: match3[4]
}
}
return null
}
// 构建网格数据结构:按列分组 → 按层分行
const buildGridData = () => {
const columns = {}
warehouseList.value.forEach(wh => {
const parsed = parseWarehouseCode(wh.actualWarehouseCode)
if (!parsed) return
wh.parsedInfo = parsed
if (!columns[parsed.column]) {
columns[parsed.column] = { layer1: [], layer2: [] }
}
if (String(parsed.layer) === '1') {
columns[parsed.column].layer1.push(wh)
} else {
columns[parsed.column].layer2.push(wh)
}
})
Object.values(columns).forEach(col => {
col.layer1.sort((a, b) => a.parsedInfo.row - b.parsedInfo.row)
col.layer2.sort((a, b) => a.parsedInfo.row - b.parsedInfo.row)
})
gridData.value = columns
}
// 显示库位详情浮层基于视口坐标tooltip 使用 position: fixed
const showDetail = (wh, event) => {
detailWarehouse.value = wh
const cellRect = event.currentTarget.getBoundingClientRect()
const vw = window.innerWidth
const vh = window.innerHeight
let left = cellRect.right + 8
let top = cellRect.top - 10
const tw = 260
const th = 180
const pad = 10
if (left + tw > vw - pad) {
left = cellRect.left - tw - 8
}
if (top + th > vh - pad) {
top = vh - th - pad
}
tooltipPos.x = Math.round(Math.max(pad, left))
tooltipPos.y = Math.round(Math.max(pad, top))
}
const closeTooltip = () => {
detailWarehouse.value = null
}
const handleDocumentMousedown = (e) => {
if (!detailWarehouse.value) return
const el = tooltipRef.value
if (el && !el.contains(e.target)) {
closeTooltip()
}
}
// 全屏切换 → 刷新 popper 位置
const handleFullscreenChange = () => {
setTimeout(() => window.dispatchEvent(new Event('resize')), 100)
}
// 初始化
onMounted(() => {
updateScale()
window.addEventListener('resize', updateScale)
updateTime()
timeInterval = setInterval(updateTime, 1000)
// 加载仓库树形结构
fetchWarehouseTree()
// 初始化环图
initChart()
document.addEventListener('mousedown', handleDocumentMousedown)
window.addEventListener('resize', handleChartResize)
document.addEventListener('fullscreenchange', handleFullscreenChange)
document.addEventListener('webkitfullscreenchange', handleFullscreenChange)
})
onBeforeUnmount(() => {
stopWatchList()
if (timeInterval) clearInterval(timeInterval)
if (resizeObserver) resizeObserver.disconnect()
if (chartResizeObserver) chartResizeObserver.disconnect()
stopAutoScroll()
resetScroll()
window.removeEventListener('resize', updateScale)
document.removeEventListener('mousemove', onGridDragMove)
document.removeEventListener('mouseup', onGridDragEnd)
if (chartInitTimer) clearTimeout(chartInitTimer)
document.removeEventListener('mousedown', handleDocumentMousedown)
window.removeEventListener('resize', handleChartResize)
document.removeEventListener('fullscreenchange', handleFullscreenChange)
document.removeEventListener('webkitfullscreenchange', handleFullscreenChange)
if (chartInstance) {
chartInstance.dispose()
chartInstance = null
}
})
return {
scaleWrapperStyle,
currentDate,
warehouseTree,
warehouseOptions,
warehouseList,
selectedNodeName,
statistics,
splitStats,
columnStats,
animatedTotal,
animatedOccupied,
animatedError,
animatedAvailable,
animatedUtilization,
statusBarData,
getParticleStyle,
getEqBarStyle,
handleSelectChange,
getStatusText,
getStatusClass,
// 网格
gridData,
sortedColumnKeys,
detailWarehouse,
showDetail,
tooltipRef,
tooltipStyle,
cellStyle,
cellMarginOffset,
columnWidth,
isGridCentered,
onGridMouseEnter,
onGridMouseLeave,
onGridDragStart,
chartRef,
gridContainerRef,
gridInnerRef
};
}
};
</script>
<style scoped>
.warehouse-screen {
width: 100vw;
height: 100vh;
background: linear-gradient(135deg, #050a15 0%, #0a1428 50%, #0d1b34 100%);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
}
.scale-wrapper {
width: 1920px;
height: 1080px;
flex-shrink: 0;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
transform-origin: center center;
}
/* 动态背景 */
.dynamic-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
overflow: hidden;
z-index: 0;
}
/* 网格背景 */
.grid-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.grid-lines {
position: absolute;
width: 100%;
height: 100%;
opacity: 0.15;
}
.grid-lines.horizontal {
background: repeating-linear-gradient(
0deg,
transparent,
transparent 50px,
rgba(0, 212, 255, 0.1) 50px,
rgba(0, 212, 255, 0.1) 51px
);
animation: gridMoveHorizontal 20s linear infinite;
}
.grid-lines.vertical {
background: repeating-linear-gradient(
90deg,
transparent,
transparent 50px,
rgba(0, 212, 255, 0.1) 50px,
rgba(0, 212, 255, 0.1) 51px
);
animation: gridMoveVertical 20s linear infinite;
}
@keyframes gridMoveHorizontal {
0% { transform: translateY(0); }
100% { transform: translateY(50px); }
}
@keyframes gridMoveVertical {
0% { transform: translateX(0); }
100% { transform: translateX(50px); }
}
/* 粒子效果 */
.particles-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.particle {
position: absolute;
width: 6px;
height: 6px;
background: radial-gradient(circle, #00d4ff 0%, rgba(0, 212, 255, 0.5) 50%, transparent 100%);
border-radius: 50%;
animation: particleFloat 10s ease-in-out infinite;
box-shadow:
0 0 10px #00d4ff,
0 0 20px rgba(0, 212, 255, 0.6),
0 0 40px rgba(0, 212, 255, 0.3);
}
@keyframes particleFloat {
0%, 100% {
top: 100%;
transform: translateX(0) scale(0.5);
opacity: 0;
}
10% {
top: 80%;
transform: translateX(0) scale(1);
opacity: 1;
}
50% {
top: 50%;
transform: translateX(30px) scale(1.2);
}
90% {
opacity: 0.8;
top: 10%;
transform: translateX(-20px) scale(0.8);
}
}
/* 扫描线 */
.scan-line {
position: absolute;
top: -10%;
left: 0;
width: 100%;
height: 100px;
background: linear-gradient(
to bottom,
transparent 0%,
rgba(0, 212, 255, 0.1) 50%,
transparent 100%
);
animation: scanDown 8s linear infinite;
}
@keyframes scanDown {
0% { top: -10%; }
100% { top: 110%; }
}
/* 光晕效果 */
.glow-globe {
position: absolute;
border-radius: 50%;
filter: blur(100px);
opacity: 0.3;
animation: globePulse 8s ease-in-out infinite;
}
.glow-globe.globe-1 {
width: 400px;
height: 400px;
background: radial-gradient(circle, rgba(0, 212, 255, 0.4) 0%, transparent 70%);
top: 10%;
right: 10%;
animation-delay: 0s;
}
.glow-globe.globe-2 {
width: 300px;
height: 300px;
background: radial-gradient(circle, rgba(124, 99, 255, 0.3) 0%, transparent 70%);
bottom: 20%;
left: 5%;
animation-delay: 2s;
}
.glow-globe.globe-3 {
width: 350px;
height: 350px;
background: radial-gradient(circle, rgba(0, 255, 136, 0.2) 0%, transparent 70%);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation-delay: 4s;
}
@keyframes globePulse {
0%, 100% {
transform: scale(1);
opacity: 0.3;
}
50% {
transform: scale(1.3);
opacity: 0.5;
}
}
.bg-border {
position: absolute;
top: 10px;
left: 10px;
right: 10px;
bottom: 10px;
pointer-events: none;
z-index: 100;
}
/* 头部 */
.screen-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
position: relative;
z-index: 10;
}
.title-border {
flex: 1;
}
.title-box {
padding: 15px 30px;
display: flex;
align-items: center;
gap: 15px;
background: rgba(10, 20, 40, 0.8);
}
.title-icon {
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 {
font-size: 32px;
color: #00d4ff;
margin: 0;
text-shadow: 0 0 20px rgba(0, 212, 255, 0.8), 0 0 40px rgba(0, 212, 255, 0.4);
letter-spacing: 8px;
}
.subtitle {
font-size: 14px;
color: #6a8cb5;
margin-left: 10px;
}
.date-border {
padding: 5px;
}
.date-box {
padding: 10px 20px;
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;
gap: 20px;
flex: 1;
min-height: 0;
position: relative;
z-index: 10;
overflow: visible;
}
.sidebar {
width: 300px;
display: flex;
flex-direction: column;
gap: 15px;
min-height: 0;
font-size: 14px;
}
.tree-panel {
flex-shrink: 0;
position: relative;
background: rgba(10, 20, 40, 0.85);
border-radius: 4px;
}
.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 {
background: rgba(10, 20, 40, 0.85);
border-radius: 6px;
overflow: hidden;
height: 100%;
display: flex;
flex-direction: column;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
background: linear-gradient(90deg, rgba(0, 212, 255, 0.15) 0%, transparent 100%);
border-bottom: 1px solid rgba(0, 212, 255, 0.2);
}
.header-icon {
color: #00d4ff;
font-size: 18px;
}
.panel-title {
font-size: 16px;
color: #00d4ff;
font-weight: 600;
text-shadow: 0 0 10px rgba(0, 212, 255, 0.5);
}
.panel-count {
font-size: 12px;
color: #6a8cb5;
}
.panel-body {
flex: 1;
padding: 15px 10px;
overflow: hidden;
min-height: 0;
}
/* 下拉选择器样式 */
.select-body {
padding: 2px 10px 10px;
}
.warehouse-select {
width: 100%;
}
/* 统计卡片包装 */
.stats-wrapper {
flex: 2;
min-height: 0;
}
/* 侧边栏统计 - 环图 */
.stats-sidebar {
display: flex;
flex-direction: column;
align-items: center;
padding: 6px 8px 0;
flex: 1;
background: rgba(10, 20, 40, 0.85);
border-radius: 4px;
}
.total-row {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 2px;
}
.total-num {
font-size: 32px;
font-weight: bold;
color: #00d4ff;
text-shadow: 0 0 20px rgba(0, 212, 255, 0.6);
line-height: 1.1;
}
.total-lbl {
font-size: 11px;
color: #6a8cb5;
margin-top: 1px;
}
.ring-chart {
width: 100%;
flex: 1;
min-height: 80px;
position: relative;
z-index: 1;
}
/* 环图外圈旋转流光 */
.ring-chart-wrapper {
position: relative;
width: 100%;
flex: 1;
min-height: 80px;
}
.ring-chart-wrapper::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 44%;
height: 44%;
transform: translate(-50%, -50%) rotate(0deg);
border-radius: 50%;
background: conic-gradient(
transparent 0deg,
transparent 310deg,
rgba(0, 212, 255, 0.08) 330deg,
rgba(0, 212, 255, 0.5) 345deg,
rgba(0, 212, 255, 0.7) 350deg,
rgba(0, 212, 255, 0.15) 355deg,
transparent 360deg
);
animation: radarSweep 2.5s linear infinite;
z-index: 0;
pointer-events: none;
}
@keyframes radarSweep {
from { transform: translate(-50%, -50%) rotate(0deg); }
to { transform: translate(-50%, -50%) rotate(360deg); }
}
.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); }
}
.util-text {
text-align: center;
padding: 4px 0 2px;
display: flex;
flex-direction: column;
align-items: center;
gap: 1px;
}
.util-number {
font-size: 22px;
font-weight: bold;
color: #00d4ff;
font-family: Arial, sans-serif;
line-height: 1.2;
text-shadow: 0 0 10px rgba(0, 212, 255, 0.5);
}
.util-label {
font-size: 12px;
color: #6a8cb5;
line-height: 1.2;
}
.chart-legend {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
padding: 0 6px;
margin-top: 2px;
}
.chart-legend .legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: #a0c4e8;
}
.chart-legend .legend-val {
margin-left: auto;
font-weight: 600;
color: #fff;
}
.chart-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.chart-dot.occupied { background: #7c63ff; }
.chart-dot.error { background: #ff6b6b; }
.chart-dot.available { background: #00ff88; }
/* 状态占比条 */
.status-bar {
display: flex;
width: 100%;
height: 8px;
border-radius: 4px;
overflow: hidden;
margin: 6px 8px 0;
background: rgba(255, 255, 255, 0.05);
gap: 2px;
}
.status-bar-fill {
height: 100%;
border-radius: 4px;
transition: width 0.6s ease;
min-width: 0;
}
.status-bar-labels {
display: flex;
width: 100%;
justify-content: space-around;
padding: 3px 8px 0;
}
.status-bar-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 10px;
color: #8aa8c8;
}
.sbl-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.sbl-text {
white-space: nowrap;
}
/* 数据呼吸指示器 */
.breath-indicators {
display: flex;
justify-content: center;
gap: 20px;
padding: 6px 0 0;
width: 100%;
}
.breath-dot {
width: 8px;
height: 8px;
border-radius: 50%;
animation: breathPulse 2s ease-in-out infinite;
}
@keyframes breathPulse {
0%, 100% { transform: scale(0.5); opacity: 0.3; }
50% { transform: scale(1.4); opacity: 1; }
}
/* 微频谱条 */
.micro-eq {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 3px;
padding: 6px 10px 0;
width: 100%;
height: 18px;
}
.micro-eq-bar {
width: 3px;
min-height: 2px;
border-radius: 1px;
background: linear-gradient(to top, transparent, rgba(0, 212, 255, 0.3), #00d4ff);
animation: microEqBounce 1s ease-in-out infinite alternate;
}
@keyframes microEqBounce {
0% { height: 2px; opacity: 0.1; }
50% { height: 14px; opacity: 0.7; }
100% { height: 3px; opacity: 0.15; }
}
/* 红色扫描线 */
.scan-line-red {
position: relative;
height: 20px;
width: 100%;
overflow: hidden;
}
.scan-line-red .scan-beam {
position: absolute;
top: 0;
left: -10%;
width: 8px;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 50, 50, 0.8), transparent);
box-shadow: 0 0 12px rgba(255, 50, 50, 0.6), 0 0 30px rgba(255, 50, 50, 0.2);
border-radius: 4px;
animation: scanRedBeam 3s ease-in-out infinite;
}
@keyframes scanRedBeam {
0% { left: -10%; opacity: 0; }
10% { opacity: 1; }
50% { left: 100%; opacity: 1; }
60% { opacity: 0.3; }
100% { left: 100%; opacity: 0; }
}
/* 动态柱状图 */
.mini-chart {
width: 100%;
padding: 4px 12px 0;
margin-top: auto;
display: flex;
flex-direction: column;
}
.mini-chart-bars {
display: flex;
align-items: flex-end;
justify-content: space-around;
height: 36px;
gap: 2px;
}
.mini-bar {
width: 8px;
min-height: 2px;
border-radius: 2px 2px 0 0;
background: linear-gradient(to top, rgba(0, 212, 255, 0.15), rgba(0, 212, 255, 0.6));
animation: miniBarPulse 2s ease-in-out infinite alternate;
}
.mini-chart-axis {
height: 1px;
background: linear-gradient(90deg, transparent, rgba(0, 212, 255, 0.2), transparent);
margin-top: 2px;
}
@keyframes miniBarPulse {
0% { opacity: 0.3; transform: scaleY(0.4); transform-origin: bottom; }
50% { opacity: 0.9; transform: scaleY(1); transform-origin: bottom; }
100% { opacity: 0.4; transform: scaleY(0.6); transform-origin: bottom; }
}
/* 中间主内容区 */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
min-width: 0;
}
.main-panel {
flex: 1;
height: 100%;
border: none;
box-shadow: none;
}
.table-container {
padding: 15px;
height: calc(100% - 60px);
overflow: hidden;
background: rgba(10, 20, 40, 0.85);
border-radius: 6px;
display: flex;
flex-direction: column;
}
.empty-tip {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
color: #6a8cb5;
background: rgba(10, 20, 40, 0.6);
border-radius: 8px;
}
.empty-icon {
font-size: 64px;
margin-bottom: 20px;
opacity: 0.5;
}
.empty-text {
font-size: 16px;
color: #8892a6;
}
/* 图例 */
.grid-legend {
display: flex;
gap: 24px;
margin-bottom: 12px;
padding: 8px 16px;
background: rgba(0, 212, 255, 0.05);
border-radius: 4px;
border: 1px solid rgba(0, 212, 255, 0.1);
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #a0c4e8;
}
.legend-dot {
width: 12px;
height: 12px;
border-radius: 2px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.legend-dot.available { background: #00ff88; }
.legend-dot.occupied { background: #7c63ff; }
.legend-dot.error { background: #ff6b6b; }
.legend-dot.has-coil-icon {
background: rgba(0, 212, 255, 0.5);
border-color: #00d4ff;
}
/* 库位网格 */
.warehouse-grid {
overflow: hidden;
flex: 1;
min-height: 0;
position: relative;
}
/* 内层内容容器flex 布局 + transform 驱动轮播 */
.grid-inner {
display: flex;
gap: 8px;
padding-bottom: 8px;
will-change: transform;
cursor: grab;
}
.grid-inner.grid-centered {
justify-content: center;
}
.grid-column {
display: flex;
flex-direction: column;
align-items: stretch;
flex-shrink: 0;
}
.column-header {
font-size: 12px;
color: #6a8cb5;
text-align: center;
padding: 4px 0;
margin-bottom: 4px;
border-bottom: 1px solid rgba(0, 212, 255, 0.2);
width: 100%;
white-space: nowrap;
}
.grid-cell {
border-radius: 4px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
border: 1px solid rgba(255, 255, 255, 0.1);
flex-shrink: 0;
}
.grid-cell.available {
background: rgba(0, 255, 136, 0.15);
border-color: rgba(0, 255, 136, 0.3);
box-shadow: 0 0 8px rgba(0, 255, 136, 0.1);
}
.grid-cell.occupied {
background: rgba(124, 99, 255, 0.2);
border-color: rgba(124, 99, 255, 0.3);
box-shadow: 0 0 8px rgba(124, 99, 255, 0.15);
}
.grid-cell.error {
background: rgba(255, 107, 107, 0.15);
border-color: rgba(255, 107, 107, 0.3);
box-shadow: 0 0 8px rgba(255, 107, 107, 0.1);
}
.grid-cell.has-coil {
border-width: 2px;
}
.grid-cell.has-coil.available,
.grid-cell.has-coil.occupied {
border-color: #00d4ff;
box-shadow: 0 0 12px rgba(0, 212, 255, 0.3);
}
.grid-cell:hover {
transform: scale(1.15);
z-index: 2;
}
.grid-cell:hover.available {
box-shadow: 0 0 20px rgba(0, 255, 136, 0.5);
}
.grid-cell:hover.occupied {
box-shadow: 0 0 20px rgba(124, 99, 255, 0.5);
}
.grid-cell:hover.error {
box-shadow: 0 0 20px rgba(255, 107, 107, 0.5);
}
.cell-code {
font-weight: 600;
color: #fff;
text-shadow: 0 0 6px rgba(255, 255, 255, 0.3);
line-height: 1.2;
text-align: center;
word-break: break-all;
}
.cell-coil {
font-size: 0.75em;
color: #4fc3f7;
line-height: 1.2;
text-align: center;
word-break: break-all;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
max-width: 100%;
padding: 0 2px;
}
.column-pair {
display: flex;
gap: 6px;
width: 100%;
flex: 1;
}
.layer-cells {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
min-width: 0;
}
.layer1-cells .grid-cell {
background: rgba(255, 167, 38, 0.06);
border-color: rgba(255, 167, 38, 0.2);
}
.layer1-cells .grid-cell.occupied {
background: rgba(124, 99, 255, 0.2);
border-color: rgba(124, 99, 255, 0.3);
}
.layer1-cells .grid-cell.available {
background: rgba(255, 167, 38, 0.12);
border-color: rgba(0, 255, 136, 0.3);
}
.layer1-cells .grid-cell.error {
background: rgba(255, 107, 107, 0.2);
border-color: rgba(255, 107, 107, 0.3);
}
.layer2-cells .grid-cell {
background: rgba(0, 212, 255, 0.06);
border-color: rgba(0, 212, 255, 0.2);
}
.layer2-cells .grid-cell.occupied {
background: rgba(124, 99, 255, 0.2);
border-color: rgba(124, 99, 255, 0.3);
}
.layer2-cells .grid-cell.available {
background: rgba(0, 212, 255, 0.1);
border-color: rgba(0, 255, 136, 0.3);
}
.layer2-cells .grid-cell.error {
background: rgba(255, 107, 107, 0.2);
border-color: rgba(255, 107, 107, 0.3);
}
/* 拆分状态卡片 */
.split-wrapper {
flex: 1;
min-height: 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);
}
.split-bar {
display: flex;
height: 8px;
border-radius: 4px;
overflow: hidden;
margin: 8px 12px 0;
background: rgba(255, 255, 255, 0.05);
}
.split-bar-fill {
height: 100%;
transition: width 0.6s ease;
min-width: 0;
}
.split-bar-fill.unsplit {
background: linear-gradient(90deg, #ffa726, #ffb74d);
}
.split-bar-fill.split {
background: linear-gradient(90deg, #42a5f5, #64b5f6);
}
.split-bar-labels {
display: flex;
justify-content: space-around;
padding: 3px 12px 0;
font-size: 10px;
color: #8aa8c8;
}
.sbl-item {
display: flex;
align-items: center;
gap: 4px;
}
.sbl-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.unsplit-dot { background: #ffa726; }
.split-dot { background: #42a5f5; }
/* 层列信息卡片 */
.layer-wrapper {
flex: 1;
min-height: 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: 13px;
color: #6a8cb5;
}
.layer-val {
font-size: 12px;
color: #fff;
font-weight: 500;
}
/* ===== 库位详情浮层(毛玻璃 tooltip ===== */
.warehouse-tooltip {
position: fixed;
z-index: 2000;
min-width: 220px;
background: rgba(8, 18, 38, 0.88);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border: 1px solid rgba(0, 212, 255, 0.25);
border-radius: 6px;
padding: 12px 14px;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.6),
0 0 20px rgba(0, 212, 255, 0.08);
animation: tooltipFadeIn 0.2s ease;
pointer-events: auto;
}
@keyframes tooltipFadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.warehouse-tooltip .tooltip-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.warehouse-tooltip .tooltip-code {
font-size: 15px;
font-weight: 700;
color: #00d4ff;
text-shadow: 0 0 8px rgba(0, 212, 255, 0.4);
letter-spacing: 1px;
}
.warehouse-tooltip .tooltip-status {
font-size: 11px;
padding: 1px 8px;
border-radius: 3px;
line-height: 1.6;
}
.warehouse-tooltip .tooltip-status.occupied {
background: rgba(124, 99, 255, 0.2);
color: #7c63ff;
border: 1px solid rgba(124, 99, 255, 0.3);
}
.warehouse-tooltip .tooltip-status.available {
background: rgba(0, 255, 136, 0.2);
color: #00ff88;
border: 1px solid rgba(0, 255, 136, 0.3);
}
.warehouse-tooltip .tooltip-status.error {
background: rgba(255, 107, 107, 0.2);
color: #ff6b6b;
border: 1px solid rgba(255, 107, 107, 0.3);
}
.warehouse-tooltip .tooltip-divider {
height: 1px;
background: linear-gradient(90deg, rgba(0, 212, 255, 0.3), transparent);
margin-bottom: 10px;
}
.warehouse-tooltip .tooltip-body {
display: flex;
flex-direction: column;
gap: 6px;
}
.warehouse-tooltip .tooltip-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.warehouse-tooltip .tooltip-label {
font-size: 12px;
color: #6a8cb5;
}
.warehouse-tooltip .tooltip-value {
font-size: 12px;
color: #e0e8f0;
font-weight: 500;
}
.warehouse-tooltip .tooltip-value.highlight {
color: #00d4ff;
}
.warehouse-tooltip .tooltip-value.dim {
color: #4a5568;
}
/* ===== 底部跃动方块 ===== */
.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; }
}
</style>
<!-- 全局样式弹窗 + 下拉框 teleport body不受 scoped 限制 -->
<style>
/* 仓库结构下拉框深色主题 */
.warehouse-select-popper {
background: rgba(10, 20, 40, 0.95) !important;
border: 1px solid rgba(0, 212, 255, 0.3) !important;
font-size: 14px;
}
.warehouse-select-popper .el-select-dropdown {
background: transparent !important;
}
.warehouse-select-popper .el-scrollbar {
background: transparent !important;
}
.warehouse-select-popper .el-select-dropdown__item {
background: transparent !important;
color: #a0c4e8 !important;
font-size: 14px;
}
.warehouse-select-popper .el-select-dropdown__item.hover,
.warehouse-select-popper .el-select-dropdown__item:hover {
background: rgba(0, 212, 255, 0.15) !important;
color: #00d4ff !important;
}
.warehouse-select-popper .el-select-dropdown__item.selected {
background: rgba(0, 212, 255, 0.1) !important;
color: #00d4ff !important;
font-weight: 600;
}
.warehouse-select-popper .el-select-group__title {
background: transparent !important;
color: #6a8cb5 !important;
font-size: 12px;
padding-left: 15px;
}
.warehouse-select-popper .el-select-dropdown__wrap {
background: transparent !important;
}
.warehouse-select-popper .el-select-dropdown__list {
background: transparent !important;
}
.warehouse-select .el-select__wrapper {
background-color: rgba(10, 20, 40, 0.95) !important;
box-shadow: 0 0 0 1px rgba(0, 212, 255, 0.3) inset !important;
border-radius: 4px;
min-height: 40px;
padding: 0 12px;
}
.warehouse-select .el-select__wrapper:hover {
box-shadow: 0 0 0 1px rgba(0, 212, 255, 0.5) inset !important;
}
.warehouse-select .el-select__wrapper.is-focus {
box-shadow: 0 0 0 1px #00d4ff inset !important;
}
.warehouse-select .el-select__input {
color: #00d4ff !important;
font-size: 14px;
}
.warehouse-select .el-select__input::placeholder {
color: #6a8cb5 !important;
}
.warehouse-select .el-select__caret {
color: #00d4ff !important;
font-size: 14px;
}
</style>