新建监控功能
This commit is contained in:
@@ -0,0 +1,988 @@
|
||||
# Disk Monitor Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 在当前仓库内完成磁盘监控页面、前端 API 契约、数据库 SQL 交付文件和手工验证入口,为后端接入完整磁盘监控能力提供可直接联调的前端实现基础。
|
||||
|
||||
**Architecture:** 以前端单页容器 `frontend/src/views/systemMonitor/diskMonitor/index.vue` 负责编排状态、加载配置、保存配置、触发手动执行和展示历史结果;页面拆分为摘要卡片、全局策略表单、盘符编辑器、通知编辑器、任务历史与详情抽屉。后端按已确认的规格提供 `/disk-monitor/**` 接口,本仓库额外产出一份 `doc/系统磁盘监控数据库设计.sql` 作为数据库交付物。该计划不在当前仓库内实现真实磁盘扫描、定时器和通知发送逻辑,只实现页面、契约和 SQL 文件。
|
||||
|
||||
**Tech Stack:** Vue 3 `<script setup>`, TypeScript, Element Plus, Axios 封装 `frontend/src/api/index.ts`, Vue Router, ESLint, `vue-tsc`, MySQL SQL 文件
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
- Modify: `D:\Work\SourceCode\CN_Tool_client\frontend\src\routers\modules\staticRouter.ts`
|
||||
Purpose: 增加 `systemMonitor` 与 `diskMonitor` 的静态路由兜底,保证 `/#/systemMonitor/diskMonitor` 可直接访问。此文件当前已有用户改动,执行前必须先读 diff 并只做外科式追加。
|
||||
- Create: `D:\Work\SourceCode\CN_Tool_client\frontend\src\api\system\diskMonitor\interface\index.ts`
|
||||
Purpose: 定义磁盘监控页面所需的策略、盘符、通知目标、任务列表、任务详情等前端类型。
|
||||
- Create: `D:\Work\SourceCode\CN_Tool_client\frontend\src\api\system\diskMonitor\index.ts`
|
||||
Purpose: 封装 `/disk-monitor/policy/detail`、`/disk-monitor/policy/save`、`/disk-monitor/job/run`、`/disk-monitor/job/list`、`/disk-monitor/job/{id}/detail`、`/disk-monitor/notify/test` 接口。
|
||||
- Create: `D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\utils\form.ts`
|
||||
Purpose: 维护默认表单、空盘符模板、空通知目标模板和同步校验函数。
|
||||
- Create: `D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\components\DiskMonitorSummary.vue`
|
||||
Purpose: 展示监控总开关、执行时间、最近任务、盘符数量、告警数量等摘要信息。
|
||||
- Create: `D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\components\DiskMonitorPolicyForm.vue`
|
||||
Purpose: 编辑全局策略,展示固定的通知规则说明,并暴露保存/立即执行操作。
|
||||
- Create: `D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\components\NotificationPathEditor.vue`
|
||||
Purpose: 编辑单个盘符下的本地目录/网络路径通知目标数组。
|
||||
- Create: `D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\components\NotificationHttpEditor.vue`
|
||||
Purpose: 编辑单个盘符下的 HTTP 回调目标数组。
|
||||
- Create: `D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\components\DiskMonitorTargetDialog.vue`
|
||||
Purpose: 盘符新增/编辑弹窗,内含阈值字段和两种通知编辑器。
|
||||
- Create: `D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\components\DiskMonitorTargetTable.vue`
|
||||
Purpose: 展示盘符列表并提供新增、编辑、删除入口。
|
||||
- Create: `D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\components\DiskMonitorJobTable.vue`
|
||||
Purpose: 展示最近任务列表,并暴露刷新和查看详情事件。
|
||||
- Create: `D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\components\DiskMonitorJobDetailDrawer.vue`
|
||||
Purpose: 展示某次任务下的盘符结果和通知日志。
|
||||
- Modify: `D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\index.vue`
|
||||
Purpose: 用页面级状态编排替换占位内容,连接所有组件和 API。
|
||||
- Create: `D:\Work\SourceCode\CN_Tool_client\doc\系统磁盘监控数据库设计.sql`
|
||||
Purpose: 交付五张表的建表 SQL,内容与已批准规格保持一致。
|
||||
|
||||
## Task 1: Add Route Fallback, API Contracts, And SQL Artifact
|
||||
|
||||
**Files:**
|
||||
- Modify: `D:\Work\SourceCode\CN_Tool_client\frontend\src\routers\modules\staticRouter.ts`
|
||||
- Create: `D:\Work\SourceCode\CN_Tool_client\frontend\src\api\system\diskMonitor\interface\index.ts`
|
||||
- Create: `D:\Work\SourceCode\CN_Tool_client\frontend\src\api\system\diskMonitor\index.ts`
|
||||
- Create: `D:\Work\SourceCode\CN_Tool_client\doc\系统磁盘监控数据库设计.sql`
|
||||
|
||||
- [ ] **Step 1: Review the existing router diff before touching the file**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
git diff -- D:\Work\SourceCode\CN_Tool_client\frontend\src\routers\modules\staticRouter.ts
|
||||
```
|
||||
|
||||
Expected: 只看当前已有改动,确认后续只追加磁盘监控路由,不覆盖用户其他改动。
|
||||
|
||||
- [ ] **Step 2: Create the disk monitor API interface namespace**
|
||||
|
||||
Create `D:\Work\SourceCode\CN_Tool_client\frontend\src\api\system\diskMonitor\interface\index.ts` with:
|
||||
|
||||
```ts
|
||||
import type { ReqPage, ResPage } from '@/api/interface'
|
||||
|
||||
export namespace DiskMonitor {
|
||||
export type MonitorStatus = 'UNKNOWN' | 'NORMAL' | 'WARNING' | 'ALARM'
|
||||
export type NotifyMode = 'STATUS_CHANGE' | 'EVERY_TIME'
|
||||
export type JobSource = 'APP_START' | 'DAILY_SCHEDULE' | 'MANUAL'
|
||||
export type JobStatus = 'RUNNING' | 'SUCCESS' | 'PARTIAL_SUCCESS' | 'FAILED'
|
||||
export type NotifyLevel = 'WARNING' | 'ALARM' | 'RECOVER'
|
||||
export type NotifyChannelType = 'PATH' | 'HTTP'
|
||||
export type NotifySendStatus = 'SUCCESS' | 'FAILED'
|
||||
|
||||
export interface NotifyPathTarget {
|
||||
path: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface NotifyHttpTarget {
|
||||
url: string
|
||||
name: string
|
||||
method: 'POST'
|
||||
timeoutMs: number
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface PolicyItem {
|
||||
id?: number
|
||||
policyName: string
|
||||
monitorEnabled: boolean
|
||||
runOnAppStart: boolean
|
||||
dailyRunTime: string
|
||||
warningNotifyMode: NotifyMode
|
||||
alarmNotifyMode: NotifyMode
|
||||
lastJobId?: number | null
|
||||
remark: string
|
||||
}
|
||||
|
||||
export interface TargetItem {
|
||||
id?: number
|
||||
policyId?: number
|
||||
driveLetter: string
|
||||
monitorEnabled: boolean
|
||||
warningUsagePercent: number
|
||||
alarmUsagePercent: number
|
||||
notifyPathEnabled: boolean
|
||||
notifyPathList: NotifyPathTarget[]
|
||||
notifyHttpEnabled: boolean
|
||||
notifyHttpList: NotifyHttpTarget[]
|
||||
lastStatus: MonitorStatus
|
||||
lastScanTime?: string | null
|
||||
lastUsedPercent?: number | null
|
||||
remark: string
|
||||
}
|
||||
|
||||
export interface PolicyDetailData {
|
||||
policy: PolicyItem
|
||||
targets: TargetItem[]
|
||||
}
|
||||
|
||||
export interface SavePolicyParams {
|
||||
policy: PolicyItem
|
||||
targets: TargetItem[]
|
||||
}
|
||||
|
||||
export interface RunJobParams {
|
||||
jobSource: 'MANUAL'
|
||||
}
|
||||
|
||||
export interface RunJobResult {
|
||||
jobId: number
|
||||
jobNo: string
|
||||
}
|
||||
|
||||
export interface JobListParams extends ReqPage {}
|
||||
|
||||
export interface JobListItem {
|
||||
id: number
|
||||
jobNo: string
|
||||
jobSource: JobSource
|
||||
startedAt: string
|
||||
finishedAt?: string | null
|
||||
jobStatus: JobStatus
|
||||
targetCount: number
|
||||
warningCount: number
|
||||
alarmCount: number
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface ResultItem {
|
||||
resultId: number
|
||||
targetId: number
|
||||
driveLetter: string
|
||||
totalBytes: number
|
||||
usedBytes: number
|
||||
freeBytes: number
|
||||
usedPercent: number
|
||||
currentStatus: MonitorStatus
|
||||
previousStatus: MonitorStatus
|
||||
statusChanged: boolean
|
||||
shouldNotify: boolean
|
||||
notifyReason: 'ALARM_EVERY_TIME' | 'STATUS_CHANGED' | 'NO_NOTIFY'
|
||||
scanTime: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface NotifyLogItem {
|
||||
id: number
|
||||
resultId: number
|
||||
driveLetter: string
|
||||
notifyLevel: NotifyLevel
|
||||
channelType: NotifyChannelType
|
||||
channelTarget: string
|
||||
sendStatus: NotifySendStatus
|
||||
responseMessage?: string
|
||||
sentAt: string
|
||||
}
|
||||
|
||||
export interface JobDetailData {
|
||||
job: JobListItem
|
||||
results: ResultItem[]
|
||||
notifyLogs: NotifyLogItem[]
|
||||
}
|
||||
|
||||
export interface NotifyTestParams {
|
||||
driveLetter: string
|
||||
}
|
||||
|
||||
export interface JobPageData extends ResPage<JobListItem> {}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create the API wrapper module**
|
||||
|
||||
Create `D:\Work\SourceCode\CN_Tool_client\frontend\src\api\system\diskMonitor\index.ts` with:
|
||||
|
||||
```ts
|
||||
import http from '@/api'
|
||||
import type { DiskMonitor } from '@/api/system/diskMonitor/interface'
|
||||
|
||||
export const getDiskMonitorPolicyDetail = () => {
|
||||
return http.get<DiskMonitor.PolicyDetailData>('/disk-monitor/policy/detail')
|
||||
}
|
||||
|
||||
export const saveDiskMonitorPolicy = (params: DiskMonitor.SavePolicyParams) => {
|
||||
return http.post('/disk-monitor/policy/save', params)
|
||||
}
|
||||
|
||||
export const runDiskMonitorJob = (params: DiskMonitor.RunJobParams) => {
|
||||
return http.post<DiskMonitor.RunJobResult>('/disk-monitor/job/run', params)
|
||||
}
|
||||
|
||||
export const getDiskMonitorJobList = (params: DiskMonitor.JobListParams) => {
|
||||
return http.post<DiskMonitor.JobPageData>('/disk-monitor/job/list', params)
|
||||
}
|
||||
|
||||
export const getDiskMonitorJobDetail = (jobId: number) => {
|
||||
return http.get<DiskMonitor.JobDetailData>(`/disk-monitor/job/${jobId}/detail`)
|
||||
}
|
||||
|
||||
export const testDiskMonitorNotify = (params: DiskMonitor.NotifyTestParams) => {
|
||||
return http.post('/disk-monitor/notify/test', params)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add static route fallbacks for local verification**
|
||||
|
||||
Append the following two children inside the existing `layout` route in `D:\Work\SourceCode\CN_Tool_client\frontend\src\routers\modules\staticRouter.ts` without rewriting unrelated lines:
|
||||
|
||||
```ts
|
||||
{
|
||||
path: '/systemMonitor',
|
||||
name: 'systemMonitor',
|
||||
component: () => import('@/views/systemMonitor/index.vue'),
|
||||
meta: {
|
||||
title: '系统监控'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/systemMonitor/diskMonitor',
|
||||
name: 'diskMonitor',
|
||||
component: () => import('@/views/systemMonitor/diskMonitor/index.vue'),
|
||||
meta: {
|
||||
title: '磁盘监控'
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Create the SQL delivery file from the approved spec**
|
||||
|
||||
Create `D:\Work\SourceCode\CN_Tool_client\doc\系统磁盘监控数据库设计.sql` and copy the exact five `CREATE TABLE` statements from `D:\Work\SourceCode\CN_Tool_client\docs\superpowers\specs\2026-04-22-disk-monitor-design.md` section `5.6 MySQL 建表 SQL`, preserving:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS `disk_monitor_policy` ( ... );
|
||||
CREATE TABLE IF NOT EXISTS `disk_monitor_target` ( ... );
|
||||
CREATE TABLE IF NOT EXISTS `disk_monitor_job` ( ... );
|
||||
CREATE TABLE IF NOT EXISTS `disk_monitor_result` ( ... );
|
||||
CREATE TABLE IF NOT EXISTS `disk_monitor_notify_log` ( ... );
|
||||
```
|
||||
|
||||
Expected: `doc/系统磁盘监控数据库设计.sql` 成为 DBA 或后端可直接引用的交付文件,语句内容与规格文档完全一致。
|
||||
|
||||
- [ ] **Step 6: Run static verification for the new route/API files**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd D:\Work\SourceCode\CN_Tool_client\frontend
|
||||
npm run lint -- src/api/system/diskMonitor/index.ts src/api/system/diskMonitor/interface/index.ts src/routers/modules/staticRouter.ts
|
||||
npm run type-check
|
||||
```
|
||||
|
||||
Expected: 两个命令都退出 `0`;如果 `type-check` 失败,应只允许失败原因为后续页面组件尚未创建,不允许出现 API 或路由类型错误。
|
||||
|
||||
- [ ] **Step 7: Commit the contract and SQL baseline**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
git add D:\Work\SourceCode\CN_Tool_client\frontend\src\routers\modules\staticRouter.ts D:\Work\SourceCode\CN_Tool_client\frontend\src\api\system\diskMonitor\index.ts D:\Work\SourceCode\CN_Tool_client\frontend\src\api\system\diskMonitor\interface\index.ts D:\Work\SourceCode\CN_Tool_client\doc\系统磁盘监控数据库设计.sql
|
||||
git commit -m "feat: add disk monitor contracts and sql"
|
||||
```
|
||||
|
||||
## Task 2: Replace The Placeholder Page With Summary And Policy State
|
||||
|
||||
**Files:**
|
||||
- Create: `D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\utils\form.ts`
|
||||
- Create: `D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\components\DiskMonitorSummary.vue`
|
||||
- Create: `D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\components\DiskMonitorPolicyForm.vue`
|
||||
- Modify: `D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\index.vue`
|
||||
|
||||
- [ ] **Step 1: Add page-level defaults and validation helpers**
|
||||
|
||||
Create `D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\utils\form.ts` with:
|
||||
|
||||
```ts
|
||||
import type { DiskMonitor } from '@/api/system/diskMonitor/interface'
|
||||
|
||||
export const createDefaultPolicy = (): DiskMonitor.PolicyItem => ({
|
||||
policyName: '默认磁盘监控策略',
|
||||
monitorEnabled: true,
|
||||
runOnAppStart: true,
|
||||
dailyRunTime: '08:30:00',
|
||||
warningNotifyMode: 'STATUS_CHANGE',
|
||||
alarmNotifyMode: 'EVERY_TIME',
|
||||
remark: ''
|
||||
})
|
||||
|
||||
export const createEmptyPathTarget = (): DiskMonitor.NotifyPathTarget => ({
|
||||
path: '',
|
||||
name: '',
|
||||
enabled: true
|
||||
})
|
||||
|
||||
export const createEmptyHttpTarget = (): DiskMonitor.NotifyHttpTarget => ({
|
||||
url: '',
|
||||
name: '',
|
||||
method: 'POST',
|
||||
timeoutMs: 5000,
|
||||
enabled: true
|
||||
})
|
||||
|
||||
export const createEmptyTarget = (): DiskMonitor.TargetItem => ({
|
||||
driveLetter: '',
|
||||
monitorEnabled: true,
|
||||
warningUsagePercent: 80,
|
||||
alarmUsagePercent: 90,
|
||||
notifyPathEnabled: false,
|
||||
notifyPathList: [],
|
||||
notifyHttpEnabled: false,
|
||||
notifyHttpList: [],
|
||||
lastStatus: 'UNKNOWN',
|
||||
lastScanTime: null,
|
||||
lastUsedPercent: null,
|
||||
remark: ''
|
||||
})
|
||||
|
||||
export const validatePolicy = (policy: DiskMonitor.PolicyItem) => {
|
||||
if (!policy.dailyRunTime) return '每日统一执行时间不能为空'
|
||||
return ''
|
||||
}
|
||||
|
||||
export const validateTarget = (target: DiskMonitor.TargetItem, exists: string[]) => {
|
||||
if (!target.driveLetter) return '盘符不能为空'
|
||||
if (exists.includes(target.driveLetter)) return '盘符不能重复'
|
||||
if (target.warningUsagePercent < 1 || target.warningUsagePercent > 100) return '预警使用率必须在 1-100 之间'
|
||||
if (target.alarmUsagePercent < 1 || target.alarmUsagePercent > 100) return '告警使用率必须在 1-100 之间'
|
||||
if (target.alarmUsagePercent < target.warningUsagePercent) return '告警使用率不能小于预警使用率'
|
||||
return ''
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create the summary card component**
|
||||
|
||||
Create `D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\components\DiskMonitorSummary.vue` with:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="summary-grid">
|
||||
<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">{{ targets.length }}</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">{{ latestJob?.jobStatus || '--' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { DiskMonitor } from '@/api/system/diskMonitor/interface'
|
||||
|
||||
defineOptions({ name: 'DiskMonitorSummary' })
|
||||
|
||||
const props = defineProps<{
|
||||
policy: DiskMonitor.PolicyItem
|
||||
targets: DiskMonitor.TargetItem[]
|
||||
latestJob: DiskMonitor.JobListItem | null
|
||||
}>()
|
||||
|
||||
const alarmCount = computed(() => props.targets.filter(item => item.lastStatus === 'ALARM').length)
|
||||
</script>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create the global policy form component**
|
||||
|
||||
Create `D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\components\DiskMonitorPolicyForm.vue` with:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<section class="policy-card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h2 class="card-title">全局策略</h2>
|
||||
<p class="card-description">配置监控总开关、启动监控与每日统一时间。</p>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<el-button :loading="runLoading" @click="$emit('run')">立即执行监控</el-button>
|
||||
<el-button type="primary" :loading="saveLoading" @click="$emit('save')">保存配置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form label-width="120px">
|
||||
<el-form-item label="启用监控">
|
||||
<el-switch :model-value="modelValue.monitorEnabled" @update:model-value="updateField('monitorEnabled', $event)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="启动即监控">
|
||||
<el-switch :model-value="modelValue.runOnAppStart" @update:model-value="updateField('runOnAppStart', $event)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="每日执行时间">
|
||||
<el-time-picker
|
||||
:model-value="modelValue.dailyRunTime"
|
||||
value-format="HH:mm:ss"
|
||||
placeholder="选择时间"
|
||||
@update:model-value="updateField('dailyRunTime', $event)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="通知规则">
|
||||
<el-alert title="预警按状态变化通知,告警每次命中都通知" type="info" :closable="false" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { DiskMonitor } from '@/api/system/diskMonitor/interface'
|
||||
|
||||
defineOptions({ name: 'DiskMonitorPolicyForm' })
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: DiskMonitor.PolicyItem
|
||||
saveLoading: boolean
|
||||
runLoading: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: DiskMonitor.PolicyItem): void
|
||||
(event: 'save'): void
|
||||
(event: 'run'): void
|
||||
}>()
|
||||
|
||||
const updateField = <K extends keyof DiskMonitor.PolicyItem>(key: K, value: DiskMonitor.PolicyItem[K]) => {
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
[key]: value
|
||||
})
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Replace the placeholder page with container state and data loading**
|
||||
|
||||
Replace `D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\index.vue` with a container that imports the new API and components, keeping the back navigation and adding concise Chinese comments on the main business flow:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { DiskMonitor } from '@/api/system/diskMonitor/interface'
|
||||
import {
|
||||
getDiskMonitorPolicyDetail,
|
||||
getDiskMonitorJobList,
|
||||
runDiskMonitorJob,
|
||||
saveDiskMonitorPolicy
|
||||
} from '@/api/system/diskMonitor'
|
||||
import { createDefaultPolicy, createEmptyTarget, validatePolicy } from './utils/form'
|
||||
import DiskMonitorSummary from './components/DiskMonitorSummary.vue'
|
||||
import DiskMonitorPolicyForm from './components/DiskMonitorPolicyForm.vue'
|
||||
|
||||
defineOptions({ name: 'DiskMonitorView' })
|
||||
|
||||
const policyForm = ref<DiskMonitor.PolicyItem>(createDefaultPolicy())
|
||||
const targetList = ref<DiskMonitor.TargetItem[]>([])
|
||||
const latestJob = ref<DiskMonitor.JobListItem | null>(null)
|
||||
const loading = reactive({
|
||||
init: false,
|
||||
save: false,
|
||||
run: false,
|
||||
jobs: false
|
||||
})
|
||||
|
||||
// 页面初始化时同时拉取全局策略和最近任务摘要。
|
||||
const loadPageData = async () => {
|
||||
loading.init = true
|
||||
try {
|
||||
const [policyResp, jobsResp] = await Promise.all([
|
||||
getDiskMonitorPolicyDetail(),
|
||||
getDiskMonitorJobList({ pageNum: 1, pageSize: 10 })
|
||||
])
|
||||
policyForm.value = policyResp.data.policy
|
||||
targetList.value = policyResp.data.targets || []
|
||||
latestJob.value = jobsResp.data.records?.[0] || null
|
||||
} finally {
|
||||
loading.init = false
|
||||
}
|
||||
}
|
||||
|
||||
// 保存前先做全局策略校验,避免向后端提交无效时间配置。
|
||||
const handleSave = async () => {
|
||||
const error = validatePolicy(policyForm.value)
|
||||
if (error) {
|
||||
ElMessage.warning(error)
|
||||
return
|
||||
}
|
||||
|
||||
loading.save = true
|
||||
try {
|
||||
await saveDiskMonitorPolicy({
|
||||
policy: policyForm.value,
|
||||
targets: targetList.value
|
||||
})
|
||||
ElMessage.success('配置保存成功')
|
||||
await loadPageData()
|
||||
} finally {
|
||||
loading.save = false
|
||||
}
|
||||
}
|
||||
|
||||
// 手动执行入口用于联调后端执行链路和验证页面摘要刷新。
|
||||
const handleRunNow = async () => {
|
||||
loading.run = true
|
||||
try {
|
||||
await runDiskMonitorJob({ jobSource: 'MANUAL' })
|
||||
ElMessage.success('监控任务已启动')
|
||||
await loadPageData()
|
||||
} finally {
|
||||
loading.run = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadPageData)
|
||||
</script>
|
||||
```
|
||||
|
||||
Expected: 页面不再显示占位摘要和占位面板,而是渲染摘要卡片和全局策略卡片,并能在挂载时请求配置与最近任务。
|
||||
|
||||
- [ ] **Step 5: Run the first full type-check after replacing the placeholder**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd D:\Work\SourceCode\CN_Tool_client\frontend
|
||||
npm run type-check
|
||||
```
|
||||
|
||||
Expected: 允许失败原因为“盘符编辑组件和任务列表组件尚未创建”,不允许出现 `form.ts`、`DiskMonitorSummary.vue`、`DiskMonitorPolicyForm.vue` 的类型错误。
|
||||
|
||||
- [ ] **Step 6: Commit the page state skeleton**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
git add D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\utils\form.ts D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\components\DiskMonitorSummary.vue D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\components\DiskMonitorPolicyForm.vue D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\index.vue
|
||||
git commit -m "feat: scaffold disk monitor page state"
|
||||
```
|
||||
|
||||
## Task 3: Build The Target Editor And Notification Editors
|
||||
|
||||
**Files:**
|
||||
- Create: `D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\components\NotificationPathEditor.vue`
|
||||
- Create: `D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\components\NotificationHttpEditor.vue`
|
||||
- Create: `D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\components\DiskMonitorTargetDialog.vue`
|
||||
- Create: `D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\components\DiskMonitorTargetTable.vue`
|
||||
- Modify: `D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\index.vue`
|
||||
|
||||
- [ ] **Step 1: Create the path notification array editor**
|
||||
|
||||
Create `D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\components\NotificationPathEditor.vue` with:
|
||||
|
||||
```vue
|
||||
<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<{ (event: 'update:modelValue', value: DiskMonitor.NotifyPathTarget[]): void }>()
|
||||
|
||||
const patchRows = (rows: DiskMonitor.NotifyPathTarget[]) => emit('update:modelValue', rows)
|
||||
|
||||
const addRow = () => patchRows([...props.modelValue, createEmptyPathTarget()])
|
||||
const removeRow = (index: number) => patchRows(props.modelValue.filter((_, rowIndex) => rowIndex !== index))
|
||||
const updateRow = (index: number, key: keyof DiskMonitor.NotifyPathTarget, value: string | boolean) => {
|
||||
patchRows(
|
||||
props.modelValue.map((row, rowIndex) =>
|
||||
rowIndex === index ? { ...row, [key]: value } : row
|
||||
)
|
||||
)
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
Expected: 组件支持新增、删除、编辑路径通知目标,不自行维护状态。
|
||||
|
||||
- [ ] **Step 2: Create the HTTP notification array editor**
|
||||
|
||||
Create `D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\components\NotificationHttpEditor.vue` with:
|
||||
|
||||
```vue
|
||||
<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<{ (event: 'update:modelValue', value: DiskMonitor.NotifyHttpTarget[]): void }>()
|
||||
|
||||
const patchRows = (rows: DiskMonitor.NotifyHttpTarget[]) => emit('update:modelValue', rows)
|
||||
|
||||
const addRow = () => patchRows([...props.modelValue, createEmptyHttpTarget()])
|
||||
const removeRow = (index: number) => patchRows(props.modelValue.filter((_, rowIndex) => rowIndex !== index))
|
||||
const updateRow = (
|
||||
index: number,
|
||||
key: keyof DiskMonitor.NotifyHttpTarget,
|
||||
value: string | number | boolean
|
||||
) => {
|
||||
patchRows(
|
||||
props.modelValue.map((row, rowIndex) =>
|
||||
rowIndex === index ? { ...row, [key]: value } : row
|
||||
)
|
||||
)
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create the target edit dialog**
|
||||
|
||||
Create `D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\components\DiskMonitorTargetDialog.vue` with a dialog shell that hosts the two editor components:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import type { DiskMonitor } from '@/api/system/diskMonitor/interface'
|
||||
import NotificationPathEditor from './NotificationPathEditor.vue'
|
||||
import NotificationHttpEditor from './NotificationHttpEditor.vue'
|
||||
|
||||
defineOptions({ name: 'DiskMonitorTargetDialog' })
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
modelValue: DiskMonitor.TargetItem
|
||||
title: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:visible', value: boolean): void
|
||||
(event: 'update:modelValue', value: DiskMonitor.TargetItem): void
|
||||
(event: 'confirm'): void
|
||||
}>()
|
||||
|
||||
const patchTarget = <K extends keyof DiskMonitor.TargetItem>(key: K, value: DiskMonitor.TargetItem[K]) => {
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
[key]: value
|
||||
})
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
Expected: 弹窗内至少包含盘符、启用开关、预警阈值、告警阈值、路径通知开关与编辑器、HTTP 通知开关与编辑器、备注。
|
||||
|
||||
- [ ] **Step 4: Create the target table with add/edit/delete events**
|
||||
|
||||
Create `D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\components\DiskMonitorTargetTable.vue` with:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import type { DiskMonitor } from '@/api/system/diskMonitor/interface'
|
||||
|
||||
defineOptions({ name: 'DiskMonitorTargetTable' })
|
||||
|
||||
defineProps<{ rows: DiskMonitor.TargetItem[] }>()
|
||||
defineEmits<{
|
||||
(event: 'add'): void
|
||||
(event: 'edit', row: DiskMonitor.TargetItem, index: number): void
|
||||
(event: 'remove', index: number): void
|
||||
}>()
|
||||
</script>
|
||||
```
|
||||
|
||||
Expected: 表格列至少显示盘符、是否监控、预警使用率、告警使用率、当前状态、最近扫描时间、最近使用率、操作按钮。
|
||||
|
||||
- [ ] **Step 5: Wire target CRUD into the page container**
|
||||
|
||||
Update `D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\index.vue` to add:
|
||||
|
||||
```ts
|
||||
import { createEmptyTarget, validateTarget } from './utils/form'
|
||||
import DiskMonitorTargetTable from './components/DiskMonitorTargetTable.vue'
|
||||
import DiskMonitorTargetDialog from './components/DiskMonitorTargetDialog.vue'
|
||||
|
||||
const targetDialogVisible = ref(false)
|
||||
const editingTargetIndex = ref(-1)
|
||||
const editingTarget = ref<DiskMonitor.TargetItem>(createEmptyTarget())
|
||||
|
||||
const openAddTarget = () => {
|
||||
editingTargetIndex.value = -1
|
||||
editingTarget.value = createEmptyTarget()
|
||||
targetDialogVisible.value = true
|
||||
}
|
||||
|
||||
const openEditTarget = (row: DiskMonitor.TargetItem, index: number) => {
|
||||
editingTargetIndex.value = index
|
||||
editingTarget.value = JSON.parse(JSON.stringify(row))
|
||||
targetDialogVisible.value = true
|
||||
}
|
||||
|
||||
const confirmTarget = () => {
|
||||
const duplicatePool = targetList.value
|
||||
.filter((_, index) => index !== editingTargetIndex.value)
|
||||
.map(item => item.driveLetter)
|
||||
const error = validateTarget(editingTarget.value, duplicatePool)
|
||||
if (error) {
|
||||
ElMessage.warning(error)
|
||||
return
|
||||
}
|
||||
|
||||
if (editingTargetIndex.value === -1) {
|
||||
targetList.value = [...targetList.value, JSON.parse(JSON.stringify(editingTarget.value))]
|
||||
} else {
|
||||
targetList.value = targetList.value.map((item, index) =>
|
||||
index === editingTargetIndex.value ? JSON.parse(JSON.stringify(editingTarget.value)) : item
|
||||
)
|
||||
}
|
||||
|
||||
targetDialogVisible.value = false
|
||||
}
|
||||
|
||||
const removeTarget = (index: number) => {
|
||||
targetList.value = targetList.value.filter((_, rowIndex) => rowIndex !== index)
|
||||
}
|
||||
```
|
||||
|
||||
Expected: 页面可以新增、编辑、删除多个盘符,并能在保存前阻止重复盘符和非法阈值。
|
||||
|
||||
- [ ] **Step 6: Run lint and type-check after target editor wiring**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd D:\Work\SourceCode\CN_Tool_client\frontend
|
||||
npm run lint -- src/views/systemMonitor/diskMonitor/index.vue src/views/systemMonitor/diskMonitor/components/NotificationPathEditor.vue src/views/systemMonitor/diskMonitor/components/NotificationHttpEditor.vue src/views/systemMonitor/diskMonitor/components/DiskMonitorTargetDialog.vue src/views/systemMonitor/diskMonitor/components/DiskMonitorTargetTable.vue
|
||||
npm run type-check
|
||||
```
|
||||
|
||||
Expected: 两个命令退出 `0`;不允许出现盘符编辑器和通知编辑器的 props/emits 类型错误。
|
||||
|
||||
- [ ] **Step 7: Commit the target editor slice**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
git add D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\components\NotificationPathEditor.vue D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\components\NotificationHttpEditor.vue D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\components\DiskMonitorTargetDialog.vue D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\components\DiskMonitorTargetTable.vue D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\index.vue
|
||||
git commit -m "feat: add disk monitor target editors"
|
||||
```
|
||||
|
||||
## Task 4: Add Manual Run, Job History, And Job Detail Views
|
||||
|
||||
**Files:**
|
||||
- Create: `D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\components\DiskMonitorJobTable.vue`
|
||||
- Create: `D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\components\DiskMonitorJobDetailDrawer.vue`
|
||||
- Modify: `D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\index.vue`
|
||||
|
||||
- [ ] **Step 1: Create the recent job table component**
|
||||
|
||||
Create `D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\components\DiskMonitorJobTable.vue` with:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import type { DiskMonitor } from '@/api/system/diskMonitor/interface'
|
||||
|
||||
defineOptions({ name: 'DiskMonitorJobTable' })
|
||||
|
||||
defineProps<{
|
||||
rows: DiskMonitor.JobListItem[]
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(event: 'refresh'): void
|
||||
(event: 'detail', row: DiskMonitor.JobListItem): void
|
||||
}>()
|
||||
</script>
|
||||
```
|
||||
|
||||
Expected: 表格列至少包含任务编号、来源、开始时间、结束时间、状态、预警数量、告警数量和“查看详情”按钮。
|
||||
|
||||
- [ ] **Step 2: Create the job detail drawer**
|
||||
|
||||
Create `D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\components\DiskMonitorJobDetailDrawer.vue` with:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import type { DiskMonitor } from '@/api/system/diskMonitor/interface'
|
||||
|
||||
defineOptions({ name: 'DiskMonitorJobDetailDrawer' })
|
||||
|
||||
defineProps<{
|
||||
visible: boolean
|
||||
detail: DiskMonitor.JobDetailData | null
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{ (event: 'update:visible', value: boolean): void }>()
|
||||
</script>
|
||||
```
|
||||
|
||||
Expected: 抽屉中分两块表格展示 `results` 与 `notifyLogs`,字段名与规格文档一致。
|
||||
|
||||
- [ ] **Step 3: Wire manual run and history loading into the page**
|
||||
|
||||
Update `D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\index.vue` with:
|
||||
|
||||
```ts
|
||||
import { getDiskMonitorJobDetail } from '@/api/system/diskMonitor'
|
||||
import DiskMonitorJobTable from './components/DiskMonitorJobTable.vue'
|
||||
import DiskMonitorJobDetailDrawer from './components/DiskMonitorJobDetailDrawer.vue'
|
||||
|
||||
const jobList = ref<DiskMonitor.JobListItem[]>([])
|
||||
const jobDetailVisible = ref(false)
|
||||
const jobDetail = ref<DiskMonitor.JobDetailData | null>(null)
|
||||
const detailLoading = ref(false)
|
||||
|
||||
const loadJobList = async () => {
|
||||
loading.jobs = true
|
||||
try {
|
||||
const resp = await getDiskMonitorJobList({ pageNum: 1, pageSize: 10 })
|
||||
jobList.value = resp.data.records || []
|
||||
latestJob.value = jobList.value[0] || null
|
||||
} finally {
|
||||
loading.jobs = false
|
||||
}
|
||||
}
|
||||
|
||||
const openJobDetail = async (row: DiskMonitor.JobListItem) => {
|
||||
detailLoading.value = true
|
||||
jobDetailVisible.value = true
|
||||
try {
|
||||
const resp = await getDiskMonitorJobDetail(row.id)
|
||||
jobDetail.value = resp.data
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Expected: 手动执行完任务后可以刷新最近任务列表,并且可点开详情查看每个盘符结果与通知日志。
|
||||
|
||||
- [ ] **Step 4: Keep the page refresh flow single-sourced**
|
||||
|
||||
Update `loadPageData` in `D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\index.vue` so it only loads config + recent任务列表 once:
|
||||
|
||||
```ts
|
||||
const loadPageData = async () => {
|
||||
loading.init = true
|
||||
try {
|
||||
const policyResp = await getDiskMonitorPolicyDetail()
|
||||
policyForm.value = policyResp.data.policy
|
||||
targetList.value = policyResp.data.targets || []
|
||||
await loadJobList()
|
||||
} finally {
|
||||
loading.init = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Expected: 保存配置、手动执行、页面初始化都复用同一套刷新入口,不出现多处重复请求逻辑。
|
||||
|
||||
- [ ] **Step 5: Run the full frontend verification commands**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd D:\Work\SourceCode\CN_Tool_client\frontend
|
||||
npm run lint
|
||||
npm run type-check
|
||||
```
|
||||
|
||||
Expected: 两个命令都退出 `0`。
|
||||
|
||||
- [ ] **Step 6: Commit the job history UI**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
git add D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\components\DiskMonitorJobTable.vue D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\components\DiskMonitorJobDetailDrawer.vue D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\index.vue
|
||||
git commit -m "feat: add disk monitor job views"
|
||||
```
|
||||
|
||||
## Task 5: Perform Manual Verification On The Hash Route
|
||||
|
||||
**Files:**
|
||||
- Verify only: `D:\Work\SourceCode\CN_Tool_client\frontend\src\routers\modules\staticRouter.ts`
|
||||
- Verify only: `D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\index.vue`
|
||||
- Verify only: `D:\Work\SourceCode\CN_Tool_client\frontend\src\views\systemMonitor\diskMonitor\components\*.vue`
|
||||
- Verify only: `D:\Work\SourceCode\CN_Tool_client\doc\系统磁盘监控数据库设计.sql`
|
||||
|
||||
- [ ] **Step 1: Start the frontend dev server**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd D:\Work\SourceCode\CN_Tool_client\frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Expected: Vite 启动成功;当前开发环境使用 `hash` 路由,因此目标页面地址为 `/#/systemMonitor/diskMonitor`。
|
||||
|
||||
- [ ] **Step 2: Verify configuration load and save behavior**
|
||||
|
||||
Manual checklist:
|
||||
|
||||
```text
|
||||
1. 访问 /#/systemMonitor/diskMonitor,能看到摘要区、全局策略区、盘符列表区、最近任务区。
|
||||
2. 修改“启用监控”“启动即监控”“每日执行时间”后点击“保存配置”,页面给出成功提示。
|
||||
3. 新增两个盘符,例如 C: 与 D:,分别配置不同的预警/告警阈值。
|
||||
4. 为其中一个盘符新增本地目录通知和 HTTP 通知目标,保存后刷新页面,配置仍正确回显。
|
||||
5. 尝试录入重复盘符或告警阈值小于预警阈值,页面必须阻止提交并给出提示。
|
||||
```
|
||||
|
||||
Expected: 五项都成立。
|
||||
|
||||
- [ ] **Step 3: Verify manual run and job detail behavior**
|
||||
|
||||
Manual checklist:
|
||||
|
||||
```text
|
||||
1. 点击“立即执行监控”,页面提示任务已启动。
|
||||
2. 最近任务列表出现一条新的 MANUAL 任务。
|
||||
3. 打开任务详情抽屉,能看到盘符结果表和通知日志表。
|
||||
4. 若后端暂未接通,页面应以接口错误提示结束,不得卡死或出现未捕获异常。
|
||||
```
|
||||
|
||||
Expected: 四项都成立。
|
||||
|
||||
- [ ] **Step 4: Verify the SQL artifact matches the approved spec**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
Get-Content -Raw D:\Work\SourceCode\CN_Tool_client\doc\系统磁盘监控数据库设计.sql
|
||||
Get-Content -Raw D:\Work\SourceCode\CN_Tool_client\docs\superpowers\specs\2026-04-22-disk-monitor-design.md
|
||||
```
|
||||
|
||||
Expected: SQL 文件包含同样的五张表和字段命名:`disk_monitor_policy`、`disk_monitor_target`、`disk_monitor_job`、`disk_monitor_result`、`disk_monitor_notify_log`。
|
||||
|
||||
- [ ] **Step 5: Record final verification status**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd D:\Work\SourceCode\CN_Tool_client\frontend
|
||||
npm run lint
|
||||
npm run type-check
|
||||
git status --short
|
||||
```
|
||||
|
||||
Expected: `lint` 和 `type-check` 退出 `0`;`git status --short` 只显示本功能相关改动和仓库原有未处理改动,不出现意外文件。
|
||||
|
||||
## Self-Review
|
||||
|
||||
- Spec coverage: 计划覆盖了静态路由兜底、前端 API 契约、数据库 SQL 文件、摘要区、全局策略区、盘符与通知编辑、手动执行、最近任务、详情抽屉和验证步骤,与已批准规格一致。
|
||||
- Placeholder scan: 没有 `TODO`、`TBD`、`后续再说` 类占位语;每个任务都给了明确文件路径、代码骨架、命令和预期结果。
|
||||
- Type consistency: 计划统一使用 `DiskMonitor.PolicyItem`、`DiskMonitor.TargetItem`、`DiskMonitor.JobListItem`、`DiskMonitor.JobDetailData`、`createDefaultPolicy`、`createEmptyTarget`、`validatePolicy`、`validateTarget` 等命名,没有前后不一致的接口名。
|
||||
@@ -0,0 +1,994 @@
|
||||
# MMS Mapping Layout And Config Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Rebuild the `mmsmapping` page into a two-phase ICD parsing and mapping generation workflow with a left-side file/result layout and a right-side configuration workspace driven by `DefaultCfg.txt`.
|
||||
|
||||
**Architecture:** Keep the existing `getIcdMmsJson` API contract intact, but replace the raw JSON editor flow with a typed page container, a simplified left-top request panel, a left-bottom result panel, and a new right-side configuration panel. Use two utility modules to parse `DefaultCfg.txt`, generate a draft from `indexCandidates`, validate editable rows, and convert the draft back into `request.indexSelection`; validation relies on `vue-tsc`, `eslint`, and manual browser checks because this repo does not currently ship an automated frontend test runner.
|
||||
|
||||
**Tech Stack:** Vue 3 `<script setup>`, TypeScript, Element Plus, Vite, ESLint, `vue-tsc`
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
- Modify: `frontend/src/api/tools/mmsmapping/interface/index.ts`
|
||||
Purpose: Add front-end-only types for the editable base form, parsed `DefaultCfg` template, draft groups, and row-level editing state.
|
||||
- Create: `frontend/src/views/tools/mmsmapping/utils/defaultCfg.ts`
|
||||
Purpose: Load `DefaultCfg.txt` as raw text, sanitize trailing commas, parse it into a normalized template object, and expose a single typed parser function.
|
||||
- Create: `frontend/src/views/tools/mmsmapping/utils/mappingDraft.ts`
|
||||
Purpose: Hold request defaults plus the pure functions that match template groups to `indexCandidates`, build the editable draft, validate row completeness, and convert the draft into `request.indexSelection`.
|
||||
- Modify: `frontend/src/views/tools/mmsmapping/components/MappingRequestPanel.vue`
|
||||
Purpose: Shrink the left-top panel down to ICD file selection, parse action, reset action, and status tags.
|
||||
- Modify: `frontend/src/views/tools/mmsmapping/components/MappingResultPanel.vue`
|
||||
Purpose: Keep it result-only for `mappingJson` and `problems`, with copy/layout aligned to the left-bottom output role.
|
||||
- Create: `frontend/src/views/tools/mmsmapping/components/MappingConfigPanel.vue`
|
||||
Purpose: Render the right-side `version`/`author` form, template/candidate helper info, editable draft rows, and the repeated `生成映射` action.
|
||||
- Modify: `frontend/src/views/tools/mmsmapping/index.vue`
|
||||
Purpose: Replace the raw JSON flow with two-phase request handling, draft state management, reset behavior, template-error handling, and the new left-stack/right-panel layout.
|
||||
|
||||
> Repo note: the current frontend package has `lint` and `type-check` scripts but no unit-test runner. Do not add Vitest/Jest in this task. Use `npm run lint`, `npm run type-check`, and the manual flow checklist in Task 5.
|
||||
|
||||
### Task 1: Add Typed Template And Draft Utilities
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/api/tools/mmsmapping/interface/index.ts`
|
||||
- Create: `frontend/src/views/tools/mmsmapping/utils/defaultCfg.ts`
|
||||
- Create: `frontend/src/views/tools/mmsmapping/utils/mappingDraft.ts`
|
||||
|
||||
- [ ] **Step 1: Extend the MMS mapping interface file with front-end draft types**
|
||||
|
||||
Add the following block near the existing request/response interfaces in `frontend/src/api/tools/mmsmapping/interface/index.ts`:
|
||||
|
||||
```ts
|
||||
export interface BaseRequestForm {
|
||||
version: string
|
||||
author: string
|
||||
}
|
||||
|
||||
export interface DefaultCfgReportTemplate {
|
||||
desc: string
|
||||
select: string
|
||||
dataSetList: string[]
|
||||
lnInstList: string[]
|
||||
}
|
||||
|
||||
export interface DefaultCfgTemplate {
|
||||
reportList: DefaultCfgReportTemplate[]
|
||||
}
|
||||
|
||||
export interface DraftCandidateReport {
|
||||
reportName: string
|
||||
dataSetName: string
|
||||
availableLnInstValues: string[]
|
||||
reportDesc?: string
|
||||
}
|
||||
|
||||
export type DraftMatchStatus = 'matched' | 'pending'
|
||||
|
||||
export interface MappingDraftRow {
|
||||
id: string
|
||||
label: string
|
||||
reportName: string
|
||||
dataSetName: string
|
||||
lnInst: string
|
||||
}
|
||||
|
||||
export interface MappingDraftGroup {
|
||||
id: string
|
||||
templateDesc: string
|
||||
selectKey: string
|
||||
dataSetList: string[]
|
||||
templateLabels: string[]
|
||||
candidateGroupKey: string
|
||||
candidateGroupDesc: string
|
||||
matchStatus: DraftMatchStatus
|
||||
candidateReports: DraftCandidateReport[]
|
||||
rows: MappingDraftRow[]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create the loose-JSON parser for `DefaultCfg.txt`**
|
||||
|
||||
Create `frontend/src/views/tools/mmsmapping/utils/defaultCfg.ts` with this implementation:
|
||||
|
||||
```ts
|
||||
import type { MmsMapping } from '@/api/tools/mmsmapping/interface'
|
||||
import defaultCfgText from '../DefaultCfg.txt?raw'
|
||||
|
||||
interface DefaultCfgRawReportItem {
|
||||
desc?: string
|
||||
Select?: string
|
||||
DataSetList?: string[]
|
||||
LnInstList?: string[]
|
||||
}
|
||||
|
||||
interface DefaultCfgRawFile {
|
||||
ReportList?: DefaultCfgRawReportItem[]
|
||||
}
|
||||
|
||||
const sanitizeLooseJson = (source: string) => source.replace(/,\s*([}\]])/g, '$1')
|
||||
|
||||
export const parseDefaultCfgTemplate = (): MmsMapping.DefaultCfgTemplate => {
|
||||
const parsed = JSON.parse(sanitizeLooseJson(defaultCfgText)) as DefaultCfgRawFile
|
||||
const reportList = (parsed.ReportList || []).map(item => ({
|
||||
desc: item.desc?.trim() || 'Default Report Group',
|
||||
select: item.Select?.trim() || '',
|
||||
dataSetList: (item.DataSetList || []).filter(Boolean),
|
||||
lnInstList: (item.LnInstList || []).filter(Boolean)
|
||||
}))
|
||||
|
||||
return { reportList }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create request defaults, matching, draft building, validation, and payload conversion**
|
||||
|
||||
Create `frontend/src/views/tools/mmsmapping/utils/mappingDraft.ts` with this implementation:
|
||||
|
||||
```ts
|
||||
import type { MmsMapping } from '@/api/tools/mmsmapping/interface'
|
||||
|
||||
export const DEFAULT_REQUEST_OPTIONS = {
|
||||
saveToDisk: false,
|
||||
prettyJson: true,
|
||||
outputDir: ''
|
||||
} satisfies Pick<MmsMapping.GetIcdMmsJsonRequestPayload, 'saveToDisk' | 'prettyJson' | 'outputDir'>
|
||||
|
||||
export const createBaseRequestPayload = (
|
||||
form: MmsMapping.BaseRequestForm
|
||||
): Omit<MmsMapping.GetIcdMmsJsonRequestPayload, 'indexSelection'> => ({
|
||||
version: form.version.trim() || '1.0',
|
||||
author: form.author.trim() || 'system',
|
||||
...DEFAULT_REQUEST_OPTIONS
|
||||
})
|
||||
|
||||
const getIntersectionSize = (left: string[], right: string[]) => {
|
||||
const rightSet = new Set(right.filter(Boolean))
|
||||
return left.filter(item => rightSet.has(item)).length
|
||||
}
|
||||
|
||||
const matchCandidateGroup = (
|
||||
template: MmsMapping.DefaultCfgReportTemplate,
|
||||
candidates: MmsMapping.IndexCandidateGroup[]
|
||||
) => {
|
||||
const scored = candidates
|
||||
.map(candidate => {
|
||||
const templateLabelScore = getIntersectionSize(template.lnInstList, candidate.templateLabels || [])
|
||||
const dataSetScore = getIntersectionSize(
|
||||
template.dataSetList,
|
||||
(candidate.reports || []).map(report => report.dataSetName || '')
|
||||
)
|
||||
|
||||
return {
|
||||
candidate,
|
||||
score:
|
||||
(candidate.groupDesc === template.desc ? 100 : 0) +
|
||||
templateLabelScore * 10 +
|
||||
dataSetScore * 5
|
||||
}
|
||||
})
|
||||
.filter(item => item.score > 0)
|
||||
.sort((left, right) => right.score - left.score)
|
||||
|
||||
if (!scored.length) return null
|
||||
if (scored.length > 1 && scored[0].score === scored[1].score) return null
|
||||
return scored[0].candidate
|
||||
}
|
||||
|
||||
export const buildDraftGroups = (
|
||||
template: MmsMapping.DefaultCfgTemplate,
|
||||
candidates: MmsMapping.IndexCandidateGroup[]
|
||||
): MmsMapping.MappingDraftGroup[] =>
|
||||
template.reportList.map((reportTemplate, groupIndex) => {
|
||||
const matchedCandidate = matchCandidateGroup(reportTemplate, candidates)
|
||||
const candidateReports = (matchedCandidate?.reports || []).map(report => ({
|
||||
reportName: report.reportName || '',
|
||||
dataSetName: report.dataSetName || '',
|
||||
reportDesc: report.reportDesc,
|
||||
availableLnInstValues: report.availableLnInstValues || []
|
||||
}))
|
||||
|
||||
return {
|
||||
id: `${reportTemplate.select || 'group'}-${groupIndex}`,
|
||||
templateDesc: reportTemplate.desc,
|
||||
selectKey: reportTemplate.select,
|
||||
dataSetList: reportTemplate.dataSetList,
|
||||
templateLabels: reportTemplate.lnInstList,
|
||||
candidateGroupKey: matchedCandidate?.groupKey || '',
|
||||
candidateGroupDesc: matchedCandidate?.groupDesc || '',
|
||||
matchStatus: matchedCandidate ? 'matched' : 'pending',
|
||||
candidateReports,
|
||||
rows: reportTemplate.lnInstList.map((label, rowIndex) => ({
|
||||
id: `${reportTemplate.select || 'group'}-${groupIndex}-${rowIndex}`,
|
||||
label,
|
||||
reportName: candidateReports[0]?.reportName || '',
|
||||
dataSetName: candidateReports[0]?.dataSetName || '',
|
||||
lnInst: ''
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
export const validateDraftGroups = (groups: MmsMapping.MappingDraftGroup[]) => {
|
||||
const problems: string[] = []
|
||||
|
||||
groups.forEach(group => {
|
||||
if (!group.candidateGroupKey) {
|
||||
problems.push(`${group.templateDesc} 尚未绑定候选分组`)
|
||||
}
|
||||
|
||||
group.rows.forEach(row => {
|
||||
if (!row.reportName || !row.dataSetName || !row.lnInst) {
|
||||
problems.push(`${group.templateDesc} / ${row.label} 的映射配置不完整`)
|
||||
return
|
||||
}
|
||||
|
||||
const matchedReport = group.candidateReports.find(
|
||||
report => report.reportName === row.reportName && report.dataSetName === row.dataSetName
|
||||
)
|
||||
|
||||
if (!matchedReport) {
|
||||
problems.push(`${group.templateDesc} / ${row.label} 选择了非法的 reportName 或 dataSetName`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!matchedReport.availableLnInstValues.includes(row.lnInst)) {
|
||||
problems.push(`${group.templateDesc} / ${row.label} 选择了非法的 lnInst`)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return problems
|
||||
}
|
||||
|
||||
export const buildIndexSelectionPayload = (
|
||||
groups: MmsMapping.MappingDraftGroup[]
|
||||
): MmsMapping.IndexSelectionGroup[] =>
|
||||
groups
|
||||
.filter(group => group.candidateGroupKey)
|
||||
.map(group => ({
|
||||
groupKey: group.candidateGroupKey,
|
||||
groupDesc: group.candidateGroupDesc || group.templateDesc,
|
||||
bindings: group.rows.map(row => ({
|
||||
reportName: row.reportName,
|
||||
dataSetName: row.dataSetName,
|
||||
label: row.label,
|
||||
lnInst: row.lnInst
|
||||
}))
|
||||
}))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run type-check after adding the new types and utility modules**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd frontend
|
||||
npm run type-check
|
||||
```
|
||||
|
||||
Expected: command exits with code `0` and no TypeScript diagnostics.
|
||||
|
||||
- [ ] **Step 5: Commit the utility scaffolding**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
git add frontend/src/api/tools/mmsmapping/interface/index.ts frontend/src/views/tools/mmsmapping/utils/defaultCfg.ts frontend/src/views/tools/mmsmapping/utils/mappingDraft.ts
|
||||
git commit -m "feat: add mmsmapping draft utilities"
|
||||
```
|
||||
|
||||
### Task 2: Simplify The Left-Side Request And Result Panels
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/tools/mmsmapping/components/MappingRequestPanel.vue`
|
||||
- Modify: `frontend/src/views/tools/mmsmapping/components/MappingResultPanel.vue`
|
||||
|
||||
- [ ] **Step 1: Replace the request panel API so it only supports file selection and ICD parsing**
|
||||
|
||||
Update `frontend/src/views/tools/mmsmapping/components/MappingRequestPanel.vue` to use this props/emits contract and action area:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'MappingRequestPanel'
|
||||
})
|
||||
|
||||
type TagType = 'success' | 'warning' | 'info' | 'primary' | 'danger'
|
||||
|
||||
defineProps<{
|
||||
selectedIcdFileName: string
|
||||
isSubmitting: boolean
|
||||
icdFileAccept: string
|
||||
requestStatusText: string
|
||||
requestStatusTagType: TagType
|
||||
canReset: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'file-change', value: Event): void
|
||||
(event: 'parse'): void
|
||||
(event: 'reset'): void
|
||||
}>()
|
||||
|
||||
const icdFileInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const openIcdFilePicker = () => {
|
||||
icdFileInputRef.value?.click()
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<section class="mapping-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2 class="panel-title">ICD 解析</h2>
|
||||
<p class="panel-description">左上仅负责文件选择与解析,解析完成后在右侧生成默认模板。</p>
|
||||
</div>
|
||||
<el-tag :type="requestStatusTagType" effect="light">{{ requestStatusText }}</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="panel-content">
|
||||
<div class="panel-section file-action-row">
|
||||
<div class="file-select-row">
|
||||
<el-input :model-value="selectedIcdFileName" readonly placeholder="请选择 `.icd`、`.cid`、`.scd` 或 `.xml` 文件" class="file-input" />
|
||||
<el-button type="primary" :loading="isSubmitting" @click="openIcdFilePicker">选择 ICD</el-button>
|
||||
<input ref="icdFileInputRef" class="hidden-file-input" type="file" :accept="icdFileAccept" @change="event => emit('file-change', event)" />
|
||||
</div>
|
||||
<el-button type="primary" plain :loading="isSubmitting" :disabled="!selectedIcdFileName" @click="emit('parse')">解析 ICD</el-button>
|
||||
<el-button :disabled="!canReset" @click="emit('reset')">清空</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Keep the result panel focused on `mappingJson` and `problems` only**
|
||||
|
||||
Update the header copy in `frontend/src/views/tools/mmsmapping/components/MappingResultPanel.vue`:
|
||||
|
||||
```vue
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2 class="panel-title">调试输出</h2>
|
||||
<p class="panel-description">左下只展示最近一次接口返回的 `mappingJson` 和 `problems`。</p>
|
||||
</div>
|
||||
<el-tag :type="responseStatusTagType" effect="light">{{ responseStatusText }}</el-tag>
|
||||
</div>
|
||||
```
|
||||
|
||||
Keep the existing tab body structure, but do not reintroduce `icdDocument` or request JSON rendering.
|
||||
|
||||
- [ ] **Step 3: Trim panel styles so the left-top card no longer reserves textarea space**
|
||||
|
||||
Remove the obsolete request textarea blocks from `frontend/src/views/tools/mmsmapping/components/MappingRequestPanel.vue` and keep only these shared styles:
|
||||
|
||||
```scss
|
||||
.panel-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.file-action-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.file-select-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run lint on the two touched panel components**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd frontend
|
||||
npm run lint -- src/views/tools/mmsmapping/components/MappingRequestPanel.vue src/views/tools/mmsmapping/components/MappingResultPanel.vue
|
||||
```
|
||||
|
||||
Expected: command exits with code `0` and no ESLint diagnostics for those files.
|
||||
|
||||
- [ ] **Step 5: Commit the left-side panel refactor**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
git add frontend/src/views/tools/mmsmapping/components/MappingRequestPanel.vue frontend/src/views/tools/mmsmapping/components/MappingResultPanel.vue
|
||||
git commit -m "refactor: simplify mmsmapping side panels"
|
||||
```
|
||||
|
||||
### Task 3: Build The Right-Side Mapping Configuration Panel
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/views/tools/mmsmapping/components/MappingConfigPanel.vue`
|
||||
|
||||
- [ ] **Step 1: Create the component shell with typed props, emits, and immutable patch helpers**
|
||||
|
||||
Create `frontend/src/views/tools/mmsmapping/components/MappingConfigPanel.vue` with this script scaffold:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import type { MmsMapping } from '@/api/tools/mmsmapping/interface'
|
||||
|
||||
defineOptions({
|
||||
name: 'MappingConfigPanel'
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
requestForm: MmsMapping.BaseRequestForm
|
||||
draftGroups: MmsMapping.MappingDraftGroup[]
|
||||
candidateGroups: MmsMapping.IndexCandidateGroup[]
|
||||
isSubmitting: boolean
|
||||
canGenerate: boolean
|
||||
templateError: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:requestForm', value: MmsMapping.BaseRequestForm): void
|
||||
(event: 'update:draftGroups', value: MmsMapping.MappingDraftGroup[]): void
|
||||
(event: 'generate'): void
|
||||
}>()
|
||||
|
||||
const patchRequestForm = (key: keyof MmsMapping.BaseRequestForm, value: string) => {
|
||||
emit('update:requestForm', {
|
||||
...props.requestForm,
|
||||
[key]: value
|
||||
})
|
||||
}
|
||||
|
||||
const patchDraftRow = (groupId: string, rowId: string, key: keyof MmsMapping.MappingDraftRow, value: string) => {
|
||||
emit(
|
||||
'update:draftGroups',
|
||||
props.draftGroups.map(group =>
|
||||
group.id !== groupId
|
||||
? group
|
||||
: {
|
||||
...group,
|
||||
rows: group.rows.map(row => (row.id !== rowId ? row : { ...row, [key]: value }))
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const patchCandidateGroup = (groupId: string, nextGroupKey: string) => {
|
||||
const nextCandidate = props.candidateGroups.find(candidate => candidate.groupKey === nextGroupKey)
|
||||
const candidateReports = (nextCandidate?.reports || []).map(report => ({
|
||||
reportName: report.reportName || '',
|
||||
dataSetName: report.dataSetName || '',
|
||||
reportDesc: report.reportDesc,
|
||||
availableLnInstValues: report.availableLnInstValues || []
|
||||
}))
|
||||
|
||||
emit(
|
||||
'update:draftGroups',
|
||||
props.draftGroups.map(group =>
|
||||
group.id !== groupId
|
||||
? group
|
||||
: {
|
||||
...group,
|
||||
candidateGroupKey: nextGroupKey,
|
||||
candidateGroupDesc: nextCandidate?.groupDesc || '',
|
||||
matchStatus: nextCandidate ? 'matched' : 'pending',
|
||||
candidateReports,
|
||||
rows: group.rows.map(row => ({
|
||||
...row,
|
||||
reportName: '',
|
||||
dataSetName: '',
|
||||
lnInst: ''
|
||||
}))
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const getLnInstOptions = (group: MmsMapping.MappingDraftGroup, row: MmsMapping.MappingDraftRow) => {
|
||||
const matchedReport = group.candidateReports.find(
|
||||
report => report.reportName === row.reportName && report.dataSetName === row.dataSetName
|
||||
)
|
||||
|
||||
return matchedReport?.availableLnInstValues || []
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the right-top base form, template error banner, and generate action**
|
||||
|
||||
Use this top section template in `MappingConfigPanel.vue`:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<section class="mapping-panel config-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2 class="panel-title">系统配置</h2>
|
||||
<p class="panel-description">右侧按 `DefaultCfg.txt` 自动生成默认模板,用户可基于候选辅助信息反复修改并多次生成映射。</p>
|
||||
</div>
|
||||
<el-button type="primary" :loading="isSubmitting" :disabled="!canGenerate || !!templateError" @click="emit('generate')">生成映射</el-button>
|
||||
</div>
|
||||
|
||||
<div class="panel-content">
|
||||
<el-alert v-if="templateError" :title="templateError" type="error" :closable="false" />
|
||||
|
||||
<div class="panel-section result-card">
|
||||
<div class="section-title">请求基础字段</div>
|
||||
<el-form label-position="top" class="request-form">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Version">
|
||||
<el-input :model-value="requestForm.version" @update:model-value="value => patchRequestForm('version', value)" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Author">
|
||||
<el-input :model-value="requestForm.author" @update:model-value="value => patchRequestForm('author', value)" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Render candidate helper info and editable rows for every template group**
|
||||
|
||||
Append this group-rendering block inside the same template:
|
||||
|
||||
```vue
|
||||
<div v-for="group in draftGroups" :key="group.id" class="panel-section result-card draft-group-card">
|
||||
<div class="draft-group-header">
|
||||
<div>
|
||||
<div class="section-title">{{ group.templateDesc }}</div>
|
||||
<p class="section-description">模板标签:{{ group.templateLabels.join('、') || '无' }}</p>
|
||||
<p class="section-description">模板数据集:{{ group.dataSetList.join('、') || '无' }}</p>
|
||||
</div>
|
||||
<el-tag :type="group.matchStatus === 'matched' ? 'success' : 'warning'" effect="light">
|
||||
{{ group.matchStatus === 'matched' ? '已匹配候选组' : '待确认候选组' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<el-form-item label="候选分组">
|
||||
<el-select :model-value="group.candidateGroupKey" placeholder="请选择候选分组" @update:model-value="value => patchCandidateGroup(group.id, value)">
|
||||
<el-option
|
||||
v-for="candidate in candidateGroups"
|
||||
:key="candidate.groupKey"
|
||||
:label="candidate.groupDesc || candidate.groupKey || 'Unnamed group'"
|
||||
:value="candidate.groupKey || ''"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<div class="candidate-report-list">
|
||||
<div v-for="report in group.candidateReports" :key="`${report.reportName}-${report.dataSetName}`" class="candidate-report-item">
|
||||
<div class="candidate-report-name">{{ report.reportName }} / {{ report.dataSetName }}</div>
|
||||
<div class="candidate-report-desc">{{ report.reportDesc || '无描述' }}</div>
|
||||
<div class="candidate-report-lninst">可选 lnInst:{{ report.availableLnInstValues.join('、') || '无' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="group.rows" border class="draft-table">
|
||||
<el-table-column prop="label" label="标签" min-width="140" />
|
||||
<el-table-column label="reportName" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<el-select :model-value="row.reportName" placeholder="选择 reportName" @update:model-value="value => patchDraftRow(group.id, row.id, 'reportName', value)">
|
||||
<el-option v-for="report in group.candidateReports" :key="report.reportName" :label="report.reportName" :value="report.reportName" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="dataSetName" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<el-select :model-value="row.dataSetName" placeholder="选择 dataSetName" @update:model-value="value => patchDraftRow(group.id, row.id, 'dataSetName', value)">
|
||||
<el-option
|
||||
v-for="report in group.candidateReports.filter(report => !row.reportName || report.reportName === row.reportName)"
|
||||
:key="`${report.reportName}-${report.dataSetName}`"
|
||||
:label="report.dataSetName"
|
||||
:value="report.dataSetName"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="lnInst" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<el-select :model-value="row.lnInst" placeholder="选择 lnInst" @update:model-value="value => patchDraftRow(group.id, row.id, 'lnInst', value)">
|
||||
<el-option
|
||||
v-for="lnInst in getLnInstOptions(group, row)"
|
||||
:key="`${group.id}-${row.id}-${lnInst}`"
|
||||
:label="lnInst"
|
||||
:value="lnInst"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
```
|
||||
|
||||
Then add the minimum styles needed to keep the panel scrollable:
|
||||
|
||||
```scss
|
||||
.config-panel {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.candidate-report-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.candidate-report-item {
|
||||
padding: 12px;
|
||||
border: 1px solid #dbe3f0;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.draft-table {
|
||||
width: 100%;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run type-check to validate the new configuration component**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd frontend
|
||||
npm run type-check
|
||||
```
|
||||
|
||||
Expected: command exits with code `0`; if it fails only because `index.vue` is not wired yet, proceed directly to Task 4 before re-running.
|
||||
|
||||
- [ ] **Step 5: Commit the new configuration panel**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
git add frontend/src/views/tools/mmsmapping/components/MappingConfigPanel.vue
|
||||
git commit -m "feat: add mmsmapping config panel"
|
||||
```
|
||||
|
||||
### Task 4: Rebuild `index.vue` Around Parse-And-Generate Flow
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/tools/mmsmapping/index.vue`
|
||||
|
||||
- [ ] **Step 1: Replace raw JSON state with typed form, candidate, draft, and template-error state**
|
||||
|
||||
In `frontend/src/views/tools/mmsmapping/index.vue`, replace `requestJsonText`, `defaultRequestPayload`, and the old JSON parsing helpers with this state block. Keep the existing `unwrapApiPayload`, `getErrorMessage`, `handleIcdFileChange`, `mappingJsonPreview`, `problemList`, and status-tag computed blocks, but rewire them to the new request flow:
|
||||
|
||||
```ts
|
||||
import { computed, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { ResultData } from '@/api/interface'
|
||||
import { getIcdMmsJsonApi } from '@/api/tools/mmsmapping'
|
||||
import type { MmsMapping } from '@/api/tools/mmsmapping/interface'
|
||||
import MappingRequestPanel from './components/MappingRequestPanel.vue'
|
||||
import MappingResultPanel from './components/MappingResultPanel.vue'
|
||||
import MappingConfigPanel from './components/MappingConfigPanel.vue'
|
||||
import { parseDefaultCfgTemplate } from './utils/defaultCfg'
|
||||
import {
|
||||
buildDraftGroups,
|
||||
buildIndexSelectionPayload,
|
||||
createBaseRequestPayload,
|
||||
validateDraftGroups
|
||||
} from './utils/mappingDraft'
|
||||
|
||||
const selectedIcdFile = ref<File | null>(null)
|
||||
const responsePayload = ref<MmsMapping.MappingTaskResponse | null>(null)
|
||||
const activeResultTab = ref<'mapping' | 'problem'>('mapping')
|
||||
const requestForm = ref<MmsMapping.BaseRequestForm>({
|
||||
version: '1.0',
|
||||
author: 'system'
|
||||
})
|
||||
const parsedCandidates = ref<MmsMapping.IndexCandidateGroup[]>([])
|
||||
const configDraft = ref<MmsMapping.MappingDraftGroup[]>([])
|
||||
const templateError = ref('')
|
||||
const defaultCfgTemplate = ref<MmsMapping.DefaultCfgTemplate>({ reportList: [] })
|
||||
const isParsing = ref(false)
|
||||
const isGenerating = ref(false)
|
||||
const icdFileAccept = '.icd,.cid,.scd,.xml'
|
||||
|
||||
try {
|
||||
defaultCfgTemplate.value = parseDefaultCfgTemplate()
|
||||
} catch {
|
||||
templateError.value = 'DefaultCfg.txt 解析失败,请检查模板内容'
|
||||
}
|
||||
|
||||
const isSubmitting = computed(() => isParsing.value || isGenerating.value)
|
||||
const canGenerate = computed(() => Boolean(selectedIcdFile.value && configDraft.value.length && !templateError.value))
|
||||
const selectedIcdFileName = computed(() => selectedIcdFile.value?.name || '')
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add separate parse and generate handlers**
|
||||
|
||||
Use these handlers in `index.vue`:
|
||||
|
||||
```ts
|
||||
const handleParseIcd = async () => {
|
||||
if (!selectedIcdFile.value) {
|
||||
ElMessage.warning('请先选择 ICD 文件')
|
||||
return
|
||||
}
|
||||
|
||||
if (templateError.value) {
|
||||
ElMessage.error(templateError.value)
|
||||
return
|
||||
}
|
||||
|
||||
isParsing.value = true
|
||||
responsePayload.value = null
|
||||
|
||||
try {
|
||||
const response = await getIcdMmsJsonApi({
|
||||
icdFile: selectedIcdFile.value,
|
||||
request: {
|
||||
...createBaseRequestPayload(requestForm.value),
|
||||
indexSelection: []
|
||||
}
|
||||
})
|
||||
|
||||
const payload = unwrapApiPayload(response)
|
||||
responsePayload.value = payload
|
||||
parsedCandidates.value = payload.indexCandidates || []
|
||||
configDraft.value = buildDraftGroups(defaultCfgTemplate.value, parsedCandidates.value)
|
||||
activeResultTab.value = payload.mappingJson ? 'mapping' : 'problem'
|
||||
ElMessage.success(payload.message || 'ICD 解析完成')
|
||||
} catch (error) {
|
||||
responsePayload.value = null
|
||||
parsedCandidates.value = []
|
||||
configDraft.value = []
|
||||
ElMessage.error(getErrorMessage(error))
|
||||
} finally {
|
||||
isParsing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerateMapping = async () => {
|
||||
if (!selectedIcdFile.value) {
|
||||
ElMessage.warning('请先选择 ICD 文件')
|
||||
return
|
||||
}
|
||||
|
||||
const draftProblems = validateDraftGroups(configDraft.value)
|
||||
if (draftProblems.length) {
|
||||
ElMessage.warning(draftProblems[0])
|
||||
responsePayload.value = {
|
||||
status: 'NEED_INDEX_SELECTION',
|
||||
message: '当前配置不完整,请继续修正',
|
||||
problems: draftProblems
|
||||
}
|
||||
activeResultTab.value = 'problem'
|
||||
return
|
||||
}
|
||||
|
||||
isGenerating.value = true
|
||||
responsePayload.value = null
|
||||
|
||||
try {
|
||||
const response = await getIcdMmsJsonApi({
|
||||
icdFile: selectedIcdFile.value,
|
||||
request: {
|
||||
...createBaseRequestPayload(requestForm.value),
|
||||
indexSelection: buildIndexSelectionPayload(configDraft.value)
|
||||
}
|
||||
})
|
||||
|
||||
const payload = unwrapApiPayload(response)
|
||||
responsePayload.value = payload
|
||||
activeResultTab.value = payload.mappingJson ? 'mapping' : 'problem'
|
||||
|
||||
if (payload.status === 'FAILED') {
|
||||
ElMessage.error(payload.message || '映射生成失败')
|
||||
return
|
||||
}
|
||||
|
||||
ElMessage.success(payload.message || '映射生成完成')
|
||||
} catch (error) {
|
||||
responsePayload.value = null
|
||||
ElMessage.error(getErrorMessage(error))
|
||||
} finally {
|
||||
isGenerating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetPage = () => {
|
||||
selectedIcdFile.value = null
|
||||
responsePayload.value = null
|
||||
parsedCandidates.value = []
|
||||
configDraft.value = []
|
||||
activeResultTab.value = 'mapping'
|
||||
requestForm.value = {
|
||||
version: '1.0',
|
||||
author: 'system'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Rebuild the template and layout to left-stack the request/result panels and mount the new configuration panel**
|
||||
|
||||
Replace the page template and layout styles in `index.vue` with:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="table-box mms-mapping-page">
|
||||
<div class="mms-mapping-layout">
|
||||
<div class="left-panel-stack">
|
||||
<MappingRequestPanel
|
||||
:selected-icd-file-name="selectedIcdFileName"
|
||||
:is-submitting="isSubmitting"
|
||||
:icd-file-accept="icdFileAccept"
|
||||
:request-status-text="requestStatusText"
|
||||
:request-status-tag-type="requestStatusTagType"
|
||||
:can-reset="Boolean(selectedIcdFile || responsePayload || configDraft.length)"
|
||||
@file-change="handleIcdFileChange"
|
||||
@parse="handleParseIcd"
|
||||
@reset="resetPage"
|
||||
/>
|
||||
|
||||
<MappingResultPanel
|
||||
v-model:active-result-tab="activeResultTab"
|
||||
:response-status-text="responseStatusText"
|
||||
:response-status-tag-type="responseStatusTagType"
|
||||
:mapping-meta-text="mappingMetaText"
|
||||
:mapping-json-preview="mappingJsonPreview"
|
||||
:problem-tab-label="problemTabLabel"
|
||||
:problem-list="problemList"
|
||||
:problem-empty-text="problemEmptyText"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MappingConfigPanel
|
||||
v-model:request-form="requestForm"
|
||||
v-model:draft-groups="configDraft"
|
||||
:candidate-groups="parsedCandidates"
|
||||
:is-submitting="isSubmitting"
|
||||
:can-generate="canGenerate"
|
||||
:template-error="templateError"
|
||||
@generate="handleGenerateMapping"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
```scss
|
||||
.mms-mapping-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 0.9fr) minmax(0, 1.1fr);
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.left-panel-stack {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.mms-mapping-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run lint and type-check on the full frontend after container integration**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd frontend
|
||||
npm run lint
|
||||
npm run type-check
|
||||
```
|
||||
|
||||
Expected: both commands exit with code `0`; the lint step may rewrite formatting, so inspect the diff before committing.
|
||||
|
||||
- [ ] **Step 5: Commit the new page flow**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
git add frontend/src/views/tools/mmsmapping/index.vue frontend/src/views/tools/mmsmapping/components/MappingRequestPanel.vue frontend/src/views/tools/mmsmapping/components/MappingResultPanel.vue frontend/src/views/tools/mmsmapping/components/MappingConfigPanel.vue frontend/src/views/tools/mmsmapping/utils/defaultCfg.ts frontend/src/views/tools/mmsmapping/utils/mappingDraft.ts frontend/src/api/tools/mmsmapping/interface/index.ts
|
||||
git commit -m "feat: rebuild mmsmapping page workflow"
|
||||
```
|
||||
|
||||
### Task 5: Verify The Two-Phase Workflow Manually
|
||||
|
||||
**Files:**
|
||||
- Verify only: `frontend/src/views/tools/mmsmapping/index.vue`
|
||||
- Verify only: `frontend/src/views/tools/mmsmapping/components/MappingRequestPanel.vue`
|
||||
- Verify only: `frontend/src/views/tools/mmsmapping/components/MappingResultPanel.vue`
|
||||
- Verify only: `frontend/src/views/tools/mmsmapping/components/MappingConfigPanel.vue`
|
||||
- Verify only: `frontend/src/views/tools/mmsmapping/utils/defaultCfg.ts`
|
||||
- Verify only: `frontend/src/views/tools/mmsmapping/utils/mappingDraft.ts`
|
||||
|
||||
- [ ] **Step 1: Run the full static verification suite one more time**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd frontend
|
||||
npm run lint
|
||||
npm run type-check
|
||||
```
|
||||
|
||||
Expected: both commands exit with code `0`.
|
||||
|
||||
- [ ] **Step 2: Start the frontend and open the MMS mapping route**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Expected: Vite starts successfully and prints a local URL. Open the page route that resolves to `/tools/mmsMapping`.
|
||||
|
||||
- [ ] **Step 3: Verify the parse flow**
|
||||
|
||||
Manual checklist:
|
||||
|
||||
```text
|
||||
1. 进入页面后,左上仅看到文件选择、解析按钮和状态标签。
|
||||
2. 选择一个合法 ICD 文件后,左上状态变为“待提交”或同类准备状态。
|
||||
3. 点击“解析 ICD”后,右侧出现 version/author 表单、默认模板分组、候选辅助信息。
|
||||
4. 左下不出现 icdDocument 树;只显示 mappingJson/problems 页签。
|
||||
5. 若后端返回 NEED_INDEX_SELECTION,左下默认切到 problems。
|
||||
```
|
||||
|
||||
Expected: all five observations are true.
|
||||
|
||||
- [ ] **Step 4: Verify repeated mapping generation without re-parsing**
|
||||
|
||||
Manual checklist:
|
||||
|
||||
```text
|
||||
1. 在右侧选择一个模板分组,补齐 reportName、dataSetName、lnInst。
|
||||
2. 点击“生成映射”,确认左下显示新的 mappingJson 或新的 problems。
|
||||
3. 不重新点击“解析 ICD”,直接修改右侧任意一行的 lnInst。
|
||||
4. 再次点击“生成映射”,确认左下结果刷新为第二次生成结果。
|
||||
5. 若第二次返回 NEED_INDEX_SELECTION 或 FAILED,右侧已编辑内容仍然保留。
|
||||
```
|
||||
|
||||
Expected: repeated generation works on the same parsed candidate set.
|
||||
|
||||
- [ ] **Step 5: Verify reset and file replacement behavior**
|
||||
|
||||
Manual checklist:
|
||||
|
||||
```text
|
||||
1. 点击“清空”,确认左下结果、右侧草稿、当前候选缓存全部清空。
|
||||
2. 重新选择另一个 ICD 文件,确认旧的候选和草稿不会继续显示。
|
||||
3. 重新点击“解析 ICD”后,右侧根据新文件重新生成默认模板。
|
||||
```
|
||||
|
||||
Expected: reset and file replacement force a fresh parse cycle.
|
||||
|
||||
## Self-Review
|
||||
|
||||
- Spec coverage: the tasks cover left-top simplification, left-bottom result-only output, right-side auto-generated template editing, hidden request defaults, repeated generation, candidate matching, template-parse failure handling, and lint/type-check/manual validation.
|
||||
- Placeholder scan: no `TODO`/`TBD`/“later” markers remain; every task includes exact file paths, code blocks, commands, and expected results.
|
||||
- Type consistency: the plan uses the same names throughout: `BaseRequestForm`, `DefaultCfgTemplate`, `MappingDraftGroup`, `parseDefaultCfgTemplate`, `buildDraftGroups`, `validateDraftGroups`, `buildIndexSelectionPayload`, and `createBaseRequestPayload`.
|
||||
Reference in New Issue
Block a user