WarehouseMap.vue 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975
  1. <template>
  2. <div class="warehouse-map">
  3. <div
  4. v-if="gridData.length === 0"
  5. class="no-data"
  6. >
  7. 暂无数据
  8. </div>
  9. <div
  10. v-else
  11. ref="mapWrapperRef"
  12. class="map-wrapper"
  13. >
  14. <div
  15. class="map-grid"
  16. :style="gridStyle"
  17. >
  18. <div
  19. v-for="(cell, index) in gridData"
  20. :key="index"
  21. :class="['grid-cell', getCellClass(cell)]"
  22. :style="getCellStyle(cell)"
  23. @mousedown.left="handleCellMouseDown(index, $event)"
  24. @mouseenter="handleCellMouseEnter(cell, index, $event)"
  25. @mousemove="handleCellMouseMove($event)"
  26. @mouseleave="handleCellMouseLeave"
  27. @click="handleCellClick(cell)"
  28. @contextmenu.prevent="handleCellContextMenu(cell)"
  29. >
  30. <div
  31. v-if="isLocationCell(cell) && isCellVisible(cell)"
  32. class="cell-content"
  33. >
  34. <div
  35. class="category-badge"
  36. :style="getCategoryStyle(cell)"
  37. >
  38. {{ getHeatLabel(cell) }}
  39. </div>
  40. <div class="loc-group">
  41. {{ cell.locGroup1 }}
  42. </div>
  43. <div
  44. :class="[
  45. 'location-id',
  46. {
  47. 'location-id-mismatch':
  48. cell.categoryMismatch && !hasAbnormalLocationAttribute(cell),
  49. 'location-id-abnormal-attribute': hasAbnormalLocationAttribute(cell)
  50. }
  51. ]"
  52. >
  53. {{ cell.locationId }}
  54. </div>
  55. <div
  56. v-if="cell.categoryMismatch"
  57. class="location-attribute-tag"
  58. >
  59. 热度编号异常
  60. </div>
  61. <div
  62. v-if="hasAbnormalLocationAttribute(cell)"
  63. class="location-attribute-tag"
  64. >
  65. {{ getLocationAttributeLabel(cell) }}
  66. </div>
  67. <div
  68. v-if="cell.containerCode"
  69. class="container-code"
  70. >
  71. {{ cell.containerCode }}
  72. </div>
  73. </div>
  74. <div
  75. v-else-if="isSpecialCell(cell) && cell.label"
  76. :class="['special-cell-label', `special-cell-label-${cell.type}`]"
  77. >
  78. {{ cell.label }}
  79. </div>
  80. </div>
  81. </div>
  82. <div
  83. v-if="props.showTooltip && tooltipVisible && tooltipLines.length"
  84. class="cell-tooltip"
  85. :style="tooltipStyle"
  86. >
  87. <div
  88. v-for="(line, index) in tooltipLines"
  89. :key="index"
  90. class="cell-tooltip-line"
  91. >
  92. {{ line }}
  93. </div>
  94. </div>
  95. </div>
  96. </div>
  97. </template>
  98. <script setup lang="ts">
  99. import { computed, onBeforeUnmount, onMounted, ref, watch, type CSSProperties } from 'vue'
  100. import type { LocationResourceDataVO } from '../types'
  101. import { applyWarehouseLayoutEnhancement } from './warehouse-layout-enhancers'
  102. import {
  103. getWarehouseLayoutSpecialCells,
  104. type WarehouseLayoutSpecialCell
  105. } from './warehouse-layout-special-cells'
  106. interface Props {
  107. locations: LocationResourceDataVO[]
  108. currentLevel: number
  109. selectedCategory: string
  110. categoryColorVisibility: Record<string, boolean>
  111. selectedLocationAttribute: string
  112. selectedHasContainer: string
  113. selectedZoneId: string
  114. locGroupKeyword: string
  115. locationIdKeyword: string
  116. showGroupBorder: boolean
  117. showTooltip: boolean
  118. }
  119. interface ParsedLocation {
  120. floor: number
  121. x: number
  122. y: number
  123. depth: number
  124. gridRow: number
  125. gridCol: number
  126. }
  127. interface GridCell extends LocationResourceDataVO {
  128. parsed: ParsedLocation
  129. expectedCategory: string | null
  130. categoryMismatch: boolean
  131. }
  132. interface AisleCell {
  133. type: 'aisle'
  134. gridRow: number
  135. gridCol: number
  136. }
  137. type MapCell = GridCell | WarehouseLayoutSpecialCell | AisleCell
  138. const props = defineProps<Props>()
  139. const emit = defineEmits<{
  140. (event: 'select-loc-group', locGroup1: string): void
  141. (event: 'select-location-id', locationId: string): void
  142. }>()
  143. const mapWrapperRef = ref<HTMLElement | null>(null)
  144. const wrapperSize = ref({
  145. width: 0,
  146. height: 0
  147. })
  148. const hoveredCell = ref<GridCell | null>(null)
  149. const tooltipVisible = ref(false)
  150. const tooltipPosition = ref({
  151. x: 0,
  152. y: 0
  153. })
  154. const selectionStart = ref<{ row: number; col: number } | null>(null)
  155. const selectionEnd = ref<{ row: number; col: number } | null>(null)
  156. const selectedRange = ref<{
  157. rowStart: number
  158. rowEnd: number
  159. colStart: number
  160. colEnd: number
  161. } | null>(null)
  162. const didSelectionMove = ref(false)
  163. const suppressNextClick = ref(false)
  164. let resizeObserver: ResizeObserver | null = null
  165. const DEPTH_CATEGORY_MAP: Record<number, string> = {
  166. 1: 'A',
  167. 2: 'B',
  168. 3: 'C'
  169. }
  170. const CATEGORY_THEME_MAP: Record<string, { solid: string; soft: string; text: string }> = {
  171. A: {
  172. solid: '#008000',
  173. soft: 'rgba(0, 128, 0, 0.16)',
  174. text: '#f5fff7'
  175. },
  176. B: {
  177. solid: '#0000FF',
  178. soft: 'rgba(0, 0, 255, 0.16)',
  179. text: '#f4f8ff'
  180. },
  181. C: {
  182. solid: '#FFFF00',
  183. soft: 'rgba(255, 255, 0, 0.16)',
  184. text: '#5a5200'
  185. }
  186. }
  187. const INACTIVE_CATEGORY_THEME = {
  188. solid: '#5f6b7a',
  189. soft: 'rgba(95, 107, 122, 0.16)',
  190. text: '#eef4f8'
  191. }
  192. const LOCATION_ATTRIBUTE_LABEL_MAP: Record<string, string> = {
  193. OK: '正常',
  194. FI: '禁入',
  195. HD: '封存',
  196. SC: '管控'
  197. }
  198. const normalizeCategory = (category: string) => category.trim().toUpperCase()
  199. const formatFieldValue = (value: string | number | null | undefined) => {
  200. if (value === null || value === undefined || value === '') {
  201. return '-'
  202. }
  203. return String(value)
  204. }
  205. // 解析库位编码 H1-0-0-2 => {floor: 1, x: 0, y: 0, depth: 2}
  206. const parseLocationId = (locationId: string) => {
  207. const match = locationId.match(/H(\d+)-(\d+)-(\d+)-(\d+)/)
  208. if (!match) return null
  209. return {
  210. floor: parseInt(match[1], 10),
  211. x: parseInt(match[2], 10),
  212. y: parseInt(match[3], 10),
  213. depth: parseInt(match[4], 10),
  214. gridRow: 0,
  215. gridCol: 0
  216. }
  217. }
  218. const parsedLocations = computed<GridCell[]>(() => {
  219. return props.locations
  220. .map((loc) => ({
  221. ...loc,
  222. parsed: parseLocationId(loc.locationId)
  223. }))
  224. .filter(
  225. (loc): loc is LocationResourceDataVO & { parsed: ParsedLocation } => loc.parsed !== null
  226. )
  227. .map((loc) => {
  228. const normalizedCategory = normalizeCategory(loc.category)
  229. const expectedCategory = DEPTH_CATEGORY_MAP[loc.parsed.depth] || null
  230. const enhancedPosition = applyWarehouseLayoutEnhancement(props.currentLevel, loc.parsed, {
  231. // 原始布局规则:第一个数字是列,第二个数字是行。
  232. gridRow: loc.parsed.y,
  233. gridCol: loc.parsed.x
  234. })
  235. return {
  236. ...loc,
  237. parsed: {
  238. ...loc.parsed,
  239. ...enhancedPosition
  240. },
  241. category: normalizedCategory,
  242. expectedCategory,
  243. categoryMismatch: expectedCategory === null || normalizedCategory !== expectedCategory
  244. }
  245. })
  246. })
  247. const specialCells = computed(() => getWarehouseLayoutSpecialCells(props.currentLevel))
  248. const specialCellMap = computed(() => {
  249. return new Map(specialCells.value.map((cell) => [`${cell.gridRow}-${cell.gridCol}`, cell]))
  250. })
  251. const locationMap = computed(() => {
  252. const map = new Map<string, GridCell>()
  253. parsedLocations.value.forEach((loc) => {
  254. map.set(`${loc.parsed.gridRow}-${loc.parsed.gridCol}`, loc)
  255. })
  256. return map
  257. })
  258. const gridBounds = computed(() => {
  259. if (parsedLocations.value.length === 0) return null
  260. const allRows = [
  261. ...parsedLocations.value.map((loc) => loc.parsed.gridRow),
  262. ...specialCells.value.map((cell) => cell.gridRow)
  263. ]
  264. const allCols = [
  265. ...parsedLocations.value.map((loc) => loc.parsed.gridCol),
  266. ...specialCells.value.map((cell) => cell.gridCol)
  267. ]
  268. return {
  269. minX: Math.min(...allRows),
  270. maxX: Math.max(...allRows),
  271. minY: Math.min(...allCols),
  272. maxY: Math.max(...allCols)
  273. }
  274. })
  275. const gridMetrics = computed(() => {
  276. if (!gridBounds.value) return null
  277. return {
  278. rows: gridBounds.value.maxX - gridBounds.value.minX + 1,
  279. cols: gridBounds.value.maxY - gridBounds.value.minY + 1
  280. }
  281. })
  282. // 构建网格数据 - 按 X(行) / Y(列) 摆放,缺口显示为过道
  283. const gridData = computed(() => {
  284. if (!gridBounds.value) return []
  285. // 构建网格:按行(X)和列(Y)排列,空位根据规则显示为过道或特殊区域。
  286. const grid: MapCell[] = []
  287. for (let x = gridBounds.value.minX; x <= gridBounds.value.maxX; x++) {
  288. for (let y = gridBounds.value.minY; y <= gridBounds.value.maxY; y++) {
  289. const key = `${x}-${y}`
  290. const locationCell = locationMap.value.get(key)
  291. if (locationCell) {
  292. grid.push(locationCell)
  293. continue
  294. }
  295. const specialCell = specialCellMap.value.get(key)
  296. if (specialCell) {
  297. grid.push(specialCell)
  298. continue
  299. }
  300. grid.push({
  301. type: 'aisle',
  302. gridRow: x,
  303. gridCol: y
  304. })
  305. }
  306. }
  307. return grid
  308. })
  309. const gridStyle = computed(() => {
  310. if (!gridMetrics.value) return {}
  311. const gap = 2
  312. const horizontalPadding = 24
  313. const verticalPadding = 12
  314. const availableWidth = Math.max(wrapperSize.value.width - horizontalPadding, 0)
  315. const availableHeight = Math.max(wrapperSize.value.height - verticalPadding, 0)
  316. const cellWidth =
  317. availableWidth > 0
  318. ? Math.max((availableWidth - (gridMetrics.value.cols - 1) * gap) / gridMetrics.value.cols, 28)
  319. : 72
  320. const cellHeight =
  321. availableHeight > 0
  322. ? Math.max(
  323. (availableHeight - (gridMetrics.value.rows - 1) * gap) / gridMetrics.value.rows,
  324. 24
  325. )
  326. : 60
  327. const compactSize = Math.min(cellWidth, cellHeight)
  328. const showBadge = compactSize > 38 ? 'inline-flex' : 'none'
  329. const showGroup = compactSize > 58 ? 'block' : 'none'
  330. const badgeFontSize = compactSize <= 42 ? 8 : compactSize <= 56 ? 9 : 10
  331. const textFontSize = compactSize <= 56 ? 9 : 10
  332. const idFontSize = compactSize <= 34 ? 9 : compactSize <= 42 ? 10 : compactSize <= 56 ? 10 : 11
  333. const contentGap = compactSize <= 42 ? 2 : 4
  334. const contentPadding = compactSize <= 42 ? 2 : 4
  335. const groupBorderWidth = compactSize <= 34 ? 1 : 2
  336. return {
  337. gridTemplateColumns: `repeat(${gridMetrics.value.cols}, ${cellWidth.toFixed(2)}px)`,
  338. gridAutoRows: `${cellHeight.toFixed(2)}px`,
  339. '--cell-badge-font-size': `${badgeFontSize}px`,
  340. '--cell-group-font-size': `${textFontSize}px`,
  341. '--cell-id-font-size': `${idFontSize}px`,
  342. '--cell-badge-display': showBadge,
  343. '--cell-group-display': showGroup,
  344. '--cell-content-gap': `${contentGap}px`,
  345. '--cell-content-padding': `${contentPadding}px`,
  346. '--group-outline-width': `${groupBorderWidth}px`
  347. }
  348. })
  349. const isCellMatched = (cell: GridCell) => {
  350. const matchedCategory = !props.selectedCategory || cell.category === props.selectedCategory
  351. const matchedLocationAttribute =
  352. !props.selectedLocationAttribute || cell.locationAttribute === props.selectedLocationAttribute
  353. const hasContainer = Boolean(cell.containerCode && cell.containerCode.trim())
  354. const matchedHasContainer =
  355. !props.selectedHasContainer ||
  356. (props.selectedHasContainer === 'Y' && hasContainer) ||
  357. (props.selectedHasContainer === 'N' && !hasContainer)
  358. const matchedZoneId = !props.selectedZoneId || String(cell.zoneId || '') === props.selectedZoneId
  359. const normalizedKeyword = props.locGroupKeyword.trim().toUpperCase()
  360. const matchedLocGroup =
  361. !normalizedKeyword || cell.locGroup1.toUpperCase().includes(normalizedKeyword)
  362. const normalizedLocationIdKeyword = props.locationIdKeyword.trim().toUpperCase()
  363. const matchedLocationId =
  364. !normalizedLocationIdKeyword ||
  365. cell.locationId.toUpperCase().includes(normalizedLocationIdKeyword)
  366. return (
  367. matchedCategory &&
  368. matchedLocationAttribute &&
  369. matchedHasContainer &&
  370. matchedZoneId &&
  371. matchedLocGroup &&
  372. matchedLocationId
  373. )
  374. }
  375. const getGridPointByIndex = (index: number) => {
  376. if (!gridBounds.value || !gridMetrics.value) {
  377. return null
  378. }
  379. const rowOffset = Math.floor(index / gridMetrics.value.cols)
  380. const colOffset = index % gridMetrics.value.cols
  381. return {
  382. row: gridBounds.value.minX + rowOffset,
  383. col: gridBounds.value.minY + colOffset
  384. }
  385. }
  386. const buildSelectionRange = (
  387. start: { row: number; col: number },
  388. end: { row: number; col: number }
  389. ) => ({
  390. rowStart: Math.min(start.row, end.row),
  391. rowEnd: Math.max(start.row, end.row),
  392. colStart: Math.min(start.col, end.col),
  393. colEnd: Math.max(start.col, end.col)
  394. })
  395. const isCellInSelectedRange = (cell: GridCell) => {
  396. if (!selectedRange.value) {
  397. return true
  398. }
  399. return (
  400. cell.parsed.gridRow >= selectedRange.value.rowStart &&
  401. cell.parsed.gridRow <= selectedRange.value.rowEnd &&
  402. cell.parsed.gridCol >= selectedRange.value.colStart &&
  403. cell.parsed.gridCol <= selectedRange.value.colEnd
  404. )
  405. }
  406. const isCellVisible = (cell: GridCell) => {
  407. return isCellMatched(cell) && isCellInSelectedRange(cell)
  408. }
  409. const isSpecialCell = (cell: MapCell): cell is WarehouseLayoutSpecialCell => {
  410. return Boolean(cell && 'type' in cell && cell.type !== 'aisle')
  411. }
  412. const isLocationCell = (cell: MapCell): cell is GridCell => {
  413. return Boolean(cell && !('type' in cell))
  414. }
  415. const getCellClass = (cell: MapCell) => {
  416. if (isSpecialCell(cell)) {
  417. return [cell.type]
  418. }
  419. if (!isLocationCell(cell)) {
  420. return ['aisle']
  421. }
  422. if (!isCellVisible(cell)) {
  423. return ['aisle']
  424. }
  425. const classNames = ['location-cell']
  426. if (props.categoryColorVisibility[cell.category] !== false) {
  427. classNames.push(`category-${cell.category.toLowerCase()}`)
  428. } else {
  429. classNames.push('category-muted')
  430. }
  431. if (props.showGroupBorder && hasSameBorderNeighbor(cell)) {
  432. classNames.push('grouped')
  433. }
  434. return classNames
  435. }
  436. const getHeatLabel = (cell: GridCell) => {
  437. return cell.category
  438. }
  439. const hasAbnormalLocationAttribute = (cell: GridCell) => {
  440. return Boolean(cell.locationAttribute && cell.locationAttribute !== 'OK')
  441. }
  442. const getLocationAttributeLabel = (cell: GridCell) => {
  443. if (!cell.locationAttribute) {
  444. return '-'
  445. }
  446. return LOCATION_ATTRIBUTE_LABEL_MAP[cell.locationAttribute] || cell.locationAttribute
  447. }
  448. const getCellTitle = (cell: GridCell | null) => {
  449. if (!cell) return '过道'
  450. if (!isCellMatched(cell)) return '未命中筛选条件'
  451. if (!isCellInSelectedRange(cell)) return '未命中当前选区'
  452. const mismatchText =
  453. cell.categoryMismatch && cell.expectedCategory
  454. ? `\n异常: 深度${cell.parsed.depth} 期望热度${cell.expectedCategory}`
  455. : cell.categoryMismatch
  456. ? `\n异常: 深度${cell.parsed.depth} 未配置对应热度`
  457. : ''
  458. const locationAttributeLabel = cell.locationAttribute
  459. ? LOCATION_ATTRIBUTE_LABEL_MAP[cell.locationAttribute] || cell.locationAttribute
  460. : '-'
  461. return [
  462. `库位号: ${formatFieldValue(cell.locationId)}`,
  463. `库位组: ${formatFieldValue(cell.locGroup1)}`,
  464. `WCS库位: ${formatFieldValue(cell.wcsLocationId)}`,
  465. `资源类别: ${formatFieldValue(cell.category)}`,
  466. `容器编码: ${formatFieldValue(cell.containerCode)}`,
  467. `库位楼层: ${formatFieldValue(cell.locLevel)}`,
  468. `库区: ${formatFieldValue(cell.zoneId)}`,
  469. `库位属性: ${locationAttributeLabel}${cell.locationAttribute ? `(${cell.locationAttribute})` : ''}`,
  470. `上架排序: ${formatFieldValue(cell.putawayLogicalSequence)}`,
  471. `X坐标: ${cell.parsed.x}`,
  472. `Y坐标: ${cell.parsed.y}`,
  473. `深度: ${cell.parsed.depth}${mismatchText}`
  474. ].join('\n')
  475. }
  476. const getCategoryStyle = (cell: GridCell) => {
  477. const theme =
  478. props.categoryColorVisibility[cell.category] === false
  479. ? INACTIVE_CATEGORY_THEME
  480. : CATEGORY_THEME_MAP[cell.category]
  481. return {
  482. background: theme?.solid || '#5f6b7a',
  483. color: theme?.text || '#fff'
  484. }
  485. }
  486. const tooltipLines = computed(() => {
  487. return getCellTitle(hoveredCell.value)
  488. .split('\n')
  489. .map((line) => line.trim())
  490. .filter(Boolean)
  491. })
  492. const tooltipStyle = computed<CSSProperties>(() => ({
  493. left: `${tooltipPosition.value.x}px`,
  494. top: `${tooltipPosition.value.y}px`
  495. }))
  496. const getBorderGroupValue = (cell: GridCell) => {
  497. if (props.showGroupBorder) {
  498. return cell.locGroup1
  499. }
  500. return ''
  501. }
  502. const isSameBorderNeighbor = (cell: GridCell, xOffset: number, yOffset: number) => {
  503. const currentGroupValue = getBorderGroupValue(cell)
  504. if (!currentGroupValue) {
  505. return false
  506. }
  507. const neighbor = locationMap.value.get(
  508. `${cell.parsed.gridRow + xOffset}-${cell.parsed.gridCol + yOffset}`
  509. )
  510. return Boolean(neighbor && getBorderGroupValue(neighbor) === currentGroupValue)
  511. }
  512. const hasSameBorderNeighbor = (cell: GridCell) => {
  513. return (
  514. isSameBorderNeighbor(cell, -1, 0) ||
  515. isSameBorderNeighbor(cell, 1, 0) ||
  516. isSameBorderNeighbor(cell, 0, -1) ||
  517. isSameBorderNeighbor(cell, 0, 1)
  518. )
  519. }
  520. const getCellStyle = (cell: MapCell): CSSProperties => {
  521. if (
  522. !isLocationCell(cell) ||
  523. !isCellVisible(cell) ||
  524. !props.showGroupBorder ||
  525. !hasSameBorderNeighbor(cell)
  526. ) {
  527. return {}
  528. }
  529. const borderWidth = 'var(--group-outline-width, 2px)'
  530. return {
  531. '--group-border-color': '#ffffff',
  532. '--group-border-top': isSameBorderNeighbor(cell, -1, 0) ? '0px' : borderWidth,
  533. '--group-border-right': isSameBorderNeighbor(cell, 0, 1) ? '0px' : borderWidth,
  534. '--group-border-bottom': isSameBorderNeighbor(cell, 1, 0) ? '0px' : borderWidth,
  535. '--group-border-left': isSameBorderNeighbor(cell, 0, -1) ? '0px' : borderWidth
  536. }
  537. }
  538. const clearSelectedRange = () => {
  539. selectedRange.value = null
  540. }
  541. const handleCellClick = (cell: MapCell) => {
  542. if (suppressNextClick.value) {
  543. suppressNextClick.value = false
  544. return
  545. }
  546. if (!isLocationCell(cell) || !isCellVisible(cell)) {
  547. if (selectedRange.value) {
  548. clearSelectedRange()
  549. }
  550. return
  551. }
  552. emit('select-loc-group', cell.locGroup1)
  553. }
  554. const handleCellContextMenu = (cell: MapCell) => {
  555. if (!isLocationCell(cell) || !isCellVisible(cell)) return
  556. emit('select-location-id', cell.locationId)
  557. }
  558. const copyText = async (text: string) => {
  559. if (!text) {
  560. return
  561. }
  562. try {
  563. await navigator.clipboard.writeText(text)
  564. return
  565. } catch (error) {
  566. console.warn('Clipboard API copy failed, fallback to execCommand.', error)
  567. }
  568. const textarea = document.createElement('textarea')
  569. textarea.value = text
  570. textarea.setAttribute('readonly', 'true')
  571. textarea.style.position = 'fixed'
  572. textarea.style.top = '-9999px'
  573. document.body.appendChild(textarea)
  574. textarea.select()
  575. document.execCommand('copy')
  576. document.body.removeChild(textarea)
  577. }
  578. const handleCellMouseDown = (index: number, event: MouseEvent) => {
  579. if (event.button !== 0) {
  580. return
  581. }
  582. const point = getGridPointByIndex(index)
  583. if (!point) {
  584. return
  585. }
  586. selectionStart.value = point
  587. selectionEnd.value = point
  588. didSelectionMove.value = false
  589. tooltipVisible.value = false
  590. hoveredCell.value = null
  591. }
  592. const updateTooltipPosition = (event: MouseEvent) => {
  593. const tooltipWidth = 300
  594. const tooltipHeight = 260
  595. const offset = 14
  596. const maxX = window.innerWidth - tooltipWidth - 12
  597. const maxY = window.innerHeight - tooltipHeight - 12
  598. tooltipPosition.value = {
  599. x: Math.max(12, Math.min(event.clientX + offset, maxX)),
  600. y: Math.max(12, Math.min(event.clientY + offset, maxY))
  601. }
  602. }
  603. const handleCellMouseEnter = (cell: MapCell, index: number, event: MouseEvent) => {
  604. if (selectionStart.value && (event.buttons & 1) === 1) {
  605. const point = getGridPointByIndex(index)
  606. if (point) {
  607. selectionEnd.value = point
  608. didSelectionMove.value =
  609. point.row !== selectionStart.value.row || point.col !== selectionStart.value.col
  610. }
  611. tooltipVisible.value = false
  612. hoveredCell.value = null
  613. return
  614. }
  615. if (!props.showTooltip || !isLocationCell(cell)) {
  616. tooltipVisible.value = false
  617. hoveredCell.value = null
  618. return
  619. }
  620. hoveredCell.value = cell
  621. tooltipVisible.value = true
  622. updateTooltipPosition(event)
  623. }
  624. const handleCellMouseMove = (event: MouseEvent) => {
  625. if (!props.showTooltip || !tooltipVisible.value) return
  626. updateTooltipPosition(event)
  627. }
  628. const handleCellMouseLeave = () => {
  629. if (selectionStart.value) {
  630. return
  631. }
  632. tooltipVisible.value = false
  633. hoveredCell.value = null
  634. }
  635. const handleWindowMouseUp = () => {
  636. if (!selectionStart.value || !selectionEnd.value) {
  637. return
  638. }
  639. if (didSelectionMove.value) {
  640. selectedRange.value = buildSelectionRange(selectionStart.value, selectionEnd.value)
  641. const selectedLocationIds = gridData.value
  642. .filter(isLocationCell)
  643. .filter((cell) => isCellMatched(cell) && isCellInSelectedRange(cell))
  644. .map((cell) => cell.locationId)
  645. void copyText(selectedLocationIds.join('\n'))
  646. suppressNextClick.value = true
  647. }
  648. selectionStart.value = null
  649. selectionEnd.value = null
  650. didSelectionMove.value = false
  651. }
  652. const updateWrapperSize = () => {
  653. if (!mapWrapperRef.value) return
  654. wrapperSize.value = {
  655. width: mapWrapperRef.value.clientWidth,
  656. height: mapWrapperRef.value.clientHeight
  657. }
  658. }
  659. onMounted(() => {
  660. updateWrapperSize()
  661. window.addEventListener('mouseup', handleWindowMouseUp)
  662. if (!mapWrapperRef.value) return
  663. resizeObserver = new ResizeObserver(() => {
  664. updateWrapperSize()
  665. })
  666. resizeObserver.observe(mapWrapperRef.value)
  667. })
  668. watch(
  669. () => props.showTooltip,
  670. (enabled) => {
  671. if (!enabled) {
  672. tooltipVisible.value = false
  673. hoveredCell.value = null
  674. }
  675. }
  676. )
  677. onBeforeUnmount(() => {
  678. window.removeEventListener('mouseup', handleWindowMouseUp)
  679. resizeObserver?.disconnect()
  680. })
  681. </script>
  682. <style scoped>
  683. .warehouse-map {
  684. width: 100%;
  685. height: 100%;
  686. display: flex;
  687. flex-direction: column;
  688. position: relative;
  689. }
  690. .no-data {
  691. display: flex;
  692. justify-content: center;
  693. align-items: center;
  694. height: 100%;
  695. color: #8b8b8b;
  696. font-size: 16px;
  697. }
  698. .map-wrapper {
  699. flex: 1;
  700. position: relative;
  701. overflow: auto;
  702. background: #000000;
  703. }
  704. .map-grid {
  705. display: grid;
  706. gap: 2px;
  707. padding: 0 12px 12px;
  708. box-sizing: border-box;
  709. width: 100%;
  710. height: 100%;
  711. align-content: start;
  712. }
  713. .grid-cell {
  714. border: none;
  715. border-radius: 4px;
  716. display: flex;
  717. align-items: center;
  718. justify-content: center;
  719. transition: all 0.2s;
  720. cursor: pointer;
  721. position: relative;
  722. }
  723. .grid-cell.grouped::after {
  724. content: '';
  725. position: absolute;
  726. inset: 0;
  727. border-style: solid;
  728. border-color: var(--group-border-color, #ffffff);
  729. border-top-width: var(--group-border-top, 0px);
  730. border-right-width: var(--group-border-right, 0px);
  731. border-bottom-width: var(--group-border-bottom, 0px);
  732. border-left-width: var(--group-border-left, 0px);
  733. border-radius: 4px;
  734. pointer-events: none;
  735. opacity: 0.95;
  736. }
  737. .grid-cell.aisle {
  738. background: #050505;
  739. }
  740. .grid-cell.wall {
  741. background: #151515;
  742. cursor: default;
  743. }
  744. .grid-cell.elevator {
  745. background: #101010;
  746. cursor: default;
  747. }
  748. .grid-cell.category-a {
  749. background: rgba(0, 128, 0, 0.16);
  750. }
  751. .grid-cell.category-b {
  752. background: rgba(0, 0, 255, 0.16);
  753. }
  754. .grid-cell.category-c {
  755. background: rgba(255, 255, 0, 0.16);
  756. }
  757. .grid-cell.category-muted {
  758. background: rgba(95, 107, 122, 0.16);
  759. }
  760. .grid-cell:not(.aisle):not(.wall):not(.elevator):hover {
  761. transform: scale(1.03);
  762. box-shadow: 0 0 12px rgba(255, 255, 255, 0.08);
  763. z-index: 10;
  764. }
  765. .cell-content {
  766. display: flex;
  767. flex-direction: column;
  768. align-items: center;
  769. justify-content: center;
  770. gap: var(--cell-content-gap, 4px);
  771. padding: var(--cell-content-padding, 4px);
  772. width: 100%;
  773. height: 100%;
  774. overflow: hidden;
  775. }
  776. .category-badge {
  777. display: var(--cell-badge-display, inline-flex);
  778. align-items: center;
  779. justify-content: center;
  780. padding: 2px 8px;
  781. border-radius: 10px;
  782. font-size: var(--cell-badge-font-size, 10px);
  783. font-weight: bold;
  784. line-height: 1;
  785. white-space: nowrap;
  786. }
  787. .loc-group {
  788. display: var(--cell-group-display, -webkit-box);
  789. font-size: var(--cell-group-font-size, 10px);
  790. color: #a0a0a0;
  791. text-align: center;
  792. line-height: 1.2;
  793. word-break: break-all;
  794. -webkit-box-orient: vertical;
  795. -webkit-line-clamp: 2;
  796. overflow: hidden;
  797. }
  798. .location-id {
  799. display: block;
  800. font-size: var(--cell-id-font-size, 11px);
  801. font-weight: bold;
  802. color: #f2f2f2;
  803. text-align: center;
  804. line-height: 1.1;
  805. white-space: nowrap;
  806. overflow: hidden;
  807. text-overflow: ellipsis;
  808. }
  809. .location-id.location-id-mismatch {
  810. color: #ff4d4f;
  811. }
  812. .location-id.location-id-abnormal-attribute {
  813. color: #ff4d4f;
  814. }
  815. .location-attribute-tag {
  816. max-width: 100%;
  817. font-size: calc(var(--cell-id-font-size, 11px) - 2px);
  818. color: #ff4d4f;
  819. text-align: center;
  820. line-height: 1.1;
  821. white-space: nowrap;
  822. overflow: hidden;
  823. text-overflow: ellipsis;
  824. }
  825. .container-code {
  826. max-width: 100%;
  827. font-size: calc(var(--cell-id-font-size, 11px) - 2px);
  828. color: #9a9a9a;
  829. text-align: center;
  830. line-height: 1.1;
  831. white-space: nowrap;
  832. overflow: hidden;
  833. text-overflow: ellipsis;
  834. }
  835. .special-cell-label {
  836. font-size: calc(var(--cell-id-font-size, 11px) - 1px);
  837. font-weight: 700;
  838. color: rgba(255, 255, 255, 0.76);
  839. letter-spacing: 1px;
  840. user-select: none;
  841. }
  842. .special-cell-label-elevator {
  843. color: rgba(255, 255, 255, 0.86);
  844. }
  845. .cell-tooltip {
  846. position: fixed;
  847. z-index: 1000;
  848. max-width: 300px;
  849. padding: 10px 12px;
  850. border: 1px solid #1f1f1f;
  851. border-radius: 8px;
  852. background: rgba(0, 0, 0, 0.96);
  853. box-shadow: 0 12px 28px rgba(0, 0, 0, 0.32);
  854. color: #f2f2f2;
  855. font-size: 12px;
  856. line-height: 1.45;
  857. pointer-events: none;
  858. backdrop-filter: blur(6px);
  859. }
  860. .cell-tooltip-line + .cell-tooltip-line {
  861. margin-top: 3px;
  862. }
  863. </style>