Files
Nexus/frontend/src/components/watch/WatchSlotCard.vue
T
Nexus Agent ac98dc496c fix(watch): 移除监测槽 hover 时 Vuetify 灰遮罩
v-card 关闭 link/ripple 并隐藏 overlay,仅保留外框主题色高亮。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 04:54:24 +08:00

268 lines
8.5 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.
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { WatchSlot } from '@/composables/useWatchPins'
import {
WATCH_TTL_OPTIONS,
normalizeWatchTtlMinutes,
watchTtlLabel,
watchTtlShortLabel,
type WatchTtlMinutes,
} from '@/constants/watchTtl'
import { formatBytesPerSec, formatRemaining, probeStatusColor, probeStatusLabel, formatProbeError, isPsutilMissingError, loadAvgColor, formatLoadAvg } from '@/utils/watchFormat'
import WatchSparkline from '@/components/watch/WatchSparkline.vue'
import WatchMetricBar from '@/components/watch/WatchMetricBar.vue'
const props = defineProps<{
slot: WatchSlot
psutilInstallLoading?: boolean
}>()
const emit = defineEmits<{
remove: [slotIndex: number]
processes: [slot: WatchSlot]
history: [slot: WatchSlot]
pick: [slotIndex: number]
monitoring: [slotIndex: number, enabled: boolean]
ttl: [slotIndex: number, minutes: WatchTtlMinutes]
installPsutil: [slotIndex: number]
}>()
const monitoringOn = computed(() => props.slot.monitoring_enabled !== false)
const slotTtlMinutes = computed(() =>
normalizeWatchTtlMinutes(props.slot.ttl_minutes ?? (props.slot.ttl_hours === 24 ? 1440 : props.slot.ttl_hours === 8 ? 480 : undefined)),
)
const showPsutilInstall = computed(() =>
monitoringOn.value && isPsutilMissingError(props.slot.metrics?.error, props.slot.metrics?.probe_status),
)
const chartMetric = ref<'cpu_pct' | 'mem_pct' | 'disk_pct'>('cpu_pct')
const load1 = computed(() => props.slot.metrics?.load_1)
</script>
<template>
<v-card
v-if="!slot.empty"
elevation="0"
border
rounded="lg"
:link="false"
:ripple="false"
class="watch-slot-card pa-3 watch-slot-card--filled"
:class="{
'watch-slot-card--error': monitoringOn && slot.metrics?.probe_status && slot.metrics.probe_status !== 'ok',
'watch-slot-card--paused': !monitoringOn,
}"
role="button"
tabindex="0"
@click="emit('history', slot)"
@keydown.enter.prevent="emit('history', slot)"
>
<div class="d-flex align-center mb-2" @click.stop>
<div class="text-subtitle-2 text-truncate flex-grow-1" :title="slot.server_name">
{{ slot.server_name || `ID ${slot.server_id}` }}
</div>
<v-chip size="x-small" variant="tonal" color="info" label>
{{ slot.slot_index + 1 }}
</v-chip>
<v-switch
:model-value="monitoringOn"
density="compact"
hide-details
color="primary"
class="watch-slot-switch ml-1"
@click.stop
@update:model-value="emit('monitoring', slot.slot_index, $event ?? false)"
/>
<v-btn icon="mdi-close" size="x-small" variant="text" @click="emit('remove', slot.slot_index)" />
</div>
<div class="watch-ttl-row mb-2" @click.stop>
<span class="text-caption text-medium-emphasis watch-ttl-label">时长</span>
<div class="watch-ttl-chips d-flex flex-wrap ga-1">
<v-chip
v-for="opt in WATCH_TTL_OPTIONS"
:key="opt.minutes"
:color="slotTtlMinutes === opt.minutes ? 'primary' : undefined"
:variant="slotTtlMinutes === opt.minutes ? 'flat' : 'outlined'"
size="x-small"
label
class="watch-ttl-chip"
@click="emit('ttl', slot.slot_index, opt.minutes)"
>
{{ opt.label }}
</v-chip>
</div>
</div>
<div v-if="!monitoringOn" class="text-caption text-medium-emphasis mb-2" @click.stop>
实时监测已关闭 · 槽位保留 · Agent 恢复 60s 智能上报
</div>
<template v-if="monitoringOn">
<div @click.stop>
<v-chip
v-if="slot.metrics?.probe_status"
size="x-small"
:color="probeStatusColor(slot.metrics.probe_status)"
variant="tonal"
class="mb-2"
label
>
{{ probeStatusLabel(slot.metrics.probe_status) }}
</v-chip>
<WatchMetricBar label="CPU" :value="slot.metrics?.cpu_pct" />
<WatchMetricBar label="内存" :value="slot.metrics?.mem_pct" />
<WatchMetricBar label="硬盘" :value="slot.metrics?.disk_pct" />
<div class="d-flex align-center justify-space-between text-caption mb-2 mt-1">
<span class="text-medium-emphasis">负载</span>
<v-tooltip location="top" text="1 分钟平均负载,与宝塔面板「负载」相同(非 CPU 百分比)">
<template #activator="{ props: tipProps }">
<v-chip
v-bind="tipProps"
size="x-small"
:color="loadAvgColor(load1)"
variant="tonal"
label
>
{{ formatLoadAvg(load1) }}
</v-chip>
</template>
</v-tooltip>
</div>
<div class="text-caption text-medium-emphasis mb-1">
{{ formatBytesPerSec(slot.metrics?.net_up_bps) }}
· {{ formatBytesPerSec(slot.metrics?.net_down_bps) }}
</div>
<div class="text-caption text-medium-emphasis mb-2">
{{ formatBytesPerSec(slot.metrics?.disk_read_bps) }}
· {{ formatBytesPerSec(slot.metrics?.disk_write_bps) }}
</div>
<v-btn-toggle v-model="chartMetric" density="compact" variant="outlined" divided class="mb-1">
<v-btn value="cpu_pct" size="x-small">CPU</v-btn>
<v-btn value="mem_pct" size="x-small">内存</v-btn>
<v-btn value="disk_pct" size="x-small">硬盘</v-btn>
</v-btn-toggle>
<WatchSparkline :points="slot.sparkline || []" :metric="chartMetric" />
<div
v-if="slot.metrics?.error"
class="text-caption text-error mt-1"
:title="formatProbeError(slot.metrics.error, slot.metrics.probe_status)"
>
<div class="text-truncate">
{{ formatProbeError(slot.metrics.error, slot.metrics.probe_status) }}
</div>
<v-btn
v-if="showPsutilInstall"
size="x-small"
variant="tonal"
color="primary"
class="mt-1"
:loading="props.psutilInstallLoading"
@click.stop="emit('installPsutil', slot.slot_index)"
>
安装 psutilSSH
</v-btn>
</div>
<div class="d-flex align-center justify-space-between mt-2">
<span class="text-caption text-medium-emphasis">
剩余 {{ formatRemaining(slot.remaining_sec) }} · {{ watchTtlShortLabel(slotTtlMinutes) }}
</span>
<div class="d-flex ga-1">
<v-btn size="x-small" variant="text" @click.stop="emit('processes', slot)">进程</v-btn>
<v-btn size="x-small" variant="text" @click.stop="emit('history', slot)">历史</v-btn>
</div>
</div>
</div>
</template>
<div v-else class="d-flex align-center justify-space-between mt-2" @click.stop>
<span class="text-caption text-medium-emphasis">已暂停 · {{ watchTtlLabel(slotTtlMinutes) }}</span>
<div class="d-flex ga-1">
<v-btn size="x-small" variant="text" @click.stop="emit('history', slot)">历史</v-btn>
</div>
</div>
</v-card>
<v-card
v-else
elevation="0"
border
rounded="lg"
:link="false"
:ripple="false"
class="watch-slot-card watch-slot-card--empty d-flex align-center justify-center pa-4"
variant="outlined"
role="button"
tabindex="0"
@click="emit('pick', slot.slot_index)"
@keydown.enter.prevent="emit('pick', slot.slot_index)"
>
<div class="text-center text-medium-emphasis">
<v-icon size="28" class="mb-1">mdi-plus-circle-outline</v-icon>
<div class="text-caption"> {{ slot.slot_index + 1 }} · 添加监测</div>
</div>
</v-card>
</template>
<style scoped>
.watch-slot-card {
min-height: 280px;
}
/* Vuetify v-card--link 会在 hover 时铺 v-card__overlay 灰层,此处仅保留外框高亮 */
.watch-slot-card :deep(.v-card__overlay) {
opacity: 0 !important;
}
.watch-slot-card--empty {
min-height: 220px;
border-style: dashed !important;
cursor: pointer;
transition: border-color 0.15s ease;
}
.watch-slot-card--empty:hover {
border-color: rgb(var(--v-theme-primary)) !important;
}
.watch-slot-card--filled {
cursor: pointer;
transition: border-color 0.15s ease;
}
.watch-slot-card--filled:hover {
border-color: rgb(var(--v-theme-primary)) !important;
}
.watch-slot-card--error {
border-color: rgb(var(--v-theme-error)) !important;
}
.watch-slot-card--paused {
opacity: 0.72;
background: rgba(var(--v-theme-surface-variant), 0.15);
}
.watch-slot-switch {
flex: 0 0 auto;
transform: scale(0.85);
}
.watch-ttl-row {
display: flex;
align-items: flex-start;
gap: 6px;
}
.watch-ttl-label {
flex: 0 0 auto;
line-height: 24px;
}
.watch-ttl-chips {
flex: 1 1 auto;
min-width: 0;
}
.watch-ttl-chip {
cursor: pointer;
user-select: none;
}
</style>