164 lines
5.7 KiB
Vue
164 lines
5.7 KiB
Vue
<script setup>
|
||||
|
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
|||
|
|
import request from '../utils/request'
|
|||
|
|
import { fmtNum } from '../utils/format'
|
|||
|
|
|
|||
|
|
const now = ref(new Date())
|
|||
|
|
let clockTimer = null
|
|||
|
|
let pollTimer = null
|
|||
|
|
|
|||
|
|
const overview = ref({})
|
|||
|
|
const maxZoneQty = computed(() => {
|
|||
|
|
const vals = Object.values(zoneList.value).map(v => Number(v.qty) || 0)
|
|||
|
|
return Math.max(...vals, 1)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const zoneList = computed(() => {
|
|||
|
|
const detail = overview.value.zoneDetail || {}
|
|||
|
|
return Object.entries(detail)
|
|||
|
|
.map(([zone, qty]) => ({ zone, qty: Number(qty) || 0 }))
|
|||
|
|
.sort((a, b) => b.qty - a.qty)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const kpis = computed(() => [
|
|||
|
|
{ key: 'totalQty', label: '在库总量', value: fmtNum(overview.value.totalQty) },
|
|||
|
|
{ key: 'lockedQty', label: '锁定量', value: fmtNum(overview.value.lockedQty) },
|
|||
|
|
{ key: 'snInStock', label: 'SN 在库', value: fmtNum(overview.value.snInStock) },
|
|||
|
|
{ key: 'semiInStock', label: '半成品在库', value: fmtNum(overview.value.semiInStock) },
|
|||
|
|
{ key: 'packageCount', label: '包装箱数', value: fmtNum(overview.value.packageCount) },
|
|||
|
|
{ key: 'inToday', label: '今日入库单', value: fmtNum(overview.value.inToday) },
|
|||
|
|
{ key: 'outToday', label: '今日出库单', value: fmtNum(overview.value.outToday) }
|
|||
|
|
])
|
|||
|
|
|
|||
|
|
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
|||
|
|
|
|||
|
|
const clock = computed(() => {
|
|||
|
|
const d = now.value
|
|||
|
|
const p = (n) => String(n).padStart(2, '0')
|
|||
|
|
return {
|
|||
|
|
date: `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`,
|
|||
|
|
time: `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`,
|
|||
|
|
week: `星期${weekdays[d.getDay()].replace('周', '')}`
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
async function load() {
|
|||
|
|
try {
|
|||
|
|
overview.value = await request.get('/display/overview')
|
|||
|
|
} catch { /* 大屏轮询失败静默重试 */ }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
onMounted(() => {
|
|||
|
|
clockTimer = setInterval(() => { now.value = new Date() }, 1000)
|
|||
|
|
load()
|
|||
|
|
pollTimer = setInterval(load, 5000)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
onBeforeUnmount(() => {
|
|||
|
|
clearInterval(clockTimer)
|
|||
|
|
clearInterval(pollTimer)
|
|||
|
|
})
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<template>
|
|||
|
|
<div class="screen">
|
|||
|
|
<header class="head">
|
|||
|
|
<div class="side left">北自所 · 库房监控</div>
|
|||
|
|
<h1 class="title">库存动态大屏</h1>
|
|||
|
|
<div class="side right">
|
|||
|
|
<span class="date">{{ clock.date }}</span>
|
|||
|
|
<span class="time">{{ clock.time }}</span>
|
|||
|
|
<span>{{ clock.week }}</span>
|
|||
|
|
</div>
|
|||
|
|
</header>
|
|||
|
|
|
|||
|
|
<section class="kpi-grid">
|
|||
|
|
<div v-for="k in kpis" :key="k.key" class="kpi">
|
|||
|
|
<div class="kpi-value">{{ k.value }}</div>
|
|||
|
|
<div class="kpi-label">{{ k.label }}</div>
|
|||
|
|
</div>
|
|||
|
|
</section>
|
|||
|
|
|
|||
|
|
<section class="zones">
|
|||
|
|
<div class="zone-title">区域明细(区域 / 质量状态)</div>
|
|||
|
|
<div class="zone-list">
|
|||
|
|
<div v-for="z in zoneList" :key="z.zone" class="zone-row">
|
|||
|
|
<div class="zone-name">{{ z.zone }}</div>
|
|||
|
|
<div class="bar-wrap">
|
|||
|
|
<div class="bar" :style="{ width: `${(z.qty / maxZoneQty) * 100}%` }"></div>
|
|||
|
|
</div>
|
|||
|
|
<div class="zone-qty">{{ z.qty.toLocaleString('zh-CN') }}</div>
|
|||
|
|
</div>
|
|||
|
|
<div v-if="!zoneList.length" class="empty">暂无区域数据…</div>
|
|||
|
|
</div>
|
|||
|
|
</section>
|
|||
|
|
|
|||
|
|
<footer class="foot">数据来源:WMS 后端 · 每 5 秒自动刷新</footer>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.screen {
|
|||
|
|
min-height: 100vh;
|
|||
|
|
background:
|
|||
|
|
radial-gradient(ellipse at 50% 0%, rgba(0, 170, 255, 0.18), transparent 55%),
|
|||
|
|
linear-gradient(180deg, #04102a 0%, #071e42 60%, #04102a 100%);
|
|||
|
|
color: #d7ecff;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
font-family: 'Microsoft YaHei', 'PingFang SC', sans-serif;
|
|||
|
|
}
|
|||
|
|
.head {
|
|||
|
|
display: flex; align-items: center; justify-content: space-between;
|
|||
|
|
padding: 18px 36px 10px;
|
|||
|
|
border-bottom: 1px solid rgba(64, 158, 255, 0.35);
|
|||
|
|
}
|
|||
|
|
.title {
|
|||
|
|
margin: 0; font-size: clamp(20px, 2.6vw, 34px); letter-spacing: 8px;
|
|||
|
|
color: #fff; text-shadow: 0 0 14px rgba(64, 190, 255, 0.9);
|
|||
|
|
}
|
|||
|
|
.side { font-size: 13px; color: #7fb8ff; }
|
|||
|
|
.right { text-align: right; }
|
|||
|
|
.date { margin-right: 12px; }
|
|||
|
|
.time {
|
|||
|
|
font-size: 22px; font-weight: 700; color: #4fd7ff; margin-right: 8px;
|
|||
|
|
font-variant-numeric: tabular-nums;
|
|||
|
|
}
|
|||
|
|
.kpi-grid {
|
|||
|
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
|||
|
|
gap: 14px; padding: 22px 36px 6px;
|
|||
|
|
}
|
|||
|
|
.kpi {
|
|||
|
|
border: 1px solid rgba(64, 158, 255, 0.45);
|
|||
|
|
background: linear-gradient(180deg, rgba(13, 47, 96, 0.75), rgba(7, 26, 58, 0.85));
|
|||
|
|
border-radius: 8px; padding: 18px 10px; text-align: center;
|
|||
|
|
box-shadow: inset 0 0 24px rgba(35, 130, 255, 0.25);
|
|||
|
|
}
|
|||
|
|
.kpi-value {
|
|||
|
|
font-size: clamp(28px, 3vw, 46px); font-weight: 800; color: #61e6ff;
|
|||
|
|
text-shadow: 0 0 16px rgba(80, 220, 255, 0.7);
|
|||
|
|
font-variant-numeric: tabular-nums;
|
|||
|
|
}
|
|||
|
|
.kpi-label { margin-top: 6px; font-size: 14px; color: #9cc7ff; letter-spacing: 2px; }
|
|||
|
|
.zones { flex: 1; padding: 16px 36px 8px; overflow: auto; }
|
|||
|
|
.zone-title {
|
|||
|
|
font-size: 16px; letter-spacing: 3px; color: #bfe0ff;
|
|||
|
|
border-left: 4px solid #409eff; padding-left: 10px; margin-bottom: 12px;
|
|||
|
|
}
|
|||
|
|
.zone-row {
|
|||
|
|
display: flex; align-items: center; gap: 12px;
|
|||
|
|
padding: 7px 0; border-bottom: 1px dashed rgba(64, 158, 255, 0.15);
|
|||
|
|
}
|
|||
|
|
.zone-name { width: 200px; text-align: right; color: #cfe6ff; font-size: 14px; }
|
|||
|
|
.bar-wrap { flex: 1; height: 16px; background: rgba(19, 56, 110, 0.6); border-radius: 9px; overflow: hidden; }
|
|||
|
|
.bar {
|
|||
|
|
height: 100%; border-radius: 9px;
|
|||
|
|
background: linear-gradient(90deg, #1673ff, #37e2ff);
|
|||
|
|
box-shadow: 0 0 10px rgba(55, 210, 255, 0.65);
|
|||
|
|
transition: width 0.6s ease;
|
|||
|
|
}
|
|||
|
|
.zone-qty { width: 90px; color: #61e6ff; font-weight: 700; font-variant-numeric: tabular-nums; }
|
|||
|
|
.empty { color: #5f86b8; padding: 30px 0; text-align: center; }
|
|||
|
|
.foot { padding: 12px 36px 16px; text-align: center; color: #47709f; font-size: 12px; }
|
|||
|
|
</style>
|