fix(watch): 监测槽空槽选机与卡片点击进历史

空槽弹出搜索选机对话框;已填槽整卡可点进监测历史;已在监测的 + 给出提示。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nexus Agent
2026-06-12 03:07:00 +08:00
parent 6140d68875
commit 6b8965aaf6
7 changed files with 216 additions and 11 deletions
@@ -0,0 +1,30 @@
# 2026-06-11 监测槽点击交互修复
## 摘要
修复服务器页实时监测槽「点击无反应」:空槽可点击选机、已填槽点击进历史;列表「+」已在监测时给出提示。
## 动机
设计文档要求空槽显示「+ 添加监测」并可点击选机,实现中未绑定事件;已填槽仅小字「历史」可点,整体卡片无交互。
## 涉及文件
- `frontend/src/components/watch/WatchSlotCard.vue` — 空/满槽点击、hover、文案
- `frontend/src/components/watch/WatchSlotPickDialog.vue` — 选机对话框(新增)
- `frontend/src/components/watch/WatchSlotRow.vue` — 串联选机与 pin API
- `frontend/src/composables/useWatchPins.ts``pinServer` 支持 `slot_index`
- `frontend/src/components/watch/WatchSparkline.vue``pointer-events: none` 避免图表挡点击
- `frontend/src/pages/ServersPage.vue` — 已在监测时 snackbar 提示
## 迁移 / 重启
无。前端构建部署即可。
## 验证
```bash
cd frontend && npm run type-check
```
浏览器 `#/servers`:点击虚线空槽 → 搜索选服务器 → 加入;点击有数据的槽卡片 → 跳转监测历史;列表对已监测服务器点 + → 提示槽位号。
@@ -12,6 +12,7 @@ const emit = defineEmits<{
remove: [slotIndex: number]
processes: [slot: WatchSlot]
history: [slot: WatchSlot]
pick: [slotIndex: number]
}>()
const chartMetric = ref<'cpu_pct' | 'mem_pct' | 'disk_pct'>('cpu_pct')
@@ -27,10 +28,14 @@ function pct(v: number | null | undefined) {
elevation="0"
border
rounded="lg"
class="watch-slot-card pa-3"
class="watch-slot-card pa-3 watch-slot-card--filled"
:class="{ 'watch-slot-card--error': slot.metrics?.probe_status && slot.metrics.probe_status !== 'ok' }"
role="button"
tabindex="0"
@click="emit('history', slot)"
@keydown.enter.prevent="emit('history', slot)"
>
<div class="d-flex align-center mb-2">
<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>
@@ -40,6 +45,7 @@ function pct(v: number | null | undefined) {
<v-btn icon="mdi-close" size="x-small" variant="text" @click="emit('remove', slot.slot_index)" />
</div>
<div @click.stop>
<v-chip
v-if="slot.metrics?.probe_status"
size="x-small"
@@ -81,10 +87,11 @@ function pct(v: number | null | undefined) {
<div class="d-flex align-center justify-space-between mt-2">
<span class="text-caption text-medium-emphasis">剩余 {{ formatRemaining(slot.remaining_sec) }}</span>
<div class="d-flex ga-1">
<v-btn size="x-small" variant="text" @click="emit('processes', slot)">进程</v-btn>
<v-btn size="x-small" variant="text" @click="emit('history', slot)">历史</v-btn>
<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>
</v-card>
<v-card
@@ -94,10 +101,14 @@ function pct(v: number | null | undefined) {
rounded="lg"
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 class="text-caption"> {{ slot.slot_index + 1 }} · 添加监测</div>
</div>
</v-card>
</template>
@@ -109,6 +120,17 @@ function pct(v: number | null | undefined) {
.watch-slot-card--empty {
min-height: 220px;
border-style: dashed !important;
cursor: pointer;
transition: background-color 0.15s ease;
}
.watch-slot-card--empty:hover {
background: rgba(var(--v-theme-primary), 0.04);
}
.watch-slot-card--filled {
cursor: pointer;
}
.watch-slot-card--filled:hover {
background: rgba(var(--v-theme-surface-variant), 0.3);
}
.watch-slot-card--error {
border-color: rgb(var(--v-theme-error)) !important;
@@ -0,0 +1,108 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { fetchPagePerPage } from '@/utils/paginatedFetch'
import type { ServerApiItem } from '@/types/api'
const open = defineModel<boolean>({ required: true })
const props = defineProps<{
slotIndex: number | null
loading?: boolean
}>()
const emit = defineEmits<{
pick: [serverId: number]
}>()
const search = ref('')
const listLoading = ref(false)
const servers = ref<ServerApiItem[]>([])
const selectedId = ref<number | null>(null)
const filteredServers = computed(() => {
const q = search.value.toLowerCase().trim()
if (!q) return servers.value
return servers.value.filter(
(s) =>
s.name.toLowerCase().includes(q) ||
(s.domain || '').toLowerCase().includes(q) ||
(s.target_path || '').toLowerCase().includes(q) ||
String(s.id).includes(q),
)
})
watch(open, async (visible) => {
if (!visible) {
search.value = ''
selectedId.value = null
return
}
listLoading.value = true
try {
const res = await fetchPagePerPage<ServerApiItem>('/servers/', {}, 1, -1)
servers.value = res.items
} catch {
servers.value = []
} finally {
listLoading.value = false
}
})
function confirmPick() {
if (selectedId.value == null) return
emit('pick', selectedId.value)
}
</script>
<template>
<v-dialog v-model="open" max-width="480" @keydown.enter.prevent="confirmPick">
<v-card rounded="lg">
<v-card-title>
加入监测
<span v-if="props.slotIndex != null" class="text-caption text-medium-emphasis ml-2">
{{ props.slotIndex + 1 }}
</span>
</v-card-title>
<v-card-text>
<v-autocomplete
v-model="selectedId"
v-model:search="search"
:items="filteredServers"
:loading="listLoading"
item-title="name"
item-value="id"
label="搜索服务器"
placeholder="名称 / 域名 / 路径 / ID"
variant="outlined"
density="compact"
prepend-inner-icon="mdi-magnify"
autofocus
:custom-filter="() => true"
>
<template #item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps" :subtitle="item.domain">
<template #prepend>
<v-icon :color="item.is_online ? 'success' : 'grey'" size="8" class="mr-2">
mdi-circle
</v-icon>
</template>
</v-list-item>
</template>
</v-autocomplete>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="open = false">取消</v-btn>
<v-btn
color="primary"
variant="flat"
:disabled="selectedId == null"
:loading="loading"
@click="confirmPick"
>
加入
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
+38 -3
View File
@@ -1,16 +1,21 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import WatchSlotCard from '@/components/watch/WatchSlotCard.vue'
import WatchProcessDrawer from '@/components/watch/WatchProcessDrawer.vue'
import WatchSlotPickDialog from '@/components/watch/WatchSlotPickDialog.vue'
import { useWatchPins, type WatchSlot } from '@/composables/useWatchPins'
import { ref } from 'vue'
import { useSnackbar } from '@/composables/useSnackbar'
const router = useRouter()
const { slots, loading, refreshPins, removeSlot } = useWatchPins()
const snackbar = useSnackbar()
const { slots, loading, refreshPins, removeSlot, pinServer } = useWatchPins()
const processDrawer = ref(false)
const processSlot = ref<WatchSlot | null>(null)
const showPickDialog = ref(false)
const pickSlotIndex = ref<number | null>(null)
const pickLoading = ref(false)
onMounted(() => {
refreshPins()
@@ -34,6 +39,29 @@ function openHistory(slot: WatchSlot) {
},
})
}
function onPickSlot(slotIndex: number) {
pickSlotIndex.value = slotIndex
showPickDialog.value = true
}
async function onPickServer(serverId: number) {
if (pickSlotIndex.value == null) return
pickLoading.value = true
try {
const res = await pinServer(serverId, { slotIndex: pickSlotIndex.value })
if (res.ok) {
snackbar(`已加入监测 · 槽 ${pickSlotIndex.value + 1}`, 'success')
showPickDialog.value = false
} else if (res.full) {
snackbar('监测槽已满,请先移除一台', 'warning')
} else {
snackbar(res.error || '加入监测失败', 'error')
}
} finally {
pickLoading.value = false
}
}
</script>
<template>
@@ -54,9 +82,16 @@ function openHistory(slot: WatchSlot) {
@remove="onRemove"
@processes="openProcesses"
@history="openHistory"
@pick="onPickSlot"
/>
</v-col>
</v-row>
<WatchProcessDrawer v-model="processDrawer" :slot-data="processSlot" />
<WatchSlotPickDialog
v-model="showPickDialog"
:slot-index="pickSlotIndex"
:loading="pickLoading"
@pick="onPickServer"
/>
</div>
</template>
@@ -52,5 +52,6 @@ const chartOption = computed(() => {
<style scoped>
.watch-sparkline {
width: 100%;
pointer-events: none;
}
</style>
+7 -2
View File
@@ -157,10 +157,15 @@ async function refreshPins() {
}
}
async function pinServer(serverId: number, replaceSlot?: number) {
async function pinServer(
serverId: number,
opts?: number | { replaceSlot?: number; slotIndex?: number },
) {
const options = typeof opts === 'number' ? { replaceSlot: opts } : opts
try {
const body: Record<string, number> = { server_id: serverId }
if (replaceSlot != null) body.replace_slot = replaceSlot
if (options?.replaceSlot != null) body.replace_slot = options.replaceSlot
if (options?.slotIndex != null) body.slot_index = options.slotIndex
const data = await http.post<{ slots: WatchSlot[] }>('/watch/pins', body)
applySlots(data)
return { ok: true as const }
+5 -1
View File
@@ -809,7 +809,11 @@ const showReplacePin = ref(false)
const replacePinServerId = ref<number | null>(null)
async function onPinServer(item: ServerApiItem) {
if (pinnedMap.value[item.id] != null) return
const existingSlot = pinnedMap.value[item.id]
if (existingSlot != null) {
snackbar(`已在监测 · 槽 ${existingSlot + 1}`, 'info')
return
}
pinLoadingId.value = item.id
try {
const res = await doPinServer(item.id)