feat: add disk monitor target editors

This commit is contained in:
2026-04-22 22:53:39 +08:00
parent 63433f7f01
commit edf0af7953
5 changed files with 579 additions and 1 deletions

View File

@@ -0,0 +1,141 @@
<template>
<el-dialog :model-value="props.visible" :title="props.title" width="880px" @close="closeDialog">
<el-form label-width="120px" class="target-form">
<el-form-item label="盘符">
<el-input
:model-value="props.modelValue.driveLetter"
placeholder="例如 C:"
maxlength="10"
@update:model-value="value => patchTarget({ driveLetter: String(value).trim().toUpperCase() })"
/>
</el-form-item>
<el-form-item label="启用监控">
<el-switch
:model-value="props.modelValue.monitorEnabled"
@update:model-value="value => patchTarget({ monitorEnabled: Boolean(value) })"
/>
</el-form-item>
<el-form-item label="预警阈值">
<el-input-number
:model-value="props.modelValue.warningUsagePercent"
:min="1"
:max="100"
:controls="false"
@update:model-value="handleWarningChange($event)"
/>
<span class="suffix-text">%</span>
</el-form-item>
<el-form-item label="告警阈值">
<el-input-number
:model-value="props.modelValue.alarmUsagePercent"
:min="1"
:max="100"
:controls="false"
@update:model-value="handleAlarmChange($event)"
/>
<span class="suffix-text">%</span>
</el-form-item>
<el-form-item label="路径通知">
<el-switch
:model-value="props.modelValue.notifyPathEnabled"
@update:model-value="value => patchTarget({ notifyPathEnabled: Boolean(value) })"
/>
</el-form-item>
<el-form-item v-if="props.modelValue.notifyPathEnabled" label="路径通知配置">
<NotificationPathEditor
:model-value="props.modelValue.notifyPathList"
@update:model-value="value => patchTarget({ notifyPathList: value })"
/>
</el-form-item>
<el-form-item label="HTTP 通知">
<el-switch
:model-value="props.modelValue.notifyHttpEnabled"
@update:model-value="value => patchTarget({ notifyHttpEnabled: Boolean(value) })"
/>
</el-form-item>
<el-form-item v-if="props.modelValue.notifyHttpEnabled" label="HTTP 通知配置">
<NotificationHttpEditor
:model-value="props.modelValue.notifyHttpList"
@update:model-value="value => patchTarget({ notifyHttpList: value })"
/>
</el-form-item>
<el-form-item label="备注">
<el-input
type="textarea"
:rows="3"
:model-value="props.modelValue.remark"
placeholder="可选"
@update:model-value="value => patchTarget({ remark: String(value) })"
/>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="closeDialog">取消</el-button>
<el-button type="primary" @click="emit('confirm')">确定</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { DiskMonitor } from '@/api/system/diskMonitor/interface'
import NotificationHttpEditor from './NotificationHttpEditor.vue'
import NotificationPathEditor from './NotificationPathEditor.vue'
defineOptions({
name: 'DiskMonitorTargetDialog'
})
const props = defineProps<{
visible: boolean
modelValue: DiskMonitor.TargetItem
title: string
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
'update:modelValue': [value: DiskMonitor.TargetItem]
confirm: []
}>()
const closeDialog = () => {
emit('update:visible', false)
}
const handleWarningChange = (value: number | undefined) => {
patchTarget({
warningUsagePercent: Number(value || 0)
})
}
const handleAlarmChange = (value: number | undefined) => {
patchTarget({
alarmUsagePercent: Number(value || 0)
})
}
const patchTarget = (patch: Partial<DiskMonitor.TargetItem>) => {
emit('update:modelValue', {
...props.modelValue,
...patch
})
}
</script>
<style scoped lang="scss">
.target-form :deep(.el-form-item__content) {
align-items: center;
}
.suffix-text {
margin-left: 8px;
color: #6b7280;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>

View File

@@ -0,0 +1,127 @@
<template>
<div class="target-table-card">
<div class="table-header">
<div>
<h3 class="table-title">监控目标</h3>
<p class="table-description">维护需要监控的盘符与通知目标</p>
</div>
<el-button type="primary" @click="emit('add')">新增目标</el-button>
</div>
<el-table :data="rows" border stripe>
<el-table-column prop="driveLetter" label="盘符" min-width="90" />
<el-table-column label="是否监控" min-width="100">
<template #default="{ row }">
{{ row.monitorEnabled ? '是' : '否' }}
</template>
</el-table-column>
<el-table-column label="预警使用率" min-width="110">
<template #default="{ row }">
{{ row.warningUsagePercent }}%
</template>
</el-table-column>
<el-table-column label="告警使用率" min-width="110">
<template #default="{ row }">
{{ row.alarmUsagePercent }}%
</template>
</el-table-column>
<el-table-column label="当前状态" min-width="110">
<template #default="{ row }">
<el-tag :type="getStatusType(row.lastStatus)" effect="light">
{{ getStatusLabel(row.lastStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="最近扫描时间" min-width="170">
<template #default="{ row }">
{{ formatScanTime(row.lastScanTime) }}
</template>
</el-table-column>
<el-table-column label="最近使用率" min-width="120">
<template #default="{ row }">
{{ formatUsedPercent(row.lastUsedPercent) }}
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row, $index }">
<el-button link type="primary" @click="emit('edit', row, $index)">编辑</el-button>
<el-button link type="danger" @click="emit('remove', $index)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup lang="ts">
import dayjs from 'dayjs'
import type { DiskMonitor } from '@/api/system/diskMonitor/interface'
defineOptions({
name: 'DiskMonitorTargetTable'
})
defineProps<{
rows: DiskMonitor.TargetItem[]
}>()
const emit = defineEmits<{
add: []
edit: [row: DiskMonitor.TargetItem, index: number]
remove: [index: number]
}>()
const getStatusType = (status: DiskMonitor.MonitorStatus) => {
if (status === 'NORMAL') return 'success'
if (status === 'WARNING') return 'warning'
if (status === 'ALARM') return 'danger'
return 'info'
}
const getStatusLabel = (status: DiskMonitor.MonitorStatus) => {
if (status === 'NORMAL') return '正常'
if (status === 'WARNING') return '预警'
if (status === 'ALARM') return '告警'
return '未知'
}
const formatScanTime = (value?: string | null) => {
if (!value) return '--'
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
}
const formatUsedPercent = (value?: number | null) => {
if (value === null || value === undefined) return '--'
return `${value}%`
}
</script>
<style scoped lang="scss">
.target-table-card {
display: flex;
flex-direction: column;
gap: 14px;
padding: 16px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #ffffff;
}
.table-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.table-title {
margin: 0 0 6px;
font-size: 18px;
color: #111827;
}
.table-description {
margin: 0;
font-size: 13px;
color: #6b7280;
}
</style>

View File

@@ -0,0 +1,133 @@
<template>
<div class="notification-editor">
<div class="editor-header">
<span class="editor-title">HTTP 通知目标</span>
<el-button type="primary" link @click="handleAdd">新增 HTTP 目标</el-button>
</div>
<div v-if="!props.modelValue.length" class="empty-text">暂无 HTTP 通知目标</div>
<div v-else class="editor-list">
<div v-for="(item, index) in props.modelValue" :key="index" class="editor-row">
<el-input
:model-value="item.name"
placeholder="名称"
@update:model-value="value => patchRow(index, 'name', String(value))"
/>
<el-input
:model-value="item.url"
placeholder="通知 URL"
@update:model-value="value => patchRow(index, 'url', String(value))"
/>
<el-select
:model-value="item.method"
@update:model-value="value => patchRow(index, 'method', value as DiskMonitor.NotifyHttpTarget['method'])"
>
<el-option label="POST" value="POST" />
</el-select>
<el-input-number
:model-value="item.timeoutMs"
:min="100"
:step="100"
:controls="false"
@update:model-value="handleTimeoutChange(index, $event)"
/>
<el-switch
:model-value="item.enabled"
@update:model-value="value => patchRow(index, 'enabled', Boolean(value))"
/>
<el-button type="danger" link @click="handleRemove(index)">删除</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { DiskMonitor } from '@/api/system/diskMonitor/interface'
import { createEmptyHttpTarget } from '../utils/form'
defineOptions({
name: 'NotificationHttpEditor'
})
const props = defineProps<{
modelValue: DiskMonitor.NotifyHttpTarget[]
}>()
const emit = defineEmits<{
'update:modelValue': [value: DiskMonitor.NotifyHttpTarget[]]
}>()
const updateList = (nextList: DiskMonitor.NotifyHttpTarget[]) => {
emit('update:modelValue', nextList)
}
const handleAdd = () => {
updateList([...props.modelValue, createEmptyHttpTarget()])
}
const handleRemove = (index: number) => {
updateList(props.modelValue.filter((_, rowIndex) => rowIndex !== index))
}
const handleTimeoutChange = (index: number, value: number | undefined) => {
patchRow(index, 'timeoutMs', Number(value || 0))
}
const patchRow = <K extends keyof DiskMonitor.NotifyHttpTarget>(
index: number,
key: K,
value: DiskMonitor.NotifyHttpTarget[K]
) => {
const nextList = props.modelValue.map((item, rowIndex) =>
rowIndex === index
? {
...item,
[key]: value
}
: item
)
updateList(nextList)
}
</script>
<style scoped lang="scss">
.notification-editor {
display: flex;
flex-direction: column;
gap: 12px;
}
.editor-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.editor-title {
font-size: 14px;
color: #111827;
}
.empty-text {
font-size: 13px;
color: #9ca3af;
}
.editor-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.editor-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.6fr) 100px 120px auto auto;
gap: 10px;
align-items: center;
}
@media (max-width: 992px) {
.editor-row {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,116 @@
<template>
<div class="notification-editor">
<div class="editor-header">
<span class="editor-title">路径通知目标</span>
<el-button type="primary" link @click="handleAdd">新增路径</el-button>
</div>
<div v-if="!props.modelValue.length" class="empty-text">暂无路径通知目标</div>
<div v-else class="editor-list">
<div v-for="(item, index) in props.modelValue" :key="index" class="editor-row">
<el-input
:model-value="item.name"
placeholder="名称"
@update:model-value="value => patchRow(index, 'name', String(value))"
/>
<el-input
:model-value="item.path"
placeholder="通知路径"
@update:model-value="value => patchRow(index, 'path', String(value))"
/>
<el-switch
:model-value="item.enabled"
@update:model-value="value => patchRow(index, 'enabled', Boolean(value))"
/>
<el-button type="danger" link @click="handleRemove(index)">删除</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { DiskMonitor } from '@/api/system/diskMonitor/interface'
import { createEmptyPathTarget } from '../utils/form'
defineOptions({
name: 'NotificationPathEditor'
})
const props = defineProps<{
modelValue: DiskMonitor.NotifyPathTarget[]
}>()
const emit = defineEmits<{
'update:modelValue': [value: DiskMonitor.NotifyPathTarget[]]
}>()
const updateList = (nextList: DiskMonitor.NotifyPathTarget[]) => {
emit('update:modelValue', nextList)
}
const handleAdd = () => {
updateList([...props.modelValue, createEmptyPathTarget()])
}
const handleRemove = (index: number) => {
updateList(props.modelValue.filter((_, rowIndex) => rowIndex !== index))
}
const patchRow = <K extends keyof DiskMonitor.NotifyPathTarget>(
index: number,
key: K,
value: DiskMonitor.NotifyPathTarget[K]
) => {
const nextList = props.modelValue.map((item, rowIndex) =>
rowIndex === index
? {
...item,
[key]: value
}
: item
)
updateList(nextList)
}
</script>
<style scoped lang="scss">
.notification-editor {
display: flex;
flex-direction: column;
gap: 12px;
}
.editor-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.editor-title {
font-size: 14px;
color: #111827;
}
.empty-text {
font-size: 13px;
color: #9ca3af;
}
.editor-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.editor-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) auto auto;
gap: 10px;
align-items: center;
}
@media (max-width: 768px) {
.editor-row {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -18,6 +18,15 @@
@save="handleSave"
@run="handleRun"
/>
<DiskMonitorTargetTable :rows="targetList" @add="openAddTarget" @edit="openEditTarget" @remove="removeTarget" />
<DiskMonitorTargetDialog
v-model:visible="targetDialogVisible"
v-model="editingTarget"
:title="editingTargetIndex >= 0 ? '编辑监控目标' : '新增监控目标'"
@confirm="confirmTarget"
/>
</div>
</template>
@@ -34,7 +43,9 @@ import {
import type { DiskMonitor } from '@/api/system/diskMonitor/interface'
import DiskMonitorPolicyForm from './components/DiskMonitorPolicyForm.vue'
import DiskMonitorSummary from './components/DiskMonitorSummary.vue'
import { createDefaultPolicy, validatePolicy } from './utils/form'
import DiskMonitorTargetDialog from './components/DiskMonitorTargetDialog.vue'
import DiskMonitorTargetTable from './components/DiskMonitorTargetTable.vue'
import { createDefaultPolicy, createEmptyTarget, validatePolicy, validateTarget } from './utils/form'
defineOptions({
name: 'DiskMonitorPage'
@@ -44,6 +55,9 @@ const router = useRouter()
const policyForm = ref<DiskMonitor.PolicyItem>(createDefaultPolicy())
const targetList = ref<DiskMonitor.TargetItem[]>([])
const targetDialogVisible = ref(false)
const editingTargetIndex = ref(-1)
const editingTarget = ref<DiskMonitor.TargetItem>(createEmptyTarget())
const latestJob = ref<DiskMonitor.JobListItem | null>(null)
const loading = reactive({
init: false,
@@ -57,6 +71,53 @@ const handleBack = async () => {
await router.push('/systemMonitor')
}
const cloneTarget = (target: DiskMonitor.TargetItem): DiskMonitor.TargetItem => ({
...target,
notifyPathList: target.notifyPathList.map(item => ({ ...item })),
notifyHttpList: target.notifyHttpList.map(item => ({ ...item }))
})
const openAddTarget = () => {
editingTargetIndex.value = -1
editingTarget.value = createEmptyTarget()
targetDialogVisible.value = true
}
const openEditTarget = (row: DiskMonitor.TargetItem, index: number) => {
editingTargetIndex.value = index
// 编辑时克隆当前行,避免未确认前直接污染列表数据
editingTarget.value = cloneTarget(row)
targetDialogVisible.value = true
}
const confirmTarget = () => {
// 提交前统一规范盘符并做去重、阈值关系校验
const normalizedDriveLetter = editingTarget.value.driveLetter.trim().toUpperCase()
const payload: DiskMonitor.TargetItem = {
...editingTarget.value,
driveLetter: normalizedDriveLetter
}
const exists = targetList.value
.filter((_, index) => index !== editingTargetIndex.value)
.map(item => item.driveLetter.trim().toUpperCase())
const errorMessage = validateTarget(payload, exists)
if (errorMessage) {
ElMessage.warning(errorMessage)
return
}
if (editingTargetIndex.value >= 0) {
targetList.value.splice(editingTargetIndex.value, 1, payload)
} else {
targetList.value.push(payload)
}
targetDialogVisible.value = false
}
const removeTarget = (index: number) => {
targetList.value.splice(index, 1)
}
const loadPolicyDetail = async () => {
const response = await getDiskMonitorPolicyDetail()
const detail = response.data