|
|
@@ -0,0 +1,327 @@
|
|
|
+<template>
|
|
|
+ <aside
|
|
|
+ v-if="visible"
|
|
|
+ :class="['capacity-notice', themeClass]"
|
|
|
+ @click.stop
|
|
|
+ >
|
|
|
+ <div class="capacity-notice-actions">
|
|
|
+ <span class="capacity-notice-status">{{ statusText }}</span>
|
|
|
+ <button
|
|
|
+ class="capacity-refresh-btn"
|
|
|
+ type="button"
|
|
|
+ :disabled="loading"
|
|
|
+ title="刷新容量监控"
|
|
|
+ @click="loadCapacityMonitor"
|
|
|
+ >
|
|
|
+ <svg
|
|
|
+ class="capacity-refresh-icon"
|
|
|
+ viewBox="0 0 16 16"
|
|
|
+ aria-hidden="true"
|
|
|
+ >
|
|
|
+ <path
|
|
|
+ d="M13.5 2.75v3.5H10a.75.75 0 0 1 0-1.5h1.65A4.25 4.25 0 1 0 12 8a.75.75 0 0 1 1.5 0 5.75 5.75 0 1 1-.7-2.76v-2.49a.75.75 0 0 1 1.5 0z"
|
|
|
+ fill="currentColor"
|
|
|
+ />
|
|
|
+ </svg>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div
|
|
|
+ v-if="loading && capacityList.length === 0"
|
|
|
+ class="capacity-empty"
|
|
|
+ >
|
|
|
+ 加载中...
|
|
|
+ </div>
|
|
|
+ <div
|
|
|
+ v-else-if="error"
|
|
|
+ class="capacity-empty capacity-error"
|
|
|
+ >
|
|
|
+ {{ error }}
|
|
|
+ </div>
|
|
|
+ <div
|
|
|
+ v-else-if="capacityList.length === 0"
|
|
|
+ class="capacity-empty"
|
|
|
+ >
|
|
|
+ 暂无容量数据
|
|
|
+ </div>
|
|
|
+ <div
|
|
|
+ v-else
|
|
|
+ class="capacity-list"
|
|
|
+ >
|
|
|
+ <section
|
|
|
+ v-for="item in capacityList"
|
|
|
+ :key="`${item.locLevel}-${item.zoneId}`"
|
|
|
+ class="capacity-item"
|
|
|
+ >
|
|
|
+ <div class="capacity-item-head">
|
|
|
+ <span class="capacity-location">{{ formatLocation(item) }}</span>
|
|
|
+ <span class="capacity-ratio">{{ formatRatio(item.occupiedRatio) }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="capacity-progress">
|
|
|
+ <span :style="{ width: formatProgressWidth(item.occupiedRatio) }" />
|
|
|
+ </div>
|
|
|
+ <div class="capacity-count">
|
|
|
+ <span>总 {{ formatCount(item.occupiedCount, item.totalCount) }}</span>
|
|
|
+ <template v-if="shouldShowZoneDetail(item)">
|
|
|
+ <span>挂 {{ formatCount(item.hangingOccupiedCount, item.hangingTotalCount) }}</span>
|
|
|
+ <span>普 {{ formatCount(item.normalOccupiedCount, item.normalTotalCount) }}</span>
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+ </div>
|
|
|
+ </aside>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
|
|
+import { fetchCapacityMonitor } from '../api/location'
|
|
|
+import { config } from '../config'
|
|
|
+import type { LocationCapacityMonitorVO } from '../types'
|
|
|
+
|
|
|
+const ZONE_NAME_MAP: Record<string, string> = {
|
|
|
+ 'WH01-3-3': '货架区',
|
|
|
+ 'WH01-3-2': '缓存区',
|
|
|
+ 'WH01-3-1': '托盘区'
|
|
|
+}
|
|
|
+
|
|
|
+interface Props {
|
|
|
+ themeMode?: 'dark' | 'light'
|
|
|
+ visible?: boolean
|
|
|
+}
|
|
|
+
|
|
|
+const props = withDefaults(defineProps<Props>(), {
|
|
|
+ themeMode: 'dark',
|
|
|
+ visible: false
|
|
|
+})
|
|
|
+
|
|
|
+const loading = ref(false)
|
|
|
+const error = ref('')
|
|
|
+const capacityList = ref<LocationCapacityMonitorVO[]>([])
|
|
|
+let refreshTimer: number | null = null
|
|
|
+
|
|
|
+const themeClass = computed(() => (props.themeMode === 'light' ? 'theme-light' : 'theme-dark'))
|
|
|
+
|
|
|
+const statusText = computed(() => {
|
|
|
+ if (loading.value) {
|
|
|
+ return '刷新中'
|
|
|
+ }
|
|
|
+ return `${capacityList.value.length} 个区域`
|
|
|
+})
|
|
|
+
|
|
|
+const normalizeRatio = (ratio: number) => {
|
|
|
+ if (!Number.isFinite(ratio) || ratio <= 0) {
|
|
|
+ return 0
|
|
|
+ }
|
|
|
+ return ratio <= 1 ? ratio * 100 : ratio
|
|
|
+}
|
|
|
+
|
|
|
+const formatRatio = (ratio: number) => {
|
|
|
+ return `${normalizeRatio(ratio).toFixed(1)}%`
|
|
|
+}
|
|
|
+
|
|
|
+const formatProgressWidth = (ratio: number) => {
|
|
|
+ return `${Math.min(normalizeRatio(ratio), 100)}%`
|
|
|
+}
|
|
|
+
|
|
|
+const formatCount = (occupiedCount: number, totalCount: number) => {
|
|
|
+ return `${occupiedCount}/${totalCount}`
|
|
|
+}
|
|
|
+
|
|
|
+const formatLocation = (item: LocationCapacityMonitorVO) => {
|
|
|
+ const levelText = item.locLevel ? `${item.locLevel}层` : '未知楼层'
|
|
|
+ const zoneText = item.zoneId ? ZONE_NAME_MAP[item.zoneId] || item.zoneId : '未知区域'
|
|
|
+ return `${levelText} · ${zoneText}`
|
|
|
+}
|
|
|
+
|
|
|
+const shouldShowZoneDetail = (item: LocationCapacityMonitorVO) => {
|
|
|
+ return String(item.locLevel) === '1' && item.zoneId === 'WH01-3-3'
|
|
|
+}
|
|
|
+
|
|
|
+const loadCapacityMonitor = async () => {
|
|
|
+ if (loading.value) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ loading.value = true
|
|
|
+ error.value = ''
|
|
|
+ try {
|
|
|
+ capacityList.value = await fetchCapacityMonitor()
|
|
|
+ } catch (err: unknown) {
|
|
|
+ error.value = err instanceof Error ? err.message : '容量监控加载失败'
|
|
|
+ } finally {
|
|
|
+ loading.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const stopRefreshTimer = () => {
|
|
|
+ if (refreshTimer !== null) {
|
|
|
+ window.clearInterval(refreshTimer)
|
|
|
+ refreshTimer = null
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const startRefreshTimer = () => {
|
|
|
+ stopRefreshTimer()
|
|
|
+ refreshTimer = window.setInterval(() => {
|
|
|
+ loadCapacityMonitor()
|
|
|
+ }, config.refreshInterval)
|
|
|
+}
|
|
|
+
|
|
|
+watch(
|
|
|
+ () => props.visible,
|
|
|
+ (visible) => {
|
|
|
+ if (!visible) {
|
|
|
+ stopRefreshTimer()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ loadCapacityMonitor()
|
|
|
+ startRefreshTimer()
|
|
|
+ },
|
|
|
+ { immediate: true }
|
|
|
+)
|
|
|
+
|
|
|
+onBeforeUnmount(() => {
|
|
|
+ stopRefreshTimer()
|
|
|
+})
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.capacity-notice {
|
|
|
+ position: absolute;
|
|
|
+ top: calc(100% + 8px);
|
|
|
+ left: 0;
|
|
|
+ z-index: 900;
|
|
|
+ width: min(360px, calc(100vw - 36px));
|
|
|
+ overflow: hidden;
|
|
|
+ border: 1px solid var(--capacity-border);
|
|
|
+ border-radius: 8px;
|
|
|
+ background: var(--capacity-bg);
|
|
|
+ box-shadow: 0 18px 34px rgba(0, 0, 0, 0.24);
|
|
|
+ color: var(--capacity-text);
|
|
|
+ backdrop-filter: blur(10px);
|
|
|
+ --capacity-bg: rgba(10, 12, 14, 0.94);
|
|
|
+ --capacity-border: rgba(255, 255, 255, 0.12);
|
|
|
+ --capacity-text: #f3f6fa;
|
|
|
+ --capacity-muted: rgba(243, 246, 250, 0.62);
|
|
|
+ --capacity-soft: rgba(255, 255, 255, 0.06);
|
|
|
+ --capacity-track: rgba(255, 255, 255, 0.1);
|
|
|
+ --capacity-accent: #4fa77b;
|
|
|
+ --capacity-danger: #f0a0a0;
|
|
|
+}
|
|
|
+
|
|
|
+.capacity-notice.theme-light {
|
|
|
+ --capacity-bg: rgba(255, 255, 255, 0.98);
|
|
|
+ --capacity-border: rgba(34, 45, 58, 0.16);
|
|
|
+ --capacity-text: #20252b;
|
|
|
+ --capacity-muted: rgba(32, 37, 43, 0.62);
|
|
|
+ --capacity-soft: rgba(32, 37, 43, 0.05);
|
|
|
+ --capacity-track: rgba(32, 37, 43, 0.1);
|
|
|
+ --capacity-accent: #2f8060;
|
|
|
+ --capacity-danger: #a83b3b;
|
|
|
+}
|
|
|
+
|
|
|
+.capacity-notice-actions {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ padding: 10px 12px 6px;
|
|
|
+}
|
|
|
+
|
|
|
+.capacity-notice-status {
|
|
|
+ color: var(--capacity-muted);
|
|
|
+ font-size: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.capacity-refresh-btn {
|
|
|
+ width: 26px;
|
|
|
+ height: 26px;
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ border: 1px solid var(--capacity-border);
|
|
|
+ border-radius: 6px;
|
|
|
+ background: var(--capacity-soft);
|
|
|
+ color: var(--capacity-text);
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+
|
|
|
+.capacity-refresh-btn:disabled {
|
|
|
+ cursor: wait;
|
|
|
+ opacity: 0.7;
|
|
|
+}
|
|
|
+
|
|
|
+.capacity-refresh-icon {
|
|
|
+ width: 14px;
|
|
|
+ height: 14px;
|
|
|
+}
|
|
|
+
|
|
|
+.capacity-list {
|
|
|
+ max-height: min(460px, calc(100vh - 176px));
|
|
|
+ overflow: auto;
|
|
|
+ padding: 0 12px 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.capacity-item {
|
|
|
+ padding: 10px 0;
|
|
|
+ border-top: 1px solid var(--capacity-border);
|
|
|
+}
|
|
|
+
|
|
|
+.capacity-item-head,
|
|
|
+.capacity-count {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.capacity-location {
|
|
|
+ min-width: 0;
|
|
|
+ overflow: hidden;
|
|
|
+ color: var(--capacity-text);
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 700;
|
|
|
+ line-height: 1.2;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+
|
|
|
+.capacity-ratio {
|
|
|
+ flex: 0 0 auto;
|
|
|
+ color: var(--capacity-accent);
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 700;
|
|
|
+}
|
|
|
+
|
|
|
+.capacity-progress {
|
|
|
+ height: 5px;
|
|
|
+ margin: 8px 0;
|
|
|
+ overflow: hidden;
|
|
|
+ border-radius: 999px;
|
|
|
+ background: var(--capacity-track);
|
|
|
+}
|
|
|
+
|
|
|
+.capacity-progress span {
|
|
|
+ display: block;
|
|
|
+ height: 100%;
|
|
|
+ border-radius: inherit;
|
|
|
+ background: var(--capacity-accent);
|
|
|
+}
|
|
|
+
|
|
|
+.capacity-count {
|
|
|
+ color: var(--capacity-muted);
|
|
|
+ font-size: 11px;
|
|
|
+ line-height: 1.2;
|
|
|
+}
|
|
|
+
|
|
|
+.capacity-empty {
|
|
|
+ padding: 18px 12px 22px;
|
|
|
+ color: var(--capacity-muted);
|
|
|
+ font-size: 12px;
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+
|
|
|
+.capacity-error {
|
|
|
+ color: var(--capacity-danger);
|
|
|
+}
|
|
|
+</style>
|