|
|
@@ -8,13 +8,14 @@
|
|
|
:key="index"
|
|
|
:class="['grid-cell', getCellClass(cell)]"
|
|
|
:style="getCellStyle(cell)"
|
|
|
- @mouseenter="handleCellMouseEnter(cell, $event)"
|
|
|
+ @mousedown.left="handleCellMouseDown(index, $event)"
|
|
|
+ @mouseenter="handleCellMouseEnter(cell, index, $event)"
|
|
|
@mousemove="handleCellMouseMove($event)"
|
|
|
@mouseleave="handleCellMouseLeave"
|
|
|
@click="handleCellClick(cell)"
|
|
|
@contextmenu.prevent="handleCellContextMenu(cell)"
|
|
|
>
|
|
|
- <div v-if="cell && isCellMatched(cell)" class="cell-content">
|
|
|
+ <div v-if="cell && isCellVisible(cell)" class="cell-content">
|
|
|
<div class="category-badge" :style="getCategoryStyle(cell)">
|
|
|
{{ getHeatLabel(cell) }}
|
|
|
</div>
|
|
|
@@ -101,6 +102,16 @@ const tooltipPosition = ref({
|
|
|
x: 0,
|
|
|
y: 0
|
|
|
})
|
|
|
+const selectionStart = ref<{ row: number; col: number } | null>(null)
|
|
|
+const selectionEnd = ref<{ row: number; col: number } | null>(null)
|
|
|
+const selectedRange = ref<{
|
|
|
+ rowStart: number
|
|
|
+ rowEnd: number
|
|
|
+ colStart: number
|
|
|
+ colEnd: number
|
|
|
+} | null>(null)
|
|
|
+const didSelectionMove = ref(false)
|
|
|
+const suppressNextClick = ref(false)
|
|
|
|
|
|
let resizeObserver: ResizeObserver | null = null
|
|
|
|
|
|
@@ -289,9 +300,48 @@ const isCellMatched = (cell: GridCell) => {
|
|
|
&& matchedLocationId
|
|
|
}
|
|
|
|
|
|
+const getGridPointByIndex = (index: number) => {
|
|
|
+ if (!gridBounds.value || !gridMetrics.value) {
|
|
|
+ return null
|
|
|
+ }
|
|
|
+
|
|
|
+ const rowOffset = Math.floor(index / gridMetrics.value.cols)
|
|
|
+ const colOffset = index % gridMetrics.value.cols
|
|
|
+
|
|
|
+ return {
|
|
|
+ row: gridBounds.value.minX + rowOffset,
|
|
|
+ col: gridBounds.value.minY + colOffset
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const buildSelectionRange = (
|
|
|
+ start: { row: number; col: number },
|
|
|
+ end: { row: number; col: number }
|
|
|
+) => ({
|
|
|
+ rowStart: Math.min(start.row, end.row),
|
|
|
+ rowEnd: Math.max(start.row, end.row),
|
|
|
+ colStart: Math.min(start.col, end.col),
|
|
|
+ colEnd: Math.max(start.col, end.col)
|
|
|
+})
|
|
|
+
|
|
|
+const isCellInSelectedRange = (cell: GridCell) => {
|
|
|
+ if (!selectedRange.value) {
|
|
|
+ return true
|
|
|
+ }
|
|
|
+
|
|
|
+ return cell.parsed.gridRow >= selectedRange.value.rowStart
|
|
|
+ && cell.parsed.gridRow <= selectedRange.value.rowEnd
|
|
|
+ && cell.parsed.gridCol >= selectedRange.value.colStart
|
|
|
+ && cell.parsed.gridCol <= selectedRange.value.colEnd
|
|
|
+}
|
|
|
+
|
|
|
+const isCellVisible = (cell: GridCell) => {
|
|
|
+ return isCellMatched(cell) && isCellInSelectedRange(cell)
|
|
|
+}
|
|
|
+
|
|
|
const getCellClass = (cell: GridCell | null) => {
|
|
|
if (!cell) return ['aisle']
|
|
|
- if (!isCellMatched(cell)) return ['aisle']
|
|
|
+ if (!isCellVisible(cell)) return ['aisle']
|
|
|
|
|
|
const classNames = [`category-${cell.category.toLowerCase()}`]
|
|
|
if (hasActiveBorder() && hasSameBorderNeighbor(cell)) {
|
|
|
@@ -318,6 +368,7 @@ const getLocationAttributeLabel = (cell: GridCell) => {
|
|
|
const getCellTitle = (cell: GridCell | null) => {
|
|
|
if (!cell) return '过道'
|
|
|
if (!isCellMatched(cell)) return '未命中筛选条件'
|
|
|
+ if (!isCellInSelectedRange(cell)) return '未命中当前选区'
|
|
|
const mismatchText = cell.categoryMismatch && cell.expectedCategory
|
|
|
? `\n异常: 深度${cell.parsed.depth} 期望热度${cell.expectedCategory}`
|
|
|
: cell.categoryMismatch
|
|
|
@@ -396,7 +447,7 @@ const hasSameBorderNeighbor = (cell: GridCell) => {
|
|
|
}
|
|
|
|
|
|
const getCellStyle = (cell: GridCell | null): CSSProperties => {
|
|
|
- if (!cell || !isCellMatched(cell) || !hasActiveBorder() || !hasSameBorderNeighbor(cell)) return {}
|
|
|
+ if (!cell || !isCellVisible(cell) || !hasActiveBorder() || !hasSameBorderNeighbor(cell)) return {}
|
|
|
|
|
|
const borderWidth = 'var(--group-outline-width, 2px)'
|
|
|
|
|
|
@@ -409,16 +460,69 @@ const getCellStyle = (cell: GridCell | null): CSSProperties => {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+const clearSelectedRange = () => {
|
|
|
+ selectedRange.value = null
|
|
|
+}
|
|
|
+
|
|
|
const handleCellClick = (cell: GridCell | null) => {
|
|
|
- if (!cell || !isCellMatched(cell)) return
|
|
|
+ if (suppressNextClick.value) {
|
|
|
+ suppressNextClick.value = false
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (!cell || !isCellVisible(cell)) {
|
|
|
+ if (selectedRange.value) {
|
|
|
+ clearSelectedRange()
|
|
|
+ }
|
|
|
+ return
|
|
|
+ }
|
|
|
emit('select-loc-group', cell.locGroup1)
|
|
|
}
|
|
|
|
|
|
const handleCellContextMenu = (cell: GridCell | null) => {
|
|
|
- if (!cell || !isCellMatched(cell)) return
|
|
|
+ if (!cell || !isCellVisible(cell)) return
|
|
|
emit('select-location-id', cell.locationId)
|
|
|
}
|
|
|
|
|
|
+const copyText = async (text: string) => {
|
|
|
+ if (!text) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ await navigator.clipboard.writeText(text)
|
|
|
+ return
|
|
|
+ } catch (error) {
|
|
|
+ console.warn('Clipboard API copy failed, fallback to execCommand.', error)
|
|
|
+ }
|
|
|
+
|
|
|
+ const textarea = document.createElement('textarea')
|
|
|
+ textarea.value = text
|
|
|
+ textarea.setAttribute('readonly', 'true')
|
|
|
+ textarea.style.position = 'fixed'
|
|
|
+ textarea.style.top = '-9999px'
|
|
|
+ document.body.appendChild(textarea)
|
|
|
+ textarea.select()
|
|
|
+ document.execCommand('copy')
|
|
|
+ document.body.removeChild(textarea)
|
|
|
+}
|
|
|
+
|
|
|
+const handleCellMouseDown = (index: number, event: MouseEvent) => {
|
|
|
+ if (event.button !== 0) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const point = getGridPointByIndex(index)
|
|
|
+ if (!point) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ selectionStart.value = point
|
|
|
+ selectionEnd.value = point
|
|
|
+ didSelectionMove.value = false
|
|
|
+ tooltipVisible.value = false
|
|
|
+ hoveredCell.value = null
|
|
|
+}
|
|
|
+
|
|
|
const updateTooltipPosition = (event: MouseEvent) => {
|
|
|
const tooltipWidth = 300
|
|
|
const tooltipHeight = 260
|
|
|
@@ -432,7 +536,19 @@ const updateTooltipPosition = (event: MouseEvent) => {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-const handleCellMouseEnter = (cell: GridCell | null, event: MouseEvent) => {
|
|
|
+const handleCellMouseEnter = (cell: GridCell | null, index: number, event: MouseEvent) => {
|
|
|
+ if (selectionStart.value && (event.buttons & 1) === 1) {
|
|
|
+ const point = getGridPointByIndex(index)
|
|
|
+ if (point) {
|
|
|
+ selectionEnd.value = point
|
|
|
+ didSelectionMove.value = point.row !== selectionStart.value.row
|
|
|
+ || point.col !== selectionStart.value.col
|
|
|
+ }
|
|
|
+ tooltipVisible.value = false
|
|
|
+ hoveredCell.value = null
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
if (!props.showTooltip) return
|
|
|
hoveredCell.value = cell
|
|
|
tooltipVisible.value = true
|
|
|
@@ -445,10 +561,34 @@ const handleCellMouseMove = (event: MouseEvent) => {
|
|
|
}
|
|
|
|
|
|
const handleCellMouseLeave = () => {
|
|
|
+ if (selectionStart.value) {
|
|
|
+ return
|
|
|
+ }
|
|
|
tooltipVisible.value = false
|
|
|
hoveredCell.value = null
|
|
|
}
|
|
|
|
|
|
+const handleWindowMouseUp = () => {
|
|
|
+ if (!selectionStart.value || !selectionEnd.value) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (didSelectionMove.value) {
|
|
|
+ selectedRange.value = buildSelectionRange(selectionStart.value, selectionEnd.value)
|
|
|
+ const selectedLocationIds = gridData.value
|
|
|
+ .filter((cell): cell is GridCell => Boolean(cell))
|
|
|
+ .filter((cell) => isCellMatched(cell) && isCellInSelectedRange(cell))
|
|
|
+ .map((cell) => cell.locationId)
|
|
|
+
|
|
|
+ void copyText(selectedLocationIds.join('\n'))
|
|
|
+ suppressNextClick.value = true
|
|
|
+ }
|
|
|
+
|
|
|
+ selectionStart.value = null
|
|
|
+ selectionEnd.value = null
|
|
|
+ didSelectionMove.value = false
|
|
|
+}
|
|
|
+
|
|
|
const updateWrapperSize = () => {
|
|
|
if (!mapWrapperRef.value) return
|
|
|
wrapperSize.value = {
|
|
|
@@ -459,6 +599,7 @@ const updateWrapperSize = () => {
|
|
|
|
|
|
onMounted(() => {
|
|
|
updateWrapperSize()
|
|
|
+ window.addEventListener('mouseup', handleWindowMouseUp)
|
|
|
if (!mapWrapperRef.value) return
|
|
|
|
|
|
resizeObserver = new ResizeObserver(() => {
|
|
|
@@ -478,6 +619,7 @@ watch(
|
|
|
)
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
+ window.removeEventListener('mouseup', handleWindowMouseUp)
|
|
|
resizeObserver?.disconnect()
|
|
|
})
|
|
|
</script>
|