mes-vue/src/views/biz/device/board.vue
2026-07-18 11:01:31 +08:00

439 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="page-container">
<n-card>
<div class="table-toolbar">
<n-space align="center">
<n-button type="primary" :loading="loading" @click="loadBoard">
<template #icon><n-icon><RefreshOutline /></n-icon></template>
刷新
</n-button>
<n-switch v-model:value="autoRefresh" @update:value="onAutoRefreshChange">
<template #checked>自动刷新 15s</template>
<template #unchecked>手动刷新</template>
</n-switch>
</n-space>
<n-button quaternary @click="router.push('/biz/device')">
返回设备管理
</n-button>
</div>
<div class="stat-row">
<div class="stat-item">
<span class="stat-num">{{ list.length }}</span>
<span class="stat-label">设备总数</span>
</div>
<div class="stat-item running">
<span class="stat-dot running" />
<span class="stat-num">{{ countByStatus(1) }}</span>
<span class="stat-label">加工中</span>
</div>
<div class="stat-item idle">
<span class="stat-dot idle" />
<span class="stat-num">{{ countByStatus(0) }}</span>
<span class="stat-label">闲置</span>
</div>
<div class="stat-item alarm">
<span class="stat-dot alarm" />
<span class="stat-num">{{ countByStatus(2) }}</span>
<span class="stat-label">警报</span>
</div>
<div class="stat-item offline">
<span class="stat-dot offline" />
<span class="stat-num">{{ offlineCount }}</span>
<span class="stat-label">离线</span>
</div>
</div>
<n-spin :show="loading">
<n-empty v-if="!loading && list.length === 0" description="暂无设备状态数据" />
<div v-else class="card-grid">
<div
v-for="item in list"
:key="item.synEquipId"
class="device-card"
:class="cardClass(item)"
>
<div class="card-head">
<div class="head-left">
<div class="status-lamp" :class="lampClass(item)" />
<div class="name-block">
<div class="name">{{ item.displayName }}</div>
<div class="meta">
<span v-if="item.deviceCode">{{ item.deviceCode }}</span>
<span v-if="item.deviceCode && item.sectionName"> · </span>
<span v-if="item.sectionName">{{ item.sectionName }}</span>
<span v-if="!item.deviceCode && !item.sectionName">设备监控</span>
</div>
</div>
</div>
<n-tag :type="statusTagType(item)" size="small">
{{ item.cncStatusText || '未知' }}
</n-tag>
</div>
<div class="metric-grid">
<div class="metric">
<div class="metric-label">当前程序</div>
<div class="metric-value program">{{ item.mainProgram || '—' }}</div>
</div>
<div class="metric">
<div class="metric-label">累计产量</div>
<div class="metric-value">{{ item.partCount ?? '—' }}</div>
</div>
<div class="metric">
<div class="metric-label">通讯状态</div>
<div class="metric-value">
<span class="link-status" :class="item.connected === 1 ? 'on' : 'off'">
{{ item.connected === 1 ? '在线' : item.connected === 0 ? '离线' : '—' }}
</span>
</div>
</div>
<div class="metric">
<div class="metric-label">运行状态</div>
<div class="metric-value">{{ item.cncStatusText || '—' }}</div>
</div>
</div>
<div v-if="item.alarmCount" class="alarm-box">
<n-icon size="14"><WarningOutline /></n-icon>
<span>{{ (item.alarmMessages || []).slice(0, 2).join('') || `当前警报 ${item.alarmCount} 条` }}</span>
</div>
</div>
</div>
</n-spin>
</n-card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import { RefreshOutline, WarningOutline } from '@vicons/ionicons5'
import { deviceApi, type DeviceRealtimeVO } from '@/api/device'
const router = useRouter()
const message = useMessage()
const list = ref<DeviceRealtimeVO[]>([])
const loading = ref(false)
const autoRefresh = ref(true)
let timer: ReturnType<typeof setInterval> | null = null
const offlineCount = computed(() => list.value.filter((i) => i.connected === 0).length)
function countByStatus(status: number) {
return list.value.filter((i) => i.connected !== 0 && i.cncStatus === status).length
}
function cardClass(item: DeviceRealtimeVO) {
if (item.connected === 0) return 'is-offline'
if (item.cncStatus === 2) return 'is-alarm'
if (item.cncStatus === 1) return 'is-running'
if (item.cncStatus === 0) return 'is-idle'
return ''
}
function lampClass(item: DeviceRealtimeVO) {
if (item.connected === 0) return 'offline'
if (item.cncStatus === 2) return 'alarm'
if (item.cncStatus === 1) return 'running'
if (item.cncStatus === 0) return 'idle'
return 'unknown'
}
function statusTagType(item: DeviceRealtimeVO) {
if (item.connected === 0) return 'default'
if (item.cncStatus === 2) return 'error'
if (item.cncStatus === 1) return 'success'
if (item.cncStatus === 0) return 'info'
return 'warning'
}
async function loadBoard() {
loading.value = true
try {
list.value = ((await deviceApi.realtimeBoard()) || []).filter((i) => i.bound)
} catch (e: any) {
message.error(e?.message || '加载设备状态失败')
} finally {
loading.value = false
}
}
function clearTimer() {
if (timer) {
clearInterval(timer)
timer = null
}
}
function onAutoRefreshChange(val: boolean) {
clearTimer()
if (val) {
timer = setInterval(loadBoard, 15000)
}
}
onMounted(() => {
loadBoard()
onAutoRefreshChange(true)
})
onUnmounted(() => {
clearTimer()
})
</script>
<style scoped>
.table-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 12px;
}
.stat-row {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.stat-item {
min-width: 100px;
padding: 12px 16px;
border-radius: 6px;
background: #f5f7fa;
border: 1px solid #e5e7eb;
display: flex;
flex-direction: column;
gap: 4px;
position: relative;
}
.stat-item.running { background: #e8f8ef; border-color: #b7ebc6; }
.stat-item.idle { background: #eef5ff; border-color: #b3d4ff; }
.stat-item.alarm { background: #ffecec; border-color: #ffc2c8; }
.stat-item.offline { background: #f5f5f5; border-color: #e5e7eb; }
.stat-dot {
width: 8px;
height: 8px;
border-radius: 50%;
position: absolute;
top: 12px;
right: 12px;
}
.stat-dot.running { background: #18a058; box-shadow: 0 0 0 3px rgba(24, 160, 88, 0.2); }
.stat-dot.idle { background: #2080f0; }
.stat-dot.alarm { background: #d03050; }
.stat-dot.offline { background: #9aa0a6; }
.stat-num {
font-size: 22px;
font-weight: 600;
color: #1f2329;
font-variant-numeric: tabular-nums;
line-height: 1.2;
}
.stat-label {
font-size: 13px;
color: #646a73;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.device-card {
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 14px 16px;
background: #ffffff;
border-left: 3px solid #d0d3d9;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.device-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.device-card.is-running { border-left-color: #18a058; }
.device-card.is-idle { border-left-color: #2080f0; }
.device-card.is-alarm { border-left-color: #d03050; }
.device-card.is-offline {
border-left-color: #c2c2c2;
opacity: 0.92;
}
.card-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
margin-bottom: 14px;
}
.head-left {
display: flex;
align-items: flex-start;
gap: 10px;
min-width: 0;
}
.status-lamp {
width: 10px;
height: 10px;
border-radius: 50%;
margin-top: 5px;
flex-shrink: 0;
background: #c2c2c2;
}
.status-lamp.running {
background: #18a058;
box-shadow: 0 0 0 3px rgba(24, 160, 88, 0.22);
animation: pulse 1.6s ease-in-out infinite;
}
.status-lamp.idle { background: #2080f0; }
.status-lamp.alarm {
background: #d03050;
box-shadow: 0 0 0 3px rgba(208, 48, 80, 0.22);
animation: pulse 1s ease-in-out infinite;
}
.status-lamp.offline { background: #9aa0a6; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.55; }
}
.name {
font-size: 15px;
font-weight: 600;
color: #1f2329;
line-height: 1.35;
}
.meta {
margin-top: 2px;
font-size: 12px;
color: #8a8f98;
}
.metric-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px 12px;
}
.metric {
min-width: 0;
padding: 8px 10px;
border-radius: 4px;
background: #f7f8fa;
}
.metric-label {
font-size: 12px;
color: #8a8f98;
margin-bottom: 4px;
}
.metric-value {
font-size: 15px;
font-weight: 600;
color: #1f2329;
word-break: break-all;
line-height: 1.35;
}
.metric-value.program {
font-weight: 500;
font-size: 13px;
}
.link-status.on { color: #18a058; }
.link-status.off { color: #8a8f98; }
.alarm-box {
margin-top: 12px;
padding: 8px 10px;
border-radius: 4px;
background: #fff1f0;
color: #d03050;
font-size: 12px;
line-height: 1.4;
display: flex;
align-items: flex-start;
gap: 6px;
}
</style>
<style>
/* 暗色主题:自定义区域不吃 Naive 组件变量,单独提亮 */
body.dark-theme .stat-item {
background: #27272a !important;
border-color: #3f3f46 !important;
}
body.dark-theme .stat-item.running {
background: rgba(24, 160, 88, 0.18) !important;
border-color: rgba(52, 211, 153, 0.4) !important;
}
body.dark-theme .stat-item.idle {
background: rgba(32, 128, 240, 0.18) !important;
border-color: rgba(96, 165, 250, 0.4) !important;
}
body.dark-theme .stat-item.alarm {
background: rgba(208, 48, 80, 0.18) !important;
border-color: rgba(251, 113, 133, 0.4) !important;
}
body.dark-theme .stat-item.offline {
background: #27272a !important;
border-color: #3f3f46 !important;
}
body.dark-theme .stat-num {
color: #f4f4f5 !important;
}
body.dark-theme .stat-label {
color: #a1a1aa !important;
}
body.dark-theme .device-card {
background: #18181c !important;
border-color: #3f3f46 !important;
}
body.dark-theme .device-card.is-offline {
opacity: 1;
}
body.dark-theme .name {
color: #f4f4f5 !important;
}
body.dark-theme .meta {
color: #a1a1aa !important;
}
body.dark-theme .metric {
background: #27272a !important;
}
body.dark-theme .metric-label {
color: #a1a1aa !important;
}
body.dark-theme .metric-value {
color: #f4f4f5 !important;
}
body.dark-theme .link-status.on {
color: #34d399 !important;
}
body.dark-theme .link-status.off {
color: #a1a1aa !important;
}
body.dark-theme .alarm-box {
background: rgba(208, 48, 80, 0.2) !important;
color: #fb7185 !important;
}
</style>