handy пре 1 месец
родитељ
комит
24086a160d
6 измењених фајлова са 352 додато и 206 уклоњено
  1. 63 126
      src/App.vue
  2. 47 0
      src/api/lock.ts
  3. 9 37
      src/components/LoginModal.vue
  4. 92 43
      src/components/WarehouseMap.vue
  5. 112 0
      src/components/WorkingHighlight.vue
  6. 29 0
      src/types/index.ts

+ 63 - 126
src/App.vue

@@ -7,14 +7,15 @@
       @cancel="handleLoginCancel"
     />
 
-    <header
-      v-if="!showLoginModal"
-      class="header"
-    >
+    <header v-if="!showLoginModal" class="header">
       <h1 class="title">
         {{ systemTitle }}
-        <span class="title-meta">库位
-          <span class="title-meta-value">{{ availableLocationsCount }}/{{ locations.length }}</span></span>
+        <span class="title-meta"
+          >库位
+          <span class="title-meta-value"
+            >{{ availableLocationsCount }}/{{ locations.length }}</span
+          ></span
+        >
         <span class="title-fill-rate">
           <span
             class="title-meta title-fill-rate-item"
@@ -78,11 +79,7 @@
             :class="['level-select', { 'level-select-placeholder': !selectedCategory }]"
           >
             <option value="">库位类型</option>
-            <option
-              v-for="category in categoryOptions"
-              :key="category"
-              :value="category"
-            >
+            <option v-for="category in categoryOptions" :key="category" :value="category">
               {{ category }}
             </option>
           </select>
@@ -120,34 +117,22 @@
               type="text"
               placeholder="库位组"
               @keydown.enter="applyLocGroupFilter"
-            >
+            />
             <button
               v-if="locGroupKeywordInput"
               class="filter-clear-btn"
               type="button"
               @click="clearLocGroupFilter"
             >
-              <svg
-                viewBox="0 0 16 16"
-                aria-hidden="true"
-                class="filter-action-icon"
-              >
+              <svg viewBox="0 0 16 16" aria-hidden="true" class="filter-action-icon">
                 <path
                   d="M4.22 4.22a.75.75 0 0 1 1.06 0L8 6.94l2.72-2.72a.75.75 0 1 1 1.06 1.06L9.06 8l2.72 2.72a.75.75 0 1 1-1.06 1.06L8 9.06l-2.72 2.72a.75.75 0 1 1-1.06-1.06L6.94 8 4.22 5.28a.75.75 0 0 1 0-1.06z"
                   fill="currentColor"
                 />
               </svg>
             </button>
-            <button
-              class="filter-confirm-btn"
-              type="button"
-              @click="applyLocGroupFilter"
-            >
-              <svg
-                viewBox="0 0 16 16"
-                aria-hidden="true"
-                class="filter-action-icon"
-              >
+            <button class="filter-confirm-btn" type="button" @click="applyLocGroupFilter">
+              <svg viewBox="0 0 16 16" aria-hidden="true" class="filter-action-icon">
                 <path
                   d="M6.5 2.5a4 4 0 1 0 2.47 7.15l2.69 2.68 1.06-1.06-2.68-2.69A4 4 0 0 0 6.5 2.5zm0 1.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5z"
                   fill="currentColor"
@@ -164,34 +149,22 @@
               type="text"
               placeholder="库位号"
               @keydown.enter="applyLocationIdFilter"
-            >
+            />
             <button
               v-if="locationIdKeywordInput"
               class="filter-clear-btn"
               type="button"
               @click="clearLocationIdFilter"
             >
-              <svg
-                viewBox="0 0 16 16"
-                aria-hidden="true"
-                class="filter-action-icon"
-              >
+              <svg viewBox="0 0 16 16" aria-hidden="true" class="filter-action-icon">
                 <path
                   d="M4.22 4.22a.75.75 0 0 1 1.06 0L8 6.94l2.72-2.72a.75.75 0 1 1 1.06 1.06L9.06 8l2.72 2.72a.75.75 0 1 1-1.06 1.06L8 9.06l-2.72 2.72a.75.75 0 1 1-1.06-1.06L6.94 8 4.22 5.28a.75.75 0 0 1 0-1.06z"
                   fill="currentColor"
                 />
               </svg>
             </button>
-            <button
-              class="filter-confirm-btn"
-              type="button"
-              @click="applyLocationIdFilter"
-            >
-              <svg
-                viewBox="0 0 16 16"
-                aria-hidden="true"
-                class="filter-action-icon"
-              >
+            <button class="filter-confirm-btn" type="button" @click="applyLocationIdFilter">
+              <svg viewBox="0 0 16 16" aria-hidden="true" class="filter-action-icon">
                 <path
                   d="M6.5 2.5a4 4 0 1 0 2.47 7.15l2.69 2.68 1.06-1.06-2.68-2.69A4 4 0 0 0 6.5 2.5zm0 1.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5z"
                   fill="currentColor"
@@ -206,11 +179,7 @@
             :class="['level-select', { 'level-select-placeholder': !selectedZoneId }]"
           >
             <option value="">库区</option>
-            <option
-              v-for="zoneId in zoneOptions"
-              :key="zoneId"
-              :value="zoneId"
-            >
+            <option v-for="zoneId in zoneOptions" :key="zoneId" :value="zoneId">
               {{ zoneId }}
             </option>
           </select>
@@ -220,18 +189,9 @@
           class="level-select level-select-floor"
           @change="handleLevelChange"
         >
-          <option
-            v-for="level in levelRange"
-            :key="level"
-            :value="level"
-          >
-            {{ level }}层
-          </option>
+          <option v-for="level in levelRange" :key="level" :value="level">{{ level }}层</option>
         </select>
-        <div
-          ref="refreshControlRef"
-          class="refresh-control"
-        >
+        <div ref="refreshControlRef" class="refresh-control">
           <button
             class="refresh-btn"
             :disabled="loading || refreshing"
@@ -240,11 +200,7 @@
           >
             {{ refreshCountdownText }}
           </button>
-          <div
-            v-if="showRefreshPopover"
-            class="refresh-popover"
-            @click.stop
-          >
+          <div v-if="showRefreshPopover" class="refresh-popover" @click.stop>
             <input
               ref="refreshIntervalInputRef"
               v-model="refreshIntervalInput"
@@ -252,17 +208,9 @@
               type="text"
               inputmode="numeric"
               @keydown.enter="applyRefreshInterval"
-            >
-            <button
-              class="refresh-popover-confirm"
-              type="button"
-              @click="applyRefreshInterval"
-            >
-              <svg
-                viewBox="0 0 16 16"
-                aria-hidden="true"
-                class="filter-action-icon"
-              >
+            />
+            <button class="refresh-popover-confirm" type="button" @click="applyRefreshInterval">
+              <svg viewBox="0 0 16 16" aria-hidden="true" class="filter-action-icon">
                 <path
                   d="M6.46 11.03 3.43 8a.75.75 0 1 1 1.06-1.06l1.97 1.96 5.05-5.04A.75.75 0 0 1 12.57 4.9l-5.58 5.59a.75.75 0 0 1-1.06 0z"
                   fill="currentColor"
@@ -279,40 +227,27 @@
               class="toggle-input"
               type="checkbox"
               @change="handleGroupBorderToggle"
-            >
+            />
             <span class="toggle-track">
               <span class="toggle-thumb" />
             </span>
           </label>
           <label class="toggle-item">
             <span class="toggle-hint">卡片</span>
-            <input
-              v-model="showTooltip"
-              class="toggle-input"
-              type="checkbox"
-            >
+            <input v-model="showTooltip" class="toggle-input" type="checkbox" />
             <span class="toggle-track">
               <span class="toggle-thumb" />
             </span>
           </label>
           <label class="toggle-item theme-toggle">
             <span class="toggle-hint">主题</span>
-            <input
-              v-model="isLightTheme"
-              class="toggle-input"
-              type="checkbox"
-            >
+            <input v-model="isLightTheme" class="toggle-input" type="checkbox" />
             <span class="toggle-track">
               <span class="toggle-thumb" />
             </span>
           </label>
         </div>
-        <button
-          class="logout-btn"
-          @click="handleLogout"
-        >
-          退出
-        </button>
+        <button class="logout-btn" @click="handleLogout">退出</button>
       </div>
     </header>
 
@@ -326,42 +261,30 @@
       </div>
     </div>
 
-    <main
-      v-if="!showLoginModal"
-      class="main-content"
-    >
-      <div
-        v-if="loading"
-        class="loading"
-      >
-        加载中...
-      </div>
-      <div
-        v-else-if="error"
-        class="error"
-      >
+    <main v-if="!showLoginModal" class="main-content">
+      <div v-if="loading" class="loading">加载中...</div>
+      <div v-else-if="error" class="error">
         {{ error }}
       </div>
-      <div
-        v-else
-        class="map-container"
-      >
-        <WarehouseMap
-          :locations="locations"
-          :current-level="currentLevel"
-          :selected-category="selectedCategory"
-          :selected-location-attribute="selectedLocationAttribute"
-          :selected-has-container="selectedHasContainer"
-          :selected-zone-id="selectedZoneId"
-          :loc-group-keyword="appliedLocGroupKeyword"
-          :location-id-keyword="appliedLocationIdKeyword"
-          :show-group-border="showGroupBorder"
-          :show-tooltip="showTooltip"
-          :category-color-visibility="categoryColorVisibility"
-          :theme-mode="isLightTheme ? 'light' : 'dark'"
-          @select-loc-group="handleSelectLocGroup"
-          @select-location-id="handleSelectLocationId"
-        />
+      <div v-else class="map-container">
+    <WorkingHighlight>
+          <WarehouseMap
+            :locations="locations"
+            :current-level="currentLevel"
+            :selected-category="selectedCategory"
+            :selected-location-attribute="selectedLocationAttribute"
+            :selected-has-container="selectedHasContainer"
+            :selected-zone-id="selectedZoneId"
+            :loc-group-keyword="appliedLocGroupKeyword"
+            :location-id-keyword="appliedLocationIdKeyword"
+            :show-group-border="showGroupBorder"
+            :show-tooltip="showTooltip"
+            :category-color-visibility="categoryColorVisibility"
+            :theme-mode="isLightTheme ? 'light' : 'dark'"
+            @select-loc-group="handleSelectLocGroup"
+            @select-location-id="handleSelectLocationId"
+          />
+        </WorkingHighlight>
       </div>
     </main>
   </div>
@@ -382,6 +305,7 @@ import { fetchLocationData } from './api/location'
 import type { LocationAttributeCode, LocationResourceDataVO } from './types'
 import WarehouseMap from './components/WarehouseMap.vue'
 import LoginModal from './components/LoginModal.vue'
+import WorkingHighlight from './components/WorkingHighlight.vue'
 import { config } from './config'
 import { AUTH_INVALID_EVENT, getApiEnvironment, isAuthenticated, removeToken } from './utils/auth'
 
@@ -457,6 +381,7 @@ const fillRateTooltipPosition = ref({
 })
 let refreshTimer: number | null = null
 let countdownTimer: number | null = null
+const WORKING_HIGHLIGHT_REFRESH_EVENT = 'working-highlight-refresh'
 
 const LOCATION_ATTRIBUTE_LABEL_MAP: Record<LocationAttributeCode, string> = {
   OK: '正常',
@@ -640,10 +565,12 @@ const copyText = async (text: string) => {
 
 const applyLocGroupFilter = () => {
   appliedLocGroupKeyword.value = locGroupKeywordInput.value.trim()
+  triggerWorkingHighlightRefresh()
 }
 
 const applyLocationIdFilter = () => {
   appliedLocationIdKeyword.value = locationIdKeywordInput.value.trim()
+  triggerWorkingHighlightRefresh()
 }
 
 const clearLocGroupFilter = () => {
@@ -683,6 +610,7 @@ const loadLocationData = async (options: { silent?: boolean } = {}) => {
     error.value = ''
   }
 
+  let loaded = false
   try {
     const data = await fetchLocationData({
       warehouse: config.warehouse,
@@ -690,6 +618,7 @@ const loadLocationData = async (options: { silent?: boolean } = {}) => {
     })
     locations.value = data
     hasLoadedOnce.value = true
+    loaded = true
   } catch (err: unknown) {
     if (!shouldUseSilentRefresh) {
       error.value = err instanceof Error ? err.message : '加载数据失败,请检查接口连接'
@@ -701,9 +630,17 @@ const loadLocationData = async (options: { silent?: boolean } = {}) => {
     } else {
       loading.value = false
     }
+    if (loaded) {
+      await nextTick()
+      triggerWorkingHighlightRefresh()
+    }
   }
 }
 
+const triggerWorkingHighlightRefresh = () => {
+  window.dispatchEvent(new Event(WORKING_HIGHLIGHT_REFRESH_EVENT))
+}
+
 const scheduleNextRefresh = () => {
   if (refreshTimer !== null) {
     window.clearTimeout(refreshTimer)

+ 47 - 0
src/api/lock.ts

@@ -0,0 +1,47 @@
+import axios from 'axios'
+import type { ApiResponse, LockPageData, LockPageRequest } from '../types'
+import { AUTH_INVALID_EVENT, getApiBaseUrl, getToken, removeToken } from '../utils/auth'
+
+const apiClient = axios.create({
+  timeout: 10000,
+  headers: {
+    'Content-Type': 'application/json'
+  }
+})
+
+apiClient.interceptors.request.use(
+  (requestConfig) => {
+    const token = getToken()
+    if (token) {
+      requestConfig.headers.Authorization = token
+    }
+    requestConfig.baseURL = getApiBaseUrl()
+    requestConfig.headers.Source = 'web'
+    return requestConfig
+  },
+  (error) => Promise.reject(error)
+)
+
+apiClient.interceptors.response.use(
+  (response) => {
+    const { code, message } = response.data || {}
+    if (code === 600) {
+      removeToken()
+      window.dispatchEvent(new CustomEvent(AUTH_INVALID_EVENT))
+      return Promise.reject(new Error(message || '登录失效,请重新登录'))
+    }
+    if (code !== 200) {
+      return Promise.reject(new Error(message || '请求失败'))
+    }
+    return response
+  },
+  (error) => Promise.reject(error)
+)
+
+export const fetchLockPage = async (params: LockPageRequest): Promise<LockPageData> => {
+  const response = await apiClient.post<ApiResponse<LockPageData>>(
+    '/api/basic/resource/lock/page',
+    params
+  )
+  return response.data.data
+}

+ 9 - 37
src/components/LoginModal.vue

@@ -1,33 +1,18 @@
 <template>
-  <div
-    v-if="visible"
-    class="modal-overlay"
-    @click.self="handleCancel"
-  >
+  <div v-if="visible" class="modal-overlay" @click.self="handleCancel">
     <div class="modal-content">
       <div class="modal-header">
         <h2>登录</h2>
       </div>
       <div class="modal-body">
-        <div
-          v-if="error"
-          class="error-message"
-        >
+        <div v-if="error" class="error-message">
           {{ error }}
         </div>
         <form @submit.prevent="handleSubmit">
           <div class="form-group">
             <label for="environment">环境</label>
-            <select
-              id="environment"
-              v-model="selectedEnvironment"
-              class="form-select"
-            >
-              <option
-                v-for="option in environmentOptions"
-                :key="option.key"
-                :value="option.key"
-              >
+            <select id="environment" v-model="selectedEnvironment" class="form-select">
+              <option v-for="option in environmentOptions" :key="option.key" :value="option.key">
                 {{ option.label }}
               </option>
             </select>
@@ -41,7 +26,7 @@
               placeholder="请输入账号"
               required
               autocomplete="username"
-            >
+            />
           </div>
           <div class="form-group">
             <label for="password">密码</label>
@@ -53,7 +38,7 @@
                 placeholder="请输入密码"
                 required
                 autocomplete="current-password"
-              >
+              />
               <button
                 type="button"
                 class="password-toggle"
@@ -73,11 +58,7 @@
                   stroke-linejoin="round"
                 >
                   <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
-                  <circle
-                    cx="12"
-                    cy="12"
-                    r="3"
-                  />
+                  <circle cx="12" cy="12" r="3" />
                 </svg>
                 <svg
                   v-else
@@ -94,22 +75,13 @@
                   <path
                     d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"
                   />
-                  <line
-                    x1="1"
-                    y1="1"
-                    x2="23"
-                    y2="23"
-                  />
+                  <line x1="1" y1="1" x2="23" y2="23" />
                 </svg>
               </button>
             </div>
           </div>
           <div class="form-actions">
-            <button
-              type="submit"
-              class="btn-primary"
-              :disabled="loading"
-            >
+            <button type="submit" class="btn-primary" :disabled="loading">
               {{ loading ? '登录中...' : '登录' }}
             </button>
           </div>

+ 92 - 43
src/components/WarehouseMap.vue

@@ -1,20 +1,8 @@
 <template>
   <div :class="['warehouse-map', themeClass]">
-    <div
-      v-if="gridData.length === 0"
-      class="no-data"
-    >
-      暂无数据
-    </div>
-    <div
-      v-else
-      ref="mapWrapperRef"
-      class="map-wrapper"
-    >
-      <div
-        class="map-grid"
-        :style="gridStyle"
-      >
+    <div v-if="gridData.length === 0" class="no-data">暂无数据</div>
+    <div v-else ref="mapWrapperRef" class="map-wrapper">
+      <div class="map-grid" :style="gridStyle">
         <div
           v-for="(cell, index) in gridData"
           :key="index"
@@ -27,14 +15,8 @@
           @click="handleCellClick(cell)"
           @contextmenu.prevent="handleCellContextMenu(cell)"
         >
-          <div
-            v-if="isLocationCell(cell) && isCellVisible(cell)"
-            class="cell-content"
-          >
-            <div
-              class="category-badge"
-              :style="getCategoryStyle(cell)"
-            >
+          <div v-if="isLocationCell(cell) && isCellVisible(cell)" class="cell-content">
+            <div class="category-badge" :style="getCategoryStyle(cell)">
               {{ getHeatLabel(cell) }}
             </div>
             <div class="loc-group">
@@ -52,22 +34,11 @@
             >
               {{ cell.locationId }}
             </div>
-            <div
-              v-if="cell.categoryMismatch"
-              class="location-attribute-tag"
-            >
-              热度编号异常
-            </div>
-            <div
-              v-if="hasAbnormalLocationAttribute(cell)"
-              class="location-attribute-tag"
-            >
+            <div v-if="cell.categoryMismatch" class="location-attribute-tag">热度编号异常</div>
+            <div v-if="hasAbnormalLocationAttribute(cell)" class="location-attribute-tag">
               {{ getLocationAttributeLabel(cell) }}
             </div>
-            <div
-              v-if="cell.containerCode"
-              class="container-code"
-            >
+            <div v-if="cell.containerCode" class="container-code">
               {{ cell.containerCode }}
             </div>
           </div>
@@ -84,11 +55,7 @@
         class="cell-tooltip"
         :style="tooltipStyle"
       >
-        <div
-          v-for="(line, index) in tooltipLines"
-          :key="index"
-          class="cell-tooltip-line"
-        >
+        <div v-for="(line, index) in tooltipLines" :key="index" class="cell-tooltip-line">
           {{ line }}
         </div>
       </div>
@@ -97,7 +64,7 @@
 </template>
 
 <script setup lang="ts">
-import { computed, onBeforeUnmount, onMounted, ref, watch, type CSSProperties } from 'vue'
+import { computed, inject, onBeforeUnmount, onMounted, ref, watch, type CSSProperties } from 'vue'
 import type { LocationResourceDataVO } from '../types'
 import { applyWarehouseLayoutEnhancement } from './warehouse-layout-enhancers'
 import {
@@ -105,6 +72,14 @@ import {
   type WarehouseLayoutSpecialCell
 } from './warehouse-layout-special-cells'
 
+const WORKING_HIGHLIGHT_KEY = 'working-highlight'
+
+interface WorkingHighlightState {
+  locGroupInbound: Set<string>
+  locGroupOutbound: Set<string>
+  containerOutbound: Set<string>
+}
+
 interface Props {
   locations: LocationResourceDataVO[]
   currentLevel: number
@@ -509,6 +484,26 @@ const isLocationCell = (cell: MapCell): cell is GridCell => {
   return Boolean(cell && !('type' in cell))
 }
 
+const workingHighlightState = inject<WorkingHighlightState | null>(WORKING_HIGHLIGHT_KEY, null)
+
+const getWorkingHighlightClass = (cell: GridCell) => {
+  if (!workingHighlightState) {
+    return null
+  }
+  const containerCode = cell.containerCode || ''
+  if (containerCode && workingHighlightState.containerOutbound.has(containerCode)) {
+    return 'working-container-outbound'
+  }
+  const locGroupCode = cell.locGroup1 || ''
+  if (locGroupCode && workingHighlightState.locGroupInbound.has(locGroupCode)) {
+    return 'working-loc-group-inbound'
+  }
+  if (locGroupCode && workingHighlightState.locGroupOutbound.has(locGroupCode)) {
+    return 'working-loc-group-outbound'
+  }
+  return null
+}
+
 const getCellClass = (cell: MapCell) => {
   if (isSpecialCell(cell)) {
     return shouldHideNonLocationCells.value ? ['hidden-cell'] : [cell.type]
@@ -523,6 +518,10 @@ const getCellClass = (cell: MapCell) => {
   }
 
   const classNames = ['location-cell']
+  const workingClass = getWorkingHighlightClass(cell)
+  if (workingClass) {
+    classNames.push(workingClass)
+  }
   if (props.categoryColorVisibility[cell.category] !== false) {
     classNames.push(`category-${cell.category.toLowerCase()}`)
   } else {
@@ -837,6 +836,9 @@ onBeforeUnmount(() => {
   --cell-group: #a0a0a0;
   --cell-container: #9a9a9a;
   --cell-alert: #ff4d4f;
+  --working-loc-group-inbound: rgba(96, 240, 160, 0.85);
+  --working-loc-group-outbound: rgba(255, 168, 74, 0.88);
+  --working-container-outbound: rgba(94, 200, 255, 0.9);
   --special-label: rgba(255, 255, 255, 0.76);
   --special-label-strong: rgba(255, 255, 255, 0.86);
   --tooltip-border: #1f1f1f;
@@ -860,6 +862,9 @@ onBeforeUnmount(() => {
   --cell-group: #5f6368;
   --cell-container: #6f7a86;
   --cell-alert: #c73939;
+  --working-loc-group-inbound: rgba(34, 174, 96, 0.9);
+  --working-loc-group-outbound: rgba(222, 120, 36, 0.92);
+  --working-container-outbound: rgba(26, 134, 207, 0.92);
   --special-label: rgba(31, 35, 40, 0.7);
   --special-label-strong: rgba(31, 35, 40, 0.82);
   --tooltip-border: #d7dce3;
@@ -957,12 +962,56 @@ onBeforeUnmount(() => {
   background: var(--cell-category-muted);
 }
 
+.grid-cell.working-loc-group-inbound::before,
+.grid-cell.working-loc-group-outbound::before,
+.grid-cell.working-container-outbound::before {
+  content: '';
+  position: absolute;
+  inset: -1px;
+  border-radius: 6px;
+  pointer-events: none;
+  animation: workingPulse 1.6s ease-in-out infinite;
+}
+
+.grid-cell.working-loc-group-inbound::before {
+  box-shadow:
+    0 0 0 2px var(--working-loc-group-inbound),
+    0 0 18px var(--working-loc-group-inbound);
+}
+
+.grid-cell.working-loc-group-outbound::before {
+  box-shadow:
+    0 0 0 2px var(--working-loc-group-outbound),
+    0 0 18px var(--working-loc-group-outbound);
+}
+
+.grid-cell.working-container-outbound::before {
+  box-shadow:
+    0 0 0 2px var(--working-container-outbound),
+    0 0 18px var(--working-container-outbound);
+}
+
 .grid-cell:not(.aisle):not(.wall):not(.elevator):hover {
   transform: scale(1.03);
   box-shadow: var(--hover-shadow);
   z-index: 10;
 }
 
+@keyframes workingPulse {
+  0% {
+    opacity: 0.35;
+    transform: scale(0.97);
+  }
+  50% {
+    opacity: 1;
+    transform: scale(1.02);
+  }
+  100% {
+    opacity: 0.35;
+    transform: scale(0.97);
+  }
+}
+
 .cell-content {
   display: flex;
   flex-direction: column;

+ 112 - 0
src/components/WorkingHighlight.vue

@@ -0,0 +1,112 @@
+<template>
+  <div class="working-highlight">
+    <slot />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { onBeforeUnmount, onMounted, provide, reactive, ref } from 'vue'
+import { fetchLockPage } from '../api/lock'
+import type { LockRecord } from '../types'
+import { AUTH_INVALID_EVENT, isAuthenticated } from '../utils/auth'
+
+interface WorkingHighlightState {
+  locGroupInbound: Set<string>
+  locGroupOutbound: Set<string>
+  containerOutbound: Set<string>
+}
+
+const WORKING_HIGHLIGHT_KEY = 'working-highlight'
+
+const highlightState = reactive<WorkingHighlightState>({
+  locGroupInbound: new Set<string>(),
+  locGroupOutbound: new Set<string>(),
+  containerOutbound: new Set<string>()
+})
+
+provide(WORKING_HIGHLIGHT_KEY, highlightState)
+
+const loading = ref(false)
+const WORKING_HIGHLIGHT_REFRESH_EVENT = 'working-highlight-refresh'
+
+const clearHighlights = () => {
+  highlightState.locGroupInbound = new Set<string>()
+  highlightState.locGroupOutbound = new Set<string>()
+  highlightState.containerOutbound = new Set<string>()
+}
+
+const normalizeCode = (value: string | null | undefined) => {
+  return (value || '').trim().toUpperCase()
+}
+
+const applyHighlights = (records: LockRecord[]) => {
+  const locGroupInbound = new Set<string>()
+  const locGroupOutbound = new Set<string>()
+  const containerOutbound = new Set<string>()
+
+  records.forEach((record) => {
+    const normalizedCode = normalizeCode(record.resourceCode)
+    if (!normalizedCode) {
+      return
+    }
+    if (record.resourceType === 'LOC_GROUP') {
+      if (record.lockSourceType === 'INBOUND') {
+        locGroupInbound.add(normalizedCode)
+      } else if (record.lockSourceType === 'OUTBOUND') {
+        locGroupOutbound.add(normalizedCode)
+      }
+      return
+    }
+    if (record.resourceType === 'CONTAINER' && record.lockSourceType === 'OUTBOUND') {
+      containerOutbound.add(normalizedCode)
+    }
+  })
+
+  highlightState.locGroupInbound = locGroupInbound
+  highlightState.locGroupOutbound = locGroupOutbound
+  highlightState.containerOutbound = containerOutbound
+}
+
+const loadHighlights = async () => {
+  if (loading.value || !isAuthenticated()) {
+    if (!isAuthenticated()) {
+      clearHighlights()
+    }
+    return
+  }
+
+  loading.value = true
+  try {
+    const data = await fetchLockPage({ page: 1, size: 50 })
+    applyHighlights(data.records || [])
+  } catch (error) {
+    console.error('Failed to load working highlights:', error)
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleAuthInvalid = () => {
+  clearHighlights()
+}
+
+const handleRefreshRequest = () => {
+  loadHighlights()
+}
+
+onMounted(() => {
+  window.addEventListener(AUTH_INVALID_EVENT, handleAuthInvalid)
+  window.addEventListener(WORKING_HIGHLIGHT_REFRESH_EVENT, handleRefreshRequest)
+})
+
+onBeforeUnmount(() => {
+  window.removeEventListener(AUTH_INVALID_EVENT, handleAuthInvalid)
+  window.removeEventListener(WORKING_HIGHLIGHT_REFRESH_EVENT, handleRefreshRequest)
+})
+</script>
+
+<style scoped>
+.working-highlight {
+  display: contents;
+}
+</style>

+ 29 - 0
src/types/index.ts

@@ -44,3 +44,32 @@ export interface LoginResponse {
   }
   version: string
 }
+
+export type LockResourceType = 'CONTAINER' | 'LOC_GROUP' | string
+export type LockSourceType = 'INBOUND' | 'OUTBOUND' | string
+
+export interface LockRecord {
+  id: number
+  resourceType: LockResourceType
+  resourceCode: string
+  lockSource: string
+  lockSourceType: LockSourceType
+  lockTime: string
+  createTime: string
+  creatorId: number
+  updateTime: string
+  updaterId: number | null
+}
+
+export interface LockPageData {
+  records: LockRecord[]
+  total: number
+  size: number
+  current: number
+  pages: number
+}
+
+export interface LockPageRequest {
+  page: number
+  size: number
+}