新建监控功能
This commit is contained in:
@@ -1,212 +0,0 @@
|
||||
# parseComtradeVector API 文档
|
||||
|
||||
## 1. 接口概述
|
||||
|
||||
- 接口名称:解析 COMTRADE 向量与电能质量指标
|
||||
- Controller:[WaveController.java](D:/Work/SourceCode/CN_Tool/tools/wave-tool/src/main/java/com/njcn/gather/tool/wave/controller/WaveController.java)
|
||||
- 方法:`parseComtradeVector`
|
||||
- 请求路径:`POST /wave/parseComtradeVector`
|
||||
- Content-Type:`multipart/form-data`
|
||||
- 返回类型:`HttpResult<WaveComtradeVectorResultVO>`
|
||||
|
||||
用途说明:
|
||||
|
||||
- 上传一组 COMTRADE `cfg/dat` 文件
|
||||
- 按原始波形逐周波计算电能质量指标
|
||||
- 返回总有效值、基波相角、谐波指标、序分量与不平衡度
|
||||
|
||||
## 2. 请求参数
|
||||
|
||||
### 2.1 文件参数
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `cfgFile` | file | 是 | COMTRADE 配置文件 `.cfg` |
|
||||
| `datFile` | file | 是 | COMTRADE 数据文件 `.dat` |
|
||||
|
||||
### 2.2 表单参数
|
||||
|
||||
参数定义来源:[WaveComtradeParseParam.java](D:/Work/SourceCode/CN_Tool/tools/wave-tool/src/main/java/com/njcn/gather/tool/wave/pojo/param/WaveComtradeParseParam.java)
|
||||
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `parseType` | integer | 否 | `3` | 本接口内部固定按原始波形口径计算,建议传 `3` |
|
||||
| `ptType` | integer | 否 | `0` | PT 接线方式:`0` 星形,`1` 三角,`2` 开口三角 |
|
||||
| `pt` | number | 否 | `1` | PT 变比,电压结果按 `pt/1000` 换算为 `kV` |
|
||||
| `ct` | number | 否 | `1` | CT 变比,电流结果按 `ct` 换算为 `A` |
|
||||
| `monitorName` | string | 否 | `未命名测点` | 测点名称 |
|
||||
|
||||
## 3. 调试请求示例
|
||||
|
||||
### 3.1 curl
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8080/wave/parseComtradeVector" \
|
||||
-F "cfgFile=@D:/00-B7-8D-00-E4-09/1_20260321_201458_748.CFG" \
|
||||
-F "datFile=@D:/00-B7-8D-00-E4-09/1_20260321_201458_748.DAT" \
|
||||
-F "parseType=3" \
|
||||
-F "ptType=0" \
|
||||
-F "pt=1" \
|
||||
-F "ct=1" \
|
||||
-F "monitorName=监测点1"
|
||||
```
|
||||
|
||||
### 3.2 Apifox / Postman
|
||||
|
||||
- Method:`POST`
|
||||
- URL:`http://localhost:8080/wave/parseComtradeVector`
|
||||
- Body:`form-data`
|
||||
|
||||
| Key | Type | 示例值 |
|
||||
| --- | --- | --- |
|
||||
| `cfgFile` | File | 选择 `.cfg` 文件 |
|
||||
| `datFile` | File | 选择 `.dat` 文件 |
|
||||
| `parseType` | Text | `3` |
|
||||
| `ptType` | Text | `0` |
|
||||
| `pt` | Text | `1` |
|
||||
| `ct` | Text | `1` |
|
||||
| `monitorName` | Text | `监测点1` |
|
||||
|
||||
## 4. 响应结构
|
||||
|
||||
### 4.1 data 字段
|
||||
|
||||
定义来源:[WaveComtradeVectorResultVO.java](D:/Work/SourceCode/CN_Tool/tools/wave-tool/src/main/java/com/njcn/gather/tool/wave/pojo/vo/WaveComtradeVectorResultVO.java)
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `monitorName` | string | 测点名称 |
|
||||
| `time` | string | 事件发生时刻 |
|
||||
| `samplePerCycle` | integer | 每周波采样点数 |
|
||||
| `cycleCount` | integer | 可计算周波数 |
|
||||
| `vectorGroups` | array | 各电压/电流组的逐周波电能质量结果 |
|
||||
|
||||
### 4.2 vectorGroups
|
||||
|
||||
定义来源:[WaveVectorGroupDTO.java](D:/Work/SourceCode/CN_Tool/tools/wave-tool/src/main/java/com/njcn/gather/tool/wave/pojo/dto/WaveVectorGroupDTO.java)
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `channelName` | string | 通道名称,例如 `U1`、`I1` |
|
||||
| `unit` | string | 单位,电压组为 `kV`,电流组为 `A` |
|
||||
| `phaseCount` | integer | 相别数量 |
|
||||
| `phaseNames` | array<string> | 相别名称列表,例如 `A相/B相/C相` |
|
||||
| `vectorSeries` | array | 当前组的逐周波结果序列 |
|
||||
|
||||
### 4.3 vectorSeries
|
||||
|
||||
定义来源:[WaveCycleVectorDTO.java](D:/Work/SourceCode/CN_Tool/tools/wave-tool/src/main/java/com/njcn/gather/tool/wave/pojo/dto/WaveCycleVectorDTO.java)
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `cycleIndex` | integer | 周波序号,从 `0` 开始 |
|
||||
| `time` | number | 当前周波中点时刻,单位毫秒 |
|
||||
| `phaseVectors` | array | 各相结果 |
|
||||
| `positiveSequence` | object | 正序分量 |
|
||||
| `negativeSequence` | object | 负序分量 |
|
||||
| `zeroSequence` | object | 零序分量 |
|
||||
| `unbalance` | object | 负序/零序不平衡度 |
|
||||
|
||||
### 4.4 phaseVectors
|
||||
|
||||
定义来源:[WavePhaseVectorDTO.java](D:/Work/SourceCode/CN_Tool/tools/wave-tool/src/main/java/com/njcn/gather/tool/wave/pojo/dto/WavePhaseVectorDTO.java)
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `phaseName` | string | 相别名称 |
|
||||
| `totalRms` | number | 电压/电流总有效值 |
|
||||
| `fundamentalAmplitude` | number | 基波幅值 |
|
||||
| `fundamentalRms` | number | 基波有效值 |
|
||||
| `fundamentalPhaseAngle` | number | 基波相角,单位度 |
|
||||
| `harmonicVoltageContentRates` | array | 仅电压组返回,2~50 次谐波电压含有率 |
|
||||
| `harmonicCurrentAmplitudes` | array | 仅电流组返回,2~50 次谐波电流幅值 |
|
||||
| `harmonicDistortionRate` | number | 谐波畸变率,百分比 |
|
||||
|
||||
### 4.5 谐波对象
|
||||
|
||||
定义来源:[WaveHarmonicDTO.java](D:/Work/SourceCode/CN_Tool/tools/wave-tool/src/main/java/com/njcn/gather/tool/wave/pojo/dto/WaveHarmonicDTO.java)
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `harmonicOrder` | integer | 谐波次数,当前范围 `2~50` |
|
||||
| `amplitude` | number | 谐波幅值 |
|
||||
| `rms` | number | 谐波有效值 |
|
||||
| `rate` | number | 谐波占基波比率,百分比,仅电压组使用 |
|
||||
|
||||
### 4.6 序分量与不平衡度
|
||||
|
||||
定义来源:
|
||||
- [WaveSequenceVectorDTO.java](D:/Work/SourceCode/CN_Tool/tools/wave-tool/src/main/java/com/njcn/gather/tool/wave/pojo/dto/WaveSequenceVectorDTO.java)
|
||||
- [WaveSequenceUnbalanceDTO.java](D:/Work/SourceCode/CN_Tool/tools/wave-tool/src/main/java/com/njcn/gather/tool/wave/pojo/dto/WaveSequenceUnbalanceDTO.java)
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `sequenceName` | string | 序分量名称 |
|
||||
| `amplitude` | number | 序分量幅值 |
|
||||
| `rms` | number | 序分量有效值 |
|
||||
| `phaseAngle` | number | 序分量相角 |
|
||||
| `negativeUnbalanceRate` | number | 负序不平衡度,`负序/正序 * 100%` |
|
||||
| `zeroUnbalanceRate` | number | 零序不平衡度,`零序/正序 * 100%` |
|
||||
|
||||
## 5. 成功响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "SUCCESS",
|
||||
"message": "成功",
|
||||
"data": {
|
||||
"monitorName": "监测点1",
|
||||
"time": "2026-03-21 20:14:58.748",
|
||||
"samplePerCycle": 512,
|
||||
"cycleCount": 30,
|
||||
"vectorGroups": [
|
||||
{
|
||||
"channelName": "U1",
|
||||
"unit": "kV",
|
||||
"phaseCount": 3,
|
||||
"phaseNames": ["A相", "B相", "C相"],
|
||||
"vectorSeries": [
|
||||
{
|
||||
"cycleIndex": 0,
|
||||
"time": -90.0,
|
||||
"phaseVectors": [
|
||||
{
|
||||
"phaseName": "A相",
|
||||
"totalRms": 104.9367,
|
||||
"fundamentalAmplitude": 148.4032,
|
||||
"fundamentalRms": 104.9367,
|
||||
"fundamentalPhaseAngle": 1.3258,
|
||||
"harmonicVoltageContentRates": [
|
||||
{ "harmonicOrder": 2, "amplitude": 0.4213, "rms": 0.2979, "rate": 0.2839 },
|
||||
{ "harmonicOrder": 3, "amplitude": 0.3187, "rms": 0.2254, "rate": 0.2147 }
|
||||
],
|
||||
"harmonicDistortionRate": 1.1284
|
||||
}
|
||||
],
|
||||
"positiveSequence": { "sequenceName": "正序", "amplitude": 148.1021, "rms": 104.7238, "phaseAngle": 0.9864 },
|
||||
"negativeSequence": { "sequenceName": "负序", "amplitude": 0.8632, "rms": 0.6104, "phaseAngle": -117.6241 },
|
||||
"zeroSequence": { "sequenceName": "零序", "amplitude": 0.2261, "rms": 0.1599, "phaseAngle": 86.3174 },
|
||||
"unbalance": { "negativeUnbalanceRate": 0.5828, "zeroUnbalanceRate": 0.1527 }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 失败场景
|
||||
|
||||
| 场景 | 说明 |
|
||||
| --- | --- |
|
||||
| `cfgFile` 或 `datFile` 未上传 | 返回业务异常,提示“cfg 或 dat 文件不能为空” |
|
||||
| CFG 文件格式错误 | 返回 CFG 解析失败 |
|
||||
| DAT 文件为空或格式错误 | 返回 DAT 解析失败 |
|
||||
| 采样点不足一个周波 | 返回波形文件数据缺失或向量计算失败 |
|
||||
| COMTRADE 向量计算过程中出现异常 | 返回“COMTRADE 向量计算失败” |
|
||||
|
||||
## 7. 备注
|
||||
|
||||
- 当前接口固定按原始波形口径计算,不依赖 `parseComtrade` 的 RMS 或特征值开关。
|
||||
- 当前谐波范围默认计算 `2~50` 次。
|
||||
- 如果单周波采样点数过低,高次谐波指标会受分辨率限制。
|
||||
@@ -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`.
|
||||
@@ -77,7 +77,8 @@ export namespace DiskMonitor {
|
||||
}
|
||||
|
||||
export interface JobListItem {
|
||||
id: number
|
||||
id?: number
|
||||
jobId?: number
|
||||
jobNo: string
|
||||
jobSource: JobSource
|
||||
startedAt: string
|
||||
|
||||
31
frontend/src/api/tools/mmsmapping/index.ts
Normal file
31
frontend/src/api/tools/mmsmapping/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import http from '@/api'
|
||||
import type { MmsMapping } from './interface'
|
||||
|
||||
const buildIcdFormData = (icdFile: File) => {
|
||||
const formData = new FormData()
|
||||
|
||||
formData.append('icdFile', icdFile)
|
||||
|
||||
return formData
|
||||
}
|
||||
|
||||
export const getIcdApi = (params: MmsMapping.GetIcdParams) => {
|
||||
const formData = buildIcdFormData(params.icdFile)
|
||||
|
||||
// 关键业务节点:解析 ICD 按钮改走独立 get-icd 接口,只上传当前选择的 ICD 文件。
|
||||
return http.post<MmsMapping.MappingTaskResponse>('/api/mms-mapping/get-icd', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
export const getIcdMmsJsonApi = (params: MmsMapping.GetIcdMmsJsonParams) => {
|
||||
const formData = buildIcdFormData(params.icdFile)
|
||||
|
||||
// 接口文档要求 request 以 application/json 分段提交,避免后端按普通字符串丢失 JSON 结构。
|
||||
formData.append('request', new Blob([JSON.stringify(params.request)], { type: 'application/json' }))
|
||||
|
||||
// 关键业务节点:生成映射仍走 get-icd-mms-json,提交时保持 icdFile + request 的 multipart 结构。
|
||||
return http.post<MmsMapping.MappingTaskResponse>('/api/mms-mapping/get-icd-mms-json', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
68
frontend/src/api/tools/mmsmapping/interface/index.ts
Normal file
68
frontend/src/api/tools/mmsmapping/interface/index.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
export namespace MmsMapping {
|
||||
export interface GetIcdParams {
|
||||
icdFile: File
|
||||
}
|
||||
|
||||
export interface IndexSelectionBinding {
|
||||
reportName: string
|
||||
dataSetName: string
|
||||
label: string
|
||||
lnInst: string
|
||||
}
|
||||
|
||||
export interface IndexSelectionGroup {
|
||||
groupKey: string
|
||||
groupDesc?: string
|
||||
bindings: IndexSelectionBinding[]
|
||||
}
|
||||
|
||||
export interface GetIcdMmsJsonRequestPayload {
|
||||
version: string
|
||||
author: string
|
||||
saveToDisk: boolean
|
||||
prettyJson: boolean
|
||||
outputDir: string
|
||||
indexSelection: IndexSelectionGroup[]
|
||||
}
|
||||
|
||||
export interface GetIcdMmsJsonParams {
|
||||
icdFile: File
|
||||
request: GetIcdMmsJsonRequestPayload
|
||||
}
|
||||
|
||||
export interface IcdDocument {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface IndexCandidateReport {
|
||||
reportName?: string
|
||||
dataSetName?: string
|
||||
reportDesc?: string
|
||||
availableLnInstValues?: string[]
|
||||
}
|
||||
|
||||
export interface IndexCandidateGroup {
|
||||
groupKey?: string
|
||||
groupDesc?: string
|
||||
reportCount?: number
|
||||
templateLabels?: string[]
|
||||
reports?: IndexCandidateReport[]
|
||||
}
|
||||
|
||||
export type MappingTaskStatus = 'SUCCESS' | 'NEED_INDEX_SELECTION' | 'FAILED' | (string & {})
|
||||
|
||||
export interface MappingTaskResponse {
|
||||
status?: MappingTaskStatus
|
||||
message?: string
|
||||
icdDocument?: IcdDocument
|
||||
mappingJson?: string
|
||||
savedPath?: string
|
||||
indexCandidates?: IndexCandidateGroup[]
|
||||
problems?: string[]
|
||||
}
|
||||
|
||||
export interface BaseRequestForm {
|
||||
version: string
|
||||
author: string
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ 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 映射'
|
||||
}
|
||||
|
||||
1034
frontend/src/views/systemMonitor/2026-04-22-disk-monitor-design.md
Normal file
1034
frontend/src/views/systemMonitor/2026-04-22-disk-monitor-design.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -13,9 +13,21 @@
|
||||
<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">{{ props.detail.job.jobStatus }}</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>
|
||||
@@ -28,38 +40,71 @@
|
||||
</div>
|
||||
|
||||
<section class="table-section">
|
||||
<h4 class="section-title">results</h4>
|
||||
<h4 class="section-title">盘符结果</h4>
|
||||
<el-table :data="props.detail.results" border stripe>
|
||||
<el-table-column prop="resultId" label="resultId" min-width="90" />
|
||||
<el-table-column prop="targetId" label="targetId" min-width="90" />
|
||||
<el-table-column prop="driveLetter" label="driveLetter" min-width="100" />
|
||||
<el-table-column label="usedPercent" min-width="110">
|
||||
<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 prop="currentStatus" label="currentStatus" min-width="130" />
|
||||
<el-table-column prop="previousStatus" label="previousStatus" min-width="130" />
|
||||
<el-table-column prop="statusChanged" label="statusChanged" min-width="120" />
|
||||
<el-table-column prop="shouldNotify" label="shouldNotify" min-width="120" />
|
||||
<el-table-column prop="notifyReason" label="notifyReason" min-width="150" />
|
||||
<el-table-column label="scanTime" min-width="170">
|
||||
<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="message" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="message" label="说明" min-width="180" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<section class="table-section">
|
||||
<h4 class="section-title">notifyLogs</h4>
|
||||
<h4 class="section-title">通知日志</h4>
|
||||
<el-table :data="props.detail.notifyLogs" border stripe>
|
||||
<el-table-column prop="id" label="id" min-width="80" />
|
||||
<el-table-column prop="resultId" label="resultId" min-width="90" />
|
||||
<el-table-column prop="driveLetter" label="driveLetter" min-width="100" />
|
||||
<el-table-column prop="notifyLevel" label="notifyLevel" min-width="110" />
|
||||
<el-table-column prop="channelType" label="channelType" min-width="120" />
|
||||
<el-table-column prop="channelTarget" label="channelTarget" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="sendStatus" label="sendStatus" min-width="110" />
|
||||
<el-table-column prop="responseMessage" label="responseMessage" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="sentAt" min-width="170">
|
||||
<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>
|
||||
@@ -88,6 +133,70 @@ const emit = defineEmits<{
|
||||
'update:visible': [value: boolean]
|
||||
}>()
|
||||
|
||||
const getSourceLabel = (source: DiskMonitor.JobSource) => {
|
||||
if (source === 'APP_START') return '应用启动'
|
||||
if (source === 'DAILY_SCHEDULE') return '定时任务'
|
||||
return '手动触发'
|
||||
}
|
||||
|
||||
const getJobStatusLabel = (status: DiskMonitor.JobStatus) => {
|
||||
if (status === 'SUCCESS') return '成功'
|
||||
if (status === 'PARTIAL_SUCCESS') return '部分成功'
|
||||
if (status === 'FAILED') return '失败'
|
||||
return '运行中'
|
||||
}
|
||||
|
||||
const getMonitorStatusType = (status: DiskMonitor.MonitorStatus) => {
|
||||
if (status === 'NORMAL') return 'success'
|
||||
if (status === 'WARNING') return 'warning'
|
||||
if (status === 'ALARM') return 'danger'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
const getMonitorStatusLabel = (status: DiskMonitor.MonitorStatus) => {
|
||||
if (status === 'NORMAL') return '正常'
|
||||
if (status === 'WARNING') return '预警'
|
||||
if (status === 'ALARM') return '告警'
|
||||
return '未知'
|
||||
}
|
||||
|
||||
const getNotifyReasonLabel = (reason: DiskMonitor.ResultItem['notifyReason']) => {
|
||||
if (reason === 'ALARM_EVERY_TIME') return '告警每次通知'
|
||||
if (reason === 'STATUS_CHANGED') return '状态变化通知'
|
||||
return '本次不通知'
|
||||
}
|
||||
|
||||
const getNotifyLevelType = (level: DiskMonitor.NotifyLevel) => {
|
||||
if (level === 'WARNING') return 'warning'
|
||||
if (level === 'ALARM') return 'danger'
|
||||
return 'success'
|
||||
}
|
||||
|
||||
const getNotifyLevelLabel = (level: DiskMonitor.NotifyLevel) => {
|
||||
if (level === 'WARNING') return '预警'
|
||||
if (level === 'ALARM') return '告警'
|
||||
return '恢复'
|
||||
}
|
||||
|
||||
const getChannelTypeLabel = (type: DiskMonitor.NotifyChannelType) => {
|
||||
if (type === 'PATH') return '路径通知'
|
||||
return 'HTTP 回调'
|
||||
}
|
||||
|
||||
const getSendStatusType = (status: DiskMonitor.NotifySendStatus) => {
|
||||
if (status === 'SUCCESS') return 'success'
|
||||
return 'danger'
|
||||
}
|
||||
|
||||
const getSendStatusLabel = (status: DiskMonitor.NotifySendStatus) => {
|
||||
if (status === 'SUCCESS') return '成功'
|
||||
return '失败'
|
||||
}
|
||||
|
||||
const formatBoolean = (value: boolean) => {
|
||||
return value ? '是' : '否'
|
||||
}
|
||||
|
||||
const formatTime = (value?: string | null) => {
|
||||
if (!value) return '--'
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
|
||||
|
||||
@@ -14,20 +14,25 @@
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">监控盘符数量</div>
|
||||
<div class="summary-value">{{ targets.length }}</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">{{ latestJob?.jobStatus || '--' }}</div>
|
||||
<div class="summary-value">{{ latestJobStatus }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs'
|
||||
import { computed } from 'vue'
|
||||
import type { DiskMonitor } from '@/api/system/diskMonitor/interface'
|
||||
|
||||
@@ -41,7 +46,24 @@ const props = defineProps<{
|
||||
latestJob: DiskMonitor.JobListItem | null
|
||||
}>()
|
||||
|
||||
const alarmCount = computed(() => props.targets.filter(item => item.lastStatus === 'ALARM').length)
|
||||
const monitorTargetCount = computed(() => props.targets.filter(item => item.monitorEnabled).length)
|
||||
const alarmCount = computed(
|
||||
() => props.targets.filter(item => item.monitorEnabled && item.lastStatus === 'ALARM').length
|
||||
)
|
||||
|
||||
const latestRunTime = computed(() => {
|
||||
if (!props.latestJob?.startedAt) return '--'
|
||||
return dayjs(props.latestJob.startedAt).format('YYYY-MM-DD HH:mm:ss')
|
||||
})
|
||||
|
||||
const latestJobStatus = computed(() => {
|
||||
const status = props.latestJob?.jobStatus
|
||||
if (!status) return '--'
|
||||
if (status === 'SUCCESS') return '成功'
|
||||
if (status === 'PARTIAL_SUCCESS') return '部分成功'
|
||||
if (status === 'FAILED') return '失败'
|
||||
return '运行中'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -97,6 +97,16 @@ const loading = reactive({
|
||||
})
|
||||
const formBusy = computed(() => loading.init || loading.save || loading.run)
|
||||
|
||||
const resolveJobId = (job: DiskMonitor.JobListItem) => {
|
||||
return job.id ?? job.jobId ?? 0
|
||||
}
|
||||
|
||||
const getTimeValue = (value?: string | null) => {
|
||||
if (!value) return 0
|
||||
const timestamp = new Date(value).getTime()
|
||||
return Number.isNaN(timestamp) ? 0 : timestamp
|
||||
}
|
||||
|
||||
const handleBack = async () => {
|
||||
await router.push('/systemMonitor')
|
||||
}
|
||||
@@ -163,7 +173,10 @@ const loadPolicyDetail = async () => {
|
||||
const detail = response.data
|
||||
if (!detail) return
|
||||
|
||||
policyForm.value = detail.policy || createDefaultPolicy()
|
||||
policyForm.value = {
|
||||
...createDefaultPolicy(),
|
||||
...(detail.policy || {})
|
||||
}
|
||||
// 后端列表字段允许为空,这里统一归一化为数组,避免编辑器和克隆流程出现空引用
|
||||
targetList.value = (detail.targets || []).map(item => normalizeTargetItem(item))
|
||||
}
|
||||
@@ -178,13 +191,15 @@ const loadJobList = async () => {
|
||||
sortField: 'startedAt',
|
||||
sortOrder: 'desc'
|
||||
})
|
||||
const records = [...(response.data?.records || [])].sort((a, b) => {
|
||||
const first = new Date(a.startedAt).getTime()
|
||||
const second = new Date(b.startedAt).getTime()
|
||||
return second - first
|
||||
const records = (response.data?.records || []).map(item => ({
|
||||
...item,
|
||||
id: resolveJobId(item)
|
||||
}))
|
||||
const sortedRecords = [...records].sort((a, b) => {
|
||||
return getTimeValue(b.startedAt) - getTimeValue(a.startedAt)
|
||||
})
|
||||
// 前端保留 startedAt 的兜底排序,同时通过显式排序参数要求后端返回真正的最新任务。
|
||||
jobList.value = records.slice(0, 10)
|
||||
jobList.value = sortedRecords.slice(0, 10)
|
||||
latestJob.value = jobList.value[0] || null
|
||||
} finally {
|
||||
loading.jobs = false
|
||||
@@ -202,6 +217,12 @@ const handleJobDetailVisibleChange = (visible: boolean) => {
|
||||
}
|
||||
|
||||
const openJobDetail = async (row: DiskMonitor.JobListItem) => {
|
||||
const jobId = resolveJobId(row)
|
||||
if (!jobId) {
|
||||
ElMessage.warning('当前任务缺少任务 ID,无法查看详情')
|
||||
return
|
||||
}
|
||||
|
||||
const currentSeq = jobDetailRequestSeq.value + 1
|
||||
jobDetailRequestSeq.value = currentSeq
|
||||
jobDetailVisible.value = true
|
||||
@@ -210,7 +231,7 @@ const openJobDetail = async (row: DiskMonitor.JobListItem) => {
|
||||
|
||||
try {
|
||||
// 仅允许最新一次详情请求回写,避免快速切换任务导致脏数据覆盖
|
||||
const response = await getDiskMonitorJobDetail(row.id)
|
||||
const response = await getDiskMonitorJobDetail(jobId)
|
||||
if (currentSeq !== jobDetailRequestSeq.value || !jobDetailVisible.value) return
|
||||
jobDetail.value = response.data || null
|
||||
} finally {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { DiskMonitor } from '@/api/system/diskMonitor/interface'
|
||||
|
||||
const DRIVE_LETTER_PATTERN = /^[A-Z]:$/
|
||||
const TIME_PATTERN = /^([01]\d|2[0-3]):[0-5]\d:[0-5]\d$/
|
||||
|
||||
export const createDefaultPolicy = (): DiskMonitor.PolicyItem => ({
|
||||
policyName: '默认磁盘监控策略',
|
||||
@@ -43,6 +44,7 @@ export const createEmptyTarget = (): DiskMonitor.TargetItem => ({
|
||||
|
||||
export const validatePolicy = (policy: DiskMonitor.PolicyItem) => {
|
||||
if (!policy.dailyRunTime) return '每日统一执行时间不能为空'
|
||||
if (!TIME_PATTERN.test(policy.dailyRunTime)) return '每日统一执行时间格式必须为 HH:mm:ss'
|
||||
return ''
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,43 +1,383 @@
|
||||
<template>
|
||||
<div class="mms-mapping-view">
|
||||
<div class="mms-mapping-card">
|
||||
<h1 class="page-title">MMS 映射</h1>
|
||||
<p class="page-description">当前页面已创建,后续可在这里接入 MMS 映射配置、映射预览和导入导出能力。</p>
|
||||
<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 || indexSelectionJsonText.trim())"
|
||||
@file-change="handleIcdFileChange"
|
||||
@parse="handleParseIcd"
|
||||
@reset="resetPage"
|
||||
/>
|
||||
|
||||
<MappingConfigPanel
|
||||
v-if="showConfigPanel"
|
||||
v-model:index-selection-json="indexSelectionJsonText"
|
||||
:is-submitting="isSubmitting"
|
||||
:can-generate="canGenerate"
|
||||
:json-error="indexSelectionError"
|
||||
:show-generate-button="showGenerateButton"
|
||||
:has-default-json="Boolean(indexSelectionJsonText.trim())"
|
||||
:empty-description="configEmptyDescription"
|
||||
@generate="handleGenerateMapping"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
:can-export-mapping="Boolean(mappingJsonPreview)"
|
||||
@export-mapping="handleExportMapping"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { ResultData } from '@/api/interface'
|
||||
import { getIcdApi, 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 { buildDefaultIndexSelection, formatIndexSelectionJson, parseIndexSelectionJson } from './utils/indexSelection'
|
||||
import { createBaseRequestPayload } from './utils/requestPayload'
|
||||
|
||||
defineOptions({
|
||||
name: 'MmsMappingView'
|
||||
})
|
||||
|
||||
type ResultTab = 'mapping' | 'problem'
|
||||
type TagType = 'success' | 'warning' | 'info' | 'primary' | 'danger'
|
||||
|
||||
const DEFAULT_REQUEST_FORM: MmsMapping.BaseRequestForm = {
|
||||
version: '1.0',
|
||||
author: 'system'
|
||||
}
|
||||
|
||||
const selectedIcdFile = ref<File | null>(null)
|
||||
const responsePayload = ref<MmsMapping.MappingTaskResponse | null>(null)
|
||||
const activeResultTab = ref<ResultTab>('mapping')
|
||||
const parsedCandidates = ref<MmsMapping.IndexCandidateGroup[]>([])
|
||||
const indexSelectionJsonText = ref('')
|
||||
const isParsing = ref(false)
|
||||
const isGenerating = ref(false)
|
||||
const icdFileAccept = '.icd,.cid,.scd,.xml'
|
||||
const problemEmptyText = '当前返回未包含 problems'
|
||||
|
||||
function unwrapApiPayload<T>(response: ResultData<T> | T): T {
|
||||
if (response && typeof response === 'object' && 'data' in response) {
|
||||
return (response as ResultData<T>).data
|
||||
}
|
||||
|
||||
return response as T
|
||||
}
|
||||
|
||||
const getErrorMessage = (error: unknown) => {
|
||||
if (error instanceof Error) return error.message
|
||||
return '接口调用失败,请检查后端服务和请求参数'
|
||||
}
|
||||
|
||||
const getFileExtension = (fileName: string) => fileName.split('.').pop()?.toLowerCase() || ''
|
||||
|
||||
const isSupportedIcdFile = (fileName: string) => ['icd', 'cid', 'scd', 'xml'].includes(getFileExtension(fileName))
|
||||
|
||||
const parsedIndexSelectionState = computed(() => {
|
||||
const source = indexSelectionJsonText.value.trim()
|
||||
|
||||
if (!source) {
|
||||
return {
|
||||
value: [] as MmsMapping.IndexSelectionGroup[],
|
||||
error: parsedCandidates.value.length ? 'request.indexSelection 不能为空' : ''
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
value: parseIndexSelectionJson(source),
|
||||
error: ''
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
value: [] as MmsMapping.IndexSelectionGroup[],
|
||||
error: getErrorMessage(error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const isSubmitting = computed(() => isParsing.value || isGenerating.value)
|
||||
const indexSelectionError = computed(() => parsedIndexSelectionState.value.error)
|
||||
const canGenerate = computed(
|
||||
() => Boolean(selectedIcdFile.value && indexSelectionJsonText.value.trim() && !indexSelectionError.value)
|
||||
)
|
||||
// 关键业务节点:请求配置区只在用户已经选择 ICD 后展示,避免初始态暴露无效的 JSON 编辑区和按钮。
|
||||
const showConfigPanel = computed(() => Boolean(selectedIcdFile.value))
|
||||
const showGenerateButton = computed(() => Boolean(selectedIcdFile.value))
|
||||
const configEmptyDescription = computed(() => {
|
||||
if (isParsing.value) return '正在根据当前 ICD 生成 request.indexSelection,请稍候。'
|
||||
if (selectedIcdFile.value) return '已选择 ICD 文件,请先点击“解析 ICD”生成 request.indexSelection。'
|
||||
return '当前 ICD 暂未生成可编辑的 request.indexSelection。'
|
||||
})
|
||||
const selectedIcdFileName = computed(() => selectedIcdFile.value?.name || '')
|
||||
|
||||
const requestStatusText = computed(() => {
|
||||
if (isParsing.value) return '解析中'
|
||||
if (isGenerating.value) return '生成中'
|
||||
if (selectedIcdFile.value && indexSelectionJsonText.value.trim()) return '已生成默认配置'
|
||||
if (selectedIcdFile.value) return '待解析'
|
||||
return '未选择文件'
|
||||
})
|
||||
|
||||
const requestStatusTagType = computed<TagType>(() => {
|
||||
if (isParsing.value || isGenerating.value) return 'warning'
|
||||
if (selectedIcdFile.value && indexSelectionJsonText.value.trim()) return 'success'
|
||||
if (selectedIcdFile.value) return 'primary'
|
||||
return 'info'
|
||||
})
|
||||
|
||||
const responseStatusText = computed(() => {
|
||||
if (isSubmitting.value) return '等待返回'
|
||||
return responsePayload.value?.status || '暂无结果'
|
||||
})
|
||||
|
||||
const responseStatusTagType = computed<TagType>(() => {
|
||||
if (isSubmitting.value) return 'warning'
|
||||
if (responsePayload.value?.status === 'FAILED') return 'danger'
|
||||
if (responsePayload.value?.status === 'NEED_INDEX_SELECTION') return 'warning'
|
||||
if (responsePayload.value) return 'success'
|
||||
return 'info'
|
||||
})
|
||||
|
||||
const mappingJsonPreview = computed(() => {
|
||||
const source = responsePayload.value?.mappingJson?.trim()
|
||||
|
||||
if (!source) return ''
|
||||
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(source), null, 4)
|
||||
} catch {
|
||||
return source
|
||||
}
|
||||
})
|
||||
|
||||
const mappingMetaText = computed(() => {
|
||||
if (!mappingJsonPreview.value) return '当前返回未包含 mappingJson'
|
||||
return `mappingJson ${mappingJsonPreview.value.length} 字符`
|
||||
})
|
||||
|
||||
const problemList = computed(() => responsePayload.value?.problems?.filter(Boolean) || [])
|
||||
|
||||
const problemTabLabel = computed(() => {
|
||||
if (!problemList.value.length) return '问题列表'
|
||||
return `问题列表(${problemList.value.length})`
|
||||
})
|
||||
|
||||
const resolveResultTab = (payload: MmsMapping.MappingTaskResponse | null): ResultTab => {
|
||||
if (payload?.mappingJson?.trim()) return 'mapping'
|
||||
if (payload?.problems?.filter(Boolean).length) return 'problem'
|
||||
return 'mapping'
|
||||
}
|
||||
|
||||
const stripProblemsFromIcdPayload = (payload: MmsMapping.MappingTaskResponse): MmsMapping.MappingTaskResponse => {
|
||||
// 关键业务节点:解析 ICD 只消费候选数据和文档结构,不把后端返回的 problems 绑定到结果区。
|
||||
const sanitizedPayload = { ...payload }
|
||||
|
||||
delete sanitizedPayload.problems
|
||||
|
||||
return sanitizedPayload
|
||||
}
|
||||
|
||||
const resetParsedState = () => {
|
||||
responsePayload.value = null
|
||||
parsedCandidates.value = []
|
||||
indexSelectionJsonText.value = ''
|
||||
activeResultTab.value = 'mapping'
|
||||
}
|
||||
|
||||
const handleIcdFileChange = (event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
|
||||
if (!file) return
|
||||
if (!isSupportedIcdFile(file.name)) {
|
||||
ElMessage.warning('请选择 ICD、CID、SCD 或 XML 文件')
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
// 关键业务节点:切换 ICD 文件后先只清空旧解析结果,等用户明确点击“解析 ICD”后再请求后台。
|
||||
selectedIcdFile.value = file
|
||||
resetParsedState()
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
const handleParseIcd = async () => {
|
||||
if (!selectedIcdFile.value) {
|
||||
ElMessage.warning('请先选择 ICD 文件')
|
||||
return
|
||||
}
|
||||
|
||||
isParsing.value = true
|
||||
responsePayload.value = null
|
||||
|
||||
try {
|
||||
// 关键业务节点:解析 ICD 时先走 get-icd,拿到当前文件的候选数据后再生成默认 request.indexSelection。
|
||||
const response = await getIcdApi({
|
||||
icdFile: selectedIcdFile.value
|
||||
})
|
||||
|
||||
const payload = unwrapApiPayload<MmsMapping.MappingTaskResponse>(response)
|
||||
const sanitizedPayload = stripProblemsFromIcdPayload(payload)
|
||||
const candidateGroups = payload.indexCandidates || []
|
||||
|
||||
responsePayload.value = sanitizedPayload
|
||||
activeResultTab.value = resolveResultTab(sanitizedPayload)
|
||||
|
||||
if (payload.status === 'FAILED') {
|
||||
parsedCandidates.value = []
|
||||
indexSelectionJsonText.value = ''
|
||||
ElMessage.error(payload.message || 'ICD 解析失败')
|
||||
return
|
||||
}
|
||||
|
||||
parsedCandidates.value = candidateGroups
|
||||
indexSelectionJsonText.value = formatIndexSelectionJson(buildDefaultIndexSelection(candidateGroups))
|
||||
ElMessage.success(payload.message || 'ICD 解析完成,已生成 request.indexSelection')
|
||||
} catch (error) {
|
||||
resetParsedState()
|
||||
ElMessage.error(getErrorMessage(error))
|
||||
} finally {
|
||||
isParsing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerateMapping = async () => {
|
||||
if (!selectedIcdFile.value) {
|
||||
ElMessage.warning('请先选择 ICD 文件')
|
||||
return
|
||||
}
|
||||
|
||||
if (!indexSelectionJsonText.value.trim()) {
|
||||
ElMessage.warning('请先解析 ICD,系统会自动生成 request.indexSelection')
|
||||
return
|
||||
}
|
||||
|
||||
const { error, value } = parsedIndexSelectionState.value
|
||||
if (error) {
|
||||
responsePayload.value = {
|
||||
status: 'NEED_INDEX_SELECTION',
|
||||
message: 'request.indexSelection 格式有误,请继续修正',
|
||||
problems: [error]
|
||||
}
|
||||
activeResultTab.value = 'problem'
|
||||
ElMessage.warning(error)
|
||||
return
|
||||
}
|
||||
|
||||
isGenerating.value = true
|
||||
responsePayload.value = null
|
||||
|
||||
try {
|
||||
// 关键业务节点:正式生成阶段只消费当前编辑区里的 request.indexSelection,确保导出的映射与页面编辑态一致。
|
||||
const response = await getIcdMmsJsonApi({
|
||||
icdFile: selectedIcdFile.value,
|
||||
request: {
|
||||
...createBaseRequestPayload(DEFAULT_REQUEST_FORM),
|
||||
indexSelection: value
|
||||
}
|
||||
})
|
||||
|
||||
const payload = unwrapApiPayload<MmsMapping.MappingTaskResponse>(response)
|
||||
|
||||
responsePayload.value = payload
|
||||
activeResultTab.value = resolveResultTab(payload)
|
||||
|
||||
if (payload.status === 'FAILED') {
|
||||
ElMessage.error(payload.message || '映射生成失败')
|
||||
return
|
||||
}
|
||||
|
||||
if (payload.status === 'NEED_INDEX_SELECTION') {
|
||||
ElMessage.warning(payload.message || '当前配置仍需补充索引信息')
|
||||
return
|
||||
}
|
||||
|
||||
ElMessage.success(payload.message || '映射生成完成')
|
||||
} catch (error) {
|
||||
responsePayload.value = null
|
||||
ElMessage.error(getErrorMessage(error))
|
||||
} finally {
|
||||
isGenerating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const buildExportFileName = () => {
|
||||
const baseFileName = selectedIcdFile.value?.name.replace(/\.[^.]+$/, '') || 'mapping-summary'
|
||||
return `${baseFileName}-mapping-summary.json`
|
||||
}
|
||||
|
||||
const handleExportMapping = () => {
|
||||
if (!mappingJsonPreview.value) {
|
||||
ElMessage.warning('当前没有可导出的映射摘要')
|
||||
return
|
||||
}
|
||||
|
||||
const blob = new Blob([mappingJsonPreview.value], { type: 'application/json;charset=utf-8' })
|
||||
const objectUrl = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
|
||||
link.href = objectUrl
|
||||
link.download = buildExportFileName()
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(objectUrl)
|
||||
ElMessage.success('映射摘要已导出')
|
||||
}
|
||||
|
||||
const resetPage = () => {
|
||||
selectedIcdFile.value = null
|
||||
resetParsedState()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.mms-mapping-view {
|
||||
min-height: 100%;
|
||||
padding: 24px;
|
||||
background: #f5f7fa;
|
||||
.mms-mapping-page {
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mms-mapping-card {
|
||||
padding: 32px;
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
|
||||
.mms-mapping-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(360px, 0.85fr) minmax(0, 1.15fr);
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
.left-panel-stack {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #4b5563;
|
||||
@media (max-width: 1280px) {
|
||||
.mms-mapping-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
# generateAndSubmitIndexSelection 标准 API 调试文档
|
||||
|
||||
## 1. 接口信息
|
||||
|
||||
- 接口名称:上传 ICD 后直接生成并提交映射
|
||||
- Controller 方法:`generateAndSubmitIndexSelection`
|
||||
- 请求方式:`POST`
|
||||
- 请求路径:`/api/mms-mapping/generate-and-submit-index-selection`
|
||||
- Content-Type:`multipart/form-data`
|
||||
- 适用场景:前端或调试人员已经能直接给出 `indexSelection`,希望一次请求完成 ICD 解析、候选分析、索引校验和正式映射生成。
|
||||
|
||||
源码参考:
|
||||
- `tools/mms-mapping/src/main/java/com/njcn/gather/icd/mapping/controller/MappingController.java`
|
||||
- `tools/mms-mapping/src/main/java/com/njcn/gather/icd/mapping/service/MappingTaskService.java`
|
||||
- `tools/mms-mapping/src/main/java/com/njcn/gather/icd/mapping/component/MappingRequestConverter.java`
|
||||
- `tools/mms-mapping/src/main/java/com/njcn/gather/icd/mapping/component/MappingResponseConverter.java`
|
||||
|
||||
## 2. 处理链路
|
||||
|
||||
该接口内部按以下顺序执行:
|
||||
1. 读取上传的 `icdFile`。
|
||||
2. 解析 ICD,生成 `icdDocument`。
|
||||
3. 加载默认模板并分析候选索引。
|
||||
4. 读取同一次请求中的 `indexSelection`。
|
||||
5. 校验分组、标签、报告、`lnInst` 是否有效。
|
||||
6. 校验通过则生成正式映射 JSON;校验不通过则返回 `NEED_INDEX_SELECTION`。
|
||||
|
||||
因此,这个接口有两类主要调试目标:
|
||||
- 调试一次成功生成映射。
|
||||
- 调试为什么需要重新选择索引。
|
||||
|
||||
## 3. 请求定义
|
||||
|
||||
### 3.1 表单字段
|
||||
|
||||
| 字段 | 类型 | 是否必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `icdFile` | file | 是 | ICD 文件 |
|
||||
| `request` | json part | 是 | JSON 请求体,必须作为独立 part 传入 |
|
||||
|
||||
### 3.2 request JSON 字段
|
||||
|
||||
`request` 结构复用 `GenerateMappingFromIcdRequest`。
|
||||
|
||||
| 字段 | 类型 | 是否必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `version` | string | 否 | 输出版本号,空值时按服务端默认逻辑处理 |
|
||||
| `author` | string | 否 | 作者,空值时回退到模块默认作者 |
|
||||
| `saveToDisk` | boolean | 否 | 是否落盘保存生成结果 |
|
||||
| `prettyJson` | boolean | 否 | 是否返回格式化 JSON |
|
||||
| `outputDir` | string | 否 | 落盘目录,仅 `saveToDisk=true` 时有意义 |
|
||||
| `indexSelection` | array | 否 | 直接提交的索引绑定关系;为空时会返回 `NEED_INDEX_SELECTION` |
|
||||
|
||||
### 3.3 indexSelection 字段
|
||||
|
||||
| 字段 | 类型 | 是否必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `groupKey` | string | 建议必填 | 候选分组唯一键,建议直接使用候选接口返回值 |
|
||||
| `groupDesc` | string | 否 | 分组中文描述,用于兼容匹配 |
|
||||
| `bindings` | array | 是 | 当前分组下的绑定关系列表 |
|
||||
|
||||
### 3.4 bindings 子项字段
|
||||
|
||||
| 字段 | 类型 | 是否必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `reportName` | string | 是 | 报告名称 |
|
||||
| `dataSetName` | string | 是 | 数据集名称 |
|
||||
| `label` | string | 是 | 模板业务标签 |
|
||||
| `lnInst` | string | 是 | 最终绑定的 `lnInst` 数字 |
|
||||
|
||||
## 4. Postman 调试方式
|
||||
|
||||
### 4.1 请求设置
|
||||
|
||||
在 Postman 中选择:
|
||||
- Method:`POST`
|
||||
- Body:`form-data`
|
||||
|
||||
新增两个表单项:
|
||||
1. `icdFile`
|
||||
- 类型:`File`
|
||||
- 值:选择本地 ICD 文件
|
||||
2. `request`
|
||||
- 类型:`Text`
|
||||
- 值:填写 JSON 字符串
|
||||
|
||||
建议把 `request` 这个 part 的 Content-Type 显式设为 `application/json`。
|
||||
|
||||
### 4.2 request 示例
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"author": "system",
|
||||
"saveToDisk": false,
|
||||
"prettyJson": true,
|
||||
"outputDir": "",
|
||||
"indexSelection": [
|
||||
{
|
||||
"groupKey": "实时数据__DSSTHARM",
|
||||
"groupDesc": "实时数据",
|
||||
"bindings": [
|
||||
{
|
||||
"reportName": "brcbStHarm",
|
||||
"dataSetName": "dsStHarm",
|
||||
"label": "A相",
|
||||
"lnInst": "1"
|
||||
},
|
||||
{
|
||||
"reportName": "brcbStHarm",
|
||||
"dataSetName": "dsStHarm",
|
||||
"label": "B相",
|
||||
"lnInst": "2"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 5. cURL 调试示例
|
||||
|
||||
### 5.1 成功生成示例
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8080/api/mms-mapping/generate-and-submit-index-selection" \
|
||||
-H "Accept: application/json" \
|
||||
-F "icdFile=@D:/data/demo.icd" \
|
||||
-F 'request={
|
||||
"version":"1.0",
|
||||
"author":"system",
|
||||
"saveToDisk":false,
|
||||
"prettyJson":true,
|
||||
"outputDir":"",
|
||||
"indexSelection":[
|
||||
{
|
||||
"groupKey":"实时数据__DSSTHARM",
|
||||
"groupDesc":"实时数据",
|
||||
"bindings":[
|
||||
{
|
||||
"reportName":"brcbStHarm",
|
||||
"dataSetName":"dsStHarm",
|
||||
"label":"A相",
|
||||
"lnInst":"1"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};type=application/json'
|
||||
```
|
||||
|
||||
### 5.2 触发 NEED_INDEX_SELECTION 示例
|
||||
|
||||
将 `indexSelection` 置空,或故意传入非法 `label` / `lnInst`:
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8080/api/mms-mapping/generate-and-submit-index-selection" \
|
||||
-H "Accept: application/json" \
|
||||
-F "icdFile=@D:/data/demo.icd" \
|
||||
-F 'request={
|
||||
"version":"1.0",
|
||||
"author":"system",
|
||||
"saveToDisk":false,
|
||||
"prettyJson":true,
|
||||
"outputDir":"",
|
||||
"indexSelection":[]
|
||||
};type=application/json'
|
||||
```
|
||||
|
||||
## 6. 最小响应定义
|
||||
|
||||
该接口已经按场景裁剪返回值。
|
||||
|
||||
### 6.1 SUCCESS
|
||||
|
||||
只返回:
|
||||
- `status`
|
||||
- `message`
|
||||
- `mappingJson`
|
||||
- `savedPath`,仅 `saveToDisk=true` 且保存成功时返回
|
||||
- `problems`,仅存在问题时返回
|
||||
|
||||
### 6.2 NEED_INDEX_SELECTION
|
||||
|
||||
只返回:
|
||||
- `status`
|
||||
- `message`
|
||||
- `icdDocument`
|
||||
- `indexCandidates`
|
||||
- `problems`
|
||||
|
||||
### 6.3 FAILED
|
||||
|
||||
只返回:
|
||||
- `status`
|
||||
- `message`
|
||||
- `problems`,仅存在问题时返回
|
||||
|
||||
## 7. 响应示例
|
||||
|
||||
### 7.1 成功响应
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "SUCCESS",
|
||||
"message": "映射生成成功",
|
||||
"mappingJson": "{\"Version\":\"1.0\",\"Author\":\"system\"}"
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 成功并落盘响应
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "SUCCESS",
|
||||
"message": "映射生成成功",
|
||||
"mappingJson": "{\"Version\":\"1.0\",\"Author\":\"system\"}",
|
||||
"savedPath": "D:/output/IED1-mapping-pretty.json"
|
||||
}
|
||||
```
|
||||
|
||||
### 7.3 需要重新选择索引响应
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "NEED_INDEX_SELECTION",
|
||||
"message": "索引配置不合法,请根据候选信息完成标签与数字索引的绑定后重新提交",
|
||||
"icdDocument": {
|
||||
"fileName": "demo.icd",
|
||||
"iedName": "IED1",
|
||||
"ldInst": "LD0"
|
||||
},
|
||||
"indexCandidates": [
|
||||
{
|
||||
"groupKey": "实时数据__DSSTHARM",
|
||||
"groupDesc": "实时数据",
|
||||
"reportCount": 1,
|
||||
"templateLabels": ["A相", "B相", "C相"],
|
||||
"reports": [
|
||||
{
|
||||
"reportName": "brcbStHarm",
|
||||
"dataSetName": "dsStHarm",
|
||||
"reportDesc": "实时数据",
|
||||
"availableLnInstValues": ["1", "2", "3"]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"problems": [
|
||||
"分组【实时数据】中 label【A相】不在模板候选中"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 7.4 失败响应
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "FAILED",
|
||||
"message": "ICD 解析失败:ICD 文件内容不能为空",
|
||||
"problems": [
|
||||
"ICD 文件内容不能为空"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 8. 常见调试问题
|
||||
|
||||
### 8.1 `request` 没有按 JSON 传入
|
||||
|
||||
现象:
|
||||
- Spring 无法正确绑定 `@RequestPart("request")`
|
||||
- 返回 400 或参数解析异常
|
||||
|
||||
处理:
|
||||
- 确保 `request` 是单独的 multipart part
|
||||
- 建议显式设置 `type=application/json`
|
||||
|
||||
### 8.2 `indexSelection` 为空
|
||||
|
||||
现象:
|
||||
- 返回 `NEED_INDEX_SELECTION`
|
||||
- `message` 提示索引配置缺失
|
||||
|
||||
处理:
|
||||
- 先调用 `generateFromIcdCandidates`
|
||||
- 使用其返回的 `groupKey`、`templateLabels`、`availableLnInstValues` 重新组装绑定
|
||||
|
||||
### 8.3 `groupKey` 不匹配
|
||||
|
||||
现象:
|
||||
- `problems` 中出现“未找到分组配置”
|
||||
|
||||
处理:
|
||||
- 不要自己拼 `groupKey`
|
||||
- 直接使用候选接口返回值原样回传
|
||||
|
||||
### 8.4 `label` 不合法
|
||||
|
||||
现象:
|
||||
- `problems` 中出现“label 不在模板候选中”
|
||||
|
||||
处理:
|
||||
- `label` 必须取自当前分组的 `templateLabels`
|
||||
|
||||
### 8.5 `lnInst` 不合法
|
||||
|
||||
现象:
|
||||
- `problems` 中出现“lnInst 不在可选数字中”
|
||||
|
||||
处理:
|
||||
- `lnInst` 必须取自当前报告的 `availableLnInstValues`
|
||||
|
||||
### 8.6 `saveToDisk=true` 但没有 `savedPath`
|
||||
|
||||
可能原因:
|
||||
- 保存过程抛异常,接口直接返回 `FAILED`
|
||||
- 输出目录无权限或路径非法
|
||||
|
||||
处理:
|
||||
- 优先先用 `saveToDisk=false` 验证生成链路
|
||||
- 再单独验证落盘目录权限
|
||||
|
||||
## 9. 联调建议
|
||||
|
||||
- 首次联调建议先走两步:
|
||||
1. 先调用 `generateFromIcdCandidates`
|
||||
2. 根据候选结果确认 `groupKey`、`label`、`lnInst` 后,再调当前接口
|
||||
- 如果只是验证“提交链路”是否通,不建议一开始就打开 `saveToDisk`
|
||||
- 如果需要排查返回值最小化是否生效,重点看:
|
||||
- `SUCCESS` 不应再返回 `icdDocument`、`indexCandidates`
|
||||
- `NEED_INDEX_SELECTION` 不应返回 `mappingJson`
|
||||
- `FAILED` 不应返回候选或映射结果字段
|
||||
371
frontend/src/views/tools/mmsmapping/API-getIcdMmsJson.md
Normal file
371
frontend/src/views/tools/mmsmapping/API-getIcdMmsJson.md
Normal file
@@ -0,0 +1,371 @@
|
||||
# getIcdMmsJson 标准 API 调试文档
|
||||
|
||||
## 1. 文档范围
|
||||
|
||||
本文档用于说明 `mms-mapping` 模块统一调试接口 `getIcdMmsJson` 的标准调用方式、请求结构、响应规则和联调注意事项。
|
||||
|
||||
本文档内容以当前源码为准,主要对照以下实现:
|
||||
|
||||
- `tools/mms-mapping/src/main/java/com/njcn/gather/icd/mapping/controller/MappingController.java`
|
||||
- `tools/mms-mapping/src/main/java/com/njcn/gather/icd/mapping/component/MappingRequestConverter.java`
|
||||
- `tools/mms-mapping/src/main/java/com/njcn/gather/icd/mapping/component/MappingResponseConverter.java`
|
||||
- `tools/mms-mapping/src/main/java/com/njcn/gather/icd/mapping/service/impl/MappingTaskServiceImpl.java`
|
||||
- `tools/mms-mapping/src/main/java/com/njcn/gather/icd/mapping/component/MappingGenerationService.java`
|
||||
- `tools/mms-mapping/src/main/java/com/njcn/gather/icd/mapping/pojo/param/GenerateMappingFromIcdRequest.java`
|
||||
- `tools/mms-mapping/src/main/java/com/njcn/gather/icd/mapping/pojo/vo/MappingTaskResponse.java`
|
||||
|
||||
说明:
|
||||
|
||||
- 本文档仅描述接口契约和调试方式,不改动业务代码。
|
||||
- 本次未执行 `mvn` 编译、打包或真实接口联调。
|
||||
- 如文档与运行结果冲突,以源码和实际部署配置为准。
|
||||
|
||||
## 2. 接口基本信息
|
||||
|
||||
| 项 | 说明 |
|
||||
| --- | --- |
|
||||
| 接口名称 | `getIcdMmsJson` |
|
||||
| 请求方法 | `POST` |
|
||||
| 请求路径 | `/api/mms-mapping/get-icd-mms-json` |
|
||||
| Content-Type | `multipart/form-data` |
|
||||
| 控制器入口 | `MappingController#getIcdMmsJson` |
|
||||
| 请求组成 | `icdFile` 文件 Part + `request` JSON Part |
|
||||
| 正常业务响应体 | `MappingTaskResponse` |
|
||||
|
||||
## 3. 接口职责
|
||||
|
||||
该接口是 `mms-mapping` 模块的统一调试入口,串联以下两个阶段:
|
||||
|
||||
1. 上传 ICD 文件并完成解析,生成 `icdDocument` 和 `indexCandidates`
|
||||
2. 根据 `request.indexSelection` 判断是否继续生成正式 `mappingJson`
|
||||
|
||||
接口行为分为三种典型结果:
|
||||
|
||||
1. `request.indexSelection` 未传或为空
|
||||
返回 `NEED_INDEX_SELECTION`,用于引导前端或调试人员先确认标签与 `lnInst` 的绑定关系。
|
||||
2. `request.indexSelection` 已传但校验不通过
|
||||
返回 `NEED_INDEX_SELECTION`,同时通过 `problems` 给出不合法原因,要求重新选择。
|
||||
3. `request.indexSelection` 校验通过
|
||||
返回 `SUCCESS`,输出正式 `mappingJson`,必要时同时落盘并返回 `savedPath`。
|
||||
|
||||
补充说明:
|
||||
|
||||
- 该接口每次都会重新解析上传的 ICD 文件,因此第二次调试仍然必须重新上传 ICD 文件。
|
||||
- 该接口正常进入业务编排后,返回体类型为 `MappingTaskResponse`。
|
||||
- 如果异常发生在控制器参数绑定或请求转换阶段,例如文件为空、Part 缺失、JSON Part 解析失败,则由全局异常处理器统一包装为 `HttpResult<String>`,而不是 `MappingTaskResponse`。
|
||||
|
||||
## 4. 请求规范
|
||||
|
||||
### 4.1 multipart/form-data Part 说明
|
||||
|
||||
| Part 名称 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `icdFile` | File | 是 | ICD 文件,不能为空 |
|
||||
| `request` | JSON Part | 是 | 生成参数,必须按 `application/json` 发送 |
|
||||
|
||||
说明:
|
||||
|
||||
- `request` Part 不能省略。即使第一次只想拿候选结果,也必须传一个最小 JSON。
|
||||
- `request.indexSelection` 可以省略或传空数组,此时接口只返回候选结果,不生成正式映射。
|
||||
|
||||
### 4.2 request JSON 结构
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "2026-04-22",
|
||||
"author": "debug-user",
|
||||
"saveToDisk": false,
|
||||
"prettyJson": true,
|
||||
"outputDir": "D:/temp/mms-output",
|
||||
"indexSelection": [
|
||||
{
|
||||
"groupKey": "harm",
|
||||
"groupDesc": "谐波数据",
|
||||
"bindings": [
|
||||
{
|
||||
"reportName": "brcbStHarm",
|
||||
"dataSetName": "dsStHarm",
|
||||
"label": "A相",
|
||||
"lnInst": "1"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 request 字段说明
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `version` | String | 否 | 输出版本号。未传或空白时,后端按当天日期补齐,格式为 `yyyy-MM-dd` |
|
||||
| `author` | String | 否 | 作者。未传或空白时,回退到配置项 `icd.mapping.default-author`,默认值为 `system` |
|
||||
| `saveToDisk` | boolean | 否 | 是否将生成结果写入磁盘 |
|
||||
| `prettyJson` | boolean | 否 | 是否输出格式化 JSON。`true` 为美化 JSON,`false` 为紧凑 JSON |
|
||||
| `outputDir` | String | 否 | 输出目录。未传或空白时,先回退到配置项 `icd.mapping.default-output-dir`;如果配置也为空,最终落到当前工作目录 |
|
||||
| `indexSelection` | Array | 否 | 标签与 `lnInst` 的最终绑定关系。未传或为空时,只返回候选结果 |
|
||||
|
||||
### 4.4 indexSelection 字段说明
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `groupKey` | String | 是 | 分组唯一键,必须使用第一次响应里返回的原值 |
|
||||
| `groupDesc` | String | 否 | 分组中文描述,便于调试查看 |
|
||||
| `bindings` | Array | 是 | 当前业务分组下最终确认的绑定关系列表 |
|
||||
|
||||
### 4.5 bindings 字段说明
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `reportName` | String | 是 | 绑定发生在哪个报告上,例如 `brcbStHarm` |
|
||||
| `dataSetName` | String | 是 | 绑定发生在哪个数据集上,例如 `dsStHarm` |
|
||||
| `label` | String | 是 | 业务标签,例如 `A相`、`最大值`、`实时数据` |
|
||||
| `lnInst` | String | 是 | 标签最终绑定到的逻辑节点实例值,例如 `1`、`2`、`3` |
|
||||
|
||||
## 5. 标准调试流程
|
||||
|
||||
### 5.1 第一次调试:只获取候选结果
|
||||
|
||||
用途:
|
||||
|
||||
- 上传 ICD 文件
|
||||
- 获取 `icdDocument`
|
||||
- 获取 `indexCandidates`
|
||||
- 确认每个业务分组下可选的 `reportName`、`dataSetName` 和 `availableLnInstValues`
|
||||
|
||||
调用要求:
|
||||
|
||||
- `request` Part 仍然必须传
|
||||
- `request.indexSelection` 可以不传,或传空数组
|
||||
|
||||
预期结果:
|
||||
|
||||
- `status = NEED_INDEX_SELECTION`
|
||||
- 响应中返回 `icdDocument`
|
||||
- 响应中返回 `indexCandidates`
|
||||
|
||||
### 5.2 第二次调试:带索引绑定生成正式结果
|
||||
|
||||
用途:
|
||||
|
||||
- 根据第一次返回的 `indexCandidates` 组装 `request.indexSelection`
|
||||
- 再次上传同一个 ICD 文件
|
||||
- 生成正式 `mappingJson`
|
||||
|
||||
调用要求:
|
||||
|
||||
- 必须继续上传 `icdFile`
|
||||
- `groupKey` 必须沿用第一次返回值
|
||||
- `reportName`、`dataSetName`、`lnInst` 必须与第一次返回的候选结果匹配
|
||||
|
||||
预期结果:
|
||||
|
||||
- `status = SUCCESS`
|
||||
- 响应中返回 `mappingJson`
|
||||
- 当 `saveToDisk = true` 时,响应中额外返回 `savedPath`
|
||||
|
||||
### 5.3 第二次调试但绑定不合法
|
||||
|
||||
适用场景:
|
||||
|
||||
- `groupKey` 与候选结果不匹配
|
||||
- `reportName` 或 `dataSetName` 不在候选集中
|
||||
- `lnInst` 不在 `availableLnInstValues` 内
|
||||
- 绑定关系缺失、不完整或结构错误
|
||||
|
||||
预期结果:
|
||||
|
||||
- `status = NEED_INDEX_SELECTION`
|
||||
- 响应中仍然返回 `icdDocument` 和 `indexCandidates`
|
||||
- `problems` 返回具体问题列表,要求重新确认绑定关系
|
||||
|
||||
## 6. 响应规范
|
||||
|
||||
### 6.1 正常业务响应体
|
||||
|
||||
接口正常进入业务编排后,统一返回 `MappingTaskResponse`。该对象使用了 `@JsonInclude(JsonInclude.Include.NON_EMPTY)`,空字段和空集合不会参与序列化。
|
||||
|
||||
基础字段说明:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `status` | Enum | 本次处理状态,可能为 `SUCCESS`、`NEED_INDEX_SELECTION`、`FAILED` |
|
||||
| `message` | String | 状态说明或错误提示 |
|
||||
| `icdDocument` | Object | 需要重新选择索引时返回的 ICD 解析结果 |
|
||||
| `mappingJson` | String | 正式生成成功后的映射 JSON 文本 |
|
||||
| `savedPath` | String | 结果已落盘时返回的绝对路径 |
|
||||
| `indexCandidates` | Array | 待绑定状态下返回的索引候选分组 |
|
||||
| `problems` | Array | 模板校验、候选分析或绑定校验问题 |
|
||||
|
||||
字段出现规则:
|
||||
|
||||
| 状态 | 必有字段 | 可能出现字段 |
|
||||
| --- | --- | --- |
|
||||
| `SUCCESS` | `status`、`message`、`mappingJson` | `savedPath`、`problems` |
|
||||
| `NEED_INDEX_SELECTION` | `status`、`message`、`icdDocument`、`indexCandidates` | `problems` |
|
||||
| `FAILED` | `status`、`message` | `problems` |
|
||||
|
||||
### 6.2 NEED_INDEX_SELECTION 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "NEED_INDEX_SELECTION",
|
||||
"message": "索引配置缺失,请根据候选信息完成标签与数字索引的绑定后重新提交",
|
||||
"icdDocument": {
|
||||
"fileName": "demo.icd",
|
||||
"iedName": "IED1",
|
||||
"ldInst": "LD0",
|
||||
"ldPrefix": "LD",
|
||||
"logicalNodes": [
|
||||
{
|
||||
"lnInst": "1"
|
||||
}
|
||||
]
|
||||
},
|
||||
"indexCandidates": [
|
||||
{
|
||||
"groupKey": "harm",
|
||||
"groupDesc": "谐波数据",
|
||||
"reportCount": 1,
|
||||
"templateLabels": [
|
||||
"A相",
|
||||
"B相",
|
||||
"C相"
|
||||
],
|
||||
"reports": [
|
||||
{
|
||||
"reportName": "brcbStHarm",
|
||||
"dataSetName": "dsStHarm",
|
||||
"reportDesc": "谐波报告",
|
||||
"availableLnInstValues": [
|
||||
"1",
|
||||
"2",
|
||||
"3"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `icdDocument` 实际字段可能比示例更多。
|
||||
- 如果本次是“索引配置不合法”而不是“索引配置缺失”,通常还会返回 `problems`。
|
||||
|
||||
### 6.3 SUCCESS 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "SUCCESS",
|
||||
"message": "映射生成成功",
|
||||
"mappingJson": "{\n \"version\": \"2026-04-22\",\n \"author\": \"debug-user\",\n \"ied\": \"IED1\",\n \"ld\": \"LD\",\n \"instList\": []\n}"
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `mappingJson` 是字符串字段,字段值本身是一段 JSON 文本。
|
||||
- 当 `saveToDisk = true` 时,响应中还会额外返回 `savedPath`。
|
||||
|
||||
### 6.4 FAILED 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "FAILED",
|
||||
"message": "映射生成失败:加载 DefaultCfg.txt 失败:默认模板文件不存在:template/DefaultCfg.txt",
|
||||
"problems": [
|
||||
"加载 DefaultCfg.txt 失败:默认模板文件不存在:template/DefaultCfg.txt"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `FAILED` 主要对应服务编排阶段捕获到的运行异常,例如 ICD 解析、模板加载、映射生成、序列化或落盘失败。
|
||||
- 并非所有错误都会进入 `FAILED`。如果异常发生在控制器参数绑定或请求转换阶段,会走全局异常处理器,而不是这里的业务响应结构。
|
||||
|
||||
## 7. 全局异常响应说明
|
||||
|
||||
以下场景通常不会返回 `MappingTaskResponse`,而是由 `GlobalBusinessExceptionHandler` 统一包装:
|
||||
|
||||
- `icdFile` 缺失或为空
|
||||
- `request` Part 缺失
|
||||
- `request` Part 的 `Content-Type` 不是 `application/json`
|
||||
- `multipart/form-data` 结构不合法
|
||||
- JSON 反序列化失败或框架参数绑定失败
|
||||
|
||||
这类异常最终会包装为统一的 `HttpResult<String>` 响应,具体字段结构以全局公共响应定义为准,本文不展开其完整协议,只强调:
|
||||
|
||||
- 不能把这类错误等同理解为 `MappingTaskResponse.status = FAILED`
|
||||
- 联调时应先区分“业务响应体”与“全局异常包装”
|
||||
|
||||
## 8. 调试示例
|
||||
|
||||
### 8.1 curl 示例:第一次调用,只获取候选结果
|
||||
|
||||
```powershell
|
||||
curl.exe -X POST "http://localhost:8080/api/mms-mapping/get-icd-mms-json" `
|
||||
-H "Accept: application/json" `
|
||||
-F 'icdFile=@D:/data/demo.icd' `
|
||||
-F 'request={"prettyJson":true,"saveToDisk":false};type=application/json'
|
||||
```
|
||||
|
||||
### 8.2 curl 示例:第二次调用,带索引绑定直接生成 MMS JSON
|
||||
|
||||
```powershell
|
||||
curl.exe -X POST "http://localhost:8080/api/mms-mapping/get-icd-mms-json" `
|
||||
-H "Accept: application/json" `
|
||||
-F 'icdFile=@D:/data/demo.icd' `
|
||||
-F 'request={"version":"2026-04-22","author":"debug-user","prettyJson":true,"saveToDisk":false,"indexSelection":[{"groupKey":"harm","groupDesc":"谐波数据","bindings":[{"reportName":"brcbStHarm","dataSetName":"dsStHarm","label":"A相","lnInst":"1"},{"reportName":"brcbStHarm","dataSetName":"dsStHarm","label":"B相","lnInst":"2"},{"reportName":"brcbStHarm","dataSetName":"dsStHarm","label":"C相","lnInst":"3"}]}]};type=application/json'
|
||||
```
|
||||
|
||||
## 9. Postman 调试要点
|
||||
|
||||
1. `Body` 选择 `form-data`
|
||||
2. `icdFile` 类型选择 `File`
|
||||
3. `request` 保持文本输入,但该 Part 的 `Content-Type` 必须显式设置为 `application/json`
|
||||
4. 第一次调试不要省略 `request` Part,只是不传 `indexSelection`
|
||||
5. 第二次调试时必须继续上传 ICD 文件,并严格按第一次返回的候选结果组装绑定关系
|
||||
|
||||
## 10. 常见问题
|
||||
|
||||
### 10.1 为什么第一次调试也必须传 `request`
|
||||
|
||||
因为控制器方法签名使用的是 `@RequestPart("request") GenerateMappingFromIcdRequest request`,该 Part 本身就是必填参数。第一次调试可以只传最小 JSON,但不能完全省略。
|
||||
|
||||
### 10.2 为什么没有传 `indexSelection`,却没有返回 `FAILED`
|
||||
|
||||
这是接口设计的正常行为。`indexSelection` 缺失或为空时,业务语义不是“接口执行失败”,而是“还需要前端继续确认索引绑定”,因此返回的是 `NEED_INDEX_SELECTION`。
|
||||
|
||||
### 10.3 `saveToDisk=true` 但没有传 `outputDir`,结果会保存到哪里
|
||||
|
||||
处理顺序如下:
|
||||
|
||||
1. 先读取请求中的 `outputDir`
|
||||
2. 如果请求空白,则回退到配置项 `icd.mapping.default-output-dir`
|
||||
3. 如果配置项也为空,则最终落到当前工作目录
|
||||
|
||||
### 10.4 `version` 不传时会变成什么
|
||||
|
||||
后端在正式生成映射文档时,会把空白 `version` 自动补成当天日期,格式为 `yyyy-MM-dd`。
|
||||
|
||||
### 10.5 `mappingJson` 为什么是字符串,不是嵌套对象
|
||||
|
||||
因为当前响应结构中 `mappingJson` 定义为 `String`,接口返回的是一段已经序列化好的 JSON 文本,而不是再次展开后的对象结构。
|
||||
|
||||
### 10.6 什么情况下会返回 `problems`
|
||||
|
||||
`problems` 主要用于承载以下问题:
|
||||
|
||||
- 默认模板校验问题
|
||||
- 索引候选分析问题
|
||||
- `indexSelection` 绑定校验问题
|
||||
- 服务编排阶段捕获到的异常原因
|
||||
|
||||
## 11. 当前边界
|
||||
|
||||
- 当前文档仅覆盖 `getIcdMmsJson` 接口,不覆盖 `get-icd` 与 `get-mms-json` 的独立接口文档
|
||||
- 当前文档重点描述业务返回体与调试方式,不展开全局 `HttpResult` 的完整协议
|
||||
- 示例中的 `icdDocument`、`indexCandidates` 和 `mappingJson` 为结构化示意,实际字段数量与内容以运行结果为准
|
||||
756
frontend/src/views/tools/mmsmapping/DefaultCfg.txt
Normal file
756
frontend/src/views/tools/mmsmapping/DefaultCfg.txt
Normal file
@@ -0,0 +1,756 @@
|
||||
{
|
||||
"ReportList": [
|
||||
{
|
||||
"desc": "统计数据",
|
||||
"inst": "01",
|
||||
"TrgOps": "96",
|
||||
"Select": "DataStatFileMap",
|
||||
"DataSetList": [
|
||||
"dsStatisticData",
|
||||
"dsStHarm",
|
||||
"dsStIHarm",
|
||||
"dsStMMXU",
|
||||
"dsStMSQI"
|
||||
],
|
||||
"LnInstList": [
|
||||
"最大值",
|
||||
"最小值",
|
||||
"平均值",
|
||||
"95值",
|
||||
"方均根值",
|
||||
"间谐波最大值",
|
||||
"间谐波最小值",
|
||||
"间谐波平均值",
|
||||
"间谐波95值",
|
||||
"间谐波方均根值"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "波动闪变",
|
||||
"inst": "01",
|
||||
"TrgOps": "96",
|
||||
"Select": "FlickerFileMap",
|
||||
"DataSetList": [
|
||||
"dsFlickerData",
|
||||
"dsPST"
|
||||
],
|
||||
"LnInstList": [
|
||||
"波动闪变值"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "实时数据",
|
||||
"inst": "01",
|
||||
"TrgOps": "40",
|
||||
"Select": "DataRealFileMap",
|
||||
"DataSetList": [
|
||||
"dsRealTimeData",
|
||||
"dsRtHarm",
|
||||
"dsRtIHarm",
|
||||
"dsRtMMXU",
|
||||
"dsRtMSQI",
|
||||
"dsRtFre"
|
||||
],
|
||||
"LnInstList": [
|
||||
"实时数据",
|
||||
"间谐波实时数据"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "暂态事件",
|
||||
"inst": "01",
|
||||
"TrgOps": "96",
|
||||
"Select": "QVVR",
|
||||
"DataSetList": [
|
||||
"dsEveQVVR"
|
||||
],
|
||||
"LnInstList": [
|
||||
"电压变动A",
|
||||
"电压变动B",
|
||||
"电压变动C"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "录波状态",
|
||||
"inst": "01",
|
||||
"TrgOps": "96",
|
||||
"Select": "RDRE",
|
||||
"DataSetList": [
|
||||
"dsEveRDRE"
|
||||
],
|
||||
"LnInstList": [
|
||||
"录波文件"
|
||||
]
|
||||
}
|
||||
],
|
||||
"LnClassList": [
|
||||
{
|
||||
"desc": "基本数据",
|
||||
"nameList": [
|
||||
"MMXU"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "序分量值",
|
||||
"nameList": [
|
||||
"MSQI"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "谐波/间谐波数据",
|
||||
"nameList": [
|
||||
"MHAI"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "波动闪变",
|
||||
"nameList": [
|
||||
"MFLK"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "电压变动",
|
||||
"nameList": [
|
||||
"QVVR"
|
||||
]
|
||||
}
|
||||
],
|
||||
"PhaseList": [
|
||||
{
|
||||
"desc": "无相别",
|
||||
"nameList": [
|
||||
"null"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "正序",
|
||||
"nameList": [
|
||||
"c1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "负序",
|
||||
"nameList": [
|
||||
"c2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "零序",
|
||||
"nameList": [
|
||||
"c3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "A相",
|
||||
"nameList": [
|
||||
"phsA",
|
||||
"phsAHar"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "B相",
|
||||
"nameList": [
|
||||
"phsB",
|
||||
"phsBHar"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "C相",
|
||||
"nameList": [
|
||||
"phsC",
|
||||
"phsCHar"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "AB线",
|
||||
"nameList": [
|
||||
"phsAB",
|
||||
"phsABHar"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "BC线",
|
||||
"nameList": [
|
||||
"phsBC",
|
||||
"phsBCHar"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "CA线",
|
||||
"nameList": [
|
||||
"phsCA",
|
||||
"phsCAHar"
|
||||
]
|
||||
}
|
||||
],
|
||||
"MultiplierList": [
|
||||
{
|
||||
"multiplier": 1,
|
||||
"nameList": [
|
||||
"null"
|
||||
]
|
||||
},
|
||||
{
|
||||
"multiplier": 1000,
|
||||
"nameList": [
|
||||
"k"
|
||||
]
|
||||
}
|
||||
],
|
||||
"UnitList": [
|
||||
{
|
||||
"desc": "other",
|
||||
"nameList": [
|
||||
"null"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "v",
|
||||
"nameList": [
|
||||
"V"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "a",
|
||||
"nameList": [
|
||||
"A"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "w",
|
||||
"nameList": [
|
||||
"W",
|
||||
"VAr",
|
||||
"VA"
|
||||
]
|
||||
}
|
||||
],
|
||||
"TypeList": [
|
||||
{
|
||||
"desc": "值",
|
||||
"nameList": [
|
||||
"mag"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "角度",
|
||||
"nameList": [
|
||||
"ang"
|
||||
]
|
||||
}
|
||||
],
|
||||
"DataObjectsList": [
|
||||
{
|
||||
"desc": "非间谐波数据",
|
||||
"LnInstList": [
|
||||
"最大值",
|
||||
"最小值",
|
||||
"平均值",
|
||||
"95值",
|
||||
"实时数据"
|
||||
],
|
||||
"ObjectList": [
|
||||
{
|
||||
"desc": "频率",
|
||||
"nameList": [
|
||||
"Hz"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "线电压总有效值",
|
||||
"nameList": [
|
||||
"PPV"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "相电压总有效值",
|
||||
"nameList": [
|
||||
"PhV"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "电流总有效值",
|
||||
"nameList": [
|
||||
"A"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "有功功率",
|
||||
"nameList": [
|
||||
"W"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "无功功率",
|
||||
"nameList": [
|
||||
"VAr"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "视在功率",
|
||||
"nameList": [
|
||||
"VA"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "功率因数",
|
||||
"nameList": [
|
||||
"PF"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "位移功率因数",
|
||||
"nameList": [
|
||||
"DF"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "三相总有功功率",
|
||||
"nameList": [
|
||||
"TotW"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "三相总无功功率",
|
||||
"nameList": [
|
||||
"TotVAr"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "三相总视在功率",
|
||||
"nameList": [
|
||||
"TotVA"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "三相功率因数",
|
||||
"nameList": [
|
||||
"TotPF"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "三相位移功率因数",
|
||||
"nameList": [
|
||||
"TotDF"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "频率偏差",
|
||||
"nameList": [
|
||||
"HzDev"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "相电压偏差",
|
||||
"nameList": [
|
||||
"PhVDev"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "线电压偏差",
|
||||
"nameList": [
|
||||
"PPVDev"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "正序负序和零序电压",
|
||||
"nameList": [
|
||||
"SeqV"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "正序负序和零序电流",
|
||||
"nameList": [
|
||||
"SeqA"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "电压负序不平衡度",
|
||||
"nameList": [
|
||||
"ImbNgV"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "电流负序不平衡度",
|
||||
"nameList": [
|
||||
"ImbNgA"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "电压零序不平衡度",
|
||||
"nameList": [
|
||||
"ImbZroV"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "电流零序不平衡度",
|
||||
"nameList": [
|
||||
"ImbZroA"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "相电压谐波总畸变率",
|
||||
"nameList": [
|
||||
"ThdPhV"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "相电压总偶次谐波畸变率",
|
||||
"nameList": [
|
||||
"ThdEvnPhV"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "相电压总奇次谐波畸变率",
|
||||
"nameList": [
|
||||
"ThdOddPhV"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "线电压谐波总畸变率",
|
||||
"nameList": [
|
||||
"ThdPPV"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "线电压总偶次谐波畸变率",
|
||||
"nameList": [
|
||||
"ThdEvnPPV"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "线电压总奇次谐波畸变率",
|
||||
"nameList": [
|
||||
"ThdOddPPV"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "相电压谐波含有率序列",
|
||||
"baseflag": 1,
|
||||
"basecount": 49,
|
||||
"nameList": [
|
||||
"HRPhV",
|
||||
"HPhVMag"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "线电压谐波含有率序列",
|
||||
"baseflag": 1,
|
||||
"basecount": 49,
|
||||
"nameList": [
|
||||
"HRPPV"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "电流总谐波畸变率",
|
||||
"nameList": [
|
||||
"ThdA"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "电流总偶次谐波畸变率",
|
||||
"nameList": [
|
||||
"ThdEvnA"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "电流总奇次谐波畸变率",
|
||||
"nameList": [
|
||||
"ThdOddA"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "谐波电流有效值序列",
|
||||
"baseflag": 1,
|
||||
"basecount": 49,
|
||||
"nameList": [
|
||||
"HA",
|
||||
"HAMag"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "谐波电压有效值序列",
|
||||
"baseflag": 1,
|
||||
"basecount": 49,
|
||||
"nameList": [
|
||||
"HPhV"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "2~50次谐波有功功率序列",
|
||||
"baseflag": 1,
|
||||
"basecount": 49,
|
||||
"nameList": [
|
||||
"HW"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "2~50次谐波无功功率序列",
|
||||
"baseflag": 1,
|
||||
"basecount": 49,
|
||||
"nameList": [
|
||||
"HVAr"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "2~50次谐波视在功率序列",
|
||||
"baseflag": 1,
|
||||
"basecount": 49,
|
||||
"nameList": [
|
||||
"HVA"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "三相总谐波视在功率",
|
||||
"nameList": [
|
||||
"TotHVA"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "三相总谐波无功功率",
|
||||
"nameList": [
|
||||
"TotHVAr"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "三相总谐波有功功率",
|
||||
"nameList": [
|
||||
"TotHW"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "相电压基波有效值",
|
||||
"baseflag": 2,
|
||||
"queuecount": 49,
|
||||
"nameList": [
|
||||
"HFundPhV",
|
||||
"FundPhV"
|
||||
],
|
||||
"queueList":[
|
||||
"HPhV"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "线电压基波有效值",
|
||||
"baseflag": 2,
|
||||
"queuecount": 49,
|
||||
"nameList": [
|
||||
"HFundPPV"
|
||||
],
|
||||
"queueList":[
|
||||
"HPPV"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "电流基波有效值",
|
||||
"baseflag": 2,
|
||||
"queuecount": 49,
|
||||
"nameList": [
|
||||
|
||||
],
|
||||
"queueList":[
|
||||
"HA"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "基波有功功率",
|
||||
"baseflag": 2,
|
||||
"queuecount": 49,
|
||||
"nameList": [
|
||||
|
||||
],
|
||||
"queueList":[
|
||||
"HW"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "基波无功功率",
|
||||
"baseflag": 2,
|
||||
"queuecount": 49,
|
||||
"nameList": [
|
||||
|
||||
],
|
||||
"queueList":[
|
||||
"HVAr"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "基波视在功率",
|
||||
"baseflag": 2,
|
||||
"queuecount": 49,
|
||||
"nameList": [
|
||||
|
||||
],
|
||||
"queueList":[
|
||||
"HVA"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "间谐波数据",
|
||||
"LnInstList": [
|
||||
"间谐波最大值",
|
||||
"间谐波最小值",
|
||||
"间谐波平均值",
|
||||
"间谐波95值",
|
||||
"间谐波实时数据"
|
||||
],
|
||||
"ObjectList": [
|
||||
{
|
||||
"desc": "相电压间谐波含有率序列",
|
||||
"baseflag": 1,
|
||||
"basecount": 50,
|
||||
"nameList": [
|
||||
"HPhV"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "线电压间谐波含有率序列",
|
||||
"baseflag": 1,
|
||||
"basecount": 50,
|
||||
"nameList": [
|
||||
"HRPPV"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "间谐波电流有效值序列",
|
||||
"baseflag": 1,
|
||||
"basecount": 50,
|
||||
"nameList": [
|
||||
"HA"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "间谐波电压有效值序列",
|
||||
"baseflag": 1,
|
||||
"basecount": 50,
|
||||
"nameList": [
|
||||
"HRPhV"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "电压变动",
|
||||
"LnInstList": [
|
||||
"电压变动A",
|
||||
"电压变动B",
|
||||
"电压变动C"
|
||||
],
|
||||
"ObjectList": [
|
||||
{
|
||||
"desc": "电压扰动事件启动",
|
||||
"nameList": [
|
||||
"VarStr"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "电压暂降事件启动",
|
||||
"nameList": [
|
||||
"DipStr"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "电压暂升事件启动",
|
||||
"nameList": [
|
||||
"SwlStr"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "电压中断事件启动",
|
||||
"nameList": [
|
||||
"IntrStr"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "电压扰动事件特征幅值",
|
||||
"nameList": [
|
||||
"VVa"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "电压扰动事件持续时间",
|
||||
"nameList": [
|
||||
"VVaTm"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "电压暂降启动定值",
|
||||
"nameList": [
|
||||
"DipStrVal"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "电压暂升启动定值",
|
||||
"nameList": [
|
||||
"SwlStrVal"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "电压中断启动定值",
|
||||
"nameList": [
|
||||
"IntrStrVal"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "其余数据",
|
||||
"LnInstList": [
|
||||
"波动闪变值",
|
||||
"录波文件"
|
||||
],
|
||||
"ObjectList": [
|
||||
{
|
||||
"desc": "线电压短时闪变值",
|
||||
"nameList": [
|
||||
"PPPst"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "相电压短时闪变值",
|
||||
"nameList": [
|
||||
"PhPst"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "线电压长时闪变值",
|
||||
"nameList": [
|
||||
"PPPlt"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "相电压长时闪变值",
|
||||
"nameList": [
|
||||
"PhPlt"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "线电压电压变动幅值",
|
||||
"nameList": [
|
||||
"PPFluc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "相电压电压变动幅值",
|
||||
"nameList": [
|
||||
"PhFluc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "线电压电压变动频度",
|
||||
"nameList": [
|
||||
"PPFlucf"
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "相电压电压变动频度",
|
||||
"nameList": [
|
||||
"PhFlucf"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<div class="icd-document-tree">
|
||||
<el-tree
|
||||
v-if="treeNodes.length"
|
||||
:data="treeNodes"
|
||||
node-key="key"
|
||||
:indent="18"
|
||||
:default-expanded-keys="defaultExpandedKeys"
|
||||
:expand-on-click-node="false"
|
||||
class="icd-tree"
|
||||
>
|
||||
<template #default="{ data }">
|
||||
<div class="icd-tree-node">
|
||||
<span class="icd-tree-node__label">{{ data.label }}</span>
|
||||
<span v-if="data.value !== undefined" class="icd-tree-node__value">{{ data.value }}</span>
|
||||
<span v-else class="icd-tree-node__summary">{{ data.summary }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-tree>
|
||||
<div v-else class="icd-tree-empty">接口返回 `icdDocument` 后,会在这里以层级结构展示文档内容。</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { MmsMapping } from '@/api/tools/mmsmapping/interface'
|
||||
|
||||
defineOptions({
|
||||
name: 'IcdDocumentTree'
|
||||
})
|
||||
|
||||
interface DocumentTreeNode {
|
||||
key: string
|
||||
label: string
|
||||
value?: string
|
||||
summary?: string
|
||||
children?: DocumentTreeNode[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
document: MmsMapping.IcdDocument | null
|
||||
}>()
|
||||
|
||||
const treeNodes = computed<DocumentTreeNode[]>(() => {
|
||||
if (!props.document) return []
|
||||
return [buildTreeNode('icdDocument', props.document, 'icdDocument')]
|
||||
})
|
||||
|
||||
const defaultExpandedKeys = computed(() => {
|
||||
const rootNode = treeNodes.value[0]
|
||||
if (!rootNode) return []
|
||||
|
||||
return [rootNode.key, ...(rootNode.children?.map(child => child.key) || [])]
|
||||
})
|
||||
|
||||
// 业务展示要求:按接口原始层级渲染 icdDocument,避免左侧信息再次退化成平铺文本。
|
||||
const buildTreeNode = (label: string, source: unknown, path: string): DocumentTreeNode => {
|
||||
if (Array.isArray(source)) {
|
||||
return {
|
||||
key: path,
|
||||
label,
|
||||
summary: `数组(${source.length})`,
|
||||
children: source.map((item, index) => buildTreeNode(`[${index}]`, item, `${path}.${index}`))
|
||||
}
|
||||
}
|
||||
|
||||
if (source && typeof source === 'object') {
|
||||
const entries = Object.entries(source as Record<string, unknown>).filter(([, value]) => value !== undefined)
|
||||
|
||||
return {
|
||||
key: path,
|
||||
label,
|
||||
summary: `对象(${entries.length})`,
|
||||
children: entries.map(([key, value]) => buildTreeNode(key, value, `${path}.${key}`))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
key: path,
|
||||
label,
|
||||
value: formatNodeValue(source)
|
||||
}
|
||||
}
|
||||
|
||||
const formatNodeValue = (source: unknown) => {
|
||||
if (source === null) return 'null'
|
||||
if (typeof source === 'string') return source || '""'
|
||||
return String(source)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.icd-document-tree {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.icd-tree {
|
||||
padding: 4px 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.icd-tree-node {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: 2px 0;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.icd-tree-node__label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #172033;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.icd-tree-node__summary,
|
||||
.icd-tree-node__value {
|
||||
font-size: 13px;
|
||||
color: #4b5563;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.icd-tree-node__value {
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
font-family: Consolas, 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.icd-tree-empty {
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
:deep(.icd-tree .el-tree-node__content) {
|
||||
min-height: 32px;
|
||||
height: auto;
|
||||
padding: 4px 0;
|
||||
align-items: flex-start;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:deep(.icd-tree .el-tree-node__expand-icon) {
|
||||
margin-top: 7px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
:deep(.icd-tree .el-tree-node:focus > .el-tree-node__content),
|
||||
:deep(.icd-tree .el-tree-node__content:hover) {
|
||||
background: #eef6ff;
|
||||
}
|
||||
|
||||
:deep(.icd-tree .el-tree-node__children) {
|
||||
overflow: visible;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<section class="mapping-panel config-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2 class="panel-title">请求配置</h2>
|
||||
<p class="panel-description">这里直接编辑 request.indexSelection,默认值会在选择 ICD 文件后自动生成。</p>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="showGenerateButton"
|
||||
type="primary"
|
||||
:icon="Connection"
|
||||
:loading="isSubmitting"
|
||||
:disabled="!canGenerate"
|
||||
@click="emit('generate')"
|
||||
>
|
||||
生成映射
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="panel-content">
|
||||
<div class="panel-section result-card">
|
||||
<el-alert v-if="jsonError" :title="jsonError" type="error" :closable="false" class="json-alert" />
|
||||
|
||||
<el-input
|
||||
type="textarea"
|
||||
class="index-selection-textarea"
|
||||
:model-value="indexSelectionJson"
|
||||
:disabled="isSubmitting"
|
||||
:rows="18"
|
||||
resize="none"
|
||||
placeholder="ICD 解析完成后,这里会自动填充 request.indexSelection,可继续直接编辑。"
|
||||
@update:model-value="value => emit('update:indexSelectionJson', String(value || ''))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!hasDefaultJson" :description="emptyDescription" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Connection } from '@element-plus/icons-vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'MappingConfigPanel'
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
indexSelectionJson: string
|
||||
isSubmitting: boolean
|
||||
canGenerate: boolean
|
||||
jsonError: string
|
||||
showGenerateButton: boolean
|
||||
hasDefaultJson: boolean
|
||||
emptyDescription: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:indexSelectionJson', value: string): void
|
||||
(event: 'generate'): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.mapping-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
padding: 24px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.config-panel {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.panel-description {
|
||||
margin: 8px 0 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-section {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
|
||||
}
|
||||
|
||||
.result-card {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.index-selection-textarea {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.index-selection-textarea :deep(.el-textarea) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.index-selection-textarea :deep(.el-textarea__inner) {
|
||||
height: 100%;
|
||||
font-family: Consolas, 'Courier New', monospace;
|
||||
line-height: 1.6;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.json-alert {
|
||||
margin: 16px 0 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mapping-panel {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<section class="mapping-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2 class="panel-title">ICD 解析</h2>
|
||||
<p class="panel-description">选择 ICD 文件后仅保存当前文件,点击“解析 ICD”后才会向后台请求候选数据。</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" :icon="FolderOpened" :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
|
||||
:icon="Search"
|
||||
:loading="isSubmitting"
|
||||
:disabled="!selectedIcdFileName"
|
||||
@click="emit('parse')"
|
||||
>
|
||||
解析 ICD
|
||||
</el-button>
|
||||
<el-button :icon="Delete" :disabled="!canReset" @click="emit('reset')">清空</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Delete, FolderOpened, Search } from '@element-plus/icons-vue'
|
||||
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>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.mapping-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
padding: 24px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.panel-description {
|
||||
margin: 8px 0 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.panel-section {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.file-input {
|
||||
width: 360px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.hidden-file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mapping-panel {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.file-select-row {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.file-input {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,282 @@
|
||||
<template>
|
||||
<section class="mapping-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2 class="panel-title">调试输出</h2>
|
||||
<p class="panel-description">右侧展示最近一次接口返回的 mappingJson 和 problems,并支持导出当前映射摘要。</p>
|
||||
</div>
|
||||
<div class="panel-actions">
|
||||
<el-button plain :icon="Download" :disabled="!canExportMapping" @click="emit('export-mapping')">
|
||||
导出映射文件
|
||||
</el-button>
|
||||
<el-tag :type="responseStatusTagType" effect="light">{{ responseStatusText }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-content panel-content--fixed">
|
||||
<div class="panel-section result-card grow-card preview-tab-section">
|
||||
<el-tabs v-model="activeTabProxy" class="preview-tabs">
|
||||
<el-tab-pane label="映射摘要" name="mapping">
|
||||
<div class="preview-header preview-header--compact">
|
||||
<div class="preview-meta">{{ mappingMetaText }}</div>
|
||||
</div>
|
||||
<div class="mapping-json-scroll">
|
||||
<pre v-if="mappingJsonPreview" class="mapping-json-text">{{ mappingJsonPreview }}</pre>
|
||||
<el-empty v-else description="当前返回未包含 mappingJson" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="problemTabLabel" name="problem">
|
||||
<div class="problem-section">
|
||||
<div v-if="problemList.length" class="problem-list">
|
||||
<div v-for="(problem, index) in problemList" :key="`${index}-${problem}`" class="problem-item">
|
||||
<span class="problem-index">{{ index + 1 }}</span>
|
||||
<span class="problem-text">{{ problem }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else :description="problemEmptyText" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Download } from '@element-plus/icons-vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'MappingResultPanel'
|
||||
})
|
||||
|
||||
type TagType = 'success' | 'warning' | 'info' | 'primary' | 'danger'
|
||||
|
||||
const props = defineProps<{
|
||||
responseStatusText: string
|
||||
responseStatusTagType: TagType
|
||||
activeResultTab: 'mapping' | 'problem'
|
||||
mappingMetaText: string
|
||||
mappingJsonPreview: string
|
||||
problemTabLabel: string
|
||||
problemList: string[]
|
||||
problemEmptyText: string
|
||||
canExportMapping: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:activeResultTab', value: 'mapping' | 'problem'): void
|
||||
(event: 'export-mapping'): void
|
||||
}>()
|
||||
|
||||
const activeTabProxy = computed({
|
||||
get: () => props.activeResultTab,
|
||||
set: value => emit('update:activeResultTab', value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.mapping-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
padding: 24px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.panel-description {
|
||||
margin: 8px 0 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.panel-content--fixed {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-section {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
|
||||
}
|
||||
|
||||
.result-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.grow-card {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.preview-tab-section {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-tabs {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.preview-tabs :deep(.el-tabs__header) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.preview-tabs :deep(.el-tabs__nav-wrap::after) {
|
||||
background-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.preview-tabs :deep(.el-tabs__item) {
|
||||
height: 36px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.preview-tabs :deep(.el-tabs__item.is-active) {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.preview-tabs :deep(.el-tabs__content) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.preview-tabs :deep(.el-tab-pane) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.preview-header--compact {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.preview-meta {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.mapping-json-scroll,
|
||||
.problem-section {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.mapping-json-text {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
border: 1px solid #dbe3f0;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
font-family: Consolas, 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: #172033;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.problem-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.problem-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #f3d19e;
|
||||
border-radius: 10px;
|
||||
background: #fff7ed;
|
||||
}
|
||||
|
||||
.problem-index {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
background: #f97316;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.problem-text {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: #7c2d12;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mapping-panel {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.panel-header,
|
||||
.panel-actions,
|
||||
.preview-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
101
frontend/src/views/tools/mmsmapping/utils/indexSelection.ts
Normal file
101
frontend/src/views/tools/mmsmapping/utils/indexSelection.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { MmsMapping } from '@/api/tools/mmsmapping/interface'
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
|
||||
const normalizeRequiredString = (value: unknown, fieldPath: string) => {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw new Error(`${fieldPath} 必须是非空字符串`)
|
||||
}
|
||||
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
const normalizeOptionalString = (value: unknown) => (typeof value === 'string' ? value.trim() : '')
|
||||
|
||||
const normalizeBindings = (value: unknown, groupIndex: number): MmsMapping.IndexSelectionBinding[] => {
|
||||
if (!Array.isArray(value) || !value.length) {
|
||||
throw new Error(`request.indexSelection[${groupIndex}].bindings 必须是非空数组`)
|
||||
}
|
||||
|
||||
return value.map((binding, bindingIndex) => {
|
||||
if (!isRecord(binding)) {
|
||||
throw new Error(`request.indexSelection[${groupIndex}].bindings[${bindingIndex}] 必须是对象`)
|
||||
}
|
||||
|
||||
return {
|
||||
reportName: normalizeRequiredString(
|
||||
binding.reportName,
|
||||
`request.indexSelection[${groupIndex}].bindings[${bindingIndex}].reportName`
|
||||
),
|
||||
dataSetName: normalizeRequiredString(
|
||||
binding.dataSetName,
|
||||
`request.indexSelection[${groupIndex}].bindings[${bindingIndex}].dataSetName`
|
||||
),
|
||||
label: normalizeRequiredString(
|
||||
binding.label,
|
||||
`request.indexSelection[${groupIndex}].bindings[${bindingIndex}].label`
|
||||
),
|
||||
lnInst: normalizeRequiredString(
|
||||
binding.lnInst,
|
||||
`request.indexSelection[${groupIndex}].bindings[${bindingIndex}].lnInst`
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const buildDefaultIndexSelection = (
|
||||
candidateGroups: MmsMapping.IndexCandidateGroup[]
|
||||
): MmsMapping.IndexSelectionGroup[] =>
|
||||
candidateGroups
|
||||
.filter(candidate => candidate.groupKey?.trim())
|
||||
.map(candidate => {
|
||||
const defaultReport = (candidate.reports || []).find(
|
||||
report => report.reportName?.trim() && report.dataSetName?.trim()
|
||||
)
|
||||
const defaultLnInst = (defaultReport?.availableLnInstValues || []).find(item => item?.trim())?.trim() || ''
|
||||
|
||||
return {
|
||||
groupKey: candidate.groupKey!.trim(),
|
||||
groupDesc: candidate.groupDesc?.trim() || '',
|
||||
bindings: (candidate.templateLabels || [])
|
||||
.map(label => label?.trim() || '')
|
||||
.filter(Boolean)
|
||||
.map(label => ({
|
||||
reportName: defaultReport?.reportName?.trim() || '',
|
||||
dataSetName: defaultReport?.dataSetName?.trim() || '',
|
||||
label,
|
||||
lnInst: defaultLnInst
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
export const formatIndexSelectionJson = (value: MmsMapping.IndexSelectionGroup[]) => JSON.stringify(value, null, 4)
|
||||
|
||||
export const parseIndexSelectionJson = (source: string): MmsMapping.IndexSelectionGroup[] => {
|
||||
let parsed: unknown
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(source)
|
||||
} catch {
|
||||
throw new Error('request.indexSelection 不是合法 JSON')
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error('request.indexSelection 必须是数组')
|
||||
}
|
||||
|
||||
return parsed.map((group, groupIndex) => {
|
||||
if (!isRecord(group)) {
|
||||
throw new Error(`request.indexSelection[${groupIndex}] 必须是对象`)
|
||||
}
|
||||
|
||||
const groupDesc = normalizeOptionalString(group.groupDesc)
|
||||
|
||||
return {
|
||||
groupKey: normalizeRequiredString(group.groupKey, `request.indexSelection[${groupIndex}].groupKey`),
|
||||
groupDesc,
|
||||
bindings: normalizeBindings(group.bindings, groupIndex)
|
||||
}
|
||||
})
|
||||
}
|
||||
15
frontend/src/views/tools/mmsmapping/utils/requestPayload.ts
Normal file
15
frontend/src/views/tools/mmsmapping/utils/requestPayload.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
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
|
||||
})
|
||||
@@ -1,266 +0,0 @@
|
||||
# parseComtrade API 文档
|
||||
|
||||
## 1. 接口概述
|
||||
|
||||
- 接口名称:解析 COMTRADE 波形文件
|
||||
- Controller:[WaveController.java](D:/Work/SourceCode/CN_Tool/tools/wave-tool/src/main/java/com/njcn/gather/tool/wave/controller/WaveController.java)
|
||||
- 方法:`parseComtrade`
|
||||
- 请求路径:`POST /wave/parseComtrade`
|
||||
- Content-Type:`multipart/form-data`
|
||||
- 返回类型:`HttpResult<WaveComtradeResultVO>`
|
||||
|
||||
用途说明:
|
||||
|
||||
- 上传一组 COMTRADE `cfg/dat` 文件
|
||||
- 解析原始波形数据
|
||||
- 按请求决定是否补充 RMS 数据、前端查看明细和特征值结果
|
||||
|
||||
## 2. 请求参数
|
||||
|
||||
### 2.1 文件参数
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `cfgFile` | file | 是 | COMTRADE 配置文件 `.cfg` |
|
||||
| `datFile` | file | 是 | COMTRADE 数据文件 `.dat` |
|
||||
|
||||
### 2.2 表单参数
|
||||
|
||||
参数定义来源:[WaveComtradeParseParam.java](D:/Work/SourceCode/CN_Tool/tools/wave-tool/src/main/java/com/njcn/gather/tool/wave/pojo/param/WaveComtradeParseParam.java)
|
||||
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `parseType` | integer | 否 | `1` | 解析类型:`0` 高级算法采样率 32-128,`1` 普通展示,`2` App 抽点,`3` 原始波形 |
|
||||
| `ptType` | integer | 否 | `0` | PT 接线方式:`0` 星形,`1` 三角,`2` 开口三角 |
|
||||
| `pt` | number | 否 | `1` | PT 变比 |
|
||||
| `ct` | number | 否 | `1` | CT 变比 |
|
||||
| `monitorName` | string | 否 | `未命名测点` | 测点名称 |
|
||||
| `calculateRms` | boolean | 否 | `true` | 是否计算 RMS |
|
||||
| `buildDetails` | boolean | 否 | `true` | 是否构建前端查看明细 |
|
||||
| `calculateEigenvalue` | boolean | 否 | `false` | 是否计算特征值 |
|
||||
| `dynamicThreshold` | boolean | 否 | `true` | 特征值是否使用浮动门槛 |
|
||||
|
||||
## 3. 请求示例
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8080/wave/parseComtrade" \
|
||||
-F "cfgFile=@D:/00-B7-8D-00-E4-09/1_20260321_201458_748.CFG" \
|
||||
-F "datFile=@D:/00-B7-8D-00-E4-09/1_20260321_201458_748.DAT" \
|
||||
-F "parseType=1" \
|
||||
-F "ptType=0" \
|
||||
-F "pt=1" \
|
||||
-F "ct=1" \
|
||||
-F "monitorName=监测点1" \
|
||||
-F "calculateRms=true" \
|
||||
-F "buildDetails=true" \
|
||||
-F "calculateEigenvalue=true" \
|
||||
-F "dynamicThreshold=true"
|
||||
```
|
||||
|
||||
## 4. 响应结构
|
||||
|
||||
### 4.1 外层响应
|
||||
|
||||
Controller 返回的是 `HttpResult<WaveComtradeResultVO>`。当前仓库内未展开 `HttpResult` 类型源码,本接口文档只对业务 `data` 部分做精确定义。
|
||||
|
||||
业务数据类型来源:[WaveComtradeResultVO.java](D:/Work/SourceCode/CN_Tool/tools/wave-tool/src/main/java/com/njcn/gather/tool/wave/pojo/vo/WaveComtradeResultVO.java)
|
||||
|
||||
### 4.2 data 字段定义
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `waveData` | object | 波形基础数据 |
|
||||
| `waveDataDetails` | array | 前端查看明细,`buildDetails=true` 时返回 |
|
||||
| `eigenvalues` | array | 特征值结果,`calculateEigenvalue=true` 时返回 |
|
||||
|
||||
## 5. 业务对象说明
|
||||
|
||||
### 5.1 waveData
|
||||
|
||||
定义来源:[WaveDataDTO.java](D:/Work/SourceCode/CN_Tool/tools/wave-tool/src/main/java/com/njcn/gather/tool/wave/pojo/dto/WaveDataDTO.java)
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `comtradeCfgDTO` | object | CFG 解析结果 |
|
||||
| `waveTitle` | array<string> | 波形标题,例如 `["Time","UA相","UB相"]` |
|
||||
| `channelNames` | array<string> | 通道名称列表 |
|
||||
| `listWaveData` | array<array<number>> | 原始波形数据,首列为时间,后续列为相电压/电流值 |
|
||||
| `listRmsData` | array<array<number>> | RMS 波形数据,`calculateRms=true` 时可用 |
|
||||
| `listRmsMinData` | array<array<number>> | RMS 最小值摘要 |
|
||||
| `iPhasic` | integer | 相别数量 |
|
||||
| `ptType` | integer | PT 接线方式 |
|
||||
| `pt` | number | PT 变比 |
|
||||
| `ct` | number | CT 变比 |
|
||||
| `time` | string | 事件发生时刻 |
|
||||
| `monitorName` | string | 测点名称 |
|
||||
|
||||
### 5.2 comtradeCfgDTO
|
||||
|
||||
定义来源:[ComtradeCfgDTO.java](D:/Work/SourceCode/CN_Tool/tools/wave-tool/src/main/java/com/njcn/gather/tool/wave/pojo/dto/ComtradeCfgDTO.java)
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `nChannelNum` | integer | 通道总数 |
|
||||
| `nPhasic` | integer | 相别数量 |
|
||||
| `nAnalogNum` | integer | 模拟量通道数 |
|
||||
| `nDigitalNum` | integer | 开关量通道数 |
|
||||
| `timeStart` | string/date | 录波开始时间 |
|
||||
| `timeTrige` | string/date | 触发时间 |
|
||||
| `lstAnalogDTO` | array | 模拟量通道配置 |
|
||||
| `lstDigitalDTO` | array | 开关量通道配置 |
|
||||
| `nRates` | integer | 采样率分段数 |
|
||||
| `lstRate` | array | 采样率分段配置 |
|
||||
| `firstTime` | string/date | 首个触发时间对象 |
|
||||
| `firstMs` | integer | 首个触发毫秒值 |
|
||||
| `nPush` | integer | 触发前推点数 |
|
||||
| `finalSampleRate` | integer | 最终采样率 |
|
||||
| `nAllWaveNum` | integer | 总周波数 |
|
||||
| `strBinType` | string | 文件编码类型,例如 `BINARY` |
|
||||
|
||||
### 5.3 waveDataDetails
|
||||
|
||||
定义来源:[WaveDataDetail.java](D:/Work/SourceCode/CN_Tool/tools/wave-tool/src/main/java/com/njcn/gather/tool/wave/pojo/bo/WaveDataDetail.java)
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `instantData` | object | 瞬时波形数据 |
|
||||
| `rmsData` | object | RMS 波形数据 |
|
||||
| `a` | string | A 相名称 |
|
||||
| `b` | string | B 相名称 |
|
||||
| `c` | string | C 相名称 |
|
||||
| `channelName` | string | 通道名称 |
|
||||
| `unit` | string | 单位 |
|
||||
| `isOpen` | boolean | 是否开口三角模式 |
|
||||
| `title` | string | 当前图标题 |
|
||||
| `colors` | array<string> | 曲线颜色 |
|
||||
|
||||
其中 `instantData` 和 `rmsData` 结构一致,定义分别来自:
|
||||
|
||||
- [InstantData.java](D:/Work/SourceCode/CN_Tool/tools/wave-tool/src/main/java/com/njcn/gather/tool/wave/pojo/bo/InstantData.java)
|
||||
- [RmsData.java](D:/Work/SourceCode/CN_Tool/tools/wave-tool/src/main/java/com/njcn/gather/tool/wave/pojo/bo/RmsData.java)
|
||||
|
||||
公共字段:
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `max` | number | 当前曲线最大值 |
|
||||
| `min` | number | 当前曲线最小值 |
|
||||
| `aValue` | array<array<number>> | A 相点位 |
|
||||
| `bValue` | array<array<number>> | B 相点位 |
|
||||
| `cValue` | array<array<number>> | C 相点位 |
|
||||
|
||||
### 5.4 eigenvalues
|
||||
|
||||
定义来源:[EigenvalueDTO.java](D:/Work/SourceCode/CN_Tool/tools/wave-tool/src/main/java/com/njcn/gather/tool/wave/pojo/dto/EigenvalueDTO.java)
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `amplitude` | number | 特征幅值百分比 |
|
||||
| `residualVoltage` | number | 残余电压 |
|
||||
| `ratedVoltage` | number | 额定电压 |
|
||||
| `durationTime` | number | 持续时间 |
|
||||
|
||||
## 6. 成功响应示例
|
||||
|
||||
以下示例基于真实样本文件联测结果整理,长数组做了截断展示。
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "SUCCESS",
|
||||
"message": "成功",
|
||||
"data": {
|
||||
"waveData": {
|
||||
"comtradeCfgDTO": {
|
||||
"nChannelNum": 6,
|
||||
"nPhasic": 3,
|
||||
"nAnalogNum": 6,
|
||||
"nDigitalNum": 0,
|
||||
"timeStart": "2026-03-21 20:14:58.648",
|
||||
"timeTrige": "2026-03-21 20:14:58.748",
|
||||
"nRates": 1,
|
||||
"firstMs": 748,
|
||||
"nPush": 100,
|
||||
"finalSampleRate": 512,
|
||||
"nAllWaveNum": 30,
|
||||
"strBinType": "BINARY"
|
||||
},
|
||||
"waveTitle": ["Time", "UA相", "UB相", "UC相", "IA相", "IB相", "IC相"],
|
||||
"channelNames": ["/", "U1", "U2", "U3", "I1", "I2", "I3"],
|
||||
"listWaveData": {
|
||||
"count": 15616,
|
||||
"first": [-100.0, -146.56, -76.9, -76.9, -0.13, 0.01, -0.2],
|
||||
"last": [509.96, 148.02, 69.73, 69.75, 0.16, 0.01, 0.15]
|
||||
},
|
||||
"listRmsData": {
|
||||
"count": 15616,
|
||||
"first": [-100.0, 104.94, 104.22, 104.23, 0.27, 0.01, 0.28],
|
||||
"last": [509.96, 105.6, 105.1, 105.12, 0.24, 0.01, 0.24]
|
||||
},
|
||||
"listRmsMinData": [
|
||||
[40.74, 41.2],
|
||||
[362.19, 0.01]
|
||||
],
|
||||
"iPhasic": 3,
|
||||
"ptType": 0,
|
||||
"pt": 1.0,
|
||||
"ct": 1.0,
|
||||
"time": "2026-03-21 20:14:58.748",
|
||||
"monitorName": "监测点1"
|
||||
},
|
||||
"waveDataDetails": [
|
||||
{
|
||||
"channelName": "U1",
|
||||
"unit": "kV",
|
||||
"a": "A相",
|
||||
"b": "B相",
|
||||
"c": "C相",
|
||||
"isOpen": false
|
||||
},
|
||||
{
|
||||
"channelName": "I1",
|
||||
"unit": "A",
|
||||
"a": "A相",
|
||||
"b": "B相",
|
||||
"c": "C相",
|
||||
"isOpen": false
|
||||
}
|
||||
],
|
||||
"eigenvalues": [
|
||||
{
|
||||
"amplitude": 0.3926178,
|
||||
"residualVoltage": 41.200005,
|
||||
"ratedVoltage": 104.936676,
|
||||
"durationTime": 48.632812
|
||||
},
|
||||
{
|
||||
"amplitude": 0.4067544,
|
||||
"residualVoltage": 42.390152,
|
||||
"ratedVoltage": 104.21559,
|
||||
"durationTime": 54.492188
|
||||
},
|
||||
{
|
||||
"amplitude": 0.40674016,
|
||||
"residualVoltage": 42.396355,
|
||||
"ratedVoltage": 104.2345,
|
||||
"durationTime": 54.492188
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 7. 失败场景
|
||||
|
||||
基于当前代码,常见失败场景包括:
|
||||
|
||||
| 场景 | 说明 |
|
||||
| --- | --- |
|
||||
| `cfgFile` 或 `datFile` 未上传 | 返回业务异常,提示“cfg 或 dat 文件不能为空” |
|
||||
| CFG 文件格式错误 | 返回 CFG 解析失败 |
|
||||
| DAT 文件为空或格式错误 | 返回 DAT 解析失败 |
|
||||
| COMTRADE 解析过程中出现异常 | 返回“COMTRADE 波形解析失败” |
|
||||
|
||||
## 8. 备注
|
||||
|
||||
- 当前接口已经移除图片生成相关参数,不再支持 `generateInstantImage`、`generateRmsImage` 等旧字段。
|
||||
- 当前接口文档只覆盖 `parseComtrade`,其他波形文本解析接口请单独编写。
|
||||
@@ -14,6 +14,7 @@
|
||||
<div class="summary-value">{{ item.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="向量信息" name="vector">
|
||||
@@ -23,27 +24,16 @@
|
||||
:last-vector-parse-error-message="lastVectorParseErrorMessage"
|
||||
:active-vector-channel-name="activeVectorChannelName"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<div class="feature-header">特征值</div>
|
||||
<div v-if="featureCards.length" class="feature-grid">
|
||||
<div v-for="item in featureCards" :key="item.title" class="feature-card">
|
||||
<div class="feature-card-title">{{ item.title }}</div>
|
||||
<div v-for="row in item.rows" :key="row.label" class="feature-row">
|
||||
<span>{{ row.label }}</span>
|
||||
<span>{{ row.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-inline">当前文件未返回特征值结果。</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="panel-body">
|
||||
<div class="empty-block">
|
||||
<div class="empty-title">暂无解析信息</div>
|
||||
<div class="empty-text">接口联调完成后,右侧会展示波形信息、向量信息和特征值。</div>
|
||||
<div class="empty-text">接口联调完成后,右侧会展示波形信息和向量信息。</div>
|
||||
<div v-if="lastParseErrorMessage" class="empty-text error-text">最近一次解析失败:{{ lastParseErrorMessage }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -53,13 +43,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { Waveform } from '@/api/tools/waveform/interface'
|
||||
import type { FeatureCardItem, SummaryItem } from './types'
|
||||
import type { SummaryItem } from './types'
|
||||
import WaveformVectorInfo from './WaveformVectorInfo.vue'
|
||||
|
||||
defineProps<{
|
||||
hasParsedWaveform: boolean
|
||||
summaryItems: SummaryItem[]
|
||||
featureCards: FeatureCardItem[]
|
||||
vectorParseResult: Waveform.WaveComtradeVectorResultVO | null
|
||||
lastParseErrorMessage: string
|
||||
lastVectorParseErrorMessage: string
|
||||
@@ -143,18 +132,40 @@ const activeInfoTab = ref('waveform')
|
||||
}
|
||||
|
||||
.info-body {
|
||||
overflow: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.info-tabs {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-tabs :deep(.el-tabs__header) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.info-tabs :deep(.el-tabs__content) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.info-tabs :deep(.el-tab-pane) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.info-tab-panel {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.info-tabs :deep(.el-tabs__item) {
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -165,23 +176,15 @@ const activeInfoTab = ref('waveform')
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.feature-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.summary-item,
|
||||
.feature-card {
|
||||
.summary-item {
|
||||
padding: 10px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 4px;
|
||||
background: var(--cn-color-canvas-bg);
|
||||
}
|
||||
|
||||
.summary-label,
|
||||
.feature-card-title,
|
||||
.feature-header {
|
||||
.summary-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
@@ -195,24 +198,7 @@ const activeInfoTab = ref('waveform')
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.feature-header {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.feature-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.summary-grid {
|
||||
@@ -221,8 +207,7 @@ const activeInfoTab = ref('waveform')
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.summary-grid,
|
||||
.feature-grid {
|
||||
.summary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
placeholder="请选择同一组.cfg和.dat文件"
|
||||
class="file-input"
|
||||
/>
|
||||
<el-button type="primary" :loading="isParsing" @click="openWaveformFilePicker">选择波形</el-button>
|
||||
<el-button type="primary" :icon="FolderOpened" :loading="isParsing" @click="openWaveformFilePicker">
|
||||
选择波形
|
||||
</el-button>
|
||||
<input
|
||||
ref="waveformFileInputRef"
|
||||
type="file"
|
||||
@@ -59,13 +61,16 @@
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<el-button type="primary" :disabled="!hasWaveformData" @click="emit('download')">下载数据</el-button>
|
||||
<el-button type="primary" :icon="Download" :disabled="!hasWaveformData" @click="emit('download')">
|
||||
下载数据
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Download, FolderOpened } from '@element-plus/icons-vue'
|
||||
import { ref } from 'vue'
|
||||
import type { DisplayMode, LabelValueOption, ValueMode, WaveformDetailOption } from './types'
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
</el-tabs>
|
||||
|
||||
<div v-if="activeCycle" class="vector-tab-content">
|
||||
<div class="feature-header feature-header--nested">基础指标</div>
|
||||
<div v-if="phaseMetricColumns.length" class="phase-metric-table">
|
||||
<div class="phase-metric-row phase-metric-row--header" :style="phaseMetricGridStyle">
|
||||
<span class="phase-metric-cell phase-metric-cell--label">指标</span>
|
||||
@@ -83,23 +84,8 @@
|
||||
/>
|
||||
</el-tabs>
|
||||
|
||||
<div v-if="activeHarmonicRows.length" class="harmonic-list">
|
||||
<div class="harmonic-row harmonic-row--header">
|
||||
<span>次数</span>
|
||||
<span>幅值</span>
|
||||
<span>有效值</span>
|
||||
<span>占比</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="item in activeHarmonicRows"
|
||||
:key="`${activePhaseKey}-${item.harmonicOrder}`"
|
||||
class="harmonic-row"
|
||||
>
|
||||
<span>{{ item.harmonicOrder ?? '--' }}</span>
|
||||
<span>{{ formatWaveValue(item.amplitude, activeVectorGroup?.unit) }}</span>
|
||||
<span>{{ formatWaveValue(item.rms, activeVectorGroup?.unit) }}</span>
|
||||
<span>{{ formatPercentValue(item.rate) }}</span>
|
||||
</div>
|
||||
<div v-if="activeHarmonicRows.length" class="harmonic-chart-card">
|
||||
<div ref="harmonicChartRef" class="harmonic-chart" />
|
||||
</div>
|
||||
<div v-else class="empty-inline">当前相未返回谐波结果。</div>
|
||||
</div>
|
||||
@@ -112,8 +98,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, type CSSProperties } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch, type CSSProperties } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import * as echarts from 'echarts'
|
||||
import type { Waveform } from '@/api/tools/waveform/interface'
|
||||
import type { SummaryItem } from './types'
|
||||
|
||||
@@ -163,26 +150,12 @@ const formatWaveformTime = (value?: string) => {
|
||||
return parsedValue.isValid() ? parsedValue.format('YYYY-MM-DD HH:mm:ss.SSS') : value
|
||||
}
|
||||
|
||||
const formatWaveValue = (value: unknown, unit?: string) => {
|
||||
const formatWaveValue = (value: unknown) => {
|
||||
const formattedValue = formatNumber(value)
|
||||
if (formattedValue === '--') return '--'
|
||||
return unit ? `${formattedValue} ${unit}` : formattedValue
|
||||
return formattedValue
|
||||
}
|
||||
|
||||
const formatPhaseAngle = (value: unknown) => {
|
||||
const formattedValue = formatNumber(value)
|
||||
return formattedValue === '--' ? '--' : `${formattedValue} °`
|
||||
}
|
||||
|
||||
const formatPercentValue = (value: unknown) => {
|
||||
const formattedValue = formatNumber(value)
|
||||
return formattedValue === '--' ? '--' : `${formattedValue}%`
|
||||
}
|
||||
|
||||
const formatCycleTime = (value: unknown) => {
|
||||
const formattedValue = formatNumber(value)
|
||||
return formattedValue === '--' ? '--' : `${formattedValue} ms`
|
||||
}
|
||||
|
||||
const buildMetricLabel = (label: string, unit?: string) => {
|
||||
return unit ? `${label} (${unit})` : label
|
||||
@@ -190,13 +163,15 @@ const buildMetricLabel = (label: string, unit?: string) => {
|
||||
|
||||
const phaseMetricConfigs: Array<{
|
||||
label: string
|
||||
getValue: (phase: Waveform.WavePhaseVectorDTO, unit?: string) => string
|
||||
unit?: string
|
||||
useGroupUnit?: boolean
|
||||
getValue: (phase: Waveform.WavePhaseVectorDTO) => string
|
||||
}> = [
|
||||
{ label: '总有效值', getValue: (phase, unit) => formatWaveValue(phase.totalRms, unit) },
|
||||
{ label: '基波幅值', getValue: (phase, unit) => formatWaveValue(phase.fundamentalAmplitude, unit) },
|
||||
{ label: '基波有效值', getValue: (phase, unit) => formatWaveValue(phase.fundamentalRms, unit) },
|
||||
{ label: '基波相角', getValue: phase => formatPhaseAngle(phase.fundamentalPhaseAngle) },
|
||||
{ label: '谐波畸变率', getValue: phase => formatPercentValue(phase.harmonicDistortionRate) }
|
||||
{ label: '总有效值', useGroupUnit: true, getValue: phase => formatWaveValue(phase.totalRms) },
|
||||
{ label: '基波幅值', useGroupUnit: true, getValue: phase => formatWaveValue(phase.fundamentalAmplitude) },
|
||||
{ label: '基波有效值', useGroupUnit: true, getValue: phase => formatWaveValue(phase.fundamentalRms) },
|
||||
{ label: '基波相角', unit: '°', getValue: phase => formatWaveValue(phase.fundamentalPhaseAngle) },
|
||||
{ label: '谐波畸变率', unit: '%', getValue: phase => formatWaveValue(phase.harmonicDistortionRate) }
|
||||
]
|
||||
|
||||
const buildVectorGroupKey = (group: Waveform.WaveVectorGroupDTO, index: number) => {
|
||||
@@ -299,15 +274,15 @@ const phaseMetricColumns = computed<PhaseMetricColumn[]>(() => {
|
||||
})
|
||||
|
||||
const phaseMetricGridStyle = computed<CSSProperties>(() => ({
|
||||
gridTemplateColumns: `96px repeat(${Math.max(phaseMetricColumns.value.length, 1)}, minmax(96px, 1fr))`
|
||||
gridTemplateColumns: `128px repeat(${Math.max(phaseMetricColumns.value.length, 1)}, minmax(96px, 1fr))`
|
||||
}))
|
||||
|
||||
const sequenceMetricGridStyle = computed<CSSProperties>(() => ({
|
||||
gridTemplateColumns: `132px repeat(${Math.max(sequenceMetricColumns.value.length, 1)}, minmax(0, 1fr))`
|
||||
gridTemplateColumns: `88px repeat(${Math.max(sequenceMetricColumns.value.length, 1)}, minmax(64px, 1fr))`
|
||||
}))
|
||||
|
||||
const unbalanceMetricGridStyle = computed<CSSProperties>(() => ({
|
||||
gridTemplateColumns: '156px minmax(0, 1fr)'
|
||||
gridTemplateColumns: '128px minmax(72px, 1fr)'
|
||||
}))
|
||||
|
||||
const phaseMetricRows = computed<PhaseMetricRow[]>(() => {
|
||||
@@ -315,11 +290,11 @@ const phaseMetricRows = computed<PhaseMetricRow[]>(() => {
|
||||
|
||||
// 相量基础指标按“指标为行、相别为列”转置,减少 A/B/C 三相重复标签。
|
||||
return phaseMetricConfigs.map(config => ({
|
||||
label: config.label,
|
||||
label: buildMetricLabel(config.label, config.useGroupUnit ? activeGroup?.unit : config.unit),
|
||||
values: activePhaseVectors.value.reduce<Record<string, string>>((result, phase, index) => {
|
||||
const column = phaseMetricColumns.value[index]
|
||||
if (column) {
|
||||
result[column.key] = config.getValue(phase, activeGroup?.unit)
|
||||
result[column.key] = config.getValue(phase)
|
||||
}
|
||||
return result
|
||||
}, {})
|
||||
@@ -380,11 +355,158 @@ const unbalanceMetricRows = computed<MetricRow[]>(() => {
|
||||
]
|
||||
})
|
||||
|
||||
const activeHarmonicRows = computed(() => {
|
||||
const activeHarmonicRows = computed<Waveform.WaveHarmonicDTO[]>(() => {
|
||||
if (!activePhaseVector.value) return []
|
||||
|
||||
return activePhaseVector.value.harmonicVoltageContentRates || activePhaseVector.value.harmonicCurrentAmplitudes || []
|
||||
})
|
||||
|
||||
const harmonicChartRef = ref<HTMLDivElement>()
|
||||
let harmonicChart: echarts.ECharts | null = null
|
||||
let harmonicResizeObserver: ResizeObserver | null = null
|
||||
|
||||
const normalizeChartValue = (value: unknown) => {
|
||||
const numberValue = Number(value)
|
||||
return Number.isFinite(numberValue) ? Number(numberValue.toFixed(3)) : null
|
||||
}
|
||||
|
||||
const harmonicChartOption = computed<echarts.EChartsOption>(() => {
|
||||
const unit = activeVectorGroup.value?.unit || ''
|
||||
const categories = activeHarmonicRows.value.map(item => `${item.harmonicOrder ?? '--'}次`)
|
||||
const hasRate = activeHarmonicRows.value.some(item => Number.isFinite(Number(item.rate)))
|
||||
const valueAxisName = unit ? `幅值 / 有效值 (${unit})` : '幅值 / 有效值'
|
||||
const series: echarts.SeriesOption[] = [
|
||||
{
|
||||
name: unit ? `幅值 (${unit})` : '幅值',
|
||||
type: 'bar',
|
||||
barMaxWidth: 16,
|
||||
data: activeHarmonicRows.value.map(item => normalizeChartValue(item.amplitude))
|
||||
},
|
||||
{
|
||||
name: unit ? `有效值 (${unit})` : '有效值',
|
||||
type: 'bar',
|
||||
barMaxWidth: 16,
|
||||
data: activeHarmonicRows.value.map(item => normalizeChartValue(item.rms))
|
||||
}
|
||||
]
|
||||
|
||||
if (hasRate) {
|
||||
series.push({
|
||||
name: '占比 (%)',
|
||||
type: 'bar',
|
||||
yAxisIndex: 1,
|
||||
barMaxWidth: 16,
|
||||
data: activeHarmonicRows.value.map(item => normalizeChartValue(item.rate))
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
color: ['#2f80ed', '#07ccca', '#ffbf00'],
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
confine: true
|
||||
},
|
||||
legend: {
|
||||
top: 0,
|
||||
right: 8,
|
||||
itemWidth: 12,
|
||||
itemHeight: 8,
|
||||
textStyle: { color: '#606266', fontSize: 12 }
|
||||
},
|
||||
grid: {
|
||||
top: 36,
|
||||
right: hasRate ? 52 : 16,
|
||||
bottom: 42,
|
||||
left: 12,
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
name: '谐波次数',
|
||||
nameGap: 22,
|
||||
data: categories,
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: '#606266', fontSize: 11 },
|
||||
axisLine: { lineStyle: { color: '#dcdfe6' } }
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: valueAxisName,
|
||||
nameTextStyle: { color: '#606266', fontSize: 11 },
|
||||
axisLabel: { color: '#606266', fontSize: 11 },
|
||||
splitLine: { lineStyle: { color: '#ebeef5', type: 'dashed' } }
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: '%',
|
||||
show: hasRate,
|
||||
nameTextStyle: { color: '#606266', fontSize: 11 },
|
||||
axisLabel: { color: '#606266', fontSize: 11 },
|
||||
splitLine: { show: false }
|
||||
}
|
||||
],
|
||||
dataZoom: [
|
||||
{
|
||||
type: 'inside',
|
||||
start: 0,
|
||||
end: Math.min(100, activeHarmonicRows.value.length > 18 ? 36 : 100)
|
||||
},
|
||||
{
|
||||
type: 'slider',
|
||||
height: 12,
|
||||
bottom: 12,
|
||||
start: 0,
|
||||
end: Math.min(100, activeHarmonicRows.value.length > 18 ? 36 : 100)
|
||||
}
|
||||
],
|
||||
series
|
||||
}
|
||||
})
|
||||
|
||||
const resizeHarmonicChart = () => {
|
||||
if (!harmonicChart || !harmonicChartRef.value || harmonicChartRef.value.offsetHeight === 0) return
|
||||
harmonicChart.resize()
|
||||
}
|
||||
|
||||
const renderHarmonicChart = async () => {
|
||||
await nextTick()
|
||||
|
||||
if (!harmonicChartRef.value || !activeHarmonicRows.value.length) {
|
||||
harmonicChart?.dispose()
|
||||
harmonicChart = null
|
||||
harmonicResizeObserver?.disconnect()
|
||||
harmonicResizeObserver = null
|
||||
return
|
||||
}
|
||||
|
||||
if (!harmonicChart) {
|
||||
harmonicChart = echarts.init(harmonicChartRef.value)
|
||||
}
|
||||
|
||||
harmonicChart.setOption(harmonicChartOption.value, true)
|
||||
|
||||
if (!harmonicResizeObserver) {
|
||||
harmonicResizeObserver = new ResizeObserver(() => resizeHarmonicChart())
|
||||
harmonicResizeObserver.observe(harmonicChartRef.value)
|
||||
}
|
||||
|
||||
resizeHarmonicChart()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
renderHarmonicChart()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
harmonicResizeObserver?.disconnect()
|
||||
harmonicChart?.dispose()
|
||||
})
|
||||
|
||||
watch(harmonicChartOption, () => {
|
||||
renderHarmonicChart()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -397,6 +519,12 @@ const activeHarmonicRows = computed(() => {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.waveform-vector-info {
|
||||
flex: none;
|
||||
min-height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.empty-text,
|
||||
.empty-inline,
|
||||
.vector-placeholder {
|
||||
@@ -425,7 +553,7 @@ const activeHarmonicRows = computed(() => {
|
||||
.summary-item,
|
||||
.feature-card,
|
||||
.vector-placeholder,
|
||||
.harmonic-list {
|
||||
.harmonic-chart-card {
|
||||
padding: 10px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 4px;
|
||||
@@ -488,6 +616,11 @@ const activeHarmonicRows = computed(() => {
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.phase-metric-table--fit .phase-metric-cell {
|
||||
padding-right: 8px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.phase-metric-table--fit .phase-metric-row {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
@@ -509,12 +642,20 @@ const activeHarmonicRows = computed(() => {
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--el-text-color-regular);
|
||||
text-align: right;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
border-right: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.phase-metric-cell--label {
|
||||
overflow: visible;
|
||||
text-align: left;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.phase-metric-row--header .phase-metric-cell {
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
@@ -523,9 +664,6 @@ const activeHarmonicRows = computed(() => {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.phase-metric-cell--label {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.vector-tabs {
|
||||
width: 100%;
|
||||
@@ -544,36 +682,14 @@ const activeHarmonicRows = computed(() => {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.harmonic-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
padding: 0;
|
||||
.harmonic-chart-card {
|
||||
height: 280px;
|
||||
padding: 10px 8px 4px;
|
||||
}
|
||||
|
||||
.harmonic-row {
|
||||
display: grid;
|
||||
grid-template-columns: 52px repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.harmonic-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.harmonic-row--header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
background: var(--el-bg-color);
|
||||
.harmonic-chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
@@ -585,7 +701,7 @@ const activeHarmonicRows = computed(() => {
|
||||
@media (min-width: 993px) {
|
||||
.vector-tab-content {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(200px, 0.65fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@@ -594,29 +710,30 @@ const activeHarmonicRows = computed(() => {
|
||||
}
|
||||
|
||||
.vector-tab-content > :first-child,
|
||||
.vector-tab-content > :nth-child(n + 6) {
|
||||
.vector-tab-content > :nth-child(2),
|
||||
.vector-tab-content > :nth-child(n + 7) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.vector-tab-content > :nth-child(2) {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.vector-tab-content > :nth-child(3) {
|
||||
grid-column: 1;
|
||||
grid-row: 3;
|
||||
}
|
||||
|
||||
.vector-tab-content > :nth-child(4) {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
grid-column: 1;
|
||||
grid-row: 4;
|
||||
}
|
||||
|
||||
.vector-tab-content > :nth-child(5) {
|
||||
grid-column: 2;
|
||||
grid-row: 3;
|
||||
}
|
||||
|
||||
.vector-tab-content > :nth-child(6) {
|
||||
grid-column: 2;
|
||||
grid-row: 4;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
@@ -626,8 +743,6 @@ const activeHarmonicRows = computed(() => {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.harmonic-row {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@
|
||||
<WaveformInfoPanel
|
||||
:has-parsed-waveform="hasParsedWaveform"
|
||||
:summary-items="summaryItems"
|
||||
:feature-cards="featureCards"
|
||||
:vector-parse-result="vectorParseResult"
|
||||
:last-parse-error-message="lastParseErrorMessage"
|
||||
:last-vector-parse-error-message="lastVectorParseErrorMessage"
|
||||
@@ -54,7 +53,6 @@ import WaveformToolbar from './components/WaveformToolbar.vue'
|
||||
import WaveformTrendPanel from './components/WaveformTrendPanel.vue'
|
||||
import type {
|
||||
DisplayMode,
|
||||
FeatureCardItem,
|
||||
LabelValueOption,
|
||||
SingleChannelTrendOption,
|
||||
SummaryItem,
|
||||
@@ -218,7 +216,6 @@ const activeWaveDetail = computed(() => {
|
||||
})
|
||||
|
||||
const activeWaveData = computed(() => waveformParseResult.value?.waveData)
|
||||
const eigenvalueList = computed(() => waveformParseResult.value?.eigenvalues || [])
|
||||
|
||||
const normalizeRatio = (value?: number) => {
|
||||
const ratio = Number(value)
|
||||
@@ -251,18 +248,6 @@ const formatNumber = (value: unknown, fractionDigits = 3) => {
|
||||
return `${Number(numberValue.toFixed(fractionDigits))}`
|
||||
}
|
||||
|
||||
const formatPercentage = (value?: number) => {
|
||||
const numberValue = Number(value)
|
||||
if (!Number.isFinite(numberValue)) return '--'
|
||||
return `${Number((numberValue * 100).toFixed(2))}%`
|
||||
}
|
||||
|
||||
const formatDuration = (value?: number) => {
|
||||
const numberValue = Number(value)
|
||||
if (!Number.isFinite(numberValue)) return '--'
|
||||
return `${Number(numberValue.toFixed(3))} ms`
|
||||
}
|
||||
|
||||
const formatWaveformTime = (value?: string) => {
|
||||
if (!value) return '--'
|
||||
|
||||
@@ -546,18 +531,6 @@ const summaryItems = computed<SummaryItem[]>(() => {
|
||||
]
|
||||
})
|
||||
|
||||
const featureCards = computed<FeatureCardItem[]>(() => {
|
||||
return eigenvalueList.value.map((item, index) => ({
|
||||
title: `特征值 ${index + 1}`,
|
||||
rows: [
|
||||
{ label: '幅值占比', value: formatPercentage(item.amplitude) },
|
||||
{ label: '残余电压', value: formatNumber(item.residualVoltage) },
|
||||
{ label: '额定电压', value: formatNumber(item.ratedVoltage) },
|
||||
{ label: '持续时间', value: formatDuration(item.durationTime) }
|
||||
]
|
||||
}))
|
||||
})
|
||||
|
||||
const getFileBaseName = (fileName: string) => {
|
||||
return fileName.replace(/\.[^.]+$/, '').toLowerCase()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user