<template>
|
<div class="group-topology">
|
<div class="panel-header">
|
<div>
|
<h3>设备组拓扑图</h3>
|
<p v-if="group">{{ group.groupName }} - 设备执行流程可视化</p>
|
<p v-else class="warning">请先选择一个设备组</p>
|
</div>
|
<div class="action-buttons">
|
<el-button :loading="loading" @click="handleRefresh">
|
<el-icon><Refresh /></el-icon>
|
刷新
|
</el-button>
|
<el-button @click="toggleLayout">
|
<el-icon><Grid /></el-icon>
|
{{ layoutMode === 'horizontal' ? '垂直布局' : '水平布局' }}
|
</el-button>
|
</div>
|
</div>
|
|
<div v-if="!group" class="empty-state">
|
<el-empty description="请选择设备组查看拓扑图" />
|
</div>
|
|
<div v-else class="topology-container" :class="`layout-${layoutMode}`">
|
<template v-for="(device, index) in devices" :key="device.id || device.deviceId">
|
<div
|
class="topology-node-wrapper"
|
:class="`layout-${layoutMode}`"
|
>
|
<div
|
class="topology-node"
|
:class="getDeviceTypeClass(device.deviceType)"
|
@click="handleNodeClick(device)"
|
:title="`点击查看设备详情 | 执行顺序: ${index + 1}`"
|
>
|
<div class="node-content">
|
<div class="node-icon">
|
<el-icon :size="24">
|
<component :is="getDeviceIcon(device.deviceType)" />
|
</el-icon>
|
</div>
|
<div class="node-info">
|
<div class="node-name">{{ device.deviceName || device.deviceCode }}</div>
|
<div class="node-type">{{ getDeviceTypeLabel(device.deviceType) }}</div>
|
<div class="node-status">
|
<el-tag :type="getStatusType(device.status)" size="small">
|
{{ getStatusLabel(device.status) }}
|
</el-tag>
|
</div>
|
</div>
|
</div>
|
<!-- 执行顺序标识:右上角的数字圆圈 -->
|
<div class="node-order" :title="`执行顺序: 第 ${index + 1} 步`">
|
{{ index + 1 }}
|
</div>
|
</div>
|
<!-- 流程方向箭头:表示设备执行顺序和数据流向 -->
|
<div
|
v-if="index < devices.length - 1"
|
class="node-arrow"
|
:title="`数据流向: ${device.deviceName || device.deviceCode} → ${devices[index + 1]?.deviceName || devices[index + 1]?.deviceCode}`"
|
>
|
<el-icon :size="20">
|
<ArrowRight v-if="layoutMode === 'horizontal'" />
|
<ArrowDown v-else />
|
</el-icon>
|
</div>
|
</div>
|
</template>
|
</div>
|
|
<!-- 设备详情卡片 -->
|
<el-card v-if="selectedDevice" class="device-detail-card" shadow="never">
|
<template #header>
|
<div class="card-header">
|
<span>设备详情</span>
|
<el-button link @click="selectedDevice = null">
|
<el-icon><Close /></el-icon>
|
</el-button>
|
</div>
|
</template>
|
<el-descriptions :column="2" border>
|
<el-descriptions-item label="设备名称">
|
{{ selectedDevice.deviceName }}
|
</el-descriptions-item>
|
<el-descriptions-item label="设备编码">
|
{{ selectedDevice.deviceCode }}
|
</el-descriptions-item>
|
<el-descriptions-item label="设备类型">
|
{{ getDeviceTypeLabel(selectedDevice.deviceType) }}
|
</el-descriptions-item>
|
<el-descriptions-item label="状态">
|
<el-tag :type="getStatusType(selectedDevice.status)">
|
{{ getStatusLabel(selectedDevice.status) }}
|
</el-tag>
|
</el-descriptions-item>
|
<el-descriptions-item label="PLC IP" v-if="selectedDevice.plcIp">
|
{{ selectedDevice.plcIp }}
|
</el-descriptions-item>
|
<el-descriptions-item label="PLC类型" v-if="selectedDevice.plcType">
|
{{ selectedDevice.plcType }}
|
</el-descriptions-item>
|
<el-descriptions-item label="模块名称" v-if="selectedDevice.moduleName">
|
{{ selectedDevice.moduleName }}
|
</el-descriptions-item>
|
<el-descriptions-item label="是否启用">
|
<el-tag :type="getEnabledType(selectedDevice.enabled)">
|
{{ getEnabledLabel(selectedDevice.enabled) }}
|
</el-tag>
|
</el-descriptions-item>
|
</el-descriptions>
|
</el-card>
|
</div>
|
</template>
|
|
<script setup>
|
import { computed, ref, watch } from 'vue'
|
import { ElMessage } from 'element-plus'
|
import {
|
Refresh,
|
Grid,
|
ArrowRight,
|
ArrowDown,
|
Close,
|
Files,
|
Box,
|
Folder
|
} from '@element-plus/icons-vue'
|
import { deviceGroupApi } from '@/api/device/deviceManagement'
|
|
const props = defineProps({
|
group: {
|
type: Object,
|
default: null
|
}
|
})
|
|
const loading = ref(false)
|
const devices = ref([])
|
const layoutMode = ref('horizontal') // 'horizontal' | 'vertical'
|
const selectedDevice = ref(null)
|
|
const fetchDevices = async () => {
|
if (!props.group) {
|
devices.value = []
|
return
|
}
|
const groupId = props.group.id || props.group.groupId
|
if (!groupId) {
|
devices.value = []
|
return
|
}
|
try {
|
loading.value = true
|
const response = await deviceGroupApi.getGroupDevices(groupId)
|
const rawList = response?.data
|
const deviceList = Array.isArray(rawList)
|
? rawList
|
: Array.isArray(rawList?.records)
|
? rawList.records
|
: Array.isArray(rawList?.data)
|
? rawList.data
|
: []
|
// 按执行顺序排序
|
devices.value = deviceList.sort((a, b) => {
|
const orderA = a.executionOrder || a.order || 0
|
const orderB = b.executionOrder || b.order || 0
|
return orderA - orderB
|
})
|
} catch (error) {
|
ElMessage.error(error?.message || '加载设备列表失败')
|
devices.value = []
|
} finally {
|
loading.value = false
|
}
|
}
|
|
const handleRefresh = () => {
|
fetchDevices()
|
}
|
|
const toggleLayout = () => {
|
layoutMode.value = layoutMode.value === 'horizontal' ? 'vertical' : 'horizontal'
|
}
|
|
const getDeviceTypeClass = (deviceType) => {
|
if (!deviceType) return 'type-unknown'
|
const type = deviceType.toUpperCase()
|
if (type.includes('VEHICLE') || type.includes('大车')) return 'type-vehicle'
|
if (type.includes('GLASS') || type.includes('大理片')) return 'type-glass'
|
if (type.includes('STORAGE') || type.includes('存储')) return 'type-storage'
|
return 'type-unknown'
|
}
|
|
const getDeviceIcon = (deviceType) => {
|
if (!deviceType) return Box
|
const type = deviceType.toUpperCase()
|
if (type.includes('VEHICLE') || type.includes('大车')) return Files
|
if (type.includes('GLASS') || type.includes('大理片')) return Box
|
if (type.includes('STORAGE') || type.includes('存储')) return Folder
|
return Box
|
}
|
|
const getDeviceTypeLabel = (deviceType) => {
|
if (!deviceType) return '未知设备'
|
const type = deviceType.toUpperCase()
|
if (type.includes('VEHICLE') || type.includes('大车')) return '上大车设备'
|
if (type.includes('GLASS') || type.includes('大理片')) return '大理片设备'
|
if (type.includes('STORAGE') || type.includes('存储')) return '玻璃存储设备'
|
return deviceType
|
}
|
|
const getStatusType = (status) => {
|
if (!status) return 'info'
|
const s = String(status).toUpperCase()
|
if (s === '1' || s === '启用' || s === 'ENABLED' || s === 'ONLINE') return 'success'
|
if (s === '0' || s === '停用' || s === 'DISABLED' || s === 'OFFLINE') return 'danger'
|
if (s === '2' || s === '维护' || s === 'MAINTENANCE') return 'warning'
|
return 'info'
|
}
|
|
const getStatusLabel = (status) => {
|
if (!status) return '未知'
|
const s = String(status).toUpperCase()
|
if (s === '1' || s === '启用' || s === 'ENABLED' || s === 'ONLINE') return '在线'
|
if (s === '0' || s === '停用' || s === 'DISABLED' || s === 'OFFLINE') return '离线'
|
if (s === '2' || s === '维护' || s === 'MAINTENANCE') return '维护中'
|
return String(status)
|
}
|
|
const getEnabledType = (enabled) => {
|
// 支持数字 1/0、布尔值 true/false、字符串 '1'/'0'
|
if (enabled === 1 || enabled === true || enabled === '1' || String(enabled).toUpperCase() === 'TRUE') {
|
return 'success'
|
}
|
return 'info'
|
}
|
|
const getEnabledLabel = (enabled) => {
|
// 支持数字 1/0、布尔值 true/false、字符串 '1'/'0'
|
if (enabled === 1 || enabled === true || enabled === '1' || String(enabled).toUpperCase() === 'TRUE') {
|
return '启用'
|
}
|
return '停用'
|
}
|
|
watch(
|
() => props.group,
|
() => {
|
fetchDevices()
|
selectedDevice.value = null
|
},
|
{ immediate: true }
|
)
|
|
// 点击节点选择设备
|
const handleNodeClick = (device) => {
|
selectedDevice.value = device
|
}
|
|
defineExpose({
|
fetchDevices
|
})
|
</script>
|
|
<style scoped>
|
.group-topology {
|
background: #fff;
|
border-radius: 12px;
|
padding: 20px;
|
box-shadow: 0 8px 32px rgba(15, 18, 63, 0.08);
|
}
|
|
.panel-header {
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
margin-bottom: 20px;
|
}
|
|
.panel-header h3 {
|
margin: 0;
|
}
|
|
.panel-header p {
|
margin: 4px 0 0;
|
color: #909399;
|
font-size: 13px;
|
}
|
|
.panel-header .warning {
|
color: #f56c6c;
|
}
|
|
.action-buttons {
|
display: flex;
|
gap: 12px;
|
}
|
|
.empty-state {
|
padding: 60px 0;
|
}
|
|
.topology-container {
|
display: flex;
|
align-items: center;
|
padding: 20px 0;
|
min-height: 200px;
|
overflow-x: auto;
|
/* 平滑滚动 */
|
scroll-behavior: smooth;
|
}
|
|
.topology-container.layout-horizontal {
|
flex-direction: row;
|
justify-content: flex-start;
|
align-items: center;
|
/* 添加左右内边距,确保第一个和最后一个节点完全可见 */
|
padding-left: 20px;
|
padding-right: 20px;
|
}
|
|
.topology-container.layout-vertical {
|
flex-direction: column;
|
align-items: center;
|
}
|
|
/* 节点包装器:水平布局时横向排列,垂直布局时纵向排列 */
|
.topology-node-wrapper {
|
display: flex;
|
align-items: center;
|
}
|
|
.topology-node-wrapper.layout-horizontal {
|
flex-direction: row;
|
align-items: center;
|
}
|
|
.topology-node-wrapper.layout-vertical {
|
flex-direction: column;
|
align-items: center;
|
}
|
|
.topology-node {
|
position: relative;
|
display: flex;
|
flex-direction: column;
|
align-items: center;
|
cursor: pointer;
|
transition: transform 0.2s;
|
}
|
|
.topology-node:hover {
|
transform: translateY(-4px);
|
}
|
|
.node-content {
|
display: flex;
|
flex-direction: column;
|
align-items: center;
|
padding: 20px;
|
background: #fff;
|
border-radius: 12px;
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
min-width: 160px;
|
transition: all 0.3s;
|
}
|
|
.topology-node:hover .node-content {
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
}
|
|
.node-icon {
|
width: 56px;
|
height: 56px;
|
border-radius: 50%;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
margin-bottom: 12px;
|
color: #fff;
|
}
|
|
.type-vehicle .node-icon {
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
}
|
|
.type-glass .node-icon {
|
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
}
|
|
.type-storage .node-icon {
|
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
}
|
|
.type-unknown .node-icon {
|
background: linear-gradient(135deg, #909399 0%, #b1b3b8 100%);
|
}
|
|
.node-info {
|
text-align: center;
|
width: 100%;
|
}
|
|
.node-name {
|
font-size: 16px;
|
font-weight: 600;
|
color: #303133;
|
margin-bottom: 4px;
|
word-break: break-all;
|
}
|
|
.node-type {
|
font-size: 12px;
|
color: #909399;
|
margin-bottom: 8px;
|
}
|
|
.node-status {
|
display: flex;
|
justify-content: center;
|
}
|
|
.node-order {
|
position: absolute;
|
top: -8px;
|
right: -8px;
|
width: 24px;
|
height: 24px;
|
border-radius: 50%;
|
background: #409eff;
|
color: #fff;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
font-size: 12px;
|
font-weight: 600;
|
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.4);
|
}
|
|
.node-arrow {
|
color: #c0c4cc;
|
flex-shrink: 0;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
}
|
|
/* 水平布局:箭头在节点右侧 */
|
.topology-node-wrapper.layout-horizontal .node-arrow {
|
margin-left: 20px;
|
margin-right: 20px;
|
}
|
|
/* 垂直布局:箭头在节点下方 */
|
.topology-node-wrapper.layout-vertical .node-arrow {
|
margin-top: 15px;
|
margin-bottom: 15px;
|
}
|
|
.device-detail-card {
|
margin-top: 20px;
|
}
|
|
.device-detail-card .card-header {
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
}
|
|
@media (max-width: 768px) {
|
.panel-header {
|
flex-direction: column;
|
align-items: flex-start;
|
gap: 12px;
|
}
|
|
.action-buttons {
|
width: 100%;
|
flex-wrap: wrap;
|
}
|
|
.topology-container.layout-horizontal {
|
flex-direction: column;
|
gap: 30px;
|
}
|
|
.node-arrow {
|
transform: rotate(90deg);
|
}
|
}
|
</style>
|