fix(watch): 监测槽空槽选机与卡片点击进历史
空槽弹出搜索选机对话框;已填槽整卡可点进监测历史;已在监测的 + 给出提示。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user