This commit is contained in:
2026-04-19 21:44:17 +08:00
parent 7f209b5fef
commit e569571462

View File

@@ -16,9 +16,7 @@
<span v-if="r.code" class="text-muted room-option-code">{{ r.code }}</span> <span v-if="r.code" class="text-muted room-option-code">{{ r.code }}</span>
</a-option> </a-option>
</a-select> </a-select>
<a-button type="outline" :disabled="!selectedRoomId" :loading="loading" @click="reloadRoomData"> <a-button type="outline" :disabled="!selectedRoomId" :loading="loading" @click="reloadRoomData">刷新</a-button>
刷新
</a-button>
</div> </div>
<div v-if="statsPartialHint" class="toolbar-meta text-muted">{{ statsPartialHint }}</div> <div v-if="statsPartialHint" class="toolbar-meta text-muted">{{ statsPartialHint }}</div>
</div> </div>
@@ -88,60 +86,76 @@
<!-- 第二行设备卡片 --> <!-- 第二行设备卡片 -->
<div v-if="selectedRoomId && deviceCardsHint" class="device-cards-hint text-muted">{{ deviceCardsHint }}</div> <div v-if="selectedRoomId && deviceCardsHint" class="device-cards-hint text-muted">{{ deviceCardsHint }}</div>
<a-row v-if="selectedRoomId" :gutter="16" class="device-cards-row"> <div v-if="selectedRoomId" class="device-cards-row">
<a-col v-for="d in devicesForCards" :key="d.id" :xs="24" :sm="12" :md="8" :lg="6"> <div v-for="d in devicesForCards" :key="d.id" class="device-card-wrapper">
<a-card class="device-card" :bordered="false"> <a-card class="device-card" :bordered="false" hoverable>
<div class="device-card-head"> <template #title>
<div class="device-card-title">{{ d.name }}</div> <div class="device-card-header">
<a-tag :color="getStatusColor(d.status)" size="small">{{ formatStatusText(d.status) }}</a-tag> <span class="device-card-title">{{ d.name }}</span>
</div> <a-tag :color="getStatusColor(d.status)" size="small">{{ formatStatusText(d.status) }}</a-tag>
<div class="device-card-meta"> </div>
<span>{{ DEVICE_CATEGORY_MAP[d.device_category] || d.device_category }}</span> </template>
<span class="text-muted"> · {{ d.type || '-' }}</span> <div class="device-card-body">
</div> <a-descriptions
<div class="device-card-meta text-muted"> :column="1"
<a-tag :color="d.collect_method === 'snmp' ? 'purple' : 'arcoblue'" size="small"> size="small"
{{ d.collect_method === 'snmp' ? 'SNMP' : 'API' }} :value-style="{ width: '100%', color: 'var(--color-text-2)' }"
</a-tag> :label-style="{ color: 'var(--color-text-3)', fontWeight: 500 }"
<span class="ml-8">{{ formatDateTime(d.last_check_time) }}</span> >
</div> <a-descriptions-item label="分类">{{ DEVICE_CATEGORY_MAP[d.device_category] || d.device_category }}</a-descriptions-item>
<div v-if="d.collect_last_result" class="device-card-result text-muted" :title="d.collect_last_result"> <a-descriptions-item label="型号">{{ d.type || '-' }}</a-descriptions-item>
{{ truncate(d.collect_last_result, 80) }} <a-descriptions-item label="采集">
</div> <a-tag :color="d.collect_method === 'snmp' ? 'purple' : 'arcoblue'" size="small">
<a-divider :margin="12" /> {{ d.collect_method === 'snmp' ? 'SNMP' : 'API' }}
<div class="device-metrics"> </a-tag>
<a-spin :loading="metricsLoading && isMetricsTarget(d)" :size="16"> </a-descriptions-item>
<template v-if="metricsForDevice(d).length"> <a-descriptions-item label="最近检查">{{ formatDateTime(d.last_check_time) }}</a-descriptions-item>
<div </a-descriptions>
v-for="(m, idx) in metricsForDevice(d).slice(0, 6)" <div v-if="d.collect_last_result" class="device-card-result" :title="d.collect_last_result">
:key="`${m.metric_name}-${idx}`" <a-typography-text :ellipsis="{ rows: 2, showTooltip: true }" class="text-muted">
class="metric-line" {{ d.collect_last_result }}
</a-typography-text>
</div>
<a-divider :margin="12" />
<div class="device-metrics">
<a-spin :loading="metricsLoading && isMetricsTarget(d)" :size="16">
<a-descriptions
bordered
:column="2"
size="small"
:label-style="{ color: 'var(--color-text-3)' }"
:value-style="{ color: 'var(--color-text-1)', fontWeight: 600, fontFamily: 'SF Mono, Menlo, Monaco, monospace' }"
> >
<span class="metric-name">{{ m.metric_name }}</span> <template v-if="metricsForDevice(d).length">
<span class="metric-val" <a-descriptions-item
>{{ m.metric_value }}{{ m.metric_unit ? ` ${m.metric_unit}` : '' }}</span v-for="(m, idx) in metricsForDevice(d)"
> :key="`${m.metric_name}-${idx}`"
</div> :label="m.metric_name"
</template> :span="1"
<span v-else-if="!isMetricsTarget(d)" class="text-muted">指标见前 {{ METRICS_CARD_LIMIT }} </span> >
<span v-else class="text-muted">暂无指标</span> {{ m.metric_value }}
</a-spin> <span v-if="m.metric_unit" class="metric-unit">({{ m.metric_unit }})</span>
</a-descriptions-item>
</template>
<template v-else-if="!isMetricsTarget(d)">
<a-descriptions-item :span="2" class="text-muted">指标见前 {{ METRICS_CARD_LIMIT }} </a-descriptions-item>
</template>
<template v-else>
<a-descriptions-item :span="2" class="text-muted">暂无指标</a-descriptions-item>
</template>
</a-descriptions>
</a-spin>
</div>
</div> </div>
</a-card> </a-card>
</a-col> </div>
</a-row> </div>
<a-empty v-if="selectedRoomId && !devicesAll.length && !loading" description="该机房下暂无设备" /> <a-empty v-if="selectedRoomId && !devicesAll.length && !loading" description="该机房下暂无设备" />
<!-- 设备列表 --> <!-- 设备列表 -->
<a-card v-if="selectedRoomId" class="table-card" title="设备列表" :bordered="false"> <a-card v-if="selectedRoomId" class="table-card" title="设备列表" :bordered="false">
<template #extra> <template #extra>
<a-select <a-select v-model="selectedCategory" placeholder="全部分类" allow-clear style="width: 160px" :options="DEVICE_CATEGORY_OPTIONS" />
v-model="selectedCategory"
placeholder="全部分类"
allow-clear
style="width: 160px"
:options="DEVICE_CATEGORY_OPTIONS"
/>
</template> </template>
<a-table <a-table
:data="tableRowsPaged" :data="tableRowsPaged"
@@ -176,16 +190,15 @@
</a-tag> </a-tag>
</template> </template>
<template #collect_last_result="{ record }"> <template #collect_last_result="{ record }">
<a-typography-text :ellipsis="{ rows: 2, showTooltip: true }" style="max-width: 240px"> <a-typography-text :ellipsis="{ rows: 2, showTooltip: true }">
{{ record.collect_last_result || '-' }} {{ record.collect_last_result || '-' }}
</a-typography-text> </a-typography-text>
</template> </template>
<template #response_time="{ record }"> <template #response_time="{ record }">
{{ {{ record.response_time != null && record.response_time !== undefined ? Number(record.response_time).toFixed(1) : '-' }}
record.response_time != null && record.response_time !== undefined </template>
? Number(record.response_time).toFixed(1) <template #last_check_time="{ record }">
: '-' {{ formatDateTime(record.last_check_time) }}
}}
</template> </template>
<template #continuous_errors="{ record }"> <template #continuous_errors="{ record }">
{{ record.continuous_errors != null ? record.continuous_errors : '-' }} {{ record.continuous_errors != null ? record.continuous_errors : '-' }}
@@ -199,12 +212,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { Message } from '@arco-design/web-vue' import { Message } from '@arco-design/web-vue'
import { import { IconBuildingCommunity, IconCircleCheck, IconActivity, IconAlertCircle } from '@tabler/icons-vue'
IconBuildingCommunity,
IconCircleCheck,
IconActivity,
IconAlertCircle,
} from '@tabler/icons-vue'
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface' import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
import { fetchRoomOptions, type RoomOptionItem } from '@/api/ops/room' import { fetchRoomOptions, type RoomOptionItem } from '@/api/ops/room'
import { import {
@@ -241,15 +249,15 @@ const tablePagination = ref({
}) })
const columns: TableColumnData[] = [ const columns: TableColumnData[] = [
{ title: '名称', dataIndex: 'name', width: 180, ellipsis: true, tooltip: true }, { title: '名称', dataIndex: 'name', minWidth: 180, ellipsis: true, tooltip: true },
{ title: '设备分类', dataIndex: 'device_category', slotName: 'device_category', width: 100 }, { title: '设备分类', dataIndex: 'device_category', slotName: 'device_category', minWidth: 100 },
{ title: '类型/型号', dataIndex: 'type', width: 120, ellipsis: true, tooltip: true }, { title: '类型/型号', dataIndex: 'type', minWidth: 120, ellipsis: true, tooltip: true },
{ title: '采集方式', dataIndex: 'collect_method', slotName: 'collect_method', width: 96 }, { title: '采集方式', dataIndex: 'collect_method', slotName: 'collect_method', minWidth: 96 },
{ title: '启用 / 周期采集', dataIndex: 'enabled', slotName: 'enabled_collect', width: 160 }, { title: '启用 / 周期采集', dataIndex: 'enabled', slotName: 'enabled_collect', minWidth: 160 },
{ title: '状态', dataIndex: 'status', slotName: 'status', width: 100, align: 'center' }, { title: '状态', dataIndex: 'status', slotName: 'status', minWidth: 100, align: 'center' },
{ title: '最近检查', dataIndex: 'last_check_time', width: 168, ellipsis: true, tooltip: true }, { title: '最近检查', dataIndex: 'last_check_time', slotName: 'last_check_time', minWidth: 250, ellipsis: true, tooltip: true },
{ title: '响应(ms)', dataIndex: 'response_time', slotName: 'response_time', width: 88 }, { title: '响应(ms)', dataIndex: 'response_time', slotName: 'response_time', minWidth: 88 },
{ title: '连续错误', dataIndex: 'continuous_errors', slotName: 'continuous_errors', width: 88 }, { title: '连续错误', dataIndex: 'continuous_errors', slotName: 'continuous_errors', minWidth: 88 },
{ title: '采集摘要', dataIndex: 'collect_last_result', slotName: 'collect_last_result', minWidth: 200 }, { title: '采集摘要', dataIndex: 'collect_last_result', slotName: 'collect_last_result', minWidth: 200 },
] ]
@@ -263,9 +271,7 @@ const statsPartialHint = computed(() => {
const summaryOnline = computed(() => devicesAll.value.filter((d) => d.status === 'online').length) const summaryOnline = computed(() => devicesAll.value.filter((d) => d.status === 'online').length)
const summaryCollectOn = computed(() => devicesAll.value.filter((d) => d.enabled && d.collect_on).length) const summaryCollectOn = computed(() => devicesAll.value.filter((d) => d.enabled && d.collect_on).length)
const summaryOfflineUnknown = computed( const summaryOfflineUnknown = computed(() => devicesAll.value.filter((d) => d.status !== 'online').length)
() => devicesAll.value.filter((d) => d.status !== 'online').length
)
const devicesForCards = computed(() => devicesAll.value) const devicesForCards = computed(() => devicesAll.value)
@@ -422,10 +428,7 @@ async function loadMetricsForFirstDevices() {
batch.map(async (d) => { batch.map(async (d) => {
try { try {
const res = await fetchLatestMetrics(d.service_identity) const res = await fetchLatestMetrics(d.service_identity)
const metrics = const metrics = res?.code === 0 && res.details?.metrics && Array.isArray(res.details.metrics) ? res.details.metrics : []
res?.code === 0 && res.details?.metrics && Array.isArray(res.details.metrics)
? res.details.metrics
: []
metricsByIdentity.value = { ...metricsByIdentity.value, [d.service_identity]: metrics } metricsByIdentity.value = { ...metricsByIdentity.value, [d.service_identity]: metrics }
} catch { } catch {
metricsByIdentity.value = { ...metricsByIdentity.value, [d.service_identity]: [] } metricsByIdentity.value = { ...metricsByIdentity.value, [d.service_identity]: [] }
@@ -519,9 +522,36 @@ export default {
} }
.device-cards-row { .device-cards-row {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-bottom: 16px; margin-bottom: 16px;
} }
.device-card-wrapper {
flex: 1 1 calc(25% - 12px);
min-width: 280px;
max-width: 100%;
}
@media (max-width: 1200px) {
.device-card-wrapper {
flex: 1 1 calc(33.33% - 11px);
}
}
@media (max-width: 992px) {
.device-card-wrapper {
flex: 1 1 calc(50% - 8px);
}
}
@media (max-width: 768px) {
.device-card-wrapper {
flex: 1 1 100%;
}
}
.stats-card { .stats-card {
height: 100%; height: 100%;
@@ -592,71 +622,67 @@ export default {
.device-card { .device-card {
height: 100%; height: 100%;
margin-bottom: 0; border-radius: 12px;
background: var(--color-bg-2);
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.06),
0 1px 2px rgba(0, 0, 0, 0.04);
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
:deep(.arco-card-header) {
padding: 16px 16px 12px;
border-bottom: 1px solid var(--color-border-1);
}
:deep(.arco-card-body) { :deep(.arco-card-body) {
padding: 14px; padding: 14px 16px 16px;
}
&:hover {
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.08),
0 2px 4px rgba(0, 0, 0, 0.06);
transform: translateY(-2px);
} }
} }
.device-card-head { .device-card-header {
display: flex; display: flex;
align-items: flex-start; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 8px; gap: 8px;
margin-bottom: 8px;
} }
.device-card-title { .device-card-title {
font-size: 14px; font-size: 15px;
font-weight: 500; font-weight: 600;
color: var(--color-text-1); color: var(--color-text-1);
line-height: 1.4; overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }
.device-card-meta { .device-card-body {
font-size: 12px; display: flex;
margin-bottom: 6px; flex-direction: column;
} gap: 10px;
.ml-8 {
margin-left: 8px;
} }
.device-card-result { .device-card-result {
font-size: 12px; font-size: 12px;
line-height: 1.4; line-height: 1.5;
word-break: break-all; padding: 10px 12px;
background: var(--color-fill-1);
border-radius: 8px;
border-left: 3px solid var(--color-border-2);
} }
.device-metrics { .device-metrics {
min-height: 48px; min-height: 48px;
} }
.metric-line {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 12px;
margin-bottom: 4px;
}
.metric-name {
color: var(--color-text-3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 55%;
}
.metric-val {
color: var(--color-text-1);
font-weight: 500;
flex-shrink: 0;
}
.table-card { .table-card {
margin-top: 8px; margin-top: 8px;