style(diskMonitor): 统一磁盘监控页面样式规范

- 添加页面边距、表格样式和按钮样式约定到 AGENTS.md 文档
- 为任务详情抽屉组件添加卡片样式和表格结构优化
- 重构任务表格组件,增加搜索功能和列设置选项
- 移除独立的策略表单组件,整合到汇总页面
- 优化监控摘要组件的网格布局和样式
- 重新设计目标对话框的表单分组和禁用状态处理
- 统一所有组件使用 card、table-main 等标准类名
- 添加文件编码规范要求确保 UTF-8 编码一致性
This commit is contained in:
2026-04-30 09:02:57 +08:00
parent 287de846a6
commit 398a2cf1dc
16 changed files with 1179 additions and 608 deletions

View File

@@ -17,6 +17,9 @@
- 外科手术式修改:只改与任务直接相关的文件和代码行,不重构无关模块,不调整无关格式或注释。
- 保持现有风格:遵循仓库已有包结构、分层方式、命名和写法,不按个人偏好重写。
- 只清理自己造成的问题:可以删除因本次修改而产生的未使用 `import`、变量或方法;不要删除仓库中原本就存在的死代码,除非用户明确要求。
- 页面边距约定:业务页面根节点默认跟随布局主内容区 `el-main``15px` 边距,不再额外叠加页面级外边距;如需特殊边距,必须先有明确的视觉参照页面或业务原因。
- 表格样式约定:业务表格优先复用仓库现有 `table-main``card``table-header` 结构,参照 `dictdata` 页面;表格卡片内部默认不再额外堆叠页面标题、说明文案或自定义装饰区,表头左侧用于主操作、次操作和危险批量操作,右侧用于刷新、列设置、搜索等工具按钮;不要在单页里重复自定义表格卡片边框、表头按钮布局和表头配色,除非有明确视觉参照或业务原因。
- 按钮样式约定:业务页面按钮参照 `dictdata` 页面;表头主操作使用 `type="primary"`,表头次操作使用 `type="primary" plain`,危险批量操作使用 `type="danger" plain`,表格行内操作统一使用 `link` 风格并优先保持 `primary` 语义与图标一致性;弹窗底部保持“取消”默认按钮、“主确认”使用 `primary`,同级辅助执行按钮使用 `primary plain`
- 先定义验证方式:执行方案里要写清楚“改哪里、怎么判断改对了”;默认通过代码路径、配置一致性、界面影响范围和启动链路检查进行验证。
- 除非用户明确要求,否则不执行 `npm run build``npm run build-w``electron-builder`、加密打包或其他重型构建命令;通常只做静态检查、必要的代码阅读和轻量验证。
@@ -35,6 +38,8 @@
## 代码风格与命名规范
前端格式化规则定义在 `frontend/.prettierrc`4 空格缩进、单引号、不写分号、单行 120 字符、LF 换行。Lint 规则基于 Vue 3 与 TypeScript。
文件编码规范:所有新增或修改的源码、脚本、配置、文档统一使用 UTF-8 编码(无 BOM和 LF 换行,不要保存为 GBK、ANSI 或其他本地编码,避免再次出现乱码。
请遵循现有命名方式:
- 页面或路由目录通常使用 `index.vue`,例如 `views/home/`
- 通用组件使用 PascalCase例如 `HomeToolCard.vue`

View File

@@ -7,31 +7,31 @@
<el-dropdown-menu>
<el-dropdown-item @click="refresh">
<el-icon><Refresh /></el-icon>
{{ $t('tabs.refresh') }}
{{ t('tabs.refresh') }}
</el-dropdown-item>
<el-dropdown-item @click="maximize">
<el-icon><FullScreen /></el-icon>
{{ $t('tabs.maximize') }}
{{ t('tabs.maximize') }}
</el-dropdown-item>
<el-dropdown-item divided @click="closeCurrentTab">
<el-icon><Remove /></el-icon>
{{ $t('tabs.closeCurrent') }}
{{ t('tabs.closeCurrent') }}
</el-dropdown-item>
<el-dropdown-item @click="tabStore.closeTabsOnSide(route.fullPath, 'left')">
<el-dropdown-item @click="tabStore.closeTabsOnSide(currentTabPath, 'left')">
<el-icon><DArrowLeft /></el-icon>
{{ $t('tabs.closeLeft') }}
{{ t('tabs.closeLeft') }}
</el-dropdown-item>
<el-dropdown-item @click="tabStore.closeTabsOnSide(route.fullPath, 'right')">
<el-dropdown-item @click="tabStore.closeTabsOnSide(currentTabPath, 'right')">
<el-icon><DArrowRight /></el-icon>
{{ $t('tabs.closeRight') }}
{{ t('tabs.closeRight') }}
</el-dropdown-item>
<el-dropdown-item divided @click="tabStore.closeMultipleTab(route.fullPath)">
<el-dropdown-item divided @click="tabStore.closeMultipleTab(currentTabPath)">
<el-icon><CircleClose /></el-icon>
{{ $t('tabs.closeOther') }}
{{ t('tabs.closeOther') }}
</el-dropdown-item>
<el-dropdown-item @click="closeAllTab">
<el-icon><FolderDelete /></el-icon>
{{ $t('tabs.closeAll') }}
{{ t('tabs.closeAll') }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
@@ -39,7 +39,8 @@
</template>
<script setup lang="ts">
import { inject, nextTick } from 'vue'
import { computed, inject, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { HOME_URL } from '@/config'
import { useTabsStore } from '@/stores/modules/tabs'
import { useGlobalStore } from '@/stores/modules/global'
@@ -48,10 +49,20 @@ import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const tabStore = useTabsStore()
const globalStore = useGlobalStore()
const keepAliveStore = useKeepAliveStore()
const currentTabPath = computed(() => {
const parentPath = route.meta.parentPath as string | undefined
return route.meta.hideTab ? parentPath || route.fullPath : route.fullPath
})
const currentTabRoute = computed(() => {
return router.getRoutes().find(item => item.path === currentTabPath.value)
})
// refresh current page
const refreshCurrentPage: Function = inject('refresh') as Function
const refresh = () => {
@@ -72,8 +83,8 @@ const maximize = () => {
// Close Current
const closeCurrentTab = () => {
if (route.meta.isAffix) return
tabStore.removeTabs(route.fullPath)
if (currentTabRoute.value?.meta.isAffix) return
tabStore.removeTabs(currentTabPath.value)
}
// Close All

View File

@@ -26,12 +26,16 @@
import Sortable from 'sortablejs'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { TabPaneName, TabsPaneContext } from 'element-plus'
import type { TabPaneName, TabsPaneContext } from 'element-plus'
import { HOME_URL } from '@/config'
import { useGlobalStore } from '@/stores/modules/global'
import { useTabsStore } from '@/stores/modules/tabs'
import MoreButton from './components/MoreButton.vue'
defineOptions({
name: 'LayoutTabs'
})
const route = useRoute()
const router = useRouter()
const tabStore = useTabsStore()
@@ -41,17 +45,41 @@ const tabsMenuValue = ref(route.fullPath)
const tabsMenuList = computed(() => tabStore.tabsMenuList)
const tabsIcon = computed(() => globalStore.tabsIcon)
const resolveCurrentTabPath = () => {
const parentPath = route.meta.parentPath as string | undefined
return route.meta.hideTab ? parentPath || route.fullPath : route.fullPath
}
onMounted(() => {
tabsDrop()
initTabs()
})
const ensureParentTab = () => {
const parentPath = resolveCurrentTabPath()
if (!parentPath || tabStore.tabsMenuList.some(item => item.path === parentPath)) return
const parentRoute = router.getRoutes().find(item => item.path === parentPath && item.name)
if (!parentRoute) return
// 直接打开隐藏子页时补齐父级主标签
tabStore.addTabs({
icon: parentRoute.meta.icon as string,
title: parentRoute.meta.title as string,
path: parentRoute.path,
name: parentRoute.name as string,
close: !parentRoute.meta.isAffix,
isKeepAlive: parentRoute.meta.isKeepAlive as boolean
})
}
watch(
() => route.fullPath,
() => {
if (route.meta.isFull) return
if (route.meta.hideTab) {
tabsMenuValue.value = route.meta.parentPath as string
ensureParentTab()
tabsMenuValue.value = resolveCurrentTabPath()
} else {
tabsMenuValue.value = route.fullPath
const tabsParams = {
@@ -112,7 +140,7 @@ const tabClick = (tabItem: TabsPaneContext) => {
}
const tabRemove = (fullPath: TabPaneName) => {
tabStore.removeTabs(fullPath as string, fullPath == route.fullPath)
tabStore.removeTabs(fullPath as string, fullPath === tabsMenuValue.value)
}
</script>

View File

@@ -5,8 +5,9 @@ import { ElNotification } from 'element-plus'
import { useUserStore } from '@/stores/modules/user'
import { useAuthStore } from '@/stores/modules/auth'
// 引入 views 文件夹下所有 vue 文件
const modules = import.meta.glob('@/views/**/*.vue')
const VIEWS_ALIAS_PREFIX = '@/views'
const VIEWS_SRC_PREFIX = '/src/views'
const STATIC_ROUTE_NAMES = new Set([
'layout',
'login',
@@ -14,6 +15,7 @@ const STATIC_ROUTE_NAMES = new Set([
'tools',
'toolWaveform',
'toolMmsMapping',
'toolAddData',
'systemMonitor',
'diskMonitor',
'403',
@@ -24,7 +26,7 @@ const STATIC_ROUTE_NAMES = new Set([
let isInitializing = false
/**
* 清除已有的动态路由
* 清除已有的动态路由,避免重复注入。
*/
const clearDynamicRoutes = () => {
const routes = router.getRoutes()
@@ -36,13 +38,19 @@ const clearDynamicRoutes = () => {
}
/**
* 根据菜单 component 路径查找对应的页面模块
* 兼容两种仓库写法:
* 1. /foo/bar.vue
* 2. /foo/bar/index.vue
* 统一菜单 component 配置格式,只允许映射到 views 目录
*/
const resolveComponentModule = (path: string) => {
let normalizedPath = path.trim()
const normalizeComponentPath = (path: string) => {
let normalizedPath = path.trim().replace(/\\/g, '/')
if (normalizedPath.startsWith(VIEWS_ALIAS_PREFIX)) {
normalizedPath = normalizedPath.slice(VIEWS_ALIAS_PREFIX.length)
} else if (normalizedPath.startsWith(VIEWS_SRC_PREFIX)) {
normalizedPath = normalizedPath.slice(VIEWS_SRC_PREFIX.length)
} else if (normalizedPath.startsWith('src/views')) {
normalizedPath = normalizedPath.slice('src/views'.length)
}
if (!normalizedPath.startsWith('/')) {
normalizedPath = '/' + normalizedPath
}
@@ -53,13 +61,31 @@ const resolveComponentModule = (path: string) => {
normalizedPath = normalizedPath.slice(0, -1)
}
const candidatePaths = [`/src/views${normalizedPath}.vue`, `/src/views${normalizedPath}/index.vue`]
return normalizedPath
}
/**
* 根据菜单 component 路径查找对应页面模块。
* 兼容菜单资源里常见的多种写法,避免因为前缀或 index 差异导致误判。
*/
const resolveComponentModule = (path: string) => {
const normalizedPath = normalizeComponentPath(path)
const viewPath = normalizedPath.endsWith('/index') ? normalizedPath.slice(0, -'/index'.length) : normalizedPath
const candidatePaths = Array.from(
new Set([
`${VIEWS_ALIAS_PREFIX}${normalizedPath}.vue`,
`${VIEWS_ALIAS_PREFIX}${normalizedPath}/index.vue`,
`${VIEWS_ALIAS_PREFIX}${viewPath}/index.vue`,
`${VIEWS_SRC_PREFIX}${normalizedPath}.vue`,
`${VIEWS_SRC_PREFIX}${normalizedPath}/index.vue`,
`${VIEWS_SRC_PREFIX}${viewPath}/index.vue`
])
)
for (const candidatePath of candidatePaths) {
const moduleLoader = modules[candidatePath]
if (moduleLoader) {
if (candidatePath in modules) {
return {
moduleLoader,
moduleLoader: modules[candidatePath],
resolvedPath: candidatePath
}
}
@@ -72,7 +98,7 @@ const resolveComponentModule = (path: string) => {
}
/**
* @description 初始化动态路由
* 初始化动态路由
*/
export const initDynamicRouter = async () => {
if (isInitializing) return Promise.reject(new Error('Dynamic router initialization in progress'))
@@ -83,15 +109,13 @@ export const initDynamicRouter = async () => {
const unresolvedRoutes: Array<{ name?: string; path?: string; component?: string; candidates: string[] }> = []
try {
// 1. 获取菜单列表 && 按钮权限列表
await authStore.getAuthMenuList()
await authStore.getAuthButtonList()
// 2. 判断当前用户有没有菜单权限
if (!authStore.authMenuListGet.length) {
ElNotification({
title: '无权限访问',
message: '当前账号无任何菜单权限,请联系系统管理员',
message: '当前账号无任何菜单权限,请联系系统管理员',
type: 'warning',
duration: 3000
})
@@ -102,21 +126,17 @@ export const initDynamicRouter = async () => {
return Promise.reject('No permission')
}
// 3. 清理之前的动态路由
clearDynamicRoutes()
// 4. 添加动态路由
for (const item of authStore.flatMenuListGet) {
// 删除 children 避免冗余嵌套
if (item.children) delete item.children
// 处理组件映射
// 动态菜单组件必须先映射成真实页面模块,否则 addRoute 后会直接落到 404。
if (item.component && typeof item.component === 'string') {
const { moduleLoader, resolvedPath } = resolveComponentModule(item.component)
if (moduleLoader) {
item.component = moduleLoader
} else {
// 动态路由组件一旦解析失败,对应菜单会落入 404这里必须打印清楚候选路径。
unresolvedRoutes.push({
name: item.name,
path: item.path,
@@ -127,7 +147,6 @@ export const initDynamicRouter = async () => {
}
}
// 类型守卫:确保满足 RouteRecordRaw 接口要求
if (
typeof item.path === 'string' &&
(typeof item.component === 'function' || typeof item.redirect === 'string')
@@ -147,7 +166,6 @@ export const initDynamicRouter = async () => {
console.error('[dynamic-router] unresolved route components', unresolvedRoutes)
}
} catch (error) {
// 当按钮 || 菜单请求出错时,重定向到登陆页
userStore.setAccessToken('')
userStore.setRefreshToken('')
userStore.setExp(0)

View File

@@ -54,11 +54,19 @@ export const staticRouter: RouteRecordRaw[] = [
path: '/tools/mmsMapping',
name: 'toolMmsMapping',
alias: ['/tools/mmsmapping', '/tools/mms-mapping'],
component: () => import('@/views/tools/mmsmapping/index.vue'),
component: () => import('@/views/tools/mmsMapping/index.vue'),
meta: {
title: 'MMS 映射'
}
},
{
path: '/tools/addData',
name: 'toolAddData',
component: () => import('@/views/tools/addData/index.vue'),
meta: {
title: '模拟数据'
}
},
{
path: '/403',
name: '403',
@@ -96,6 +104,10 @@ export const staticRouter: RouteRecordRaw[] = [
name: 'diskMonitor',
component: () => import('@/views/systemMonitor/diskMonitor/index.vue'),
meta: {
// 磁盘监控页复用系统监控主标签
activeMenu: '/systemMonitor',
hideTab: true,
parentPath: '/systemMonitor',
title: '磁盘监控'
}
},

View File

@@ -12,6 +12,8 @@ declare namespace Menu {
icon: string;
title: string;
activeMenu?: string;
hideTab?: boolean;
parentPath?: string;
isLink?: string;
isHide: boolean;
isFull: boolean;

View File

@@ -1,5 +1,6 @@
<template>
<el-drawer
class="job-detail-drawer"
:model-value="props.visible"
size="70%"
title="任务详情"
@@ -8,107 +9,128 @@
>
<div v-loading="props.loading" class="job-detail">
<template v-if="props.detail">
<div class="meta-grid">
<div class="meta-item">
<span class="meta-label">任务编号</span>
<span class="meta-value">{{ props.detail.job.jobNo }}</span>
</div>
<div class="meta-item">
<span class="meta-label">任务来源</span>
<span class="meta-value">{{ getSourceLabel(props.detail.job.jobSource) }}</span>
</div>
<div class="meta-item">
<span class="meta-label">任务状态</span>
<span class="meta-value">{{ getJobStatusLabel(props.detail.job.jobStatus) }}</span>
</div>
<div class="meta-item">
<span class="meta-label">预警数量</span>
<span class="meta-value">{{ props.detail.job.warningCount }}</span>
</div>
<div class="meta-item">
<span class="meta-label">告警数量</span>
<span class="meta-value">{{ props.detail.job.alarmCount }}</span>
</div>
<div class="meta-item">
<span class="meta-label">开始时间</span>
<span class="meta-value">{{ formatTime(props.detail.job.startedAt) }}</span>
</div>
<div class="meta-item">
<span class="meta-label">结束时间</span>
<span class="meta-value">{{ formatTime(props.detail.job.finishedAt) }}</span>
<section class="card job-detail-meta-card">
<div class="meta-grid">
<div class="meta-item">
<span class="meta-label">任务编号</span>
<span class="meta-value">{{ props.detail.job.jobNo }}</span>
</div>
<div class="meta-item">
<span class="meta-label">任务来源</span>
<span class="meta-value">{{ getSourceLabel(props.detail.job.jobSource) }}</span>
</div>
<div class="meta-item">
<span class="meta-label">任务状态</span>
<span class="meta-value">{{ getJobStatusLabel(props.detail.job.jobStatus) }}</span>
</div>
<div class="meta-item">
<span class="meta-label">预警数量</span>
<span class="meta-value">{{ props.detail.job.warningCount }}</span>
</div>
<div class="meta-item">
<span class="meta-label">告警数量</span>
<span class="meta-value">{{ props.detail.job.alarmCount }}</span>
</div>
<div class="meta-item">
<span class="meta-label">开始时间</span>
<span class="meta-value">{{ formatTime(props.detail.job.startedAt) }}</span>
</div>
<div class="meta-item">
<span class="meta-label">结束时间</span>
<span class="meta-value">{{ formatTime(props.detail.job.finishedAt) }}</span>
</div>
</div>
</section>
<div class="job-detail-table-sections">
<section class="table-section">
<h4 class="section-title">盘符结果</h4>
<div class="table-main card job-detail-table-card">
<el-table :data="props.detail.results" class="job-detail-el-table" border stripe height="100%">
<el-table-column prop="driveLetter" label="盘符" min-width="100" align="center" />
<el-table-column label="使用率" min-width="110" align="center">
<template #default="{ row }">{{ row.usedPercent }}%</template>
</el-table-column>
<el-table-column label="当前状态" min-width="120" align="center">
<template #default="{ row }">
<el-tag :type="getMonitorStatusType(row.currentStatus)" effect="light">
{{ getMonitorStatusLabel(row.currentStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="上次状态" min-width="120" align="center">
<template #default="{ row }">
<el-tag :type="getMonitorStatusType(row.previousStatus)" effect="light">
{{ getMonitorStatusLabel(row.previousStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态变化" min-width="100" align="center">
<template #default="{ row }">{{ formatBoolean(row.statusChanged) }}</template>
</el-table-column>
<el-table-column label="是否通知" min-width="100" align="center">
<template #default="{ row }">{{ formatBoolean(row.shouldNotify) }}</template>
</el-table-column>
<el-table-column label="通知原因" min-width="130" align="center">
<template #default="{ row }">{{ getNotifyReasonLabel(row.notifyReason) }}</template>
</el-table-column>
<el-table-column label="扫描时间" min-width="170" align="center">
<template #default="{ row }">{{ formatTime(row.scanTime) }}</template>
</el-table-column>
<el-table-column
prop="message"
label="说明"
min-width="180"
align="center"
show-overflow-tooltip
/>
</el-table>
</div>
</section>
<section class="table-section">
<h4 class="section-title">通知日志</h4>
<div class="table-main card job-detail-table-card">
<el-table :data="props.detail.notifyLogs" class="job-detail-el-table" border stripe height="100%">
<el-table-column prop="driveLetter" label="盘符" min-width="100" align="center" />
<el-table-column label="通知级别" min-width="110" align="center">
<template #default="{ row }">
<el-tag :type="getNotifyLevelType(row.notifyLevel)" effect="light">
{{ getNotifyLevelLabel(row.notifyLevel) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="通知通道" min-width="120" align="center">
<template #default="{ row }">{{ getChannelTypeLabel(row.channelType) }}</template>
</el-table-column>
<el-table-column
prop="channelTarget"
label="通知目标"
min-width="220"
align="center"
show-overflow-tooltip
/>
<el-table-column label="发送状态" min-width="110" align="center">
<template #default="{ row }">
<el-tag :type="getSendStatusType(row.sendStatus)" effect="light">
{{ getSendStatusLabel(row.sendStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="responseMessage"
label="响应信息"
min-width="220"
align="center"
show-overflow-tooltip
/>
<el-table-column label="发送时间" min-width="170" align="center">
<template #default="{ row }">{{ formatTime(row.sentAt) }}</template>
</el-table-column>
</el-table>
</div>
</section>
</div>
<section class="table-section">
<h4 class="section-title">盘符结果</h4>
<el-table :data="props.detail.results" border stripe>
<el-table-column prop="driveLetter" label="盘符" min-width="100" />
<el-table-column label="使用率" min-width="110">
<template #default="{ row }">{{ row.usedPercent }}%</template>
</el-table-column>
<el-table-column label="当前状态" min-width="120">
<template #default="{ row }">
<el-tag :type="getMonitorStatusType(row.currentStatus)" effect="light">
{{ getMonitorStatusLabel(row.currentStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="上次状态" min-width="120">
<template #default="{ row }">
<el-tag :type="getMonitorStatusType(row.previousStatus)" effect="light">
{{ getMonitorStatusLabel(row.previousStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态变化" min-width="100">
<template #default="{ row }">{{ formatBoolean(row.statusChanged) }}</template>
</el-table-column>
<el-table-column label="是否通知" min-width="100">
<template #default="{ row }">{{ formatBoolean(row.shouldNotify) }}</template>
</el-table-column>
<el-table-column label="通知原因" min-width="130">
<template #default="{ row }">{{ getNotifyReasonLabel(row.notifyReason) }}</template>
</el-table-column>
<el-table-column label="扫描时间" min-width="170">
<template #default="{ row }">{{ formatTime(row.scanTime) }}</template>
</el-table-column>
<el-table-column prop="message" label="说明" min-width="180" show-overflow-tooltip />
</el-table>
</section>
<section class="table-section">
<h4 class="section-title">通知日志</h4>
<el-table :data="props.detail.notifyLogs" border stripe>
<el-table-column prop="driveLetter" label="盘符" min-width="100" />
<el-table-column label="通知级别" min-width="110">
<template #default="{ row }">
<el-tag :type="getNotifyLevelType(row.notifyLevel)" effect="light">
{{ getNotifyLevelLabel(row.notifyLevel) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="通知通道" min-width="120">
<template #default="{ row }">{{ getChannelTypeLabel(row.channelType) }}</template>
</el-table-column>
<el-table-column prop="channelTarget" label="通知目标" min-width="220" show-overflow-tooltip />
<el-table-column label="发送状态" min-width="110">
<template #default="{ row }">
<el-tag :type="getSendStatusType(row.sendStatus)" effect="light">
{{ getSendStatusLabel(row.sendStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="responseMessage"
label="响应信息"
min-width="220"
show-overflow-tooltip
/>
<el-table-column label="发送时间" min-width="170">
<template #default="{ row }">{{ formatTime(row.sentAt) }}</template>
</el-table-column>
</el-table>
</section>
</template>
<el-empty v-else description="暂无详情数据" />
</div>
@@ -204,45 +226,121 @@ const formatTime = (value?: string | null) => {
</script>
<style scoped lang="scss">
.job-detail-drawer :deep(.el-drawer__body) {
display: flex;
min-height: 0;
overflow: hidden;
}
.job-detail {
display: flex;
flex: 1;
flex-direction: column;
gap: 16px;
min-height: 0;
}
.job-detail-meta-card {
flex: none;
height: auto;
}
.job-detail-table-sections {
display: flex;
flex: 1;
flex-direction: column;
gap: 16px;
min-height: 0;
}
.job-detail-table-card {
flex: 1;
min-height: 0;
}
.job-detail-el-table {
flex: 1;
}
.job-detail-el-table :deep(.el-table__inner-wrapper) {
display: flex;
flex-direction: column;
height: 100%;
}
.job-detail-el-table :deep(.el-table__body-wrapper) {
flex: 1;
height: 0;
min-height: 0;
}
.job-detail-el-table :deep(.el-scrollbar),
.job-detail-el-table :deep(.el-scrollbar__wrap),
.job-detail-el-table :deep(.el-scrollbar__view) {
height: 100%;
}
.job-detail-el-table :deep(.el-table__empty-block) {
height: 100%;
min-height: 100%;
}
.job-detail-el-table :deep(.el-table__empty-text) {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.meta-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 18px;
padding: 14px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #f9fafb;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.meta-item {
display: flex;
gap: 8px;
flex-direction: column;
gap: 6px;
padding: 14px 16px;
background: var(--el-fill-color-lighter);
border-radius: 6px;
}
.meta-label {
color: #6b7280;
color: var(--el-text-color-secondary);
}
.meta-value {
color: #111827;
color: var(--el-text-color-primary);
font-weight: 500;
word-break: break-word;
}
.table-section {
display: flex;
flex: 1;
flex-direction: column;
gap: 8px;
min-height: 0;
}
.section-title {
margin: 0;
font-size: 16px;
color: #111827;
color: var(--el-text-color-primary);
}
@media (max-width: 1200px) {
.meta-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.meta-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -1,49 +1,102 @@
<template>
<div class="job-table-card">
<div class="table-main card disk-monitor-table-card">
<div class="table-header">
<div>
<h3 class="table-title">最近任务</h3>
<p class="table-description">展示最近 10 条磁盘监控任务执行记录</p>
<div class="header-button-ri table-tools">
<el-button circle :icon="Refresh" :loading="loading" @click="emit('refresh')" />
<el-popover trigger="click" placement="bottom-end" :width="220">
<div class="column-setting-panel">
<el-checkbox v-for="column in columnOptions" :key="column.key" v-model="column.visible">
{{ column.label }}
</el-checkbox>
</div>
<template #reference>
<el-button circle :icon="Operation" />
</template>
</el-popover>
<el-button circle :icon="Search" @click="showSearch = !showSearch" />
</div>
<el-button :loading="loading" @click="emit('refresh')">刷新</el-button>
</div>
<el-table v-loading="loading" :data="rows" border stripe>
<el-table-column prop="jobNo" label="任务编号" min-width="180" />
<el-table-column label="来源" min-width="120">
<template #default="{ row }">
{{ getSourceLabel(row.jobSource) }}
</template>
</el-table-column>
<el-table-column label="开始时间" min-width="170">
<template #default="{ row }">
{{ formatTime(row.startedAt) }}
</template>
</el-table-column>
<el-table-column label="结束时间" min-width="170">
<template #default="{ row }">
{{ formatTime(row.finishedAt) }}
</template>
</el-table-column>
<el-table-column label="状态" min-width="130">
<template #default="{ row }">
<el-tag :type="getStatusType(row.jobStatus)" effect="light">
{{ getStatusLabel(row.jobStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="warningCount" label="预警数量" min-width="100" />
<el-table-column prop="alarmCount" label="告警数量" min-width="100" />
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="emit('detail', row)">查看详情</el-button>
</template>
</el-table-column>
</el-table>
<div v-show="showSearch" class="table-search">
<el-form label-width="80px" class="disk-monitor-search-form">
<div class="search-grid">
<el-form-item label="任务编号">
<el-input v-model="searchForm.jobNo" clearable placeholder="请输入任务编号" />
</el-form-item>
<el-form-item label="任务来源">
<el-select v-model="searchForm.jobSource" clearable placeholder="请选择来源">
<el-option label="应用启动" value="APP_START" />
<el-option label="定时任务" value="DAILY_SCHEDULE" />
<el-option label="手动触发" value="MANUAL" />
</el-select>
</el-form-item>
<el-form-item label="任务状态">
<el-select v-model="searchForm.jobStatus" clearable placeholder="请选择状态">
<el-option label="成功" value="SUCCESS" />
<el-option label="部分成功" value="PARTIAL_SUCCESS" />
<el-option label="失败" value="FAILED" />
<el-option label="运行中" value="RUNNING" />
</el-select>
</el-form-item>
</div>
<div class="operation">
<el-button @click="resetSearch">重置</el-button>
</div>
</el-form>
</div>
<div class="disk-monitor-table-body">
<el-table v-loading="loading" class="disk-monitor-el-table" :data="filteredRows" border stripe height="100%">
<el-table-column v-if="isColumnVisible('jobNo')" prop="jobNo" label="任务编号" min-width="180" align="center" />
<el-table-column v-if="isColumnVisible('jobSource')" label="来源" min-width="120" align="center">
<template #default="{ row }">
{{ getSourceLabel(row.jobSource) }}
</template>
</el-table-column>
<el-table-column v-if="isColumnVisible('startedAt')" label="开始时间" min-width="170" align="center">
<template #default="{ row }">
{{ formatTime(row.startedAt) }}
</template>
</el-table-column>
<el-table-column v-if="isColumnVisible('finishedAt')" label="结束时间" min-width="170" align="center">
<template #default="{ row }">
{{ formatTime(row.finishedAt) }}
</template>
</el-table-column>
<el-table-column v-if="isColumnVisible('jobStatus')" label="状态" min-width="130" align="center">
<template #default="{ row }">
<el-tag :type="getStatusType(row.jobStatus)" effect="light">
{{ getStatusLabel(row.jobStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('warningCount')"
prop="warningCount"
label="预警数量"
min-width="100"
align="center"
/>
<el-table-column
v-if="isColumnVisible('alarmCount')"
prop="alarmCount"
label="告警数量"
min-width="100"
align="center"
/>
<el-table-column label="操作" width="160" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" :icon="View" @click="emit('detail', row)">查看详情</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { Operation, Refresh, Search, View } from '@element-plus/icons-vue'
import dayjs from 'dayjs'
import type { DiskMonitor } from '@/api/system/diskMonitor/interface'
@@ -51,7 +104,7 @@ defineOptions({
name: 'DiskMonitorJobTable'
})
defineProps<{
const props = defineProps<{
rows: DiskMonitor.JobListItem[]
loading: boolean
}>()
@@ -61,6 +114,54 @@ const emit = defineEmits<{
detail: [row: DiskMonitor.JobListItem]
}>()
type JobColumnKey = 'jobNo' | 'jobSource' | 'startedAt' | 'finishedAt' | 'jobStatus' | 'warningCount' | 'alarmCount'
const showSearch = ref(false)
const searchForm = reactive({
jobNo: '',
jobSource: '',
jobStatus: ''
})
const columnOptions = reactive<{ key: JobColumnKey; label: string; visible: boolean }[]>([
{ key: 'jobNo', label: '任务编号', visible: true },
{ key: 'jobSource', label: '来源', visible: true },
{ key: 'startedAt', label: '开始时间', visible: true },
{ key: 'finishedAt', label: '结束时间', visible: true },
{ key: 'jobStatus', label: '状态', visible: true },
{ key: 'warningCount', label: '预警数量', visible: true },
{ key: 'alarmCount', label: '告警数量', visible: true }
])
const includesKeyword = (value?: string | number | null, keyword?: string) => {
const normalizedKeyword = String(keyword || '')
.trim()
.toLowerCase()
if (!normalizedKeyword) return true
return String(value ?? '')
.trim()
.toLowerCase()
.includes(normalizedKeyword)
}
const isColumnVisible = (key: JobColumnKey) => {
return columnOptions.find(column => column.key === key)?.visible ?? true
}
const filteredRows = computed(() => {
return props.rows.filter(row => {
const matchesJobNo = includesKeyword(row.jobNo, searchForm.jobNo)
const matchesSource = !searchForm.jobSource || row.jobSource === searchForm.jobSource
const matchesStatus = !searchForm.jobStatus || row.jobStatus === searchForm.jobStatus
return matchesJobNo && matchesSource && matchesStatus
})
})
const resetSearch = () => {
searchForm.jobNo = ''
searchForm.jobSource = ''
searchForm.jobStatus = ''
}
const getSourceLabel = (source: DiskMonitor.JobSource) => {
if (source === 'APP_START') return '应用启动'
if (source === 'DAILY_SCHEDULE') return '定时任务'
@@ -88,32 +189,66 @@ const formatTime = (value?: string | null) => {
</script>
<style scoped lang="scss">
.job-table-card {
.disk-monitor-table-card {
display: flex;
flex: 1;
flex-direction: column;
gap: 14px;
padding: 16px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #ffffff;
min-height: 0;
overflow: hidden;
}
.table-header {
display: flow-root;
flex: none;
}
.table-tools {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.table-title {
margin: 0 0 6px;
font-size: 18px;
color: #111827;
.column-setting-panel {
display: flex;
flex-direction: column;
gap: 10px;
}
.table-description {
margin: 0;
font-size: 13px;
color: #6b7280;
.search-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0 12px;
}
.disk-monitor-table-body {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
.disk-monitor-el-table {
flex: 1;
min-height: 0;
}
.disk-monitor-table-body :deep(.el-table__inner-wrapper) {
height: 100%;
}
@media (max-width: 768px) {
.table-header {
display: flex;
flex-direction: column;
gap: 12px;
}
.header-button-ri {
float: none;
}
.search-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -1,149 +0,0 @@
<template>
<div class="policy-form-card">
<div class="policy-header">
<div>
<h3 class="policy-title">全局策略</h3>
<p class="policy-description">配置监控总开关启动监控与每日统一时间</p>
</div>
<div class="policy-actions">
<el-button :loading="runLoading" :disabled="disabled" @click="emit('run')">立即执行监控</el-button>
<el-button type="primary" :loading="saveLoading" :disabled="disabled" @click="emit('save')">保存配置</el-button>
</div>
</div>
<el-alert
class="policy-alert"
title="通知规则"
description="预警按状态变化通知,告警每次命中都通知"
type="info"
:closable="false"
show-icon
/>
<el-form label-width="130px" class="policy-form">
<el-form-item label="启用监控">
<el-switch
:model-value="modelValue.monitorEnabled"
:disabled="disabled"
@update:model-value="handleMonitorEnabledChange"
/>
</el-form-item>
<el-form-item label="启动即监控">
<el-switch
:model-value="modelValue.runOnAppStart"
:disabled="disabled"
@update:model-value="handleRunOnAppStartChange"
/>
</el-form-item>
<el-form-item label="每日执行时间">
<el-time-picker
:model-value="modelValue.dailyRunTime"
:disabled="disabled"
format="HH:mm:ss"
value-format="HH:mm:ss"
placeholder="选择时间"
@update:model-value="value => updatePolicy('dailyRunTime', typeof value === 'string' ? value : '')"
/>
</el-form-item>
</el-form>
</div>
</template>
<script setup lang="ts">
import type { DiskMonitor } from '@/api/system/diskMonitor/interface'
defineOptions({
name: 'DiskMonitorPolicyForm'
})
const props = withDefaults(
defineProps<{
modelValue: DiskMonitor.PolicyItem
disabled?: boolean
saveLoading?: boolean
runLoading?: boolean
}>(),
{
disabled: false,
saveLoading: false,
runLoading: false
}
)
const emit = defineEmits<{
'update:modelValue': [value: DiskMonitor.PolicyItem]
save: []
run: []
}>()
const handleMonitorEnabledChange = (value: string | number | boolean) => {
updatePolicy('monitorEnabled', Boolean(value))
}
const handleRunOnAppStartChange = (value: string | number | boolean) => {
updatePolicy('runOnAppStart', Boolean(value))
}
const updatePolicy = <K extends keyof DiskMonitor.PolicyItem>(key: K, value: DiskMonitor.PolicyItem[K]) => {
emit('update:modelValue', {
...props.modelValue,
[key]: value
})
}
</script>
<style scoped lang="scss">
.policy-form-card {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #ffffff;
}
.policy-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.policy-title {
margin: 0 0 8px;
font-size: 18px;
color: #111827;
}
.policy-description {
margin: 0;
font-size: 13px;
color: #6b7280;
line-height: 1.6;
}
.policy-actions {
display: flex;
gap: 10px;
flex-shrink: 0;
}
.policy-alert {
margin-bottom: 4px;
}
.policy-form :deep(.el-form-item) {
margin-bottom: 14px;
}
@media (max-width: 768px) {
.policy-header {
flex-direction: column;
}
.policy-actions {
width: 100%;
}
}
</style>

View File

@@ -1,32 +1,34 @@
<template>
<div class="disk-monitor-summary">
<div class="summary-card">
<div class="summary-label">监控状态</div>
<div class="summary-value">{{ policy.monitorEnabled ? '已启用' : '已停用' }}</div>
</div>
<div class="summary-card">
<div class="summary-label">启动即监控</div>
<div class="summary-value">{{ policy.runOnAppStart ? '是' : '否' }}</div>
</div>
<div class="summary-card">
<div class="summary-label">每日执行时间</div>
<div class="summary-value">{{ policy.dailyRunTime || '--' }}</div>
</div>
<div class="summary-card">
<div class="summary-label">监控盘符数量</div>
<div class="summary-value">{{ monitorTargetCount }}</div>
</div>
<div class="summary-card">
<div class="summary-label">当前告警盘符</div>
<div class="summary-value">{{ alarmCount }}</div>
</div>
<div class="summary-card">
<div class="summary-label">最近执行时间</div>
<div class="summary-value">{{ latestRunTime }}</div>
</div>
<div class="summary-card">
<div class="summary-label">最近执行状态</div>
<div class="summary-value">{{ latestJobStatus }}</div>
<div class="card disk-monitor-summary-card">
<div class="summary-grid">
<div class="summary-item">
<div class="summary-label">监控状态</div>
<div class="summary-value">{{ policy.monitorEnabled ? '已启用' : '已停用' }}</div>
</div>
<div class="summary-item">
<div class="summary-label">启动即监控</div>
<div class="summary-value">{{ policy.runOnAppStart ? '是' : '否' }}</div>
</div>
<div class="summary-item">
<div class="summary-label">每日执行时间</div>
<div class="summary-value">{{ policy.dailyRunTime || '--' }}</div>
</div>
<div class="summary-item">
<div class="summary-label">监控目标数量</div>
<div class="summary-value">{{ monitorTargetCount }}</div>
</div>
<div class="summary-item">
<div class="summary-label">当前告警目标</div>
<div class="summary-value">{{ alarmCount }}</div>
</div>
<div class="summary-item">
<div class="summary-label">最近执行时间</div>
<div class="summary-value">{{ latestRunTime }}</div>
</div>
<div class="summary-item">
<div class="summary-label">最近执行状态</div>
<div class="summary-value">{{ latestJobStatus }}</div>
</div>
</div>
</div>
</template>
@@ -67,40 +69,47 @@ const latestJobStatus = computed(() => {
</script>
<style scoped lang="scss">
.disk-monitor-summary {
.disk-monitor-summary-card {
display: flex;
flex-direction: column;
gap: 0;
overflow: visible;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.summary-card {
.summary-item {
padding: 14px 16px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #ffffff;
background: var(--el-fill-color-lighter);
border-radius: 6px;
}
.summary-label {
margin-bottom: 8px;
font-size: 13px;
color: #6b7280;
color: var(--el-text-color-secondary);
}
.summary-value {
font-size: 20px;
line-height: 1.2;
font-weight: 600;
color: #111827;
color: var(--el-text-color-primary);
word-break: break-word;
}
@media (max-width: 992px) {
.disk-monitor-summary {
.summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.disk-monitor-summary {
.summary-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -1,78 +1,104 @@
<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="2"
@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-dialog
class="disk-monitor-dialog"
:model-value="props.visible"
:title="props.title"
:show-close="!props.disabled"
:close-on-click-modal="!props.disabled"
:close-on-press-escape="!props.disabled"
width="880px"
@close="closeDialog"
>
<el-form :disabled="props.disabled" label-width="120px" class="target-form">
<div class="dialog-section">
<div class="section-title">监控基础信息</div>
<el-form-item label="盘符">
<el-input
:model-value="props.modelValue.driveLetter"
placeholder="例如 C:"
maxlength="2"
@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="预警阈值">
<div class="threshold-field">
<el-input-number
:model-value="props.modelValue.warningUsagePercent"
:min="1"
:max="100"
:controls="false"
@update:model-value="handleWarningChange($event)"
/>
<span class="suffix-text">%</span>
</div>
</el-form-item>
<el-form-item label="告警阈值">
<div class="threshold-field">
<el-input-number
:model-value="props.modelValue.alarmUsagePercent"
:min="1"
:max="100"
:controls="false"
@update:model-value="handleAlarmChange($event)"
/>
<span class="suffix-text">%</span>
</div>
</el-form-item>
</div>
<div class="dialog-section">
<div class="section-title">通知配置</div>
<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" class="form-item--editor" 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" class="form-item--editor" label="HTTP 通知配置">
<NotificationHttpEditor
:model-value="props.modelValue.notifyHttpList"
@update:model-value="value => patchTarget({ notifyHttpList: value })"
/>
</el-form-item>
</div>
<div class="dialog-section">
<div class="section-title">补充说明</div>
<el-form-item class="form-item--remark" label="备注">
<el-input
type="textarea"
:rows="3"
:model-value="props.modelValue.remark"
placeholder="可选"
@update:model-value="value => patchTarget({ remark: String(value) })"
/>
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="closeDialog">取消</el-button>
<el-button type="primary" @click="emit('confirm')">确定</el-button>
<el-button :disabled="props.disabled" @click="closeDialog">取消</el-button>
<el-button type="primary" :loading="props.confirmLoading" :disabled="props.disabled" @click="emit('confirm')">
确定
</el-button>
</div>
</template>
</el-dialog>
@@ -87,11 +113,19 @@ defineOptions({
name: 'DiskMonitorTargetDialog'
})
const props = defineProps<{
visible: boolean
modelValue: DiskMonitor.TargetItem
title: string
}>()
const props = withDefaults(
defineProps<{
visible: boolean
modelValue: DiskMonitor.TargetItem
title: string
disabled?: boolean
confirmLoading?: boolean
}>(),
{
disabled: false,
confirmLoading: false
}
)
const emit = defineEmits<{
'update:visible': [value: boolean]
@@ -124,13 +158,65 @@ const patchTarget = (patch: Partial<DiskMonitor.TargetItem>) => {
</script>
<style scoped lang="scss">
.disk-monitor-dialog :deep(.el-dialog__body) {
display: flex;
flex-direction: column;
gap: 16px;
}
.target-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.dialog-section {
padding: 16px;
background: var(--el-fill-color-lighter);
border-radius: 6px;
}
.section-title {
margin-bottom: 14px;
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.target-form :deep(.el-form-item__content) {
align-items: flex-start;
}
.target-form :deep(.el-form-item) {
margin-bottom: 14px;
}
.target-form :deep(.el-form-item:last-child) {
margin-bottom: 0;
}
.threshold-field {
display: inline-flex;
align-items: center;
}
.threshold-field :deep(.el-input-number) {
width: 140px;
}
.suffix-text {
margin-left: 8px;
color: #6b7280;
color: var(--el-text-color-secondary);
}
.form-item--editor :deep(.el-form-item__content),
.form-item--remark :deep(.el-form-item__content) {
align-items: stretch;
}
.form-item--editor :deep(.el-form-item__content > *),
.form-item--remark :deep(.el-form-item__content > *) {
width: 100%;
}
.dialog-footer {

View File

@@ -1,62 +1,119 @@
<template>
<div class="target-table-card">
<div class="table-main card disk-monitor-table-card">
<div class="table-header">
<div>
<h3 class="table-title">监控目标</h3>
<p class="table-description">维护需要监控的盘符与通知目标</p>
<div class="header-button-lf">
<el-button type="primary" :icon="CirclePlus" :disabled="props.disabled" @click="emit('add')">
新增目标
</el-button>
</div>
<div class="header-button-ri table-tools">
<el-button circle :icon="Refresh" :disabled="props.disabled" @click="emit('refresh')" />
<el-popover trigger="click" placement="bottom-end" :width="220">
<div class="column-setting-panel">
<el-checkbox v-for="column in columnOptions" :key="column.key" v-model="column.visible">
{{ column.label }}
</el-checkbox>
</div>
<template #reference>
<el-button circle :icon="Operation" />
</template>
</el-popover>
<el-button circle :icon="Search" @click="showSearch = !showSearch" />
</div>
<el-button type="primary" :disabled="props.disabled" @click="emit('add')">新增目标</el-button>
</div>
<el-table :data="props.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" :disabled="props.disabled" @click="emit('edit', row, $index)">
编辑
</el-button>
<el-button link type="danger" :disabled="props.disabled" @click="emit('remove', $index)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div v-show="showSearch" class="table-search">
<el-form label-width="80px" class="disk-monitor-search-form">
<div class="search-grid">
<el-form-item label="盘符">
<el-input v-model="searchForm.driveLetter" clearable placeholder="请输入盘符" />
</el-form-item>
<el-form-item label="当前状态">
<el-select v-model="searchForm.lastStatus" clearable placeholder="请选择状态">
<el-option label="正常" value="NORMAL" />
<el-option label="预警" value="WARNING" />
<el-option label="告警" value="ALARM" />
<el-option label="未知" value="UNKNOWN" />
</el-select>
</el-form-item>
</div>
<div class="operation">
<el-button @click="resetSearch">重置</el-button>
</div>
</el-form>
</div>
<div class="disk-monitor-table-body">
<el-table class="disk-monitor-el-table" :data="filteredRows" border stripe height="100%">
<el-table-column
v-if="isColumnVisible('driveLetter')"
prop="driveLetter"
label="盘符"
min-width="90"
align="center"
/>
<el-table-column v-if="isColumnVisible('monitorEnabled')" label="是否监控" min-width="100" align="center">
<template #default="{ row }">
{{ row.monitorEnabled ? '是' : '否' }}
</template>
</el-table-column>
<el-table-column v-if="isColumnVisible('warningUsagePercent')" label="预警使用率" min-width="110" align="center">
<template #default="{ row }">
{{ row.warningUsagePercent }}%
</template>
</el-table-column>
<el-table-column v-if="isColumnVisible('alarmUsagePercent')" label="告警使用率" min-width="110" align="center">
<template #default="{ row }">
{{ row.alarmUsagePercent }}%
</template>
</el-table-column>
<el-table-column v-if="isColumnVisible('lastStatus')" label="当前状态" min-width="110" align="center">
<template #default="{ row }">
<el-tag :type="getStatusType(row.lastStatus)" effect="light">
{{ getStatusLabel(row.lastStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column v-if="isColumnVisible('lastScanTime')" label="最近扫描时间" min-width="170" align="center">
<template #default="{ row }">
{{ formatScanTime(row.lastScanTime) }}
</template>
</el-table-column>
<el-table-column v-if="isColumnVisible('lastUsedPercent')" label="最近使用率" min-width="120" align="center">
<template #default="{ row }">
{{ formatUsedPercent(row.lastUsedPercent) }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right" align="center">
<template #default="{ row, $index }">
<el-button
link
type="primary"
:icon="EditPen"
:disabled="props.disabled"
@click="emit('edit', row, $index)"
>
编辑
</el-button>
<el-button
link
type="primary"
:icon="Delete"
:disabled="props.disabled"
@click="emit('remove', $index)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { CirclePlus, Delete, EditPen, Operation, Refresh, Search } from '@element-plus/icons-vue'
import dayjs from 'dayjs'
import type { DiskMonitor } from '@/api/system/diskMonitor/interface'
@@ -78,8 +135,63 @@ const emit = defineEmits<{
add: []
edit: [row: DiskMonitor.TargetItem, index: number]
remove: [index: number]
refresh: []
}>()
type TargetColumnKey =
| 'driveLetter'
| 'monitorEnabled'
| 'warningUsagePercent'
| 'alarmUsagePercent'
| 'lastStatus'
| 'lastScanTime'
| 'lastUsedPercent'
const showSearch = ref(false)
const searchForm = reactive({
driveLetter: '',
lastStatus: ''
})
const columnOptions = reactive<{ key: TargetColumnKey; label: string; visible: boolean }[]>([
{ key: 'driveLetter', label: '盘符', visible: true },
{ key: 'monitorEnabled', label: '是否监控', visible: true },
{ key: 'warningUsagePercent', label: '预警使用率', visible: true },
{ key: 'alarmUsagePercent', label: '告警使用率', visible: true },
{ key: 'lastStatus', label: '当前状态', visible: true },
{ key: 'lastScanTime', label: '最近扫描时间', visible: true },
{ key: 'lastUsedPercent', label: '最近使用率', visible: true }
])
const includesKeyword = (value?: string | number | null, keyword?: string) => {
const normalizedKeyword = String(keyword || '')
.trim()
.toLowerCase()
if (!normalizedKeyword) return true
return String(value ?? '')
.trim()
.toLowerCase()
.includes(normalizedKeyword)
}
const isColumnVisible = (key: TargetColumnKey) => {
return columnOptions.find(column => column.key === key)?.visible ?? true
}
const filteredRows = computed(() => {
return props.rows.filter(row => {
const matchesDriveLetter = includesKeyword(row.driveLetter, searchForm.driveLetter)
const selectedStatus = searchForm.lastStatus
const actualStatus = row.lastStatus || 'UNKNOWN'
const matchesStatus = !selectedStatus || actualStatus === selectedStatus
return matchesDriveLetter && matchesStatus
})
})
const resetSearch = () => {
searchForm.driveLetter = ''
searchForm.lastStatus = ''
}
const getStatusType = (status: DiskMonitor.MonitorStatus) => {
if (status === 'NORMAL') return 'success'
if (status === 'WARNING') return 'warning'
@@ -106,32 +218,67 @@ const formatUsedPercent = (value?: number | null) => {
</script>
<style scoped lang="scss">
.target-table-card {
.disk-monitor-table-card {
display: flex;
flex: 1;
flex-direction: column;
gap: 14px;
padding: 16px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #ffffff;
min-height: 0;
overflow: hidden;
}
.table-header {
display: flow-root;
flex: none;
}
.table-tools {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.table-title {
margin: 0 0 6px;
font-size: 18px;
color: #111827;
.column-setting-panel {
display: flex;
flex-direction: column;
gap: 10px;
}
.table-description {
margin: 0;
font-size: 13px;
color: #6b7280;
.search-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 12px;
}
.disk-monitor-table-body {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
.disk-monitor-el-table {
flex: 1;
min-height: 0;
}
.disk-monitor-table-body :deep(.el-table__inner-wrapper) {
height: 100%;
}
@media (max-width: 768px) {
.table-header {
display: flex;
flex-direction: column;
gap: 12px;
}
.header-button-lf,
.header-button-ri {
float: none;
}
.search-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -2,7 +2,7 @@
<div class="notification-editor">
<div class="editor-header">
<span class="editor-title">HTTP 通知目标</span>
<el-button type="primary" link @click="handleAdd">新增 HTTP 目标</el-button>
<el-button type="primary" plain size="small" :icon="CirclePlus" @click="handleAdd">新增 HTTP 目标</el-button>
</div>
<div v-if="!props.modelValue.length" class="empty-text">暂无 HTTP 通知目标</div>
<div v-else class="editor-list">
@@ -34,13 +34,14 @@
:model-value="item.enabled"
@update:model-value="value => patchRow(index, 'enabled', Boolean(value))"
/>
<el-button type="danger" link @click="handleRemove(index)">删除</el-button>
<el-button type="primary" link :icon="Delete" @click="handleRemove(index)">删除</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { CirclePlus, Delete } from '@element-plus/icons-vue'
import type { DiskMonitor } from '@/api/system/diskMonitor/interface'
import { createEmptyHttpTarget } from '../utils/form'
@@ -95,22 +96,34 @@ const patchRow = <K extends keyof DiskMonitor.NotifyHttpTarget>(
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
padding: 14px 16px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
}
.editor-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.editor-title {
font-size: 14px;
color: #111827;
font-weight: 600;
color: var(--el-text-color-primary);
}
.empty-text {
padding: 14px 16px;
font-size: 13px;
color: #9ca3af;
color: var(--el-text-color-secondary);
background: var(--el-fill-color-lighter);
border: 1px dashed var(--el-border-color);
border-radius: 6px;
}
.editor-list {
@@ -124,6 +137,15 @@ const patchRow = <K extends keyof DiskMonitor.NotifyHttpTarget>(
grid-template-columns: minmax(0, 1fr) minmax(0, 1.6fr) 100px 120px auto auto;
gap: 10px;
align-items: center;
padding: 12px;
background: var(--el-fill-color-lighter);
border-radius: 6px;
}
.editor-row :deep(.el-input),
.editor-row :deep(.el-select),
.editor-row :deep(.el-input-number) {
width: 100%;
}
@media (max-width: 992px) {

View File

@@ -2,7 +2,7 @@
<div class="notification-editor">
<div class="editor-header">
<span class="editor-title">路径通知目标</span>
<el-button type="primary" link @click="handleAdd">新增路径</el-button>
<el-button type="primary" plain size="small" :icon="CirclePlus" @click="handleAdd">新增路径</el-button>
</div>
<div v-if="!props.modelValue.length" class="empty-text">暂无路径通知目标</div>
<div v-else class="editor-list">
@@ -21,13 +21,14 @@
:model-value="item.enabled"
@update:model-value="value => patchRow(index, 'enabled', Boolean(value))"
/>
<el-button type="danger" link @click="handleRemove(index)">删除</el-button>
<el-button type="primary" link :icon="Delete" @click="handleRemove(index)">删除</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { CirclePlus, Delete } from '@element-plus/icons-vue'
import type { DiskMonitor } from '@/api/system/diskMonitor/interface'
import { createEmptyPathTarget } from '../utils/form'
@@ -77,22 +78,34 @@ const patchRow = <K extends keyof DiskMonitor.NotifyPathTarget>(
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
padding: 14px 16px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
}
.editor-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.editor-title {
font-size: 14px;
color: #111827;
font-weight: 600;
color: var(--el-text-color-primary);
}
.empty-text {
padding: 14px 16px;
font-size: 13px;
color: #9ca3af;
color: var(--el-text-color-secondary);
background: var(--el-fill-color-lighter);
border: 1px dashed var(--el-border-color);
border-radius: 6px;
}
.editor-list {
@@ -106,6 +119,13 @@ const patchRow = <K extends keyof DiskMonitor.NotifyPathTarget>(
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) auto auto;
gap: 10px;
align-items: center;
padding: 12px;
background: var(--el-fill-color-lighter);
border-radius: 6px;
}
.editor-row :deep(.el-input) {
width: 100%;
}
@media (max-width: 768px) {

View File

@@ -1,40 +1,61 @@
<template>
<div v-loading="loading.init" class="table-box disk-monitor-page">
<div class="page-header">
<div class="header-main">
<el-button class="back-button" link type="primary" @click="handleBack">返回系统监控</el-button>
<h2 class="page-title">磁盘监控</h2>
<p class="page-description">查看当前策略状态并维护全局执行配置</p>
<div class="disk-monitor-summary-section">
<DiskMonitorSummary :policy="policyForm" :targets="targetList" :latest-job="latestJob" />
<div class="disk-monitor-actions">
<el-button type="primary" plain :icon="Setting" :disabled="formBusy" @click="openPolicyDialog">
全局策略
</el-button>
</div>
</div>
<DiskMonitorSummary :policy="policyForm" :targets="targetList" :latest-job="latestJob" />
<DiskMonitorPolicyForm
v-model="policyForm"
<DiskMonitorPolicyDialog
v-model:visible="policyDialogVisible"
v-model="editingPolicy"
:disabled="formBusy"
:save-loading="loading.save"
:run-loading="loading.run"
@save="handleSave"
@confirm="confirmPolicy"
@run="handleRun"
/>
<DiskMonitorTargetTable
:rows="targetList"
:disabled="formBusy"
@add="openAddTarget"
@edit="openEditTarget"
@remove="removeTarget"
/>
<DiskMonitorTargetDialog
v-model:visible="targetDialogVisible"
v-model="editingTarget"
:title="editingTargetIndex >= 0 ? '编辑监控目标' : '新增监控目标'"
:disabled="formBusy"
:confirm-loading="loading.save"
@confirm="confirmTarget"
/>
<DiskMonitorJobTable :rows="jobList" :loading="loading.jobs" @refresh="loadJobList" @detail="openJobDetail" />
<section class="disk-monitor-tabs-card">
<el-tabs v-model="activeTab" class="disk-monitor-tabs">
<el-tab-pane label="监控记录" name="jobs">
<div class="disk-monitor-tab-panel">
<DiskMonitorJobTable
:rows="jobList"
:loading="loading.jobs"
@refresh="loadJobList"
@detail="openJobDetail"
/>
</div>
</el-tab-pane>
<el-tab-pane label="监测目标" name="targets">
<div class="disk-monitor-tab-panel">
<DiskMonitorTargetTable
:rows="targetList"
:disabled="formBusy"
@add="openAddTarget"
@edit="openEditTarget"
@remove="removeTarget"
@refresh="loadPageData"
/>
</div>
</el-tab-pane>
</el-tabs>
</section>
<DiskMonitorJobDetailDrawer
:visible="jobDetailVisible"
:detail="jobDetail"
@@ -47,7 +68,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { Setting } from '@element-plus/icons-vue'
import {
getDiskMonitorJobDetail,
getDiskMonitorJobList,
@@ -58,7 +79,7 @@ import {
import type { DiskMonitor } from '@/api/system/diskMonitor/interface'
import DiskMonitorJobDetailDrawer from './components/DiskMonitorJobDetailDrawer.vue'
import DiskMonitorJobTable from './components/DiskMonitorJobTable.vue'
import DiskMonitorPolicyForm from './components/DiskMonitorPolicyForm.vue'
import DiskMonitorPolicyDialog from './components/DiskMonitorPolicyDialog.vue'
import DiskMonitorSummary from './components/DiskMonitorSummary.vue'
import DiskMonitorTargetDialog from './components/DiskMonitorTargetDialog.vue'
import DiskMonitorTargetTable from './components/DiskMonitorTargetTable.vue'
@@ -76,9 +97,11 @@ defineOptions({
name: 'DiskMonitorPage'
})
const router = useRouter()
type DiskMonitorTab = 'targets' | 'jobs'
const policyForm = ref<DiskMonitor.PolicyItem>(createDefaultPolicy())
const policyDialogVisible = ref(false)
const editingPolicy = ref<DiskMonitor.PolicyItem>(createDefaultPolicy())
const targetList = ref<DiskMonitor.TargetItem[]>([])
const targetDialogVisible = ref(false)
const editingTargetIndex = ref(-1)
@@ -89,6 +112,7 @@ const jobDetailVisible = ref(false)
const jobDetail = ref<DiskMonitor.JobDetailData | null>(null)
const detailLoading = ref(false)
const jobDetailRequestSeq = ref(0)
const activeTab = ref<DiskMonitorTab>('jobs')
const loading = reactive({
init: false,
save: false,
@@ -107,9 +131,10 @@ const getTimeValue = (value?: string | null) => {
return Number.isNaN(timestamp) ? 0 : timestamp
}
const handleBack = async () => {
await router.push('/systemMonitor')
}
const clonePolicy = (policy: DiskMonitor.PolicyItem): DiskMonitor.PolicyItem => ({
...createDefaultPolicy(),
...policy
})
const cloneTarget = (target: DiskMonitor.TargetItem): DiskMonitor.TargetItem => {
const normalized = normalizeTargetItem(target)
@@ -120,6 +145,12 @@ const cloneTarget = (target: DiskMonitor.TargetItem): DiskMonitor.TargetItem =>
}
}
const openPolicyDialog = () => {
// 打开弹窗时复制当前策略,避免取消时污染页面已加载状态。
editingPolicy.value = clonePolicy(policyForm.value)
policyDialogVisible.value = true
}
const openAddTarget = () => {
editingTargetIndex.value = -1
editingTarget.value = createEmptyTarget()
@@ -128,13 +159,14 @@ const openAddTarget = () => {
const openEditTarget = (row: DiskMonitor.TargetItem, index: number) => {
editingTargetIndex.value = index
// 编辑时克隆当前行,避免未确认前直接污染列表数据
// 编辑时克隆当前行,避免未确认前直接改动表格数据
editingTarget.value = cloneTarget(normalizeTargetItem(row))
targetDialogVisible.value = true
}
const confirmTarget = () => {
// 提交前统一规范盘符并做去重、阈值与通知配置校验
if (formBusy.value) return
const normalizedDriveLetter = editingTarget.value.driveLetter.trim().toUpperCase()
const payload: DiskMonitor.TargetItem = {
...normalizeTargetItem(editingTarget.value),
@@ -156,16 +188,25 @@ const confirmTarget = () => {
return
}
const nextTargetList = targetList.value.map(item => cloneTarget(item))
if (editingTargetIndex.value >= 0) {
targetList.value.splice(editingTargetIndex.value, 1, payload)
nextTargetList.splice(editingTargetIndex.value, 1, payload)
} else {
targetList.value.push(payload)
nextTargetList.push(payload)
}
// 目标配置先保留在页面本地状态里,统一通过“全局策略”弹窗中的保存入口提交。
targetList.value = nextTargetList.map(item => cloneTarget(item))
targetDialogVisible.value = false
}
const removeTarget = (index: number) => {
targetList.value.splice(index, 1)
if (formBusy.value) return
// 删除目标时仅更新本地暂存列表,避免样式调整顺带改成“操作即落库”。
targetList.value = targetList.value
.filter((_, currentIndex) => currentIndex !== index)
.map(item => cloneTarget(item))
}
const loadPolicyDetail = async () => {
@@ -177,14 +218,14 @@ const loadPolicyDetail = async () => {
...createDefaultPolicy(),
...(detail.policy || {})
}
// 后端列表字段允许为空,这里统一归一化为数组,避免编辑器和克隆流程出现空引用
// 后端列表字段允许为空,这里统一归一化为数组,避免编辑器和克隆流程出现空引用
targetList.value = (detail.targets || []).map(item => normalizeTargetItem(item))
}
const loadJobList = async () => {
loading.jobs = true
try {
// 统一拉取最近任务列表,并按 startedAt 倒序确保摘要展示真实最新任务
// 统一按 startedAt 倒序拉取任务,确保顶部摘要展示最新一条记录。
const response = await getDiskMonitorJobList({
pageNum: 1,
pageSize: 100,
@@ -198,7 +239,6 @@ const loadJobList = async () => {
const sortedRecords = [...records].sort((a, b) => {
return getTimeValue(b.startedAt) - getTimeValue(a.startedAt)
})
// 前端保留 startedAt 的兜底排序,同时通过显式排序参数要求后端返回真正的最新任务。
jobList.value = sortedRecords.slice(0, 10)
latestJob.value = jobList.value[0] || null
} finally {
@@ -209,7 +249,7 @@ const loadJobList = async () => {
const handleJobDetailVisibleChange = (visible: boolean) => {
jobDetailVisible.value = visible
if (!visible) {
// 抽屉关闭时旧请求全部失效,避免回写已关闭的详情状态
// 抽屉关闭时使旧请求失效,避免晚到数据回写已关闭的详情面板。
jobDetailRequestSeq.value += 1
detailLoading.value = false
jobDetail.value = null
@@ -230,7 +270,7 @@ const openJobDetail = async (row: DiskMonitor.JobListItem) => {
detailLoading.value = true
try {
// 仅允许最一次详情请求回写,避免快速切换任务导致脏数据覆盖
// 仅允许最一次详情请求回写,避免快速切换任务时详情串台。
const response = await getDiskMonitorJobDetail(jobId)
if (currentSeq !== jobDetailRequestSeq.value || !jobDetailVisible.value) return
jobDetail.value = response.data || null
@@ -244,58 +284,85 @@ const openJobDetail = async (row: DiskMonitor.JobListItem) => {
const loadPageData = async () => {
loading.init = true
try {
// 页面刷新入口单一化:统一并行加载策略与最近任务
// 页面刷新统一并行拉取策略与任务列表,减少顶部和 tab 区的状态抖动。
await Promise.all([loadPolicyDetail(), loadJobList()])
} finally {
loading.init = false
}
}
const handleSave = async () => {
if (formBusy.value) return
const errorMessage = validatePolicy(policyForm.value)
const persistPolicyAndTargets = async (
policy: DiskMonitor.PolicyItem,
targets: DiskMonitor.TargetItem[] = targetList.value,
successMessage: string | null = '配置保存成功'
) => {
const errorMessage = validatePolicy(policy)
if (errorMessage) {
ElMessage.warning(errorMessage)
return
return false
}
const normalizedTargets = targetList.value.map(item => ({
const normalizedPolicy = clonePolicy(policy)
const normalizedTargets = targets.map(item => ({
...normalizeTargetItem(item),
driveLetter: item.driveLetter.trim().toUpperCase()
}))
const targetListErrorMessage = validateTargetList(normalizedTargets)
if (targetListErrorMessage) {
ElMessage.warning(targetListErrorMessage)
return
return false
}
loading.save = true
try {
// 整页保存前再次规范化所有盘符配置,避免历史脏数据绕过弹窗校验链路。
targetList.value = normalizedTargets
await saveDiskMonitorPolicy({
policy: policyForm.value,
policy: normalizedPolicy,
targets: normalizedTargets
})
ElMessage.success('配置保存成功')
// 保存完成后重新拉取数据,避免本地状态与服务端策略偏差
await loadPageData()
// 仅在服务端保存成功后回写本地状态,避免页面误以为已持久化。
policyForm.value = normalizedPolicy
targetList.value = normalizedTargets.map(item => cloneTarget(item))
if (successMessage) {
ElMessage.success(successMessage)
}
try {
await loadPageData()
} catch {
// 保存已成功时保留当前状态,后续刷新失败交由全局请求错误处理。
}
return true
} catch {
return false
} finally {
loading.save = false
}
}
const confirmPolicy = async () => {
if (formBusy.value) return
const saved = await persistPolicyAndTargets(clonePolicy(editingPolicy.value))
if (saved) {
policyDialogVisible.value = false
}
}
const handleRun = async () => {
if (formBusy.value) return
if (policyDialogVisible.value) {
// 弹窗内执行监控前先落当前策略草稿,避免运行的还是上一次已保存配置。
const saved = await persistPolicyAndTargets(clonePolicy(editingPolicy.value), targetList.value, null)
if (!saved) return
policyDialogVisible.value = false
}
loading.run = true
try {
await runDiskMonitorJob({
jobSource: 'MANUAL'
})
ElMessage.success('监控任务已启动')
// 手动触发任务后通过页面统一刷新流更新摘要和任务列表
await loadPageData()
} finally {
loading.run = false
@@ -312,35 +379,90 @@ onMounted(async () => {
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px;
width: 100%;
height: 100%;
min-height: 0;
// 页面根节点跟随主内容区边距,不再额外叠加页面级外边距。
padding: 0;
overflow: hidden;
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
.disk-monitor-summary-section {
position: relative;
flex: none;
}
.header-main {
.disk-monitor-summary-section :deep(.disk-monitor-summary-card) {
padding-bottom: 36px;
}
.disk-monitor-actions {
display: flex;
position: absolute;
right: 20px;
top: calc(50% + 44px);
z-index: 1;
}
.disk-monitor-tabs-card {
display: flex;
flex: 1;
min-height: 0;
padding: 16px;
overflow: hidden;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 4px;
}
.disk-monitor-tabs {
display: flex;
flex: 1;
flex-direction: column;
gap: 6px;
width: 100%;
min-height: 0;
overflow: hidden;
}
.back-button {
align-self: flex-start;
padding-left: 0;
.disk-monitor-tabs :deep(.el-tabs__header) {
margin-bottom: 12px;
}
.page-title {
margin: 0;
font-size: 22px;
color: #111827;
.disk-monitor-tabs :deep(.el-tabs__nav-wrap::after) {
background-color: var(--el-border-color-light);
}
.page-description {
margin: 0;
.disk-monitor-tabs :deep(.el-tabs__content) {
flex: 1;
min-height: 0;
overflow: hidden;
}
.disk-monitor-tabs :deep(.el-tab-pane) {
display: flex;
height: 100%;
min-height: 0;
}
.disk-monitor-tabs :deep(.el-tabs__item) {
font-size: 13px;
color: #6b7280;
}
.disk-monitor-tab-panel {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
@media (max-width: 768px) {
.disk-monitor-tabs :deep(.el-tabs__nav) {
width: 100%;
}
.disk-monitor-tabs :deep(.el-tabs__item) {
flex: 1;
justify-content: center;
}
}
</style>

View File

@@ -18,6 +18,11 @@
<div class="tool-name">MMS 映射</div>
<div class="tool-text">进入 MMS 映射页面后续可继续补充映射配置和预览能力</div>
</button>
<button class="tool-item" type="button" @click="handleNavigate('/tools/addData')">
<div class="tool-name">addData</div>
<div class="tool-text">进入 addData 页面壳子后续在此扩展补录数据能力和业务交互</div>
</button>
</div>
</div>
</div>