|
@@ -1,13 +1,39 @@
|
|
|
<template>
|
|
<template>
|
|
|
<ContentWrap>
|
|
<ContentWrap>
|
|
|
<div class="time-card">
|
|
<div class="time-card">
|
|
|
- <Icon icon="ep:time" class="mr-5px" />
|
|
|
|
|
|
|
+ <Icon icon="ep:timer" class="mr-5px" :size="35"/>
|
|
|
<div class="time-content">
|
|
<div class="time-content">
|
|
|
<div class="label">最后更新</div>
|
|
<div class="label">最后更新</div>
|
|
|
<div class="time">{{ updateTime }}</div>
|
|
<div class="time">{{ updateTime }}</div>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
- <div ref="container" class="map-container"></div>
|
|
|
|
|
|
|
+
|
|
|
|
|
+ <div class="map-container">
|
|
|
|
|
+ <v-stage :config="stageConfig" ref="stageRef">
|
|
|
|
|
+ <v-layer ref="layerRef">
|
|
|
|
|
+ <!-- 动态渲染仓位行 -->
|
|
|
|
|
+ <template v-for="(rowSlots, rowIndex) in groupedSlots" :key="rowIndex">
|
|
|
|
|
+ <!-- 行容器 -->
|
|
|
|
|
+ <v-rect :config="getRowBoxConfig(rowIndex)" />
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 仓位图标 -->
|
|
|
|
|
+ <template v-for="(slot, slotIndex) in rowSlots" :key="`${rowIndex}-${slotIndex}`">
|
|
|
|
|
+ <v-image
|
|
|
|
|
+ :config="getSlotImageConfig(slot, rowIndex, slotIndex, rowSlots)"
|
|
|
|
|
+ @click="handleSlotClick(slot)"
|
|
|
|
|
+ />
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 异常图标 -->
|
|
|
|
|
+ <v-image
|
|
|
|
|
+ v-if="slot.status === '1'"
|
|
|
|
|
+ :config="getExceptionImageConfig(slot, rowIndex, slotIndex, rowSlots)"
|
|
|
|
|
+ @click="showErrorDialog(slot)"
|
|
|
|
|
+ />
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </v-layer>
|
|
|
|
|
+ </v-stage>
|
|
|
|
|
+ </div>
|
|
|
|
|
|
|
|
<Dialog v-model="dialogVisible" title="异常信息" width="400">
|
|
<Dialog v-model="dialogVisible" title="异常信息" width="400">
|
|
|
<h4 class="text-center font-bold">{{ errorInfo }}</h4>
|
|
<h4 class="text-center font-bold">{{ errorInfo }}</h4>
|
|
@@ -19,51 +45,147 @@
|
|
|
</template>
|
|
</template>
|
|
|
|
|
|
|
|
<script lang="ts" setup>
|
|
<script lang="ts" setup>
|
|
|
-import { ref, onMounted } from 'vue'
|
|
|
|
|
-import Konva from 'konva'
|
|
|
|
|
-import { getIsSystemAttributeByKey } from '@/api/system/configuration'
|
|
|
|
|
-import { getIsLockCabinetSlotsPage } from '@/api/mes/lockCabinet/slots'
|
|
|
|
|
-
|
|
|
|
|
|
|
+import { ref, computed, onMounted } from 'vue'
|
|
|
|
|
+import { getIsSystemAttributeByKey } from '@/api/basic/configuration/index'
|
|
|
|
|
+import { getIsLockCabinetSlotsPage } from '@/api/hw/hardware/lockCabinet/slots'
|
|
|
|
|
+import { formatDate } from '@/utils/formatTime'
|
|
|
defineOptions({ name: 'LockCabinetMap' })
|
|
defineOptions({ name: 'LockCabinetMap' })
|
|
|
|
|
|
|
|
const props = defineProps<{
|
|
const props = defineProps<{
|
|
|
cabinetId: string
|
|
cabinetId: string
|
|
|
}>()
|
|
}>()
|
|
|
|
|
|
|
|
-const stage = ref<Konva.Stage | null>(null)
|
|
|
|
|
-const layer = ref<Konva.Layer | null>(null)
|
|
|
|
|
|
|
+const stageRef = ref()
|
|
|
|
|
+const layerRef = ref()
|
|
|
const cachedResults = ref<Record<string, string>>({})
|
|
const cachedResults = ref<Record<string, string>>({})
|
|
|
-const cachedImages = ref<Record<string, Konva.Image>>({})
|
|
|
|
|
|
|
+const cachedImages = ref<Record<string, HTMLImageElement>>({})
|
|
|
const slotData = ref<any[]>([])
|
|
const slotData = ref<any[]>([])
|
|
|
const dialogVisible = ref(false)
|
|
const dialogVisible = ref(false)
|
|
|
const errorInfo = ref('')
|
|
const errorInfo = ref('')
|
|
|
const updateTime = ref<string | null>(null)
|
|
const updateTime = ref<string | null>(null)
|
|
|
-const container = ref<HTMLElement>()
|
|
|
|
|
-
|
|
|
|
|
-/** 初始化 Konva */
|
|
|
|
|
-const initKonva = () => {
|
|
|
|
|
- if (!container.value) return
|
|
|
|
|
- stage.value = new Konva.Stage({
|
|
|
|
|
- container: container.value,
|
|
|
|
|
- width: 900,
|
|
|
|
|
- height: 800
|
|
|
|
|
- })
|
|
|
|
|
- layer.value = new Konva.Layer()
|
|
|
|
|
- stage.value.add(layer.value)
|
|
|
|
|
|
|
+
|
|
|
|
|
+// Stage 配置
|
|
|
|
|
+const stageConfig = {
|
|
|
|
|
+ width: 900,
|
|
|
|
|
+ height: 1000
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 计算属性:按行分组仓位数据
|
|
|
|
|
+const groupedSlots = computed(() => {
|
|
|
|
|
+ const grouped: Record<string, any[]> = {}
|
|
|
|
|
+ for (const slot of slotData.value) {
|
|
|
|
|
+ const key = `${slot.row}`
|
|
|
|
|
+ if (!grouped[key]) grouped[key] = []
|
|
|
|
|
+ grouped[key].push(slot)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 按行号排序
|
|
|
|
|
+ return Object.keys(grouped)
|
|
|
|
|
+ .sort((a, b) => Number(a) - Number(b))
|
|
|
|
|
+ .map(rowKey => grouped[rowKey])
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+// 获取行容器配置
|
|
|
|
|
+const getRowBoxConfig = (rowIndex: number) => {
|
|
|
|
|
+ const startY = 20
|
|
|
|
|
+ const rowHeight = 120
|
|
|
|
|
+ const rowGap = 20
|
|
|
|
|
+ const boxWidth = 860
|
|
|
|
|
+ const centerX = stageConfig.width / 2
|
|
|
|
|
+ const boxStartX = centerX - boxWidth / 2
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ x: boxStartX,
|
|
|
|
|
+ y: startY + rowIndex * (rowHeight + rowGap),
|
|
|
|
|
+ width: boxWidth,
|
|
|
|
|
+ height: rowHeight,
|
|
|
|
|
+ stroke: 'black',
|
|
|
|
|
+ strokeWidth: 2,
|
|
|
|
|
+ fill: 'transparent'
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 获取仓位图标配置
|
|
|
|
|
+const getSlotImageConfig = (slot: any, rowIndex: number, slotIndex: number, rowSlots: any[]) => {
|
|
|
|
|
+ const { slotType, isOccupied } = slot
|
|
|
|
|
+ let baseKey = ''
|
|
|
|
|
+
|
|
|
|
|
+ if (slotType === '0') {
|
|
|
|
|
+ baseKey = isOccupied === '1' ? 'icon.locker.normal' : 'icon.locker.out'
|
|
|
|
|
+ } else {
|
|
|
|
|
+ baseKey = isOccupied === '1' ? 'icon.padlock.normal' : 'icon.padlock.out'
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const baseUrl = cachedResults.value[baseKey]
|
|
|
|
|
+ if (!baseUrl || !cachedImages.value[baseUrl]) {
|
|
|
|
|
+ return null
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const width = slotType === '0' ? 110 : 40
|
|
|
|
|
+ const height = 90
|
|
|
|
|
+ const startY = 20
|
|
|
|
|
+ const rowHeight = 120
|
|
|
|
|
+ const rowGap = 20
|
|
|
|
|
+ const padding = 20
|
|
|
|
|
+ const boxWidth = 860
|
|
|
|
|
+ const centerX = stageConfig.width / 2
|
|
|
|
|
+ const boxStartX = centerX - boxWidth / 2
|
|
|
|
|
+
|
|
|
|
|
+ // 计算位置
|
|
|
|
|
+ const totalSlots = rowSlots.length
|
|
|
|
|
+ const spacing = totalSlots > 1
|
|
|
|
|
+ ? (boxWidth - 2 * padding - totalSlots * width) / (totalSlots - 1)
|
|
|
|
|
+ : 0
|
|
|
|
|
+
|
|
|
|
|
+ const x = boxStartX + padding + slotIndex * (width + spacing)
|
|
|
|
|
+ const y = startY + rowIndex * (rowHeight + rowGap) + (rowHeight - height) / 2
|
|
|
|
|
+ return {
|
|
|
|
|
+ x,
|
|
|
|
|
+ y,
|
|
|
|
|
+ width,
|
|
|
|
|
+ height,
|
|
|
|
|
+ image: cachedImages.value[baseUrl],
|
|
|
|
|
+ listening: true
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 获取异常图标配置
|
|
|
|
|
+const getExceptionImageConfig = (slot: any, rowIndex: number, slotIndex: number, rowSlots: any[]) => {
|
|
|
|
|
+ const exUrl = cachedResults.value['icon.locker.exception']
|
|
|
|
|
+ console.log('getExceptionImageConfig', exUrl)
|
|
|
|
|
+ if (!exUrl || !cachedImages.value[exUrl]) {
|
|
|
|
|
+ return null
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const slotConfig = getSlotImageConfig(slot, rowIndex, slotIndex, rowSlots)
|
|
|
|
|
+ if (!slotConfig) return null
|
|
|
|
|
+
|
|
|
|
|
+ const exWidth = 30
|
|
|
|
|
+ const exHeight = 30
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ x: slotConfig.x + (slotConfig.width - exWidth) / 2,
|
|
|
|
|
+ y: slotConfig.y + (slotConfig.height - exHeight) / 2,
|
|
|
|
|
+ width: exWidth,
|
|
|
|
|
+ height: exHeight,
|
|
|
|
|
+ image: cachedImages.value[exUrl],
|
|
|
|
|
+ listening: true
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/** 获取数据 */
|
|
|
|
|
|
|
+// 获取数据
|
|
|
const getData = async () => {
|
|
const getData = async () => {
|
|
|
const data = {
|
|
const data = {
|
|
|
pageNo: 1,
|
|
pageNo: 1,
|
|
|
pageSize: -1,
|
|
pageSize: -1,
|
|
|
cabinetId: props.cabinetId
|
|
cabinetId: props.cabinetId
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
try {
|
|
try {
|
|
|
const res = await getIsLockCabinetSlotsPage(data)
|
|
const res = await getIsLockCabinetSlotsPage(data)
|
|
|
- updateTime.value = res.records[0]?.updateTime
|
|
|
|
|
- slotData.value = res.records || []
|
|
|
|
|
|
|
|
|
|
|
|
+ updateTime.value = formatDate(res.list[0].createTime)
|
|
|
|
|
+ slotData.value = res.list || []
|
|
|
const icons = [
|
|
const icons = [
|
|
|
'icon.locker.normal',
|
|
'icon.locker.normal',
|
|
|
'icon.locker.out',
|
|
'icon.locker.out',
|
|
@@ -71,152 +193,59 @@ const getData = async () => {
|
|
|
'icon.padlock.out',
|
|
'icon.padlock.out',
|
|
|
'icon.locker.exception'
|
|
'icon.locker.exception'
|
|
|
]
|
|
]
|
|
|
|
|
+
|
|
|
const results = await Promise.all(icons.map(key => getIsSystemAttributeByKey(key)))
|
|
const results = await Promise.all(icons.map(key => getIsSystemAttributeByKey(key)))
|
|
|
cachedResults.value = icons.reduce((map, key, idx) => {
|
|
cachedResults.value = icons.reduce((map, key, idx) => {
|
|
|
- map[key] = results[idx].data?.sysAttrValue || ''
|
|
|
|
|
|
|
+ map[key] = results[idx].sysAttrValue || ''
|
|
|
return map
|
|
return map
|
|
|
}, {} as Record<string, string>)
|
|
}, {} as Record<string, string>)
|
|
|
|
|
|
|
|
await preloadImages()
|
|
await preloadImages()
|
|
|
- renderSlots()
|
|
|
|
|
} catch (err) {
|
|
} catch (err) {
|
|
|
console.error('获取数据失败:', err)
|
|
console.error('获取数据失败:', err)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/** 显示错误对话框 */
|
|
|
|
|
|
|
+// 显示错误对话框
|
|
|
const showErrorDialog = (slot: any) => {
|
|
const showErrorDialog = (slot: any) => {
|
|
|
errorInfo.value = slot.remark || '未知异常'
|
|
errorInfo.value = slot.remark || '未知异常'
|
|
|
dialogVisible.value = true
|
|
dialogVisible.value = true
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/** 预加载图片 */
|
|
|
|
|
|
|
+// 处理仓位点击
|
|
|
|
|
+const handleSlotClick = (slot: any) => {
|
|
|
|
|
+ console.log('点击仓位:', slot)
|
|
|
|
|
+ // 可以在这里添加仓位点击逻辑
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 预加载图片
|
|
|
const preloadImages = async () => {
|
|
const preloadImages = async () => {
|
|
|
|
|
+
|
|
|
const urls = Object.values(cachedResults.value)
|
|
const urls = Object.values(cachedResults.value)
|
|
|
const promises = urls.map(url => loadImageOnce(url))
|
|
const promises = urls.map(url => loadImageOnce(url))
|
|
|
await Promise.all(promises)
|
|
await Promise.all(promises)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/** 渲染仓位 */
|
|
|
|
|
-const renderSlots = async () => {
|
|
|
|
|
- if (!layer.value) return
|
|
|
|
|
- layer.value.destroyChildren()
|
|
|
|
|
-
|
|
|
|
|
- const grouped: Record<string, any[]> = {}
|
|
|
|
|
- for (const slot of slotData.value) {
|
|
|
|
|
- const key = `${slot.row}`
|
|
|
|
|
- if (!grouped[key]) grouped[key] = []
|
|
|
|
|
- grouped[key].push(slot)
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const rows = Object.keys(grouped).sort((a, b) => Number(a) - Number(b))
|
|
|
|
|
- const startY = 20
|
|
|
|
|
- const rowHeight = 120
|
|
|
|
|
- const rowGap = 20
|
|
|
|
|
-
|
|
|
|
|
- for (let i = 0; i < rows.length; i++) {
|
|
|
|
|
- const rowSlots = grouped[rows[i]]
|
|
|
|
|
- await renderSlotRow(rowSlots, startY + i * (rowHeight + rowGap), rowHeight)
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- layer.value.draw()
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-/** 渲染仓位行 */
|
|
|
|
|
-const renderSlotRow = async (slots: any[], boxTopY: number, boxHeight: number) => {
|
|
|
|
|
- if (!layer.value || !stage.value) return
|
|
|
|
|
-
|
|
|
|
|
- const padding = 20
|
|
|
|
|
- const boxWidth = 860
|
|
|
|
|
- const centerX = stage.value.width() / 2
|
|
|
|
|
- const boxStartX = centerX - boxWidth / 2
|
|
|
|
|
-
|
|
|
|
|
- const box = new Konva.Rect({
|
|
|
|
|
- x: boxStartX,
|
|
|
|
|
- y: boxTopY,
|
|
|
|
|
- width: boxWidth,
|
|
|
|
|
- height: boxHeight,
|
|
|
|
|
- stroke: 'black',
|
|
|
|
|
- strokeWidth: 2
|
|
|
|
|
- })
|
|
|
|
|
- layer.value.add(box)
|
|
|
|
|
-
|
|
|
|
|
- const loadedImages = []
|
|
|
|
|
- for (const slot of slots) {
|
|
|
|
|
- const { slotType, isOccupied } = slot
|
|
|
|
|
- let baseKey = ''
|
|
|
|
|
- if (slotType === '0') {
|
|
|
|
|
- baseKey = isOccupied === '1' ? 'icon.locker.normal' : 'icon.locker.out'
|
|
|
|
|
- } else {
|
|
|
|
|
- baseKey = isOccupied === '1' ? 'icon.padlock.normal' : 'icon.padlock.out'
|
|
|
|
|
- }
|
|
|
|
|
- const baseUrl = cachedResults.value[baseKey]
|
|
|
|
|
- if (!baseUrl || !cachedImages.value[baseUrl]) continue
|
|
|
|
|
-
|
|
|
|
|
- const imageNode = cachedImages.value[baseUrl].clone()
|
|
|
|
|
- const width = slotType === '0' ? 110 : 40
|
|
|
|
|
- const height = 90
|
|
|
|
|
- imageNode.setAttrs({ width, height })
|
|
|
|
|
-
|
|
|
|
|
- loadedImages.push({ imageNode, slot, width, height })
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const totalSlots = loadedImages.length
|
|
|
|
|
- const spacing = totalSlots > 1
|
|
|
|
|
- ? (boxWidth - 2 * padding - totalSlots * loadedImages[0].width) / (totalSlots - 1)
|
|
|
|
|
- : 0
|
|
|
|
|
- let currentX = boxStartX + padding
|
|
|
|
|
-
|
|
|
|
|
- for (const { imageNode, slot, width, height } of loadedImages) {
|
|
|
|
|
- imageNode.setAttrs({
|
|
|
|
|
- x: currentX,
|
|
|
|
|
- y: boxTopY + (boxHeight - height) / 2
|
|
|
|
|
- })
|
|
|
|
|
- layer.value.add(imageNode)
|
|
|
|
|
-
|
|
|
|
|
- if (slot.status === '1') {
|
|
|
|
|
- const exUrl = cachedResults.value['icon.locker.exception']
|
|
|
|
|
- if (exUrl && cachedImages.value[exUrl]) {
|
|
|
|
|
- const exImage = cachedImages.value[exUrl].clone()
|
|
|
|
|
- const exWidth = 30
|
|
|
|
|
- const exHeight = 30
|
|
|
|
|
- exImage.setAttrs({
|
|
|
|
|
- x: currentX + (width - exWidth) / 2,
|
|
|
|
|
- y: boxTopY + (boxHeight - exHeight) / 2,
|
|
|
|
|
- width: exWidth,
|
|
|
|
|
- height: exHeight
|
|
|
|
|
- })
|
|
|
|
|
- exImage.on('click', () => {
|
|
|
|
|
- showErrorDialog(slot)
|
|
|
|
|
- })
|
|
|
|
|
- layer.value.add(exImage)
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- currentX += width + spacing
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-/** 加载图片 */
|
|
|
|
|
|
|
+// 加载图片
|
|
|
const loadImageOnce = (url: string) => {
|
|
const loadImageOnce = (url: string) => {
|
|
|
- if (cachedImages.value[url]) return Promise.resolve(cachedImages.value[url])
|
|
|
|
|
|
|
+ if (cachedImages.value[url]) {
|
|
|
|
|
+ return Promise.resolve(cachedImages.value[url])
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- return new Promise<Konva.Image>((resolve, reject) => {
|
|
|
|
|
|
|
+ return new Promise<HTMLImageElement>((resolve, reject) => {
|
|
|
const img = new window.Image()
|
|
const img = new window.Image()
|
|
|
img.crossOrigin = 'Anonymous'
|
|
img.crossOrigin = 'Anonymous'
|
|
|
img.src = url
|
|
img.src = url
|
|
|
img.onload = () => {
|
|
img.onload = () => {
|
|
|
- const konvaImage = new Konva.Image({ image: img })
|
|
|
|
|
- cachedImages.value[url] = konvaImage
|
|
|
|
|
- resolve(konvaImage)
|
|
|
|
|
|
|
+ cachedImages.value[url] = img
|
|
|
|
|
+ resolve(img)
|
|
|
}
|
|
}
|
|
|
img.onerror = reject
|
|
img.onerror = reject
|
|
|
})
|
|
})
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/** 初始化 **/
|
|
|
|
|
|
|
+// 初始化
|
|
|
onMounted(async () => {
|
|
onMounted(async () => {
|
|
|
- initKonva()
|
|
|
|
|
await getData()
|
|
await getData()
|
|
|
})
|
|
})
|
|
|
</script>
|
|
</script>
|
|
@@ -230,7 +259,10 @@ onMounted(async () => {
|
|
|
border-radius: 8px;
|
|
border-radius: 8px;
|
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
|
transition: all 0.3s;
|
|
transition: all 0.3s;
|
|
|
-
|
|
|
|
|
|
|
+ .mr-5px {
|
|
|
|
|
+ font-size: 20px;
|
|
|
|
|
+ color: rgb(64, 158, 254);
|
|
|
|
|
+ }
|
|
|
&.glow {
|
|
&.glow {
|
|
|
box-shadow: 0 0 15px rgba(64, 158, 255, 0.5);
|
|
box-shadow: 0 0 15px rgba(64, 158, 255, 0.5);
|
|
|
}
|
|
}
|
|
@@ -250,7 +282,7 @@ onMounted(async () => {
|
|
|
|
|
|
|
|
.map-container {
|
|
.map-container {
|
|
|
width: 900px;
|
|
width: 900px;
|
|
|
- height: 400px;
|
|
|
|
|
|
|
+ height: 800px;
|
|
|
margin: 0 auto;
|
|
margin: 0 auto;
|
|
|
}
|
|
}
|
|
|
</style>
|
|
</style>
|