mes-web/src/views/plcTest/components/DeviceGroup/GroupTopology.vue
@@ -44,9 +44,19 @@
                <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 :type="getStatusType(getDeviceStatus(device))" size="small">
                    {{ getStatusLabel(getDeviceStatus(device)) }}
                  </el-tag>
                </div>
                <div class="node-actions">
                  <el-button
                    size="small"
                    text
                    @click.stop="clearPlc(device)"
                    :loading="clearingDeviceId === (device.deviceId || device.id)"
                  >
                    清空 PLC
                  </el-button>
                </div>
              </div>
            </div>
@@ -91,9 +101,28 @@
          {{ getDeviceTypeLabel(selectedDevice.deviceType) }}
        </el-descriptions-item>
        <el-descriptions-item label="状态">
          <el-tag :type="getStatusType(selectedDevice.status)">
            {{ getStatusLabel(selectedDevice.status) }}
          </el-tag>
          <div class="status-control">
            <el-tag :type="getStatusType(getDeviceStatus(selectedDevice))">
              {{ getStatusLabel(getDeviceStatus(selectedDevice)) }}
            </el-tag>
            <el-switch
              v-if="isLoadVehicleDevice(selectedDevice)"
              :model-value="Boolean(selectedDevice.onlineState)"
              active-text="联机"
              inactive-text="脱机"
              :loading="togglingDeviceId === (selectedDevice.deviceId || selectedDevice.id)"
              size="small"
              @change="(val) => toggleOnlineState(selectedDevice, val)"
            />
            <el-button
              size="small"
              text
              @click="clearPlc(selectedDevice)"
              :loading="clearingDeviceId === (selectedDevice.deviceId || selectedDevice.id)"
            >
              清空 PLC
            </el-button>
          </div>
        </el-descriptions-item>
        <el-descriptions-item label="PLC IP" v-if="selectedDevice.plcIp">
          {{ selectedDevice.plcIp }}
@@ -115,7 +144,7 @@
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { computed, ref, watch, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import {
  Refresh,
@@ -127,7 +156,7 @@
  Box,
  Folder
} from '@element-plus/icons-vue'
import { deviceGroupApi } from '@/api/device/deviceManagement'
import { deviceGroupApi, deviceInteractionApi } from '@/api/device/deviceManagement'
const props = defineProps({
  group: {
@@ -140,6 +169,10 @@
const devices = ref([])
const layoutMode = ref('horizontal') // 'horizontal' | 'vertical'
const selectedDevice = ref(null)
const togglingDeviceId = ref(null)
const clearingDeviceId = ref(null)
const refreshIntervalMs = 5000
let refreshTimer = null
const fetchDevices = async () => {
  if (!props.group) {
@@ -163,11 +196,14 @@
      ? 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
    })
    devices.value = deviceList
      .map((device) => normalizeDevice(device))
      .sort((a, b) => {
        const orderA = a.executionOrder || a.order || 0
        const orderB = b.executionOrder || b.order || 0
        return orderA - orderB
      })
    syncSelectedDevice()
  } catch (error) {
    ElMessage.error(error?.message || '加载设备列表失败')
    devices.value = []
@@ -178,6 +214,21 @@
const handleRefresh = () => {
  fetchDevices()
}
const stopAutoRefresh = () => {
  if (refreshTimer) {
    clearInterval(refreshTimer)
    refreshTimer = null
  }
}
const startAutoRefresh = () => {
  stopAutoRefresh()
  if (!props.group) return
  refreshTimer = setInterval(() => {
    fetchDevices()
  }, refreshIntervalMs)
}
const toggleLayout = () => {
@@ -250,13 +301,129 @@
  () => {
    fetchDevices()
    selectedDevice.value = null
    startAutoRefresh()
  },
  { immediate: true }
)
onMounted(() => {
  startAutoRefresh()
})
onUnmounted(() => {
  stopAutoRefresh()
})
// 点击节点选择设备
const handleNodeClick = (device) => {
  selectedDevice.value = device
}
const syncSelectedDevice = () => {
  if (!selectedDevice.value) return
  const deviceId = selectedDevice.value.deviceId || selectedDevice.value.id
  if (!deviceId) return
  const updated = devices.value.find(
    (item) => (item.deviceId || item.id) === deviceId
  )
  if (updated) {
    selectedDevice.value = updated
  }
}
const isLoadVehicleDevice = (device) => {
  if (!device || !device.deviceType) return false
  const type = device.deviceType.toUpperCase()
  return type.includes('VEHICLE') || type.includes('大车')
}
const normalizeDevice = (device) => {
  if (!device) return device
  const normalized = { ...device }
  if (normalized.onlineState !== undefined) {
    normalized.onlineState = toBoolean(normalized.onlineState)
  } else if (normalized.isOnline === true || normalized.isOnline === false) {
    normalized.onlineState = normalized.isOnline
  } else if (normalized.status) {
    normalized.onlineState = String(normalized.status).toUpperCase() === 'ONLINE'
  }
  if (isLoadVehicleDevice(normalized) && normalized.onlineState !== undefined) {
    normalized.status = normalized.onlineState ? 'ONLINE' : 'OFFLINE'
  }
  return normalized
}
const toBoolean = (value) => {
  if (value === true || value === false) return value
  if (typeof value === 'number') return value !== 0
  const str = String(value).trim().toLowerCase()
  if (str === 'true' || str === '1') return true
  if (str === 'false' || str === '0') return false
  return Boolean(value)
}
const getDeviceStatus = (device) => {
  if (!device) return 'UNKNOWN'
  if (isLoadVehicleDevice(device) && device.onlineState !== undefined) {
    return device.onlineState ? 'ONLINE' : 'OFFLINE'
  }
  if (device.isOnline === true || device.isOnline === false) {
    return device.isOnline ? 'ONLINE' : 'OFFLINE'
  }
  if (device.status) return device.status
  if (device.deviceStatus) return device.deviceStatus
  return 'UNKNOWN'
}
const toggleOnlineState = async (device, value) => {
  if (!device) return
  const deviceId = device.deviceId || device.id
  if (!deviceId) {
    ElMessage.warning('设备ID不存在,无法设置联机状态')
    return
  }
  try {
    togglingDeviceId.value = deviceId
    await deviceInteractionApi.executeOperation({
      deviceId,
      operation: 'setOnlineState',
      params: {
        onlineState: value
      }
    })
    device.onlineState = value
    device.status = value ? 'ONLINE' : 'OFFLINE'
    if (selectedDevice.value && (selectedDevice.value.deviceId === deviceId || selectedDevice.value.id === deviceId)) {
      selectedDevice.value.onlineState = device.onlineState
      selectedDevice.value.status = device.status
    }
    ElMessage.success(`已将 ${device.deviceName || device.deviceCode} 设置为${value ? '联机' : '脱机'}`)
  } catch (error) {
    ElMessage.error(error?.message || '设置联机状态失败')
  } finally {
    togglingDeviceId.value = null
  }
}
const clearPlc = async (device) => {
  if (!device) return
  const deviceId = device.deviceId || device.id
  if (!deviceId) {
    ElMessage.warning('设备ID不存在,无法清空PLC')
    return
  }
  try {
    clearingDeviceId.value = deviceId
    await deviceInteractionApi.executeOperation({
      deviceId,
      operation: 'clearPlc'
    })
    ElMessage.success(`已清空 ${device.deviceName || device.deviceCode} 的PLC数据`)
  } catch (error) {
    ElMessage.error(error?.message || '清空PLC失败')
  } finally {
    clearingDeviceId.value = null
  }
}
defineExpose({
@@ -422,6 +589,12 @@
  justify-content: center;
}
.node-actions {
  margin-top: 6px;
  display: flex;
  justify-content: center;
}
.node-order {
  position: absolute;
  top: -8px;
@@ -447,6 +620,12 @@
  justify-content: center;
}
.status-control {
  display: flex;
  align-items: center;
  gap: 12px;
}
/* 水平布局:箭头在节点右侧 */
.topology-node-wrapper.layout-horizontal .node-arrow {
  margin-left: 20px;