Просмотр исходного кода

feat(capacity-monitor): 新增容量监控侧边栏组件

- 在接口层新增 fetchCapacityMonitor 方法获取容量监控数据
- 新增 LocationCapacityMonitorVO 类型支持容量监控数据结构
- 创建 CapacityMonitorNotice 组件展示容量使用情况及刷新功能
- 在 App.vue 中添加容量监控显示入口及对应状态管理
- 支持暗黑和明亮主题切换,样式细节优化
- 移除 warehouse-layout-special-cells.ts 中多余类型定义和方法
- 优化界面相关样式,清理无用 CSS 和相关代码
handy 1 неделя назад
Родитель
Сommit
241284855a

+ 33 - 66
src/App.vue

@@ -24,12 +24,20 @@
           <span class="title-meta-value">{{ filteredOccupiedLocationsCount }}/{{ filteredLocationsCount }}</span></span>
         <span class="title-fill-rate">
           <span
-            class="title-meta title-fill-rate-item"
+            class="title-meta title-fill-rate-item title-fill-rate-overall"
             @mouseenter="handleFillRateMouseEnter($event, overallFillRateDetail)"
             @mousemove="handleFillRateMouseMove"
             @mouseleave="handleFillRateMouseLeave"
           >
-            库满度 <span class="title-meta-value">{{ overallFillRate }}</span>
+            <span
+              class="title-fill-rate-label"
+              @click.stop="toggleCapacityMonitorVisible"
+            >库满度</span>
+            <span class="title-meta-value">{{ overallFillRate }}</span>
+            <CapacityMonitorNotice
+              :visible="capacityMonitorVisible"
+              :theme-mode="isLightTheme ? 'light' : 'dark'"
+            />
           </span>
           <span
             :class="[
@@ -414,6 +422,7 @@ import WarehouseMap from './components/WarehouseMap.vue'
 import LoginModal from './components/LoginModal.vue'
 import WorkingHighlight from './components/WorkingHighlight.vue'
 import CreateLocationSqlModal from './components/CreateLocationSqlModal.vue'
+import CapacityMonitorNotice from './components/CapacityMonitorNotice.vue'
 import { config } from './config'
 import {
   AUTH_INVALID_EVENT,
@@ -493,6 +502,7 @@ const appliedWcsLocationIdKeyword = ref('')
 const showGroupBorder = ref(false)
 const showTooltip = ref(true)
 const selectionMode = ref(false)
+const capacityMonitorVisible = ref(false)
 const createLocationSqlModalVisible = ref(false)
 const createLocationSqlPayload = ref({
   floor: 1,
@@ -864,6 +874,10 @@ const toggleSelectionMode = () => {
   selectionMode.value = !selectionMode.value
 }
 
+const toggleCapacityMonitorVisible = () => {
+  capacityMonitorVisible.value = !capacityMonitorVisible.value
+}
+
 const handleSelectionComplete = () => {
   selectionMode.value = false
 }
@@ -1041,6 +1055,7 @@ const handleLogout = () => {
   }
   removeToken()
   locations.value = []
+  capacityMonitorVisible.value = false
   hasLoadedOnce.value = false
   loading.value = false
   refreshing.value = false
@@ -1053,6 +1068,7 @@ const handleAuthInvalid = () => {
     refreshTimer = null
   }
   locations.value = []
+  capacityMonitorVisible.value = false
   hasLoadedOnce.value = false
   loading.value = false
   refreshing.value = false
@@ -1270,13 +1286,6 @@ onBeforeUnmount(() => {
   font-weight: 700;
 }
 
-.title-legend {
-  display: inline-flex;
-  align-items: center;
-  gap: 4px;
-  margin-left: 2px;
-}
-
 .title-fill-rate {
   display: inline-flex;
   align-items: center;
@@ -1288,6 +1297,21 @@ onBeforeUnmount(() => {
   cursor: help;
 }
 
+.title-fill-rate-overall {
+  position: relative;
+  display: inline-flex;
+  align-items: baseline;
+  gap: 3px;
+}
+
+.title-fill-rate-label {
+  cursor: pointer;
+}
+
+.title-fill-rate-label:hover {
+  color: var(--text);
+}
+
 .fill-rate-tooltip {
   position: fixed;
   z-index: 1000;
@@ -1394,51 +1418,6 @@ onBeforeUnmount(() => {
   color: var(--rate-inactive);
 }
 
-.legend-chip {
-  display: inline-flex;
-  align-items: center;
-  justify-content: center;
-  min-width: 18px;
-  height: 14px;
-  padding: 0 4px;
-  border-radius: 999px;
-  font-size: 9px;
-  font-weight: 600;
-  line-height: 1;
-  color: #f4f7fa;
-  border: none;
-  cursor: pointer;
-  transition:
-    transform 0.2s ease,
-    opacity 0.2s ease,
-    box-shadow 0.2s ease;
-}
-
-.legend-chip:hover {
-  transform: translateY(-1px);
-  box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.14);
-}
-
-.legend-chip-inactive {
-  opacity: 0.42;
-  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18);
-}
-
-.legend-chip-a {
-  background: #1f7a3f;
-  color: #f3fff7;
-}
-
-.legend-chip-b {
-  background: #2f5fd7;
-  color: #f5f8ff;
-}
-
-.legend-chip-c {
-  background: #b8921f;
-  color: #161200;
-}
-
 .controls {
   display: flex;
   align-items: center;
@@ -1447,13 +1426,6 @@ onBeforeUnmount(() => {
   row-gap: 6px;
 }
 
-.level-selector {
-  display: flex;
-  align-items: center;
-  gap: 6px;
-  color: var(--text-dim);
-}
-
 .filter-item {
   display: flex;
   align-items: center;
@@ -1479,11 +1451,6 @@ onBeforeUnmount(() => {
   flex-wrap: nowrap;
 }
 
-.selector-label {
-  font-size: 11px;
-  color: var(--label-text);
-}
-
 .level-select {
   min-width: 0;
   width: auto;

+ 13 - 1
src/api/location.ts

@@ -1,5 +1,10 @@
 import axios from 'axios'
-import type { ApiResponse, LocationRequest, LocationResourceDataVO } from '../types'
+import type {
+  ApiResponse,
+  LocationCapacityMonitorVO,
+  LocationRequest,
+  LocationResourceDataVO
+} from '../types'
 import { AUTH_INVALID_EVENT, getApiBaseUrl, getToken, removeToken } from '../utils/auth'
 
 const apiClient = axios.create({
@@ -53,3 +58,10 @@ export const fetchLocationData = async (
   )
   return response.data.data
 }
+
+export const fetchCapacityMonitor = async (): Promise<LocationCapacityMonitorVO[]> => {
+  const response = await apiClient.get<ApiResponse<LocationCapacityMonitorVO[]>>(
+    '/api/basic/location/resource/getCapacityMonitor'
+  )
+  return response.data.data
+}

+ 327 - 0
src/components/CapacityMonitorNotice.vue

@@ -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>

+ 0 - 14
src/components/warehouse-layout-special-cells.ts

@@ -1,8 +1,3 @@
-export interface WarehouseLayoutWallCell {
-  gridRow: number
-  gridCol: number
-}
-
 export interface WarehouseLayoutSpecialCell {
   gridRow: number
   gridCol: number
@@ -94,12 +89,3 @@ export const getWarehouseLayoutSpecialCells = (level: number): WarehouseLayoutSp
 
   return [...SHARED_SPECIAL_CELLS]
 }
-
-export const getWarehouseLayoutWallCells = (level: number): WarehouseLayoutWallCell[] => {
-  return getWarehouseLayoutSpecialCells(level)
-    .filter((cell): cell is WarehouseLayoutSpecialCell & { type: 'wall' } => cell.type === 'wall')
-    .map((cell) => ({
-      gridRow: cell.gridRow,
-      gridCol: cell.gridCol
-    }))
-}

+ 14 - 0
src/types/index.ts

@@ -12,6 +12,20 @@ export interface LocationResourceDataVO {
   putawayLogicalSequence: string | null
 }
 
+export interface LocationCapacityMonitorVO {
+  locLevel: string
+  zoneId: string
+  totalCount: number
+  occupiedCount: number
+  occupiedRatio: number
+  hangingTotalCount: number
+  hangingOccupiedCount: number
+  hangingOccupiedRatio: number
+  normalTotalCount: number
+  normalOccupiedCount: number
+  normalOccupiedRatio: number
+}
+
 export interface ApiResponse<T> {
   code: number
   data: T