handy преди 1 месец
родител
ревизия
199e921416
променени са 2 файла, в които са добавени 436 реда и са изтрити 72 реда
  1. 223 69
      src/App.vue
  2. 213 3
      src/components/WarehouseMap.vue

+ 223 - 69
src/App.vue

@@ -75,9 +75,11 @@
         <label class="filter-item">
           <select
             v-model="selectedCategory"
+            ref="categorySelectRef"
             :class="['level-select', { 'level-select-placeholder': !selectedCategory }]"
+            :style="{ width: selectWidths.category ? `${selectWidths.category}px` : 'auto' }"
           >
-            <option value="">库位类型</option>
+            <option value="">热度</option>
             <option
               v-for="category in categoryOptions"
               :key="category"
@@ -90,9 +92,11 @@
         <label class="filter-item">
           <select
             v-model="selectedLocationAttribute"
+            ref="locationAttributeSelectRef"
             :class="['level-select', { 'level-select-placeholder': !selectedLocationAttribute }]"
+            :style="{ width: selectWidths.attribute ? `${selectWidths.attribute}px` : 'auto' }"
           >
-            <option value="">库位属性</option>
+            <option value="">状态</option>
             <option
               v-for="attribute in locationAttributeOptions"
               :key="attribute"
@@ -105,27 +109,29 @@
         <label class="filter-item">
           <select
             v-model="selectedHasContainer"
+            ref="hasContainerSelectRef"
             :class="['level-select', { 'level-select-placeholder': !selectedHasContainer }]"
+            :style="{ width: selectWidths.container ? `${selectWidths.container}px` : 'auto' }"
           >
             <option value="">容器</option>
-            <option value="Y">有容器</option>
-            <option value="N">无容器</option>
+            <option value="Y">有</option>
+            <option value="N">无</option>
           </select>
         </label>
         <label class="filter-item filter-input-item">
           <span class="filter-input-wrap">
             <input
-              v-model="locGroupKeywordInput"
+              v-model="locationIdKeywordInput"
               class="filter-input"
               type="text"
-              placeholder="库位"
-              @keydown.enter="applyLocGroupFilter"
+              placeholder="库位"
+              @keydown.enter="applyLocationIdFilter"
             >
             <button
-              v-if="locGroupKeywordInput"
+              v-if="locationIdKeywordInput"
               class="filter-clear-btn"
               type="button"
-              @click="clearLocGroupFilter"
+              @click="clearLocationIdFilter"
             >
               <svg
                 viewBox="0 0 16 16"
@@ -141,7 +147,7 @@
             <button
               class="filter-confirm-btn"
               type="button"
-              @click="applyLocGroupFilter"
+              @click="applyLocationIdFilter"
             >
               <svg
                 viewBox="0 0 16 16"
@@ -156,20 +162,37 @@
             </button>
           </span>
         </label>
+        <label class="filter-item">
+          <select
+            v-model="selectedZoneId"
+            ref="zoneSelectRef"
+            :class="['level-select', { 'level-select-placeholder': !selectedZoneId }]"
+            :style="{ width: selectWidths.zone ? `${selectWidths.zone}px` : 'auto' }"
+          >
+            <option value="">库区</option>
+            <option
+              v-for="zoneId in zoneOptions"
+              :key="zoneId"
+              :value="zoneId"
+            >
+              {{ zoneId }}
+            </option>
+          </select>
+        </label>
         <label class="filter-item filter-input-item">
           <span class="filter-input-wrap">
             <input
-              v-model="locationIdKeywordInput"
+              v-model="locGroupKeywordInput"
               class="filter-input"
               type="text"
-              placeholder="库位号"
-              @keydown.enter="applyLocationIdFilter"
+              placeholder="库位"
+              @keydown.enter="applyLocGroupFilter"
             >
             <button
-              v-if="locationIdKeywordInput"
+              v-if="locGroupKeywordInput"
               class="filter-clear-btn"
               type="button"
-              @click="clearLocationIdFilter"
+              @click="clearLocGroupFilter"
             >
               <svg
                 viewBox="0 0 16 16"
@@ -185,7 +208,7 @@
             <button
               class="filter-confirm-btn"
               type="button"
-              @click="applyLocationIdFilter"
+              @click="applyLocGroupFilter"
             >
               <svg
                 viewBox="0 0 16 16"
@@ -200,24 +223,11 @@
             </button>
           </span>
         </label>
-        <label class="filter-item">
-          <select
-            v-model="selectedZoneId"
-            :class="['level-select', { 'level-select-placeholder': !selectedZoneId }]"
-          >
-            <option value="">库区</option>
-            <option
-              v-for="zoneId in zoneOptions"
-              :key="zoneId"
-              :value="zoneId"
-            >
-              {{ zoneId }}
-            </option>
-          </select>
-        </label>
         <select
           v-model.number="currentLevel"
           class="level-select level-select-floor"
+          ref="levelSelectRef"
+          :style="{ width: selectWidths.level ? `${selectWidths.level}px` : 'auto' }"
           @change="handleLevelChange"
         >
           <option
@@ -228,6 +238,65 @@
             {{ level }}层
           </option>
         </select>
+        <button
+          class="selection-btn"
+          :class="{ 'selection-btn-active': selectionMode }"
+          type="button"
+          @click="toggleSelectionMode"
+        >
+          <svg
+            viewBox="0 0 16 16"
+            aria-hidden="true"
+            class="selection-btn-icon"
+          >
+            <path
+              d="M2 3.5a1.5 1.5 0 0 1 1.5-1.5H6v1.5H3.5V6H2V3.5zm8-1.5h2.5A1.5 1.5 0 0 1 14 3.5V6h-1.5V3.5H10V2zm2.5 8.5H14v2.5a1.5 1.5 0 0 1-1.5 1.5H10v-1.5h2.5V10.5zM2 10.5h1.5V13H6v1.5H3.5A1.5 1.5 0 0 1 2 13v-2.5z"
+              fill="currentColor"
+            />
+            <path
+              d="M6.5 6.5h3v3h-3z"
+              fill="currentColor"
+              opacity="0.5"
+            />
+          </svg>
+          <span>{{ selectionMode ? '框选中' : '框选' }}</span>
+        </button>
+        <div class="toggle-group">
+          <label class="toggle-item">
+            <span class="toggle-hint">组框</span>
+            <input
+              :checked="showGroupBorder"
+              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"
+            >
+            <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"
+            >
+            <span class="toggle-track">
+              <span class="toggle-thumb" />
+            </span>
+          </label>
+        </div>
         <div
           ref="refreshControlRef"
           class="refresh-control"
@@ -271,42 +340,6 @@
             </button>
           </div>
         </div>
-        <div class="toggle-group">
-          <label class="toggle-item">
-            <span class="toggle-hint">组框</span>
-            <input
-              :checked="showGroupBorder"
-              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"
-            >
-            <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"
-            >
-            <span class="toggle-track">
-              <span class="toggle-thumb" />
-            </span>
-          </label>
-        </div>
         <button
           class="logout-btn"
           @click="handleLogout"
@@ -360,8 +393,10 @@
             :show-tooltip="showTooltip"
             :category-color-visibility="categoryColorVisibility"
             :theme-mode="isLightTheme ? 'light' : 'dark'"
+            :selection-mode="selectionMode"
             @select-loc-group="handleSelectLocGroup"
             @select-location-id="handleSelectLocationId"
+            @selection-complete="handleSelectionComplete"
           />
         </WorkingHighlight>
       </div>
@@ -434,6 +469,18 @@ const selectedCategory = ref('')
 const selectedLocationAttribute = ref<LocationAttributeCode | ''>('')
 const selectedHasContainer = ref<'Y' | 'N' | ''>('')
 const selectedZoneId = ref('')
+const categorySelectRef = ref<HTMLSelectElement | null>(null)
+const locationAttributeSelectRef = ref<HTMLSelectElement | null>(null)
+const hasContainerSelectRef = ref<HTMLSelectElement | null>(null)
+const zoneSelectRef = ref<HTMLSelectElement | null>(null)
+const levelSelectRef = ref<HTMLSelectElement | null>(null)
+const selectWidths = ref({
+  category: 0,
+  attribute: 0,
+  container: 0,
+  zone: 0,
+  level: 0
+})
 const categoryColorVisibility = ref<Record<'A' | 'B' | 'C', boolean>>({
   A: true,
   B: true,
@@ -445,6 +492,7 @@ const locationIdKeywordInput = ref('')
 const appliedLocationIdKeyword = ref('')
 const showGroupBorder = ref(false)
 const showTooltip = ref(true)
+const selectionMode = ref(false)
 const refreshIntervalMs = ref(getInitialRefreshInterval())
 const showRefreshPopover = ref(false)
 const refreshIntervalInput = ref(String(Math.max(Math.floor(refreshIntervalMs.value / 1000), 1)))
@@ -583,6 +631,18 @@ const getLocationAttributeLabel = (attribute: LocationAttributeCode) => {
   return LOCATION_ATTRIBUTE_LABEL_MAP[attribute] || attribute
 }
 
+const categoryLabel = computed(() => selectedCategory.value || '热度')
+const locationAttributeLabel = computed(() =>
+  selectedLocationAttribute.value ? getLocationAttributeLabel(selectedLocationAttribute.value) : '状态'
+)
+const hasContainerLabel = computed(() => {
+  if (selectedHasContainer.value === 'Y') return '有'
+  if (selectedHasContainer.value === 'N') return '无'
+  return '容器'
+})
+const zoneLabel = computed(() => selectedZoneId.value || '库区')
+const levelLabel = computed(() => `${currentLevel.value}层`)
+
 const toggleCategoryColorVisibility = (category: 'A' | 'B' | 'C') => {
   categoryColorVisibility.value = {
     ...categoryColorVisibility.value,
@@ -590,6 +650,40 @@ const toggleCategoryColorVisibility = (category: 'A' | 'B' | 'C') => {
   }
 }
 
+const measureTextWidth = (() => {
+  const canvas = document.createElement('canvas')
+  const context = canvas.getContext('2d')
+  return (text: string, font: string) => {
+    if (!context) return text.length * 8
+    context.font = font
+    return context.measureText(text).width
+  }
+})()
+
+const updateSelectWidth = (
+  key: keyof typeof selectWidths.value,
+  element: HTMLSelectElement | null,
+  text: string
+) => {
+  if (!element) return
+  const style = window.getComputedStyle(element)
+  const font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`
+  const paddingLeft = Number.parseFloat(style.paddingLeft) || 0
+  const paddingRight = Number.parseFloat(style.paddingRight) || 0
+  const extra = 18
+  const textWidth = measureTextWidth(text, font)
+  const width = Math.ceil(textWidth + paddingLeft + paddingRight + extra)
+  selectWidths.value[key] = Math.max(width, 44)
+}
+
+const updateSelectWidths = () => {
+  updateSelectWidth('category', categorySelectRef.value, categoryLabel.value)
+  updateSelectWidth('attribute', locationAttributeSelectRef.value, locationAttributeLabel.value)
+  updateSelectWidth('container', hasContainerSelectRef.value, hasContainerLabel.value)
+  updateSelectWidth('zone', zoneSelectRef.value, zoneLabel.value)
+  updateSelectWidth('level', levelSelectRef.value, levelLabel.value)
+}
+
 const updateFillRateTooltipPosition = (event: MouseEvent) => {
   const tooltipWidth = 120
   const tooltipHeight = 42
@@ -666,6 +760,14 @@ const handleGroupBorderToggle = (event: Event) => {
   showGroupBorder.value = (event.target as HTMLInputElement).checked
 }
 
+const toggleSelectionMode = () => {
+  selectionMode.value = !selectionMode.value
+}
+
+const handleSelectionComplete = () => {
+  selectionMode.value = false
+}
+
 const refreshCountdownText = computed(() => {
   const remainMs = Math.max(nextRefreshAt.value - now.value, 0)
   const totalSeconds = Math.floor(remainMs / 1000)
@@ -850,6 +952,7 @@ onMounted(() => {
   }, 1000)
   document.addEventListener('mousedown', handleDocumentClick)
   window.addEventListener(AUTH_INVALID_EVENT, handleAuthInvalid)
+  window.addEventListener('resize', updateSelectWidths)
 
   if (!isAuthenticated()) {
     showLoginModal.value = true
@@ -857,6 +960,9 @@ onMounted(() => {
     loadLocationData()
     scheduleNextRefresh()
   }
+  nextTick(() => {
+    updateSelectWidths()
+  })
 })
 
 watchEffect(() => {
@@ -867,6 +973,15 @@ watch(isLightTheme, (isLight) => {
   window.localStorage.setItem(THEME_STORAGE_KEY, isLight ? 'light' : 'dark')
 })
 
+watch(
+  [categoryLabel, locationAttributeLabel, hasContainerLabel, zoneLabel, levelLabel],
+  () => {
+    nextTick(() => {
+      updateSelectWidths()
+    })
+  }
+)
+
 onBeforeUnmount(() => {
   if (refreshTimer !== null) {
     window.clearTimeout(refreshTimer)
@@ -876,6 +991,7 @@ onBeforeUnmount(() => {
   }
   document.removeEventListener('mousedown', handleDocumentClick)
   window.removeEventListener(AUTH_INVALID_EVENT, handleAuthInvalid)
+  window.removeEventListener('resize', updateSelectWidths)
 })
 </script>
 
@@ -1171,7 +1287,8 @@ onBeforeUnmount(() => {
 }
 
 .level-select {
-  min-width: 76px;
+  min-width: 0;
+  width: auto;
   padding: 6px 24px 6px 8px;
   background: var(--input-bg);
   border: 1px solid var(--input-border);
@@ -1200,7 +1317,7 @@ onBeforeUnmount(() => {
 }
 
 .level-select-floor {
-  min-width: 64px;
+  min-width: 0;
 }
 
 .filter-input-wrap {
@@ -1395,6 +1512,43 @@ onBeforeUnmount(() => {
   cursor: wait;
 }
 
+.selection-btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  padding: 6px 8px;
+  border: 1px solid var(--btn-neutral-border);
+  background: var(--btn-neutral-bg);
+  color: var(--btn-neutral-text);
+  border-radius: 4px;
+  cursor: pointer;
+  font-size: 11px;
+  line-height: 1;
+  transition: all 0.2s;
+}
+
+.selection-btn:hover {
+  background: var(--btn-neutral-hover-bg);
+  border-color: var(--btn-neutral-hover-border);
+}
+
+.selection-btn-active {
+  background: rgba(71, 129, 255, 0.18);
+  border-color: rgba(71, 129, 255, 0.6);
+  color: #cfe0ff;
+}
+
+.theme-light .selection-btn-active {
+  background: rgba(47, 95, 215, 0.12);
+  border-color: rgba(47, 95, 215, 0.65);
+  color: #2f5fd7;
+}
+
+.selection-btn-icon {
+  width: 14px;
+  height: 14px;
+}
+
 .logout-btn {
   padding: 6px 10px;
   background: var(--danger-bg);

+ 213 - 3
src/components/WarehouseMap.vue

@@ -9,7 +9,7 @@
     <div
       v-else
       ref="mapWrapperRef"
-      class="map-wrapper"
+      :class="['map-wrapper', { 'selection-mode': props.selectionMode }]"
     >
       <div
         class="map-grid"
@@ -20,6 +20,7 @@
           :key="index"
           :class="['grid-cell', getCellClass(cell)]"
           :style="getCellStyle(cell)"
+          :ref="(el) => setCellRef(el, index)"
           @mousedown.left="handleCellMouseDown(index, $event)"
           @mouseenter="handleCellMouseEnter(cell, index, $event)"
           @mousemove="handleCellMouseMove($event)"
@@ -73,6 +74,22 @@
           </div>
         </div>
       </div>
+      <div
+        v-if="props.selectionMode && selectionRect"
+        class="selection-overlay"
+        :style="selectionOverlayStyle"
+      >
+        <div
+          v-for="(maskStyle, index) in selectionMaskStyles"
+          :key="`mask-${index}`"
+          class="selection-mask"
+          :style="maskStyle"
+        />
+        <div
+          class="selection-rect"
+          :style="selectionRect"
+        />
+      </div>
       <div
         v-if="props.showTooltip && tooltipVisible && tooltipLines.length"
         class="cell-tooltip"
@@ -119,6 +136,7 @@ interface Props {
   locationIdKeyword: string
   showGroupBorder: boolean
   showTooltip: boolean
+  selectionMode?: boolean
   themeMode?: 'dark' | 'light'
 }
 
@@ -146,17 +164,24 @@ interface AisleCell {
 type MapCell = GridCell | WarehouseLayoutSpecialCell | AisleCell
 
 const props = withDefaults(defineProps<Props>(), {
-  themeMode: 'dark'
+  themeMode: 'dark',
+  selectionMode: false
 })
 const emit = defineEmits<{
   (event: 'select-loc-group', locGroup1: string): void
   (event: 'select-location-id', locationId: string): void
+  (event: 'selection-complete'): void
 }>()
 const mapWrapperRef = ref<HTMLElement | null>(null)
+const cellRefs = ref<Array<HTMLElement | null>>([])
 const wrapperSize = ref({
   width: 0,
   height: 0
 })
+const overlaySize = ref({
+  width: 0,
+  height: 0
+})
 const hoveredCell = ref<GridCell | null>(null)
 const tooltipVisible = ref(false)
 const tooltipPosition = ref({
@@ -427,6 +452,23 @@ const getGridPointByIndex = (index: number) => {
   }
 }
 
+const getGridIndexByPoint = (point: { row: number; col: number }) => {
+  if (!gridBounds.value || !gridMetrics.value) {
+    return null
+  }
+  const rowOffset = point.row - gridBounds.value.minX
+  const colOffset = point.col - gridBounds.value.minY
+  if (
+    rowOffset < 0 ||
+    colOffset < 0 ||
+    rowOffset >= gridMetrics.value.rows ||
+    colOffset >= gridMetrics.value.cols
+  ) {
+    return null
+  }
+  return rowOffset * gridMetrics.value.cols + colOffset
+}
+
 const buildSelectionRange = (
   start: { row: number; col: number },
   end: { row: number; col: number }
@@ -623,6 +665,115 @@ const getCellStyle = (cell: MapCell): CSSProperties => {
   }
 }
 
+const setCellRef = (el: Element | null, index: number) => {
+  cellRefs.value[index] = el as HTMLElement | null
+}
+
+const selectionPoints = computed(() => {
+  if (selectionStart.value && selectionEnd.value) {
+    return {
+      start: selectionStart.value,
+      end: selectionEnd.value
+    }
+  }
+  if (selectedRange.value) {
+    return {
+      start: {
+        row: selectedRange.value.rowStart,
+        col: selectedRange.value.colStart
+      },
+      end: {
+        row: selectedRange.value.rowEnd,
+        col: selectedRange.value.colEnd
+      }
+    }
+  }
+  return null
+})
+
+const selectionRect = computed<CSSProperties | null>(() => {
+  if (!mapWrapperRef.value || !selectionPoints.value) {
+    return null
+  }
+
+  const startIndex = getGridIndexByPoint(selectionPoints.value.start)
+  const endIndex = getGridIndexByPoint(selectionPoints.value.end)
+  if (startIndex === null || endIndex === null) {
+    return null
+  }
+
+  const startEl = cellRefs.value[startIndex]
+  const endEl = cellRefs.value[endIndex]
+  if (!startEl || !endEl) {
+    return null
+  }
+
+  const wrapperRect = mapWrapperRef.value.getBoundingClientRect()
+  const startRect = startEl.getBoundingClientRect()
+  const endRect = endEl.getBoundingClientRect()
+  const scrollLeft = mapWrapperRef.value.scrollLeft
+  const scrollTop = mapWrapperRef.value.scrollTop
+
+  const left = Math.min(startRect.left, endRect.left) - wrapperRect.left + scrollLeft
+  const right = Math.max(startRect.right, endRect.right) - wrapperRect.left + scrollLeft
+  const top = Math.min(startRect.top, endRect.top) - wrapperRect.top + scrollTop
+  const bottom = Math.max(startRect.bottom, endRect.bottom) - wrapperRect.top + scrollTop
+
+  return {
+    left: `${Math.max(left, 0)}px`,
+    top: `${Math.max(top, 0)}px`,
+    width: `${Math.max(right - left, 0)}px`,
+    height: `${Math.max(bottom - top, 0)}px`
+  }
+})
+
+const selectionOverlayStyle = computed<CSSProperties>(() => ({
+  width: `${overlaySize.value.width}px`,
+  height: `${overlaySize.value.height}px`
+}))
+
+const selectionMaskStyles = computed<CSSProperties[]>(() => {
+  if (!selectionRect.value) {
+    return []
+  }
+
+  const left = Number.parseFloat(selectionRect.value.left as string) || 0
+  const top = Number.parseFloat(selectionRect.value.top as string) || 0
+  const width = Number.parseFloat(selectionRect.value.width as string) || 0
+  const height = Number.parseFloat(selectionRect.value.height as string) || 0
+  const right = left + width
+  const bottom = top + height
+  const fullWidth = overlaySize.value.width
+  const fullHeight = overlaySize.value.height
+
+  return [
+    {
+      left: '0px',
+      top: '0px',
+      width: `${fullWidth}px`,
+      height: `${Math.max(top, 0)}px`
+    },
+    {
+      left: '0px',
+      top: `${bottom}px`,
+      width: `${fullWidth}px`,
+      height: `${Math.max(fullHeight - bottom, 0)}px`
+    },
+    {
+      left: '0px',
+      top: `${top}px`,
+      width: `${Math.max(left, 0)}px`,
+      height: `${Math.max(height, 0)}px`
+    },
+    {
+      left: `${right}px`,
+      top: `${top}px`,
+      width: `${Math.max(fullWidth - right, 0)}px`,
+      height: `${Math.max(height, 0)}px`
+    }
+  ]
+})
+
 const clearSelectedRange = () => {
   selectedRange.value = null
 }
@@ -670,6 +821,9 @@ const copyText = async (text: string) => {
 }
 
 const handleCellMouseDown = (index: number, event: MouseEvent) => {
+  if (!props.selectionMode) {
+    return
+  }
   if (event.button !== 0) {
     return
   }
@@ -712,7 +866,7 @@ const handleCellMouseEnter = (cell: MapCell, index: number, event: MouseEvent) =
     return
   }
 
-  if (!props.showTooltip || !isLocationCell(cell)) {
+  if (props.selectionMode || !props.showTooltip || !isLocationCell(cell)) {
     tooltipVisible.value = false
     hoveredCell.value = null
     return
@@ -736,6 +890,9 @@ const handleCellMouseLeave = () => {
 }
 
 const handleWindowMouseUp = () => {
+  if (!props.selectionMode) {
+    return
+  }
   if (!selectionStart.value || !selectionEnd.value) {
     return
   }
@@ -749,6 +906,7 @@ const handleWindowMouseUp = () => {
 
     void copyText(selectedLocationIds.join('\n'))
     suppressNextClick.value = true
+    emit('selection-complete')
   }
 
   selectionStart.value = null
@@ -767,6 +925,10 @@ const updateWrapperSize = () => {
     width: nextWidth,
     height: nextHeight
   }
+  overlaySize.value = {
+    width: Math.round(mapWrapperRef.value.scrollWidth),
+    height: Math.round(mapWrapperRef.value.scrollHeight)
+  }
 }
 
 onMounted(() => {
@@ -790,6 +952,21 @@ watch(
   }
 )
 
+watch(
+  () => props.selectionMode,
+  (enabled) => {
+    if (!enabled) {
+      selectionStart.value = null
+      selectionEnd.value = null
+      didSelectionMove.value = false
+      suppressNextClick.value = false
+    } else {
+      tooltipVisible.value = false
+      hoveredCell.value = null
+    }
+  }
+)
+
 onBeforeUnmount(() => {
   window.removeEventListener('mouseup', handleWindowMouseUp)
   resizeObserver?.disconnect()
@@ -871,6 +1048,11 @@ onBeforeUnmount(() => {
   background: var(--map-bg);
 }
 
+.map-wrapper.selection-mode {
+  cursor: crosshair;
+  user-select: none;
+}
+
 .map-grid {
   display: grid;
   gap: 2px;
@@ -978,6 +1160,34 @@ onBeforeUnmount(() => {
   z-index: 10;
 }
 
+.selection-overlay {
+  position: absolute;
+  inset: 0;
+  pointer-events: none;
+  z-index: 30;
+}
+
+.selection-mask {
+  position: absolute;
+  background: rgba(0, 0, 0, 0.45);
+}
+
+.warehouse-map.theme-light .selection-mask {
+  background: rgba(31, 35, 40, 0.18);
+}
+
+.selection-rect {
+  position: absolute;
+  border: 2px dashed #f5f5f5;
+  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
+  background: rgba(255, 255, 255, 0.05);
+}
+
+.warehouse-map.theme-light .selection-rect {
+  border-color: #1f2328;
+  background: rgba(31, 35, 40, 0.08);
+}
+
 @keyframes workingPulse {
   0% {
     opacity: 0.35;