zh 3 месяцев назад
Родитель
Сommit
b486cea49f
4 измененных файлов с 1499 добавлено и 0 удалено
  1. 111 0
      src/api/location/merge.ts
  2. 5 0
      src/hooks/basic/menu.js
  3. 6 0
      src/router/index.ts
  4. 1377 0
      src/views/robot/merge/index.vue

+ 111 - 0
src/api/location/merge.ts

@@ -0,0 +1,111 @@
+// @ts-ignore
+import request from '@/utils/request'
+
+// 库位合并详情
+export interface LocationMergeDetails {
+  id: number
+  code: string
+  warehouse: string
+  sourceLocation: string
+  targetLocation: string
+  sourceBox: string
+  targetBox: string
+  status: string
+  quantity: number
+  moveQty: number
+  sku: string
+  barcode: string
+  productName: string
+  lotNumber: string
+  owner: string
+  qualityStatus: string
+  warehouseType: string
+  productionDate: string
+  expiryDate: string
+}
+
+// 料箱相关的并库详情VO
+export interface BoxRelatedMergeDetailsVO {
+  boxCode: string
+  station: string
+  mergeDetails: LocationMergeDetails[]
+  boxStatus: number // 0,10-等待调箱, 20-到达, 30-取消, 40-异常
+  inventoryLocations: string[]
+}
+
+/**
+ * 通过料箱号查看相关作业中任务详情
+ * @param boxCode 料箱号
+ */
+export function getWorkingDetailsByBox(boxCode: string, warehouse: string) {
+  return request<BoxRelatedMergeDetailsVO[]>({
+    url: '/api/wms/location/merge/workingDetailsByBox',
+    method: 'get',
+    params: { boxCode, warehouse }
+  })
+}
+
+/**
+ * 获取料箱库位的隔口种类
+ * @param warehouse 仓库
+ * @param locations 库位列表
+ */
+export function getBoxSplitCode(warehouse: string, locations: string[]) {
+  return request<Record<string, number>>({
+    url: `/api/basic/location/getBoxSplitCode/${warehouse}`,
+    method: 'post',
+    data: locations
+  })
+}
+
+/**
+ * 执行料箱回库(呼唤机器人)
+ * @param warehouse 仓库
+ */
+export function boxInbound(warehouse: string) {
+  return request<[string, string][]>({
+    url: '/api/wms/location/merge/boxInbound',
+    method: 'post',
+    params: { warehouse }
+  })
+}
+
+/**
+ * 重新下发指定站点和料箱的任务
+ * @param warehouse 仓库
+ * @param stationCode 站点编码
+ * @param boxCode 料箱编码
+ */
+export function reissueTask(warehouse: string, stationCode: string, boxCode: string) {
+  return request<boolean>({
+    url: '/api/wms/location/merge/reissueTask',
+    method: 'post',
+    params: { warehouse, stationCode, boxCode }
+  })
+}
+
+/**
+ * 获取料箱状态映射
+ * @param warehouse 仓库
+ * @param boxCodeList 料箱编码列表
+ */
+export function getBoxStatus(warehouse: string, boxCodeList: string[]) {
+  return request<Record<string, number>>({
+    url: '/api/wms/location/merge/boxStatus',
+    method: 'get',
+    params: { warehouse, boxCodeList },
+    paramsSerializer: (params: any) => {
+      const searchParams = new URLSearchParams()
+      Object.keys(params).forEach(key => {
+        const value = params[key]
+        if (Array.isArray(value)) {
+          // Spring @RequestParam List 期望格式: key=v1&key=v2
+          value.forEach(item => searchParams.append(key, item))
+        } else {
+          searchParams.append(key, value)
+        }
+      })
+      return searchParams.toString()
+    }
+  })
+}

+ 5 - 0
src/hooks/basic/menu.js

@@ -108,6 +108,11 @@ export default function() {
               title: '海柔快上',
               icon: 'newspaper-o',
               path: 'robot-putaway',
+            },
+            {
+              title: '库位合并',
+              icon: 'newspaper-o',
+              path: 'robot-merge',
             }
           ],
         },

+ 6 - 0
src/router/index.ts

@@ -85,6 +85,12 @@ const routes: RouteRecordRaw[] = [
     meta:{title:'海柔快上'},
     component: () => import('@/views/robot/putaway/index.vue')
   },
+  {
+    path: '/robot-merge',
+    name: 'RobotMerge',
+    meta:{title:'库位合并'},
+    component: () => import('@/views/robot/merge/index.vue')
+  },
   {
     path: '/robot-take-delivery',
     name: 'RobotTakeDelivery',

+ 1377 - 0
src/views/robot/merge/index.vue

@@ -0,0 +1,1377 @@
+<template>
+  <div class="merge-container">
+    <!-- 扫描输入框区域 -->
+    <div class="scan-section">
+      <van-field
+        ref="boxCodeInputRef"
+        v-model="boxCode"
+        placeholder="请扫描料箱号"
+        clearable
+        @click="onBoxCodeClick"
+        @keyup.enter="onBoxCodeEnter"
+      />
+    </div>
+
+    <!-- 信息展示表格 -->
+    <div class="info-table">
+      <div class="table-row">
+        <div class="cell label">源库位</div>
+        <div class="cell value input-cell">
+          <van-field
+            ref="sourceLocationInputRef"
+            v-model="sourceLocation"
+            placeholder="请扫描源库位"
+            clearable
+            @click="onSourceLocationClick"
+            @keyup.enter="onSourceLocationEnter"
+          />
+        </div>
+        <div class="cell label">库存数量</div>
+        <div class="cell value">{{ productInfo.stockQty }}</div>
+      </div>
+      <div class="table-row">
+        <div class="cell label">商品名称</div>
+        <div class="cell value span-2">{{ productInfo.productName }}</div>
+      </div>
+      <div class="table-row">
+        <div class="cell label">商品条码</div>
+        <div class="cell value span-2 input-cell">
+          <van-field
+            ref="barcodeInputRef"
+            v-model="scanBarcode"
+            placeholder="请扫描商品条码"
+            clearable
+            @click="onBarcodeClick"
+            @keyup.enter="onBarcodeEnter"
+          />
+        </div>
+      </div>
+      <div class="table-row row-small">
+        <div class="cell label">质量状态</div>
+        <div class="cell value value-small">{{ productInfo.qualityStatus }}</div>
+        <div class="cell label label-small">属性仓</div>
+        <div class="cell value value-large">{{ productInfo.warehouseType }}</div>
+        <div class="cell label label-small">批号</div>
+        <div class="cell value value-small"></div>
+      </div>
+      <div class="table-row">
+        <div class="cell label">生产日期</div>
+        <div class="cell value">{{ productInfo.productionDate }}</div>
+        <div class="cell label">失效日期</div>
+        <div class="cell value">{{ productInfo.expiryDate }}</div>
+      </div>
+      <div class="table-row">
+        <div class="cell label">目标库位</div>
+        <div class="cell value input-cell input-wide">
+          <van-field
+            ref="targetLocationInputRef"
+            v-model="productInfo.targetLocationNew"
+            placeholder="请扫描目标库位"
+            clearable
+            @click="onTargetLocationClick"
+            @keyup.enter="onTargetLocationEnter"
+          />
+        </div>
+        <div class="cell label">移库数量</div>
+        <div class="cell value editable" @dblclick="editMoveQty">
+          <template v-if="isEditingMoveQty">
+            <van-field
+              v-model="productInfo.moveQty"
+              type="number"
+              autofocus
+              @blur="confirmMoveQty"
+              @keyup.enter="confirmMoveQty"
+            />
+          </template>
+          <template v-else>
+            <span>{{ productInfo.moveQty }}</span>
+            <span v-if="!productInfo.moveQty" class="placeholder">双击编辑</span>
+          </template>
+        </div>
+      </div>
+    </div>
+
+    <!-- 料箱选择区域 -->
+    <div class="grid-section">
+      <div class="grid-container">
+        <div
+          v-for="station in stationList"
+          :key="station.id"
+          class="box-wrapper"
+        >
+          <!-- 站台序号在上方 -->
+          <div class="box-number">{{ station.displayNumber }}</div>
+          <div
+            class="box-item"
+            :class="{
+              'box-filled': station.status === 'filled' && !station.splitCount,
+              'box-empty-box': station.status === 'emptyBox',
+              'box-waiting': station.status === 'waiting',
+              'box-error': station.status === 'error',
+              'box-selected': selectedBox === station.stationCode,
+              'box-split': station.splitCount && station.status !== 'error' && station.status !== 'waiting'
+            }"
+                        @click="handleStationClick(station)"
+          >
+                      <!-- 分割的料箱(异常/等待调箱状态不渲染分割) -->
+            <template v-if="station.splitCount && station.subLocations && station.status !== 'error' && station.status !== 'waiting'">
+              <div class="sub-grid" :style="getSubGridStyle(station.splitCount)">
+                <div
+                  v-for="sub in station.subLocations"
+                  :key="sub.id"
+                  class="sub-location"
+                  :class="{
+                    'sub-filled': sub.status === 'filled',
+                    'sub-selected': selectedBox === sub.locationCode,
+                    'sub-disabled': !isLocationClickable(sub.locationCode)
+                  }"
+                  @click.stop="isLocationClickable(sub.locationCode) && selectSubLocation(station, sub)"
+                ></div>
+              </div>
+            </template>
+            <!-- 普通站台或异常状态 -->
+            <template v-else>
+              <span v-if="station.label" class="box-label">{{ station.label }}</span>
+            </template>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- 底部按钮 -->
+    <div class="footer-buttons">
+      <van-button class="btn-robot" size="small" @click="callRobot">呼唤机器人</van-button>
+      <div class="btn-right">
+        <van-button type="primary" class="btn-reset" size="small" @click="resetInput">重新输入</van-button>
+        <van-button class="btn-submit" size="small" @click="submitMove">提交移库</van-button>
+      </div>
+    </div>
+
+    <!-- 库位信息弹窗 -->
+    <van-popup
+      v-model:show="showLocationPopup"
+      position="bottom"
+      round
+      :style="{ maxHeight: '70%' }"
+    >
+      <div class="location-popup">
+        <div class="popup-header">
+          <span class="popup-title">库位信息</span>
+          <van-icon name="cross" @click="showLocationPopup = false" />
+        </div>
+        <div class="popup-content">
+          <div class="info-row">
+            <span class="info-label">库位编号</span>
+            <span class="info-value">{{ currentLocation.id }}</span>
+          </div>
+          <div class="info-row">
+            <span class="info-label">SKU</span>
+            <span class="info-value">{{ currentLocation.sku || '-' }}</span>
+          </div>
+          <!-- 推荐库位列表 -->
+          <div class="recommend-section">
+            <div class="recommend-title">
+              {{ currentLocation.recommendType === 'clear' ? '推荐清空' : '推荐保留' }}
+            </div>
+            <div v-if="currentLocation.relatedLocations.length > 0" class="recommend-list">
+              <div
+                v-for="(item, index) in currentLocation.relatedLocations"
+                :key="index"
+                class="recommend-item"
+              >
+                <span class="recommend-location">{{ currentLocation.recommendType === 'clear' ? '保留库位' : '清空库位' }}: {{ item.location }}</span>
+                <span class="recommend-qty">推荐移库数量: {{ item.quantity }}</span>
+              </div>
+            </div>
+            <div v-else class="recommend-empty">暂无推荐库位</div>
+          </div>
+        </div>
+        <div class="popup-footer">
+          <van-button type="primary" block @click="confirmSelectLocation">选择此库位</van-button>
+        </div>
+      </div>
+    </van-popup>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { closeListener, openListener, scanInit } from '@/utils/keydownListener'
+import { onMounted, onUnmounted, ref, reactive, nextTick } from 'vue'
+import { showToast, showLoadingToast, closeToast } from 'vant'
+import { useStore } from '@/store/modules/user'
+import { getWorkingDetailsByBox, getBoxSplitCode, boxInbound, reissueTask, getBoxStatus, type BoxRelatedMergeDetailsVO, type LocationMergeDetails } from '@/api/location/merge'
+import { getInventory, inventoryMovement } from '@/api/inventory'
+import { showConfirmDialog } from 'vant'
+
+const store = useStore()
+const warehouse = store.warehouse
+
+// 扫描类型: 1=料箱号, 2=源库位, 3=商品条码, 4=目标库位
+const scanType = ref(1)
+
+// 输入框引用
+const boxCodeInputRef = ref<any>(null)
+const sourceLocationInputRef = ref<any>(null)
+const barcodeInputRef = ref<any>(null)
+const targetLocationInputRef = ref<any>(null)
+
+// 扫描料箱号
+const boxCode = ref('')
+// 源库位
+const sourceLocation = ref('')
+// 商品条码
+const scanBarcode = ref('')
+
+// 轮询定时器
+let pollingTimer: ReturnType<typeof setInterval> | null = null
+
+// 页面初始化
+onMounted(() => {
+  openListener()
+  scanInit(_handlerScan)
+  // 获取焦点
+  nextTick(() => {
+    focusBoxCodeInput()
+  })
+})
+
+onUnmounted(() => {
+  closeListener()
+  stopPolling()
+})
+
+// 设置料箱号输入框焦点
+const focusBoxCodeInput = () => {
+  nextTick(() => {
+    boxCodeInputRef.value?.focus()
+  })
+}
+
+// 设置源库位输入框焦点
+const focusSourceLocationInput = () => {
+  nextTick(() => {
+    sourceLocationInputRef.value?.focus()
+  })
+}
+
+// 设置商品条码输入框焦点
+const focusBarcodeInput = () => {
+  nextTick(() => {
+    barcodeInputRef.value?.focus()
+  })
+}
+
+// 设置目标库位输入框焦点
+const focusTargetLocationInput = () => {
+  nextTick(() => {
+    targetLocationInputRef.value?.focus()
+  })
+}
+
+// 扫描监听
+const _handlerScan = (code: string) => {
+  if (!code) return
+  
+  if (scanType.value === 1) {
+    // 扫描料箱号
+    boxCode.value = code
+    loadBoxData(code)
+  } else if (scanType.value === 2) {
+    // 扫描源库位
+    sourceLocation.value = code
+    onSourceLocationEnter()
+  } else if (scanType.value === 3) {
+    // 扫描商品条码
+    scanBarcode.value = code
+    onBarcodeEnter()
+  } else if (scanType.value === 4) {
+    // 扫描目标库位
+    productInfo.targetLocationNew = code
+    onTargetLocationEnter()
+  }
+}
+
+// 料箱号输入框点击 - 重置所有数据
+const onBoxCodeClick = () => {
+  resetAllData()
+}
+
+// 料箱号回车
+const onBoxCodeEnter = () => {
+  if (boxCode.value && boxCode.value.length > 5) {
+    loadBoxData(boxCode.value)
+  }
+}
+
+// 源库位输入框点击 - 重置除料箱号外的数据
+const onSourceLocationClick = () => {
+  resetExceptBoxCode()
+}
+
+// 源库位回车
+const onSourceLocationEnter = () => {
+  if (!sourceLocation.value) return
+  
+  // 清空商品信息
+  resetProductInfo()
+  
+  // 切换到扫描商品条码
+  scanType.value = 3
+  focusBarcodeInput()
+}
+
+// 商品条码输入框点击 - 只重置商品信息
+const onBarcodeClick = () => {
+  resetProductInfo()
+}
+
+// 目标库位输入框点击
+const onTargetLocationClick = () => {
+  scanType.value = 4
+}
+
+// 目标库位回车
+const onTargetLocationEnter = () => {
+  if (!productInfo.targetLocationNew) return
+  showToast(`已输入目标库位: ${productInfo.targetLocationNew}`)
+}
+
+// 当前选中的库存数据(用于提交移库)
+const currentInventoryData = ref<any>(null)
+
+// 商品条码回车 - 调用getInventory获取商品信息
+const onBarcodeEnter = async () => {
+  if (!scanBarcode.value) return
+  if (!sourceLocation.value) {
+    showToast('请先扫描源库位')
+    return
+  }
+  
+  try {
+    showLoadingToast({ message: '查询中...', forbidClick: true })
+    
+    const params = {
+      warehouse,
+      barcode: scanBarcode.value,
+      location: sourceLocation.value,
+      locationRegexp: '^(?!STAGE_|SORTATION_).*$'
+    }
+    
+    const res = await getInventory(params)
+    closeToast()
+    
+    if (res.data && res.data.length > 0) {
+      const inventoryData = res.data[0]
+      // 保存完整的库存数据
+      currentInventoryData.value = inventoryData
+      // 填充商品信息
+      productInfo.targetLocation = sourceLocation.value
+      productInfo.stockQty = inventoryData.quantityAvailable || inventoryData.quantity || ''
+      productInfo.productName = inventoryData.productName || inventoryData.skuName || ''
+      productInfo.barcode = inventoryData.barcode || scanBarcode.value
+      productInfo.qualityStatus = inventoryData.lotAtt08 || inventoryData.qualityStatus || ''
+      productInfo.warehouseType = inventoryData.lotAtt05 || inventoryData.warehouseType || ''
+      productInfo.batchNo = inventoryData.lotNumber || inventoryData.lotNum || ''
+      productInfo.productionDate = inventoryData.lotAtt01 || inventoryData.productionDate || ''
+      productInfo.expiryDate = inventoryData.lotAtt02 || inventoryData.expiryDate || ''
+      productInfo.moveQty = inventoryData.quantityAvailable || inventoryData.quantity || ''
+      
+      showToast('商品信息获取成功')
+      // 切换到扫描目标库位
+      scanType.value = 4
+      focusTargetLocationInput()
+    } else {
+      showToast('未找到库存信息')
+      currentInventoryData.value = null
+      resetProductInfo()
+    }
+  } catch (error: any) {
+    closeToast()
+    showToast(error.message || '查询失败')
+  }
+}
+
+// 重置所有数据(回到最开始状态)
+const resetAllData = () => {
+  boxCode.value = ''
+  sourceLocation.value = ''
+  scanBarcode.value = ''
+  selectedBox.value = null
+  mergeDataList.value = []
+  clickableLocationsMap.value = new Map()
+  resetProductInfo()
+  initStations()
+  scanType.value = 1
+  focusBoxCodeInput()
+}
+
+// 重置除料箱号外的数据
+const resetExceptBoxCode = () => {
+  sourceLocation.value = ''
+  scanBarcode.value = ''
+  selectedBox.value = null
+  resetProductInfo()
+  scanType.value = 2
+  focusSourceLocationInput()
+}
+
+// 重置商品信息
+const resetProductInfo = () => {
+  scanBarcode.value = ''
+  currentInventoryData.value = null
+  productInfo.targetLocation = ''
+  productInfo.stockQty = ''
+  productInfo.productName = ''
+  productInfo.barcode = ''
+  productInfo.qualityStatus = ''
+  productInfo.warehouseType = ''
+  productInfo.batchNo = ''
+  productInfo.productionDate = ''
+  productInfo.expiryDate = ''
+  productInfo.moveQty = ''
+}
+
+// 加载料箱数据
+const loadBoxData = async (code: string) => {
+  if (!code) return
+
+  try {
+    showLoadingToast({ message: '加载中...', forbidClick: true })
+
+    // 1. 调用 getWorkingDetailsByBox 获取任务详情
+    const res = await getWorkingDetailsByBox(code, warehouse)
+    const boxDetailsList: BoxRelatedMergeDetailsVO[] = res.data || []
+
+    if (boxDetailsList.length === 0) {
+      closeToast()
+      showToast('未找到相关任务')
+      return
+    }
+
+    // 保存返回的数据
+    mergeDataList.value = boxDetailsList
+
+    // 2. 遍历 mergeDetails 取源库位和目标库位,去重
+    const locationSet = new Set<string>()
+    boxDetailsList.forEach(boxDetail => {
+      boxDetail.mergeDetails?.forEach((detail: LocationMergeDetails) => {
+        if (detail.sourceLocation) locationSet.add(detail.sourceLocation)
+        if (detail.targetLocation) locationSet.add(detail.targetLocation)
+      })
+    })
+
+    const locations = Array.from(locationSet)
+
+    if (locations.length > 0) {
+      // 3. 调用 getBoxSplitCode 获取库位分割信息
+      const splitRes = await getBoxSplitCode(warehouse, locations)
+      const splitMap: Record<string, number> = splitRes.data || {}
+
+      // 4. 构建可点击库位信息
+      buildClickableLocationsMap(boxDetailsList)
+
+      // 5. 初始化站台区域
+      updateStationList(boxDetailsList, splitMap)
+    }
+
+    closeToast()
+    
+    // 加载完成后检查是否需要启动轮询
+    if (hasWaitingBox()) {
+      startPolling()
+    }
+    
+    // 加载完成后focus到源库位输入框
+    scanType.value = 2
+    focusSourceLocationInput()
+  } catch (error: any) {
+    closeToast()
+    showToast(error.message || '加载失败')
+  }
+}
+
+// 合并数据列表
+const mergeDataList = ref<BoxRelatedMergeDetailsVO[]>([])
+
+// 构建可点击库位信息Map
+const buildClickableLocationsMap = (boxDetailsList: BoxRelatedMergeDetailsVO[]) => {
+  const map = new Map<string, ClickableLocationInfo>()
+
+  boxDetailsList.forEach(boxDetail => {
+    boxDetail.mergeDetails?.forEach((detail: LocationMergeDetails) => {
+      const sourceLocation = detail.sourceLocation
+      const targetLocation = detail.targetLocation
+      const moveQty = detail.moveQty || 0
+
+      // 处理源库位(推荐清空库位)
+      if (sourceLocation) {
+        if (!map.has(sourceLocation)) {
+          map.set(sourceLocation, {
+            recommendType: 'clear',
+            relatedLocations: [],
+            sku: detail.sku || ''
+          })
+        }
+        const sourceInfo = map.get(sourceLocation)!
+        // 添加对应的保留库位
+        if (targetLocation) {
+          const existingIdx = sourceInfo.relatedLocations.findIndex(r => r.location === targetLocation)
+          if (existingIdx === -1) {
+            sourceInfo.relatedLocations.push({ location: targetLocation, quantity: moveQty })
+          } else {
+            sourceInfo.relatedLocations[existingIdx].quantity += moveQty
+          }
+        }
+      }
+
+      // 处理目标库位(推荐保留库位)
+      if (targetLocation) {
+        if (!map.has(targetLocation)) {
+          map.set(targetLocation, {
+            recommendType: 'keep',
+            relatedLocations: [],
+            sku: detail.sku || ''
+          })
+        }
+        const targetInfo = map.get(targetLocation)!
+        // 添加对应的清空库位
+        if (sourceLocation) {
+          const existingIdx = targetInfo.relatedLocations.findIndex(r => r.location === sourceLocation)
+          if (existingIdx === -1) {
+            targetInfo.relatedLocations.push({ location: sourceLocation, quantity: moveQty })
+          } else {
+            targetInfo.relatedLocations[existingIdx].quantity += moveQty
+          }
+        }
+      }
+    })
+  })
+
+  clickableLocationsMap.value = map
+}
+
+// 商品信息
+const productInfo = reactive({
+  targetLocation: '',
+  stockQty: '',
+  productName: '',
+  barcode: '',
+  qualityStatus: '',
+  warehouseType: '',
+  batchNo: '',
+  productionDate: '',
+  expiryDate: '',
+  moveQty: '',
+  targetLocationNew: ''
+})
+
+// 移库数量编辑
+const isEditingMoveQty = ref(false)
+const editMoveQty = () => {
+  isEditingMoveQty.value = true
+}
+const confirmMoveQty = () => {
+  isEditingMoveQty.value = false
+}
+
+// 站台数据结构
+interface SubLocation {
+  id: string
+  status: 'empty' | 'filled' | 'selected'
+  locationCode: string // 实际库位编码
+}
+
+interface StationItem {
+  id: number
+  stationCode: string // 站台编码 RLOCHK13A01011
+  displayNumber: string // 显示序号 13-24
+  status: 'offline' | 'waiting' | 'filled' | 'emptyBox' | 'error'
+  label?: string
+  splitCount?: number
+  subLocations?: SubLocation[]
+  boxCode?: string
+  inventoryLocations?: string[]
+}
+
+// 站台列表
+const stationList = ref<StationItem[]>([])
+
+// 初始化站台列表(12个站台,编号13-24)
+const initStations = () => {
+  stationList.value = Array.from({ length: 12 }, (_, i) => {
+    const num = String(i + 13).padStart(2, '0')
+    return {
+      id: i + 1,
+      stationCode: `RLOCHK${num}A01011`,
+      displayNumber: num,
+      status: 'offline' as const
+    }
+  })
+}
+
+// 判断库位是否可点击
+const isLocationClickable = (locationCode: string): boolean => {
+  return clickableLocationsMap.value.has(locationCode)
+}
+
+// 生成子库位
+const generateSubLocations = (boxCode: string, splitCount: number, inventoryLocations: string[]): SubLocation[] => {
+  return Array.from({ length: splitCount }, (_, i) => {
+    // 库位编码: 分割数为1时库位=料箱编码;否则为 料箱编码-序号
+    const locationCode = splitCount === 1 ? boxCode : `${boxCode}-${i + 1}`
+    return {
+      id: `${boxCode}-${i + 1}`,
+      locationCode: locationCode,
+      status: inventoryLocations.includes(locationCode) ? 'filled' : 'empty'
+    }
+  })
+}
+
+// 根据接口数据更新站台列表
+const updateStationList = (boxDetailsList: BoxRelatedMergeDetailsVO[], splitMap: Record<string, number>) => {
+  const stationToBoxMap = new Map<string, BoxRelatedMergeDetailsVO>()
+  boxDetailsList.forEach(boxDetail => {
+    if (boxDetail.station) {
+      stationToBoxMap.set(boxDetail.station, boxDetail)
+    }
+  })
+
+  stationList.value = stationList.value.map(station => {
+    const boxDetail = stationToBoxMap.get(station.stationCode)
+
+    if (!boxDetail) {
+      return { ...station, status: 'offline' as const, label: undefined, splitCount: undefined, subLocations: undefined, boxCode: undefined }
+    }
+
+    const boxCode = boxDetail.boxCode
+    const boxStatus = boxDetail.boxStatus
+    const inventoryLocations = boxDetail.inventoryLocations || []
+
+    let status: StationItem['status'] = 'offline'
+    let label: string | undefined = undefined
+
+    if (boxStatus === 0 || boxStatus === 10) {
+      status = 'waiting'
+      label = '等待调箱'
+    } else if (boxStatus === 20) {
+      if (inventoryLocations.length === 0) {
+        status = 'emptyBox'
+        label = '空箱'
+      } else {
+        status = 'filled'
+      }
+    } else if (boxStatus === 30) {
+      status = 'offline'
+    } else if (boxStatus === 40) {
+      status = 'error'
+      label = '异'
+    }
+
+    // 获取分割数量
+    let splitCount = 1
+    if (splitMap[boxCode]) {
+      splitCount = splitMap[boxCode]
+    } else {
+      boxDetail.mergeDetails?.forEach(detail => {
+        if (detail.sourceLocation && splitMap[detail.sourceLocation]) {
+          splitCount = Math.max(splitCount, splitMap[detail.sourceLocation])
+        }
+        if (detail.targetLocation && splitMap[detail.targetLocation]) {
+          splitCount = Math.max(splitCount, splitMap[detail.targetLocation])
+        }
+      })
+    }
+
+    const updatedStation: StationItem = { ...station, status, label, boxCode, inventoryLocations }
+
+    if (splitCount > 1) {
+      updatedStation.splitCount = splitCount
+      updatedStation.subLocations = generateSubLocations(boxCode, splitCount, inventoryLocations)
+    }
+
+    return updatedStation
+  })
+}
+
+initStations()
+
+// 获取子库位的grid样式
+const getSubGridStyle = (splitCount: number) => {
+  switch (splitCount) {
+    case 2:
+      // 2分割:横着分割(上下分割)
+      return { gridTemplateColumns: '1fr', gridTemplateRows: 'repeat(2, 1fr)' }
+    case 4:
+      return { gridTemplateColumns: 'repeat(2, 1fr)', gridTemplateRows: 'repeat(2, 1fr)' }
+    case 6:
+      return { gridTemplateColumns: 'repeat(2, 1fr)', gridTemplateRows: 'repeat(3, 1fr)' }
+    case 8:
+      return { gridTemplateColumns: 'repeat(2, 1fr)', gridTemplateRows: 'repeat(4, 1fr)' }
+    default:
+      return { gridTemplateColumns: 'repeat(2, 1fr)', gridTemplateRows: 'repeat(2, 1fr)' }
+  }
+}
+
+// 选中的料箱/库位
+const selectedBox = ref<string | number | null>(null)
+
+// 可点击库位信息(来自mergeDetails的sourceLocation和targetLocation)
+interface LocationRecommendInfo {
+  location: string
+  quantity: number
+}
+
+interface ClickableLocationInfo {
+  recommendType: 'clear' | 'keep' // clear=推荐清空库位(源), keep=推荐保留库位(目标)
+  relatedLocations: LocationRecommendInfo[] // 对应的推荐库位列表
+  sku: string
+}
+
+// 可点击库位集合 Map<库位编码, 库位推荐信息>
+const clickableLocationsMap = ref<Map<string, ClickableLocationInfo>>(new Map())
+
+// 库位信息弹窗
+const showLocationPopup = ref(false)
+const currentLocation = reactive({
+  id: '',
+  boxId: '',
+  status: '' as 'empty' | 'filled',
+  recommendType: '' as 'clear' | 'keep',
+  sku: '',
+  relatedLocations: [] as LocationRecommendInfo[]
+})
+
+// 点击站台处理
+const handleStationClick = (station: StationItem) => {
+  // 离线状态不可点击
+  if (station.status === 'offline') return
+  // 异常状态弹出重新下发确认框
+  if (station.status === 'error') {
+    showReissueConfirm(station)
+    return
+  }
+  // 分割的料箱不处理,由子库位处理
+  if (station.splitCount) return
+  // 其他状态正常选择
+  selectStation(station)
+}
+
+// 显示重新下发确认框
+const showReissueConfirm = (station: StationItem) => {
+  showConfirmDialog({
+    title: '重新下发任务',
+    message: `站台${station.displayNumber}的料箱出现异常,是否重新下发任务?`
+  })
+    .then(() => {
+      doReissueTask(station)
+    })
+    .catch(() => {
+      // 用户取消
+    })
+}
+
+// 获取当前所有料箱编码列表
+const getBoxCodeList = (): string[] => {
+  return stationList.value
+    .filter(s => s.boxCode && s.status !== 'offline')
+    .map(s => s.boxCode!)
+}
+
+// 检查是否存在等待调箱的料箱
+const hasWaitingBox = (): boolean => {
+  return stationList.value.some(s => s.status === 'waiting')
+}
+
+// 局部更新料箱状态
+const refreshBoxStatus = async () => {
+  const boxCodeList = getBoxCodeList()
+  if (boxCodeList.length === 0) return
+
+  try {
+    const res = await getBoxStatus(warehouse, boxCodeList)
+    const statusMap = res.data || {}
+    
+    // 更新站台列表中的状态
+    stationList.value = stationList.value.map(station => {
+      if (!station.boxCode || !statusMap.hasOwnProperty(station.boxCode)) {
+        return station
+      }
+      
+      const newBoxStatus = statusMap[station.boxCode]
+      const inventoryLocations = station.inventoryLocations || []
+      
+      let status: StationItem['status'] = 'offline'
+      let label: string | undefined = undefined
+      
+      if (newBoxStatus === 0 || newBoxStatus === 10) {
+        status = 'waiting'
+        label = '等待调箱'
+      } else if (newBoxStatus === 20) {
+        if (inventoryLocations.length === 0) {
+          status = 'emptyBox'
+          label = '空箱'
+        } else {
+          status = 'filled'
+        }
+      } else if (newBoxStatus === 30) {
+        status = 'offline'
+      } else if (newBoxStatus === 40) {
+        status = 'error'
+        label = '异'
+      }
+      
+      return { ...station, status, label }
+    })
+    
+    // 检查是否需要继续轮询
+    if (hasWaitingBox()) {
+      startPolling()
+    } else {
+      stopPolling()
+    }
+  } catch (error) {
+    console.error('刷新料箱状态失败', error)
+  }
+}
+
+// 启动轮询
+const startPolling = () => {
+  if (pollingTimer) return // 已经在轮询中
+  pollingTimer = setInterval(() => {
+    refreshBoxStatus()
+  }, 10000) // 10秒
+}
+
+// 停止轮询
+const stopPolling = () => {
+  if (pollingTimer) {
+    clearInterval(pollingTimer)
+    pollingTimer = null
+  }
+}
+
+// 执行重新下发任务
+const doReissueTask = async (station: StationItem) => {
+  if (!station.boxCode) {
+    showToast('料箱编码不存在')
+    return
+  }
+  try {
+    showLoadingToast({ message: '正在重新下发...', forbidClick: true })
+    await reissueTask(warehouse, station.stationCode, station.boxCode)
+    closeToast()
+    showToast('重新下发成功')
+    // 局部刷新料箱状态
+    setTimeout(() => {
+      refreshBoxStatus()
+    }, 500)
+  } catch (error: any) {
+    closeToast()
+    showToast(error.message || '重新下发失败')
+  }
+}
+
+// 选择站台(对于未分割的料箱,库位编码等于料箱编码)
+const selectStation = (station: StationItem) => {
+  if (station.status === 'waiting' || station.status === 'emptyBox' || station.status === 'offline') return
+
+  const locationCode = station.boxCode || ''
+
+  // 检查是否可点击
+  if (!isLocationClickable(locationCode)) {
+    showToast('该库位不在合并任务中')
+    return
+  }
+
+  selectedBox.value = station.stationCode
+
+  // 获取库位推荐信息
+  const locationInfo = clickableLocationsMap.value.get(locationCode)
+
+  // 显示库位信息弹窗
+  currentLocation.id = locationCode
+  currentLocation.boxId = locationCode
+  currentLocation.status = station.status === 'filled' ? 'filled' : 'empty'
+  currentLocation.recommendType = locationInfo?.recommendType || 'keep'
+  currentLocation.sku = locationInfo?.sku || ''
+  currentLocation.relatedLocations = locationInfo?.relatedLocations || []
+  showLocationPopup.value = true
+}
+
+// 选择子库位
+const selectSubLocation = (station: StationItem, sub: SubLocation) => {
+  // 检查是否可点击
+  if (!isLocationClickable(sub.locationCode)) {
+    return
+  }
+
+  selectedBox.value = sub.locationCode
+
+  // 获取库位推荐信息
+  const locationInfo = clickableLocationsMap.value.get(sub.locationCode)
+
+  // 显示库位信息弹窗
+  currentLocation.id = sub.locationCode
+  currentLocation.boxId = station.boxCode || ''
+  currentLocation.status = sub.status === 'filled' ? 'filled' : 'empty'
+  currentLocation.recommendType = locationInfo?.recommendType || 'keep'
+  currentLocation.sku = locationInfo?.sku || ''
+  currentLocation.relatedLocations = locationInfo?.relatedLocations || []
+  showLocationPopup.value = true
+}
+
+// 确认选择库位
+const confirmSelectLocation = () => {
+  productInfo.targetLocationNew = currentLocation.id
+  showLocationPopup.value = false
+  showToast(`已选择库位: ${currentLocation.id}`)
+}
+
+// 呼唤机器人
+const callRobot = async () => {
+  try {
+    showLoadingToast({ message: '正在呼唤机器人...', forbidClick: true })
+    await boxInbound(warehouse)
+    closeToast()
+    showToast('呼唤机器人成功')
+  } catch (error: any) {
+    closeToast()
+    showToast(error.message || '呼唤机器人失败')
+  }
+}
+
+// 重新输入(不重置料箱号)
+const resetInput = () => {
+  resetExceptBoxCode()
+}
+
+// 提交移库
+const submitMove = () => {
+  if (!boxCode.value) {
+    showToast('请先扫描料箱号')
+    return
+  }
+  if (!sourceLocation.value) {
+    showToast('请先扫描源库位')
+    return
+  }
+  if (!productInfo.barcode) {
+    showToast('请先扫描商品条码')
+    return
+  }
+  if (!productInfo.targetLocationNew) {
+    showToast('请先选择目标库位')
+    return
+  }
+  if (!productInfo.moveQty || Number(productInfo.moveQty) <= 0) {
+    showToast('请输入有效的移库数量')
+    return
+  }
+  if (Number(productInfo.moveQty) > Number(productInfo.stockQty)) {
+    showToast('移库数量不能大于库存数量')
+    return
+  }
+
+  showConfirmDialog({
+    title: '移库确认',
+    message: `${productInfo.barcode}从"${sourceLocation.value}"移动至"${productInfo.targetLocationNew}"共:${productInfo.moveQty}件`
+  })
+    .then(() => {
+      const { traceId, lotNum, lotNumber, ownerCode, owner, sku } = currentInventoryData.value || {}
+      const data = {
+        fmLocation: sourceLocation.value,
+        fmContainer: traceId || boxCode.value,
+        owner: ownerCode || owner || '',
+        sku: sku || productInfo.barcode,
+        lotNum: lotNum || lotNumber || productInfo.batchNo || '',
+        warehouse,
+        quantity: Number(productInfo.moveQty),
+        toLocation: productInfo.targetLocationNew
+      }
+      showLoadingToast({ message: '提交中...', forbidClick: true })
+      inventoryMovement(data)
+        .then(() => {
+          closeToast()
+          showToast('提交移库成功')
+          // 重置除料箱号外的数据,继续下一个移库
+          resetExceptBoxCode()
+        })
+        .catch((err: any) => {
+          closeToast()
+          showToast(err.message || '提交移库失败')
+        })
+    })
+    .catch(() => {
+      // 用户取消
+    })
+}
+</script>
+
+<style scoped lang="scss">
+.merge-container {
+  min-height: 100vh;
+  background: #f5f5f5;
+  padding: 12px;
+  box-sizing: border-box;
+}
+
+.scan-section {
+  margin-bottom: 12px;
+
+  :deep(.van-field) {
+    border-radius: 4px;
+  }
+}
+
+.info-table {
+  background: #fff;
+  border: 1px solid #ddd;
+  margin-bottom: 12px;
+
+  .table-row {
+    display: flex;
+    border-bottom: 1px solid #ddd;
+
+    &:last-child {
+      border-bottom: none;
+    }
+
+    &.row-small .cell {
+      font-size: 11px;
+      padding: 6px 6px;
+    }
+  }
+
+  .cell {
+    padding: 8px 6px;
+    font-size: 13px;
+    border-right: 1px solid #ddd;
+    flex: 1;
+    display: flex;
+    align-items: center;
+
+    &:last-child {
+      border-right: none;
+    }
+
+    &.label {
+      background: #f9f9f9;
+      color: #666;
+      flex: 0 0 55px;
+    }
+
+    &.label-small {
+      flex: 0 0 40px;
+    }
+
+    &.value {
+      color: #333;
+    
+      &.input-cell {
+        padding: 0;
+    
+        :deep(.van-field) {
+          padding: 4px 6px;
+    
+          .van-field__body {
+            height: 24px;
+          }
+    
+          .van-field__control {
+            font-size: 13px;
+          }
+        }
+      }
+    
+      &.editable {
+        cursor: pointer;
+        min-height: 20px;
+
+        .placeholder {
+          color: #ccc;
+          font-size: 12px;
+        }
+
+        :deep(.van-field) {
+          padding: 0;
+
+          .van-field__body {
+            height: 20px;
+          }
+
+          .van-field__control {
+            font-size: 13px;
+          }
+        }
+      }
+    }
+
+    &.span-2 {
+      flex: 2;
+    }
+
+    &.value-small {
+      flex: 0.9;
+    }
+
+    &.value-large {
+      flex: 1.4;
+    }
+
+    &.input-wide {
+      flex: 1.13;
+    }
+  }
+}
+
+.grid-section {
+  background: #fff;
+  padding: 12px;
+  margin-bottom: 80px;
+  border-radius: 4px;
+
+  .grid-container {
+    display: grid;
+    grid-template-columns: repeat(6, 1fr);
+    gap: 8px;
+  }
+
+  .box-wrapper {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+
+    .box-number {
+      font-size: 12px;
+      color: #333;
+      margin-bottom: 2px;
+    }
+  }
+
+  .box-item {
+    width: 100%;
+    aspect-ratio: 0.8;
+    border: 1px solid #ddd;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    font-size: 14px;
+    color: #333;
+    background: #fff;
+    cursor: pointer;
+    border-radius: 4px;
+    position: relative;
+
+    .box-label {
+      font-size: 11px;
+      color: #666;
+      text-align: center;
+      line-height: 1.2;
+    }
+
+    &.box-filled {
+      background: #e8d4f7;
+      border-color: #c9a0dc;
+    }
+
+    &.box-empty-box {
+      background: #d4f7e0;
+      border-color: #a0dcb0;
+    }
+
+    &.box-waiting {
+      background: #f5d4d4;
+      border-color: #e0b0b0;
+    }
+
+        &.box-error {
+      background: #ffcccc;
+      border-color: #ff6666;
+      cursor: pointer;
+
+      .box-label {
+        color: #cc0000;
+      }
+    }
+
+    &.box-selected {
+      border: 2px solid #1989fa;
+    }
+
+    &.box-split {
+      padding: 3px;
+      cursor: default;
+
+      .sub-grid {
+        width: 100%;
+        height: 100%;
+        display: grid;
+        gap: 2px;
+      }
+
+      .sub-location {
+        border: 1px solid #ddd;
+        background: #fff;
+        cursor: pointer;
+        border-radius: 2px;
+
+        &.sub-filled {
+          background: #e8d4f7;
+          border-color: #c9a0dc;
+        }
+
+        &.sub-selected {
+          border: 2px solid #1989fa;
+        }
+
+        &.sub-disabled {
+          background: #f0f0f0;
+          border-color: #ddd;
+          cursor: not-allowed;
+          opacity: 0.6;
+        }
+      }
+    }
+  }
+}
+
+.footer-buttons {
+  position: fixed;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 10px 12px;
+  background: #fff;
+  box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
+
+  .btn-right {
+    display: flex;
+    gap: 8px;
+  }
+
+  .van-button {
+    height: 32px;
+    font-size: 13px;
+    padding: 0 12px;
+  }
+
+  .btn-robot {
+    background: #fff;
+    border: 1px solid #ff9800;
+    color: #ff9800;
+  }
+
+  .btn-reset {
+    background: #5b9bd5;
+    border-color: #5b9bd5;
+  }
+
+  .btn-submit {
+    background: #ff9800;
+    border-color: #ff9800;
+    color: #fff;
+  }
+}
+
+.location-popup {
+  padding: 16px;
+
+  .popup-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    padding-bottom: 12px;
+    border-bottom: 1px solid #eee;
+
+    .popup-title {
+      font-size: 16px;
+      font-weight: 500;
+      color: #333;
+    }
+  }
+
+  .popup-content {
+    padding: 12px 0;
+
+    .info-row {
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      padding: 10px 0;
+      border-bottom: 1px solid #f5f5f5;
+
+      &:last-child {
+        border-bottom: none;
+      }
+
+      .info-label {
+        font-size: 14px;
+        color: #666;
+      }
+
+      .info-value {
+        font-size: 14px;
+        color: #333;
+      }
+    }
+  }
+
+  .popup-footer {
+    padding-top: 12px;
+  }
+
+  .recommend-section {
+    margin-top: 12px;
+    padding-top: 12px;
+    border-top: 1px solid #eee;
+
+    .recommend-title {
+      font-size: 14px;
+      font-weight: 500;
+      color: #333;
+      margin-bottom: 8px;
+    }
+
+    .recommend-list {
+      max-height: 150px;
+      overflow-y: auto;
+    }
+
+    .recommend-item {
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      padding: 8px 12px;
+      background: #f9f9f9;
+      border-radius: 4px;
+      margin-bottom: 6px;
+
+      &:last-child {
+        margin-bottom: 0;
+      }
+
+      .recommend-location {
+        font-size: 13px;
+        color: #333;
+        font-weight: 500;
+      }
+
+      .recommend-qty {
+        font-size: 12px;
+        color: #666;
+      }
+    }
+
+    .recommend-empty {
+      font-size: 13px;
+      color: #999;
+      text-align: center;
+      padding: 12px 0;
+    }
+  }
+}
+</style>