Compare commits
24 Commits
55a7c54d54
...
2026-04
| Author | SHA1 | Date | |
|---|---|---|---|
| a1e1fb124a | |||
| fe3ab1f679 | |||
| 32f324909d | |||
| 2babe9d99d | |||
| 407ab0a7f6 | |||
| bf9f3719a4 | |||
| 483b3d7ae4 | |||
| 297f89ef52 | |||
| 0dc0e4ecdc | |||
| 398a2cf1dc | |||
| 287de846a6 | |||
| 7dee9092dc | |||
| c6e3662248 | |||
| 10e6bd5151 | |||
| e1cb4fb694 | |||
| edf0af7953 | |||
| 63433f7f01 | |||
| eb384e8eef | |||
| a6e32e0e19 | |||
| 2314b03404 | |||
| 455d394682 | |||
| 93d6416da1 | |||
| 9a388627a0 | |||
| 45ab5c9e84 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
node_modules
|
||||
.worktrees/
|
||||
out/
|
||||
logs/
|
||||
run/
|
||||
|
||||
33
AGENTS.md
33
AGENTS.md
@@ -17,6 +17,9 @@
|
||||
- 外科手术式修改:只改与任务直接相关的文件和代码行,不重构无关模块,不调整无关格式或注释。
|
||||
- 保持现有风格:遵循仓库已有包结构、分层方式、命名和写法,不按个人偏好重写。
|
||||
- 只清理自己造成的问题:可以删除因本次修改而产生的未使用 `import`、变量或方法;不要删除仓库中原本就存在的死代码,除非用户明确要求。
|
||||
- 页面边距约定:业务页面根节点默认跟随布局主内容区 `el-main` 的 `15px` 边距,不再额外叠加页面级外边距;如需特殊边距,必须先有明确的视觉参照页面或业务原因。
|
||||
- 表格样式约定:业务表格优先复用仓库现有 `table-main`、`card`、`table-header` 结构,参照 `dictdata` 页面;表格卡片内部默认不再额外堆叠页面标题、说明文案或自定义装饰区,表头左侧用于主操作、次操作和危险批量操作,右侧用于刷新、列设置、搜索等工具按钮;不要在单页里重复自定义表格卡片边框、表头按钮布局和表头配色,除非有明确视觉参照或业务原因。
|
||||
- 按钮样式约定:业务页面按钮参照 `dictdata` 页面;表头主操作使用 `type="primary"`,表头次操作使用 `type="primary" plain`,危险批量操作使用 `type="danger" plain`,表格行内操作统一使用 `link` 风格并优先保持 `primary` 语义与图标一致性;弹窗底部保持“取消”默认按钮、“主确认”使用 `primary`,同级辅助执行按钮使用 `primary plain`。
|
||||
- 先定义验证方式:执行方案里要写清楚“改哪里、怎么判断改对了”;默认通过代码路径、配置一致性、界面影响范围和启动链路检查进行验证。
|
||||
- 除非用户明确要求,否则不执行 `npm run build`、`npm run build-w`、`electron-builder`、加密打包或其他重型构建命令;通常只做静态检查、必要的代码阅读和轻量验证。
|
||||
|
||||
@@ -35,6 +38,8 @@
|
||||
## 代码风格与命名规范
|
||||
前端格式化规则定义在 `frontend/.prettierrc`:4 空格缩进、单引号、不写分号、单行 120 字符、LF 换行。Lint 规则基于 Vue 3 与 TypeScript。
|
||||
|
||||
文件编码规范:所有新增或修改的源码、脚本、配置、文档统一使用 UTF-8 编码(无 BOM)和 LF 换行,不要保存为 GBK、ANSI 或其他本地编码,避免再次出现乱码。
|
||||
|
||||
请遵循现有命名方式:
|
||||
- 页面或路由目录通常使用 `index.vue`,例如 `views/home/`
|
||||
- 通用组件使用 PascalCase,例如 `HomeToolCard.vue`
|
||||
@@ -59,4 +64,32 @@ PR 应包含:
|
||||
## 安全与配置提示
|
||||
`public/ssl/`、`build/extraResources/` 与 `electron/config/` 包含敏感运行资源。不要硬编码新的密钥或口令;凡是影响打包或启动的本地 `.env`、端口或运行配置调整,都应同步记录到 `doc/`。
|
||||
|
||||
## 趋势图纵坐标显示规则
|
||||
涉及 waveform 或其他趋势图纵坐标时,统一遵循以下规则:
|
||||
|
||||
- 必须显示纵坐标最大值和最小值,图表配置中应显式保留 `showMaxLabel` 与 `showMinLabel`。
|
||||
- 纵坐标刻度值采用均分方式生成,不再使用会改变刻度间隔的“友好刻度”取整逻辑。
|
||||
- 纵坐标最大值和最小值基于图形内真实最大值、最小值按 `1.2` 倍扩展;正数下边界使用 `0.8` 倍向下留白,负数上边界使用 `0.8` 倍向上留白,避免正数最小值或负数最大值被扩展到数据内侧。
|
||||
- 当数据同时包含正负值且正负幅值接近时,纵坐标最大值和最小值应尽量对称,按较大绝对值向外取整后取 `±同一边界`,例如最大值 `178`、最小值 `-177` 时显示为 `180` 与 `-180`。
|
||||
- 当最大值、最小值相同或数据接近 `0` 时,需要补充兜底范围,避免坐标轴退化为一条线;小于 `1` 的小数范围按实际小数精度保留,不强制取整。
|
||||
- 当纵坐标区间较小且均分后出现冗长小数时,应优先使用 `1`、`2`、`2.5`、`5` 等可读步长归一化刻度;必要时可少量增加分段,但必须继续保证刻度均分、最大最小值显示、真实数据完整落在坐标范围内。
|
||||
- 纵坐标标签不能重叠。小高度趋势图应减少均分段数,优先保证最大值、最小值和必要中间值可读;高度足够时再增加分段。
|
||||
|
||||
## 多图趋势图对齐规则
|
||||
涉及 waveform 或其他上下堆叠的多张趋势图时,统一遵循以下规则:
|
||||
|
||||
- 同一组多图必须保证绘图区左边界一致,纵向观察时各图的 y 轴线、x 轴 `0` 起点和曲线起始位置应上下对齐。
|
||||
- 多图不得让 ECharts 按各自纵坐标标签宽度自动改变绘图区起点;应使用统一的 `grid.left`,并显式配置 `grid.containLabel: false` 或等效方案,避免 `150`、`2`、`-100` 等标签宽度差异导致曲线区域错位。
|
||||
- 纵坐标标签宽度预留应按同组图中最长标签统一评估,必要时增加统一的左侧 `grid.left`,不能为单张图单独调整左边距。
|
||||
- 横坐标首尾标签、单位文字或底部留白只能影响底部显示空间,不应改变绘图区左边界;调整 `grid.bottom`、`axisLabel.margin`、`nameGap` 时,需要同步检查多图 x=0 起点是否仍然对齐。
|
||||
- 验证多图趋势图时,至少检查单通道拆分图和全部通道列表图两种场景;判断标准是多张图左侧坐标轴竖线形成同一条垂直线,底部横坐标标签不遮挡、不贴线。
|
||||
|
||||
|
||||
## 趋势图线宽显示规则
|
||||
涉及 waveform 或其他线形趋势图时,线宽应按当前可见点数动态计算,避免初始化大数据量时线条糊成一片,也避免放大后线条过细。
|
||||
|
||||
- 当前可见点数按横向缩放范围计算:`ceil(seriesDataLength * ((dataZoom.end - dataZoom.start) / 100))`。
|
||||
- 初始全量展示时,点数越多线越细;横向放大后可见点数减少,线宽可逐步变粗;横向缩小或重置后线宽恢复到对应细线档位。
|
||||
- Y 轴缩放、测量模式、峰值显示不改变主线线宽,避免状态切换造成额外视觉跳动。
|
||||
- 主线最大线宽不得超过 `1.6`。
|
||||
- 线宽分档统一为:`>= 200000` 使用 `0.35`,`100000 - 199999` 使用 `0.45`,`50000 - 99999` 使用 `0.55`,`20000 - 49999` 使用 `0.65`,`10000 - 19999` 使用 `0.75`,`5000 - 9999` 使用 `0.9`,`2000 - 4999` 使用 `1`,`800 - 1999` 使用 `1.2`,`200 - 799` 使用 `1.4`,`< 200` 使用 `1.6`。
|
||||
|
||||
@@ -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` 次。
|
||||
- 如果单周波采样点数过低,高次谐波指标会受分辨率限制。
|
||||
104
doc/系统磁盘监控数据库设计.sql
Normal file
104
doc/系统磁盘监控数据库设计.sql
Normal file
@@ -0,0 +1,104 @@
|
||||
CREATE TABLE IF NOT EXISTS `disk_monitor_policy` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`policy_name` VARCHAR(100) NOT NULL DEFAULT '默认磁盘监控策略' COMMENT '策略名称',
|
||||
`monitor_enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用监控:0否 1是',
|
||||
`run_on_app_start` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '应用启动后是否执行一次:0否 1是',
|
||||
`daily_run_time` TIME NOT NULL COMMENT '每日统一执行时间',
|
||||
`warning_notify_mode` VARCHAR(32) NOT NULL DEFAULT 'STATUS_CHANGE' COMMENT '预警通知模式',
|
||||
`alarm_notify_mode` VARCHAR(32) NOT NULL DEFAULT 'EVERY_TIME' COMMENT '告警通知模式',
|
||||
`last_job_id` BIGINT NULL COMMENT '最近一次任务ID',
|
||||
`remark` VARCHAR(500) NULL COMMENT '备注',
|
||||
`created_by` VARCHAR(64) NULL COMMENT '创建人',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_by` VARCHAR(64) NULL COMMENT '更新人',
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='磁盘监控全局策略表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `disk_monitor_target` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`policy_id` BIGINT NOT NULL COMMENT '策略ID',
|
||||
`drive_letter` VARCHAR(10) NOT NULL COMMENT '盘符,例如 C:',
|
||||
`monitor_enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用监控:0否 1是',
|
||||
`warning_usage_percent` TINYINT UNSIGNED NOT NULL COMMENT '预警使用率阈值',
|
||||
`alarm_usage_percent` TINYINT UNSIGNED NOT NULL COMMENT '告警使用率阈值',
|
||||
`notify_path_enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否启用路径通知:0否 1是',
|
||||
`notify_path_list_json` JSON NULL COMMENT '路径通知目标列表JSON',
|
||||
`notify_http_enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否启用HTTP通知:0否 1是',
|
||||
`notify_http_list_json` JSON NULL COMMENT 'HTTP通知目标列表JSON',
|
||||
`last_status` VARCHAR(32) NOT NULL DEFAULT 'UNKNOWN' COMMENT '最近一次状态',
|
||||
`last_scan_time` DATETIME NULL COMMENT '最近扫描时间',
|
||||
`last_used_percent` DECIMAL(5,2) NULL COMMENT '最近一次使用率',
|
||||
`remark` VARCHAR(500) NULL COMMENT '备注',
|
||||
`created_by` VARCHAR(64) NULL COMMENT '创建人',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_by` VARCHAR(64) NULL COMMENT '更新人',
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_drive_letter` (`drive_letter`),
|
||||
KEY `idx_policy_id` (`policy_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='磁盘监控盘符配置表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `disk_monitor_job` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`job_no` VARCHAR(64) NOT NULL COMMENT '任务编号',
|
||||
`job_source` VARCHAR(32) NOT NULL COMMENT '任务来源',
|
||||
`planned_time` DATETIME NULL COMMENT '计划执行时间',
|
||||
`started_at` DATETIME NOT NULL COMMENT '开始时间',
|
||||
`finished_at` DATETIME NULL COMMENT '结束时间',
|
||||
`job_status` VARCHAR(32) NOT NULL COMMENT '任务状态',
|
||||
`target_count` INT NOT NULL DEFAULT 0 COMMENT '计划扫描盘符数量',
|
||||
`success_count` INT NOT NULL DEFAULT 0 COMMENT '成功扫描数量',
|
||||
`warning_count` INT NOT NULL DEFAULT 0 COMMENT '预警数量',
|
||||
`alarm_count` INT NOT NULL DEFAULT 0 COMMENT '告警数量',
|
||||
`message` VARCHAR(1000) NULL COMMENT '结果说明',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_job_no` (`job_no`),
|
||||
KEY `idx_job_source` (`job_source`),
|
||||
KEY `idx_started_at` (`started_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='磁盘监控任务批次表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `disk_monitor_result` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`job_id` BIGINT NOT NULL COMMENT '任务ID',
|
||||
`target_id` BIGINT NOT NULL COMMENT '盘符配置ID',
|
||||
`drive_letter` VARCHAR(10) NOT NULL COMMENT '盘符',
|
||||
`total_bytes` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '总容量字节数',
|
||||
`used_bytes` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已使用字节数',
|
||||
`free_bytes` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '剩余字节数',
|
||||
`used_percent` DECIMAL(5,2) NOT NULL DEFAULT 0.00 COMMENT '使用率',
|
||||
`current_status` VARCHAR(32) NOT NULL COMMENT '当前状态',
|
||||
`previous_status` VARCHAR(32) NOT NULL DEFAULT 'UNKNOWN' COMMENT '上一次状态',
|
||||
`status_changed` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '状态是否变化:0否 1是',
|
||||
`should_notify` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '本次是否通知:0否 1是',
|
||||
`notify_reason` VARCHAR(32) NOT NULL DEFAULT 'NO_NOTIFY' COMMENT '通知原因',
|
||||
`scan_time` DATETIME NOT NULL COMMENT '扫描时间',
|
||||
`message` VARCHAR(1000) NULL COMMENT '扫描说明',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_job_id` (`job_id`),
|
||||
KEY `idx_target_id` (`target_id`),
|
||||
KEY `idx_drive_letter` (`drive_letter`),
|
||||
KEY `idx_scan_time` (`scan_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='磁盘监控结果表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `disk_monitor_notify_log` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`job_id` BIGINT NOT NULL COMMENT '任务ID',
|
||||
`result_id` BIGINT NOT NULL COMMENT '结果ID',
|
||||
`target_id` BIGINT NOT NULL COMMENT '盘符配置ID',
|
||||
`drive_letter` VARCHAR(10) NOT NULL COMMENT '盘符',
|
||||
`notify_level` VARCHAR(32) NOT NULL COMMENT '通知级别',
|
||||
`channel_type` VARCHAR(32) NOT NULL COMMENT '通知通道类型',
|
||||
`channel_target` VARCHAR(1000) NOT NULL COMMENT '通知目标',
|
||||
`notify_title` VARCHAR(255) NOT NULL COMMENT '通知标题',
|
||||
`notify_content` TEXT NOT NULL COMMENT '通知内容',
|
||||
`send_status` VARCHAR(32) NOT NULL COMMENT '发送状态',
|
||||
`response_message` VARCHAR(2000) NULL COMMENT '响应结果或异常信息',
|
||||
`sent_at` DATETIME NOT NULL COMMENT '发送时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_job_id` (`job_id`),
|
||||
KEY `idx_result_id` (`result_id`),
|
||||
KEY `idx_target_id` (`target_id`),
|
||||
KEY `idx_sent_at` (`sent_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='磁盘监控通知日志表';
|
||||
@@ -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`.
|
||||
1038
docs/superpowers/specs/2026-04-22-disk-monitor-design.md
Normal file
1038
docs/superpowers/specs/2026-04-22-disk-monitor-design.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,398 @@
|
||||
# MMS Mapping 页面重构设计
|
||||
|
||||
## 1. 背景
|
||||
|
||||
当前 `frontend/src/views/tools/mmsmapping/` 页面已具备基础联调能力,但页面结构与本次使用方式不一致:
|
||||
|
||||
- 左侧仍保留大段 `request JSON` 编辑区
|
||||
- 结果展示位于右侧,不符合“左下统一看输出”的操作习惯
|
||||
- `request.indexSelection` 仍依赖手工编辑 JSON,不利于基于 `DefaultCfg.txt` 做可重复调试
|
||||
- 页面尚未把解析返回的辅助信息与最终映射配置联动起来
|
||||
|
||||
本次重构目标是让页面围绕 `getIcdMmsJson` 的两步联调流程工作:
|
||||
|
||||
1. 先选择 ICD 文件并解析,获取候选辅助信息
|
||||
2. 基于候选辅助信息和 `DefaultCfg.txt` 生成一份可编辑配置
|
||||
3. 用户可反复修改该配置,并多次生成新的 `mappingJson`
|
||||
|
||||
## 2. 目标
|
||||
|
||||
### 2.1 页面目标
|
||||
|
||||
- 左上只保留 ICD 文件选择和 `解析 ICD` 按钮
|
||||
- 左下统一展示接口返回中的 `mappingJson` 和 `problems`
|
||||
- 右侧作为系统配置区,承载精简请求字段、默认模板配置表格和辅助信息
|
||||
- 页面不再要求用户直接编辑 `request JSON`
|
||||
|
||||
### 2.2 业务目标
|
||||
|
||||
- `解析 ICD` 与 `生成映射` 分离为两个动作
|
||||
- `解析 ICD` 后生成一份“基于候选结果的可编辑配置草稿”
|
||||
- 用户在不重新解析的前提下,可以多次修改草稿并重复生成 `mappingJson`
|
||||
- 当后端返回 `NEED_INDEX_SELECTION` 时,页面保留当前配置,便于继续调整
|
||||
|
||||
## 3. 非目标
|
||||
|
||||
- 不改后端接口契约
|
||||
- 不扩展 `mappingJson` 之外的新结果展示结构
|
||||
- 不直接编辑后端原始 `indexCandidates`
|
||||
- 不把 `icdDocument` 树保留在主界面中单独展示
|
||||
|
||||
## 4. 已知接口约束
|
||||
|
||||
基于 `API-getIcdMmsJson.md`,本次页面需要遵守以下接口规则:
|
||||
|
||||
- 接口路径为 `/api/mms-mapping/get-icd-mms-json`
|
||||
- 请求仍为 `multipart/form-data`
|
||||
- 请求由 `icdFile` 文件 Part 和 `request` JSON Part 组成
|
||||
- `request` 的基础字段为:
|
||||
- `version`
|
||||
- `author`
|
||||
- `saveToDisk`
|
||||
- `prettyJson`
|
||||
- `outputDir`
|
||||
- `indexSelection`
|
||||
- 前端页面只对用户开放 `version`、`author` 和 `indexSelection` 相关配置
|
||||
- `saveToDisk`、`prettyJson`、`outputDir` 仍按接口契约参与请求,但采用页面内部默认值,不在右侧配置区开放编辑
|
||||
- 第一次解析时可不传有效 `indexSelection`,接口会返回 `NEED_INDEX_SELECTION`
|
||||
- 第二次生成时,`indexSelection` 必须沿用解析返回的合法候选值
|
||||
- `mappingJson` 为字符串字段,需要前端格式化展示
|
||||
- `problems` 用于承载配置问题、校验问题或运行异常信息
|
||||
|
||||
## 5. 页面布局设计
|
||||
|
||||
页面采用双列布局,左侧再拆成上下两块。
|
||||
|
||||
### 5.1 左上:文件入口区
|
||||
|
||||
仅保留以下元素:
|
||||
|
||||
- 已选择 ICD 文件名
|
||||
- `选择 ICD`
|
||||
- `解析 ICD`
|
||||
- 当前解析状态标签
|
||||
|
||||
该区域不再展示可编辑 JSON 文本,也不承载系统配置字段。
|
||||
|
||||
### 5.2 左下:统一结果区
|
||||
|
||||
仅展示:
|
||||
|
||||
- `mappingJson`
|
||||
- `problems`
|
||||
|
||||
展示规则:
|
||||
|
||||
- 返回存在 `mappingJson` 时,默认激活 `mappingJson` 页签
|
||||
- 仅存在 `problems` 时,默认激活 `problems` 页签
|
||||
- 每次发起解析或生成请求后,左下刷新为最近一次接口返回结果
|
||||
- 左下不单独展示 `icdDocument` 树和候选结构
|
||||
|
||||
### 5.3 右侧:系统配置区
|
||||
|
||||
右侧固定为配置与生成工作区,包含三部分:
|
||||
|
||||
1. 精简请求字段表单
|
||||
2. 基于 `DefaultCfg.txt` 自动生成的默认模板配置表格
|
||||
3. 解析返回的辅助信息展示及 `生成映射` 操作区
|
||||
|
||||
## 6. 右侧配置区设计
|
||||
|
||||
### 6.1 前端可配置请求字段
|
||||
|
||||
右侧仅保留以下可配置字段:
|
||||
|
||||
- `version`
|
||||
- `author`
|
||||
|
||||
使用表单形式维护,不再暴露底层 JSON 文本。
|
||||
|
||||
以下字段不在右侧开放配置,而是由页面内部写死默认值:
|
||||
|
||||
- `saveToDisk = false`
|
||||
- `prettyJson = true`
|
||||
- `outputDir = ''`
|
||||
|
||||
上述字段仍参与两类请求:
|
||||
|
||||
- 解析 ICD 请求
|
||||
- 生成映射请求
|
||||
|
||||
区别在于解析请求强制使用空的 `indexSelection`。
|
||||
|
||||
### 6.2 默认模板表格骨架
|
||||
|
||||
右侧表格的骨架由 `DefaultCfg.txt` 提供。
|
||||
页面在解析完成后,基于 `DefaultCfg.txt` 自动生成一份默认模板,作为右侧的初始可编辑草稿。
|
||||
|
||||
优先使用 `ReportList` 生成分组,每个分组至少展示:
|
||||
|
||||
- `desc`
|
||||
- `Select`
|
||||
- `DataSetList`
|
||||
- `LnInstList`
|
||||
|
||||
每个模板分组下,以 `LnInstList` 为行生成可编辑配置行,用于最终组装:
|
||||
|
||||
- `bindings[].label`
|
||||
- `bindings[].reportName`
|
||||
- `bindings[].dataSetName`
|
||||
- `bindings[].lnInst`
|
||||
|
||||
### 6.3 解析返回的辅助信息
|
||||
|
||||
右侧必须展示解析接口返回的辅助信息,至少包括:
|
||||
|
||||
- `groupKey`
|
||||
- `groupDesc`
|
||||
- `templateLabels`
|
||||
- `reports[].reportName`
|
||||
- `reports[].dataSetName`
|
||||
- `reports[].availableLnInstValues`
|
||||
|
||||
这些内容的目的不是直接提交,而是作为模板匹配和用户确认的依据。
|
||||
|
||||
### 6.4 可编辑草稿
|
||||
|
||||
页面不直接编辑后端原始 `indexCandidates`,而是在解析完成后生成一份“配置草稿”。
|
||||
|
||||
草稿由三部分合并得到:
|
||||
|
||||
1. `DefaultCfg.txt` 模板骨架
|
||||
2. 本次解析返回的 `indexCandidates`
|
||||
3. 页面上当前用户编辑态
|
||||
|
||||
用户后续反复编辑的是草稿,提交时再把草稿转换为最终 `request.indexSelection`。
|
||||
|
||||
## 7. 模板与候选匹配规则
|
||||
|
||||
### 7.1 匹配原则
|
||||
|
||||
`DefaultCfg.txt` 负责定义“页面配置骨架”,但不能单独作为最终提交源。
|
||||
真正提交给后端的分组和值,仍必须基于本次解析返回的合法候选结果。
|
||||
|
||||
### 7.2 自动匹配建议
|
||||
|
||||
解析完成后,页面尝试将默认模板分组与候选分组自动匹配:
|
||||
|
||||
1. 优先按 `groupDesc` 匹配
|
||||
2. 若不唯一,则结合以下信息辅助判断:
|
||||
- 模板 `LnInstList` 与候选 `templateLabels` 的交集
|
||||
- 模板 `DataSetList` 与候选 `reports[].dataSetName` 的交集
|
||||
|
||||
### 7.3 待确认规则
|
||||
|
||||
若自动匹配结果不唯一或无法判断,则不强行绑定,直接标记为“待确认”。
|
||||
此时用户需手动选择当前模板分组对应的候选组。
|
||||
|
||||
### 7.4 行级编辑规则
|
||||
|
||||
每个模板行至少允许用户设置:
|
||||
|
||||
- `reportName`
|
||||
- `dataSetName`
|
||||
- `lnInst`
|
||||
|
||||
候选值优先来自当前已绑定候选组中的合法项,避免用户录入非法值。
|
||||
|
||||
## 8. 交互流程设计
|
||||
|
||||
### 8.1 选择文件
|
||||
|
||||
用户选择 ICD 文件后,页面更新当前文件状态。
|
||||
|
||||
若文件发生变化,页面清空:
|
||||
|
||||
- 解析缓存
|
||||
- 配置草稿
|
||||
- 左下结果
|
||||
|
||||
### 8.2 解析 ICD
|
||||
|
||||
点击 `解析 ICD` 时:
|
||||
|
||||
- 使用当前前端可配置字段
|
||||
- 同时附带页面内部默认值:
|
||||
- `saveToDisk = false`
|
||||
- `prettyJson = true`
|
||||
- `outputDir = ''`
|
||||
- 强制令 `request.indexSelection = []`
|
||||
- 调用 `getIcdMmsJson`
|
||||
|
||||
解析成功后:
|
||||
|
||||
- 保存本次 `indexCandidates`
|
||||
- 基于 `DefaultCfg.txt` 自动生成新的默认模板草稿
|
||||
- 右侧展示辅助信息与模板配置表格
|
||||
|
||||
### 8.3 生成映射
|
||||
|
||||
点击 `生成映射` 时:
|
||||
|
||||
- 取右侧当前最新草稿
|
||||
- 做本地校验
|
||||
- 组装最终 `request.indexSelection`
|
||||
- 同时附带页面内部默认值:
|
||||
- `saveToDisk = false`
|
||||
- `prettyJson = true`
|
||||
- `outputDir = ''`
|
||||
- 再次调用 `getIcdMmsJson`
|
||||
|
||||
### 8.4 多次生成
|
||||
|
||||
在不重新选择文件且不重新点击 `解析 ICD` 的前提下:
|
||||
|
||||
- 用户可以反复修改右侧草稿
|
||||
- 每次点击 `生成映射` 都重新组装新的 `indexSelection`
|
||||
- 左下结果始终更新为最近一次返回的 `mappingJson` / `problems`
|
||||
|
||||
### 8.5 重新解析
|
||||
|
||||
仅在以下情况需要重新解析:
|
||||
|
||||
- 更换 ICD 文件
|
||||
- 用户主动点击 `解析 ICD`
|
||||
|
||||
重新解析后,旧候选和旧草稿均失效,以新解析结果为准。
|
||||
|
||||
## 9. 状态与错误处理
|
||||
|
||||
### 9.1 业务状态
|
||||
|
||||
- `SUCCESS`:左下优先展示 `mappingJson`
|
||||
- `NEED_INDEX_SELECTION`:视为继续修正配置,不视为页面异常
|
||||
- `FAILED`:左下展示 `problems`,并同步提示错误消息
|
||||
|
||||
### 9.2 页面保留策略
|
||||
|
||||
当生成请求返回 `NEED_INDEX_SELECTION` 或 `FAILED` 时:
|
||||
|
||||
- 左下结果区刷新为新结果
|
||||
- 右侧当前草稿不清空
|
||||
- 用户可以继续修改后再次生成
|
||||
|
||||
### 9.3 模板解析异常
|
||||
|
||||
`DefaultCfg.txt` 含尾逗号等非严格 JSON 片段,前端模板读取需要容错处理。
|
||||
|
||||
若模板解析失败:
|
||||
|
||||
- 右侧配置区展示明确错误
|
||||
- 禁用 `生成映射`
|
||||
- 不影响重新解析动作
|
||||
|
||||
### 9.4 本地校验
|
||||
|
||||
生成前至少校验:
|
||||
|
||||
- 分组已绑定到候选组
|
||||
- 每个待提交行已填写 `reportName`
|
||||
- 每个待提交行已填写 `dataSetName`
|
||||
- 每个待提交行已填写 `lnInst`
|
||||
|
||||
## 10. 组件与模块拆分
|
||||
|
||||
### 10.1 页面入口
|
||||
|
||||
`frontend/src/views/tools/mmsmapping/index.vue`
|
||||
|
||||
职责:
|
||||
|
||||
- 编排左右布局
|
||||
- 管理请求与响应状态
|
||||
- 管理解析缓存、草稿状态和结果区状态
|
||||
|
||||
### 10.2 左上文件区组件
|
||||
|
||||
保留并收缩当前请求组件职责,建议继续使用:
|
||||
|
||||
- `frontend/src/views/tools/mmsmapping/components/MappingRequestPanel.vue`
|
||||
|
||||
调整后仅负责:
|
||||
|
||||
- 文件选择
|
||||
- `解析 ICD`
|
||||
- 解析状态展示
|
||||
|
||||
### 10.3 左下结果区组件
|
||||
|
||||
继续使用:
|
||||
|
||||
- `frontend/src/views/tools/mmsmapping/components/MappingResultPanel.vue`
|
||||
|
||||
职责聚焦为:
|
||||
|
||||
- `mappingJson` 展示
|
||||
- `problems` 展示
|
||||
|
||||
### 10.4 右侧配置组件
|
||||
|
||||
新增:
|
||||
|
||||
- `frontend/src/views/tools/mmsmapping/components/MappingConfigPanel.vue`
|
||||
|
||||
职责:
|
||||
|
||||
- 精简请求字段表单
|
||||
- 基于 `DefaultCfg.txt` 自动生成的默认模板配置表格
|
||||
- 辅助信息展示
|
||||
- `生成映射`
|
||||
|
||||
### 10.5 配置转换工具
|
||||
|
||||
建议新增一个页面级工具文件,负责:
|
||||
|
||||
- 容错解析 `DefaultCfg.txt`
|
||||
- 生成草稿结构
|
||||
- 模板与候选匹配
|
||||
- 草稿转 `request.indexSelection`
|
||||
|
||||
## 11. 预计修改点
|
||||
|
||||
预计改动集中在以下范围:
|
||||
|
||||
- `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/api/tools/mmsmapping/interface/index.ts` 中的前端草稿类型
|
||||
|
||||
## 12. 验证方案
|
||||
|
||||
### 12.1 静态验证
|
||||
|
||||
- `cd frontend && npm run lint`
|
||||
- `cd frontend && npm run type-check`
|
||||
|
||||
### 12.2 人工流程验证
|
||||
|
||||
1. 选择 ICD 文件后,左上只显示文件入口和 `解析 ICD`
|
||||
2. 点击 `解析 ICD` 后,右侧出现基础字段、模板表格和辅助信息
|
||||
3. 在不重新解析的情况下,多次修改右侧草稿并点击 `生成映射`
|
||||
4. 左下每次都刷新为最近一次 `mappingJson` / `problems`
|
||||
5. 当返回 `NEED_INDEX_SELECTION` 时,右侧草稿仍保留
|
||||
6. 更换 ICD 文件后,旧候选、旧草稿和旧结果全部清空
|
||||
|
||||
### 12.3 风险专项验证
|
||||
|
||||
- `DefaultCfg.txt` 容错解析是否稳定
|
||||
- 自动匹配不唯一时是否正确进入“待确认”
|
||||
- 多次生成时是否始终按最新草稿提交
|
||||
- 左下结果区是否始终只绑定 `mappingJson` 和 `problems`
|
||||
|
||||
## 13. 风险与注意事项
|
||||
|
||||
- `DefaultCfg.txt` 与接口候选结构不是一一直接映射,匹配策略必须保守,不能静默误绑
|
||||
- 候选辅助信息较多,右侧滚动区域需要合理拆分,避免整页滚动体验过重
|
||||
- 若前端继续把复杂转换逻辑堆在 `index.vue` 中,后续维护成本会迅速上升,必须提前拆出配置组件和工具文件
|
||||
|
||||
## 14. 结论
|
||||
|
||||
本设计采用“左侧入口与结果、右侧配置与生成”的结构,将原本的手工 JSON 联调页重构为面向两步流程的配置页面:
|
||||
|
||||
- 先解析
|
||||
- 再配置
|
||||
- 可多次生成
|
||||
|
||||
页面最终提交的仍是标准 `request.indexSelection`,但用户操作对象变为“基于候选生成的可编辑草稿”,从而兼顾接口约束、调试效率和页面可用性。
|
||||
26
frontend/src/api/system/diskMonitor/index.ts
Normal file
26
frontend/src/api/system/diskMonitor/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
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)
|
||||
}
|
||||
133
frontend/src/api/system/diskMonitor/interface/index.ts
Normal file
133
frontend/src/api/system/diskMonitor/interface/index.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
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 {
|
||||
sortField?: 'startedAt'
|
||||
sortOrder?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
export interface JobListItem {
|
||||
id?: number
|
||||
jobId?: 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> {}
|
||||
}
|
||||
98
frontend/src/api/tools/addData/index.ts
Normal file
98
frontend/src/api/tools/addData/index.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import http from '@/api'
|
||||
import type { ResultData } from '@/api/interface'
|
||||
import type { AddData } from './interface'
|
||||
|
||||
type AddDataRequestMethod = 'get' | 'post'
|
||||
|
||||
const ADD_DATA_ROUTE_PATHS = ['/addData', '/api/addData'] as const
|
||||
const ADD_DATA_BASE_URL = String(import.meta.env.VITE_API_URL || '').trim()
|
||||
|
||||
const resolveDevProxyTarget = () => {
|
||||
const proxyConfig = import.meta.env.VITE_PROXY
|
||||
if (!Array.isArray(proxyConfig)) return ''
|
||||
|
||||
const matchedProxy = proxyConfig.find(item => Array.isArray(item) && item[0] === '/api')
|
||||
if (!matchedProxy?.[1]) return ''
|
||||
|
||||
return String(matchedProxy[1]).replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
const buildAddDataRequestPaths = (path: string) => {
|
||||
const requestPaths = new Set<string>()
|
||||
const devProxyTarget = resolveDevProxyTarget()
|
||||
|
||||
for (const routePath of ADD_DATA_ROUTE_PATHS) {
|
||||
if (ADD_DATA_BASE_URL === '/api' && routePath.startsWith('/api/')) {
|
||||
if (devProxyTarget) {
|
||||
requestPaths.add(`${devProxyTarget}${routePath}${path}`)
|
||||
}
|
||||
|
||||
requestPaths.add(`${window.location.origin}${routePath}${path}`)
|
||||
continue
|
||||
}
|
||||
|
||||
requestPaths.add(`${routePath}${path}`)
|
||||
}
|
||||
|
||||
return Array.from(requestPaths)
|
||||
}
|
||||
|
||||
const isFallbackableAddDataError = (error: unknown) => {
|
||||
const responseCode = typeof error === 'object' && error !== null && 'code' in error ? String(error.code) : ''
|
||||
const responseMessage = typeof error === 'object' && error !== null && 'message' in error ? String(error.message) : ''
|
||||
const normalizedMessage = responseMessage.toLowerCase()
|
||||
|
||||
// 部分部署环境会把未命中的 addData 路由转到旧的操作分发入口,
|
||||
// 前端在识别到“unknown operate”或典型路由错误时回退到备用前缀重试一次。
|
||||
return (
|
||||
responseCode === '404' ||
|
||||
normalizedMessage.includes('unknown operate') ||
|
||||
normalizedMessage.includes('not found') ||
|
||||
normalizedMessage.includes('no handler found')
|
||||
)
|
||||
}
|
||||
|
||||
const requestAddData = async <T>(
|
||||
method: AddDataRequestMethod,
|
||||
path: string,
|
||||
params?: object
|
||||
): Promise<ResultData<T>> => {
|
||||
let lastError: unknown
|
||||
const requestPaths = buildAddDataRequestPaths(path)
|
||||
|
||||
for (let index = 0; index < requestPaths.length; index += 1) {
|
||||
const requestPath = requestPaths[index]
|
||||
|
||||
try {
|
||||
if (method === 'get') {
|
||||
return await http.get<T>(requestPath)
|
||||
}
|
||||
|
||||
return await http.post<T>(requestPath, params)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
|
||||
if (index === requestPaths.length - 1 || !isFallbackableAddDataError(error)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError
|
||||
}
|
||||
|
||||
export const getAddDataPreview = (params: AddData.TaskRequestParams) => {
|
||||
return requestAddData<AddData.PreviewResponse>('post', '/task/preview', params)
|
||||
}
|
||||
|
||||
export const createAddDataTask = (params: AddData.TaskRequestParams) => {
|
||||
return requestAddData<AddData.CreateTaskResponse>('post', '/task/create', params)
|
||||
}
|
||||
|
||||
export const getAddDataTaskStatus = (taskId: string | number) => {
|
||||
return requestAddData<AddData.TaskStatusResponse>('get', `/task/status/${taskId}`)
|
||||
}
|
||||
|
||||
export const getAddDataTemplateList = () => {
|
||||
return requestAddData<AddData.TemplateItem[]>('get', '/template/list')
|
||||
}
|
||||
109
frontend/src/api/tools/addData/interface/index.ts
Normal file
109
frontend/src/api/tools/addData/interface/index.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
export namespace AddData {
|
||||
export type LineMode = 'single' | 'multiple'
|
||||
export type IntervalMinutes = 1 | 3 | 5 | 10
|
||||
export type TaskStatus = 'WAITING' | 'RUNNING' | 'SUCCESS' | 'FAILED' | (string & {})
|
||||
|
||||
export interface TaskRequestParams {
|
||||
lineIds: string[]
|
||||
startTime: string
|
||||
endTime: string
|
||||
intervalMinutes: IntervalMinutes
|
||||
}
|
||||
|
||||
export interface TaskFormModel {
|
||||
lineMode: LineMode
|
||||
lineIds: string[]
|
||||
startTime: string
|
||||
endTime: string
|
||||
intervalMinutes: IntervalMinutes
|
||||
}
|
||||
|
||||
export interface PreviewTableStat {
|
||||
tableName?: string
|
||||
timePointCount?: number | string
|
||||
phaseCount?: number | string
|
||||
rowCount?: number | string
|
||||
}
|
||||
|
||||
export interface PreviewResponse {
|
||||
lineCount?: number | string
|
||||
intervalMinutes?: number | string
|
||||
totalRowCount?: number | string
|
||||
tableStats?: PreviewTableStat[]
|
||||
}
|
||||
|
||||
export interface CreateTaskResponse {
|
||||
taskId?: string | number
|
||||
status?: TaskStatus
|
||||
}
|
||||
|
||||
export interface TaskStatusResponse {
|
||||
taskId?: string
|
||||
status?: TaskStatus
|
||||
currentTableName?: string
|
||||
currentBatchInfo?: string
|
||||
insertedCount?: number | string
|
||||
skippedCount?: number | string
|
||||
failedCount?: number | string
|
||||
failureReason?: string
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
hourlyTimeResults?: string[]
|
||||
}
|
||||
|
||||
export interface TemplateItem {
|
||||
parameterName?: string
|
||||
tableName?: string
|
||||
phaseDisplay?: string
|
||||
phaseCodes?: string[]
|
||||
display?: boolean
|
||||
showQualified?: boolean
|
||||
maxValueRule?: string
|
||||
minValueRule?: string
|
||||
averageValueRule?: string
|
||||
cp95ValueRule?: string
|
||||
decimalScale?: number | string
|
||||
}
|
||||
|
||||
export interface PreviewTableSummary {
|
||||
tableName: string
|
||||
timePointCount: number
|
||||
phaseCount: number
|
||||
rowCount: number
|
||||
}
|
||||
|
||||
export interface NormalizedPreview {
|
||||
lineCount: number
|
||||
intervalMinutes: number
|
||||
totalRowCount: number
|
||||
tableStats: PreviewTableSummary[]
|
||||
}
|
||||
|
||||
export interface NormalizedTaskStatus {
|
||||
taskId: string
|
||||
status: TaskStatus
|
||||
currentTableName: string
|
||||
currentBatchInfo: string
|
||||
insertedCount: number
|
||||
skippedCount: number
|
||||
failedCount: number
|
||||
failureReason: string
|
||||
hourlyTimeResults: string[]
|
||||
startTime: string
|
||||
endTime: string
|
||||
}
|
||||
|
||||
export interface NormalizedTemplateItem {
|
||||
parameterName: string
|
||||
tableName: string
|
||||
phaseDisplay: string
|
||||
phaseCodesText: string
|
||||
displayText: string
|
||||
showQualifiedText: string
|
||||
maxValueRule: string
|
||||
minValueRule: string
|
||||
averageValueRule: string
|
||||
cp95ValueRule: string
|
||||
decimalScaleText: string
|
||||
}
|
||||
}
|
||||
52
frontend/src/api/tools/mmsmapping/index.ts
Normal file
52
frontend/src/api/tools/mmsmapping/index.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
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' }
|
||||
})
|
||||
}
|
||||
|
||||
export const getXmlFromJsonApi = (params: MmsMapping.GetXmlFromJsonParams) => {
|
||||
const formData = new FormData()
|
||||
|
||||
// 关键业务节点:XML 映射由后端根据已生成的 mappingJson 转换,前端保持 request JSON Part 的提交格式。
|
||||
formData.append('request', new Blob([JSON.stringify(params.request)], { type: 'application/json' }))
|
||||
|
||||
return http.post<MmsMapping.MappingTaskResponse>('/api/mms-mapping/get-xml-from-json', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
export const buildIndexConfirmDataApi = (params: MmsMapping.IndexCandidateGroup[]) => {
|
||||
// 关键业务节点:ICD 候选数据需要先转换成前端确认弹窗模型,后续人工确认才能继续生成正式索引配置。
|
||||
return http.post<MmsMapping.IndexConfirmGroup[]>('/api/mms-mapping/build-index-confirm-data', params)
|
||||
}
|
||||
|
||||
export const buildIndexSelectionApi = (params: MmsMapping.BuildIndexSelectionRequest) => {
|
||||
// 关键业务节点:人工确认完成后,必须把 confirmData 和 confirmedData 一并提交给后端生成正式 request.indexSelection。
|
||||
return http.post<MmsMapping.IndexSelectionGroup[]>('/api/mms-mapping/build-index-selection', params)
|
||||
}
|
||||
131
frontend/src/api/tools/mmsmapping/interface/index.ts
Normal file
131
frontend/src/api/tools/mmsmapping/interface/index.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
export namespace MmsMapping {
|
||||
export interface GetIcdParams {
|
||||
icdFile: File
|
||||
}
|
||||
|
||||
export interface IndexConfirmTarget {
|
||||
reportName?: string
|
||||
dataSetName?: string
|
||||
reportDesc?: string
|
||||
availableLnInstValues?: string[]
|
||||
}
|
||||
|
||||
export interface IndexConfirmLabelItem {
|
||||
label?: string
|
||||
required?: boolean
|
||||
configurableOnce?: boolean
|
||||
defaultLnInst?: string
|
||||
commonLnInstValues?: string[]
|
||||
targets?: IndexConfirmTarget[]
|
||||
}
|
||||
|
||||
export interface IndexConfirmGroup {
|
||||
groupKey?: string
|
||||
groupDesc?: string
|
||||
labelItems?: IndexConfirmLabelItem[]
|
||||
}
|
||||
|
||||
export interface ConfirmedIndexLabelItem {
|
||||
label: string
|
||||
enabled: boolean
|
||||
lnInst: string
|
||||
}
|
||||
|
||||
export interface ConfirmedIndexGroup {
|
||||
groupKey: string
|
||||
labelItems: ConfirmedIndexLabelItem[]
|
||||
}
|
||||
|
||||
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 GetXmlFromJsonRequestPayload {
|
||||
mappingJson: string
|
||||
}
|
||||
|
||||
export interface GetXmlFromJsonParams {
|
||||
request: GetXmlFromJsonRequestPayload
|
||||
}
|
||||
|
||||
export interface BuildIndexSelectionRequest {
|
||||
confirmData: IndexConfirmGroup[]
|
||||
confirmedData: ConfirmedIndexGroup[]
|
||||
}
|
||||
|
||||
export interface XmlFileResponse {
|
||||
fileName?: string
|
||||
contentType?: string
|
||||
encoding?: string
|
||||
content?: string
|
||||
}
|
||||
|
||||
export interface IcdDocument {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface MappingDocument {
|
||||
[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
|
||||
methodDescribe?: string
|
||||
icdDocument?: IcdDocument
|
||||
mappingDocument?: MappingDocument
|
||||
mappingJson?: string
|
||||
mappingXml?: string
|
||||
xmlContent?: string
|
||||
xmlText?: string
|
||||
xmlFile?: XmlFileResponse
|
||||
savedPath?: string
|
||||
indexCandidates?: IndexCandidateGroup[]
|
||||
problems?: string[]
|
||||
}
|
||||
|
||||
export interface BaseRequestForm {
|
||||
version: string
|
||||
author: string
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,10 @@ import * as echarts from 'echarts' // 全引入
|
||||
// import 'echarts-liquidfill'
|
||||
// import 'echarts/lib/component/dataZoom'
|
||||
|
||||
defineOptions({
|
||||
name: 'LineChart'
|
||||
})
|
||||
|
||||
const color = [
|
||||
'var(--el-color-primary)',
|
||||
'#07CCCA',
|
||||
@@ -29,7 +33,162 @@ const color = [
|
||||
const chartRef = ref<HTMLDivElement>()
|
||||
|
||||
const props = defineProps(['options', 'isInterVal', 'pieInterVal', 'group'])
|
||||
const emit = defineEmits<{
|
||||
'chart-data-zoom': [
|
||||
value: {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
]
|
||||
'chart-click': [
|
||||
value: {
|
||||
dataIndex: number
|
||||
axisValue: string | number
|
||||
}
|
||||
]
|
||||
}>()
|
||||
let chart: echarts.ECharts | any = null
|
||||
let isPanPointerDown = false
|
||||
|
||||
const getChartViewportRoot = () => chart?.getZr()?.painter?.getViewportRoot?.() as HTMLElement | undefined
|
||||
|
||||
const getAxisPixel = (dataIndex: number) => {
|
||||
const pixelValue = chart?.convertToPixel?.({ xAxisIndex: 0 }, dataIndex)
|
||||
|
||||
if (Array.isArray(pixelValue)) return Number(pixelValue[0])
|
||||
|
||||
return Number(pixelValue)
|
||||
}
|
||||
|
||||
const getClosestAxisDataIndex = (axisValue: unknown, offsetX: number) => {
|
||||
const xAxisData = props.options?.xAxis?.data
|
||||
|
||||
if (!Array.isArray(xAxisData) || !xAxisData.length) return -1
|
||||
|
||||
const candidateIndexes = new Set<number>()
|
||||
const axisNumber = Number(axisValue)
|
||||
const directIndex = xAxisData.findIndex((item: unknown) => item === axisValue)
|
||||
|
||||
if (Number.isFinite(axisNumber)) {
|
||||
candidateIndexes.add(Math.round(axisNumber))
|
||||
}
|
||||
|
||||
if (directIndex >= 0) {
|
||||
candidateIndexes.add(directIndex)
|
||||
}
|
||||
|
||||
xAxisData.forEach((item: unknown, index: number) => {
|
||||
if (String(item) === String(axisValue)) {
|
||||
candidateIndexes.add(index)
|
||||
}
|
||||
})
|
||||
|
||||
const validCandidates = Array.from(candidateIndexes).filter(index => index >= 0 && index < xAxisData.length)
|
||||
|
||||
if (validCandidates.length) {
|
||||
return validCandidates.reduce((closestIndex, currentIndex) => {
|
||||
const closestDistance = Math.abs(getAxisPixel(closestIndex) - offsetX)
|
||||
const currentDistance = Math.abs(getAxisPixel(currentIndex) - offsetX)
|
||||
|
||||
return currentDistance < closestDistance ? currentIndex : closestIndex
|
||||
}, validCandidates[0])
|
||||
}
|
||||
|
||||
return xAxisData.reduce((closestIndex: number, _item: unknown, currentIndex: number) => {
|
||||
const closestDistance = Math.abs(getAxisPixel(closestIndex) - offsetX)
|
||||
const currentDistance = Math.abs(getAxisPixel(currentIndex) - offsetX)
|
||||
|
||||
return currentDistance < closestDistance ? currentIndex : closestIndex
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const resetChartCursor = () => {
|
||||
const viewportRoot = getChartViewportRoot()
|
||||
if (viewportRoot) viewportRoot.style.cursor = ''
|
||||
isPanPointerDown = false
|
||||
}
|
||||
|
||||
const updatePanCursor = (event: { offsetX: number; offsetY: number }) => {
|
||||
const viewportRoot = getChartViewportRoot()
|
||||
|
||||
if (!viewportRoot || (props.options?.activeTool !== 'pan' && props.options?.activeTool !== 'mark')) {
|
||||
resetChartCursor()
|
||||
return
|
||||
}
|
||||
|
||||
// 平移只在图形绘图区内生效,鼠标样式同步限制到同一范围,避免坐标轴和空白区误导操作。
|
||||
const isInGrid = chart?.containPixel?.({ gridIndex: 0 }, [event.offsetX, event.offsetY])
|
||||
viewportRoot.style.cursor = isInGrid
|
||||
? props.options?.activeTool === 'mark'
|
||||
? 'crosshair'
|
||||
: isPanPointerDown
|
||||
? 'grabbing'
|
||||
: 'grab'
|
||||
: ''
|
||||
}
|
||||
|
||||
const bindPanCursorEvents = () => {
|
||||
const zr = chart?.getZr?.()
|
||||
if (!zr) return
|
||||
|
||||
zr.off('mousemove', updatePanCursor)
|
||||
zr.off('mousedown', handlePanCursorMouseDown)
|
||||
zr.off('mouseup', handlePanCursorMouseUp)
|
||||
zr.off('globalout', resetChartCursor)
|
||||
zr.off('click', handleChartClick)
|
||||
zr.on('mousemove', updatePanCursor)
|
||||
zr.on('mousedown', handlePanCursorMouseDown)
|
||||
zr.on('mouseup', handlePanCursorMouseUp)
|
||||
zr.on('globalout', resetChartCursor)
|
||||
zr.on('click', handleChartClick)
|
||||
}
|
||||
|
||||
const unbindPanCursorEvents = () => {
|
||||
const zr = chart?.getZr?.()
|
||||
if (!zr) return
|
||||
|
||||
zr.off('mousemove', updatePanCursor)
|
||||
zr.off('mousedown', handlePanCursorMouseDown)
|
||||
zr.off('mouseup', handlePanCursorMouseUp)
|
||||
zr.off('globalout', resetChartCursor)
|
||||
zr.off('click', handleChartClick)
|
||||
resetChartCursor()
|
||||
}
|
||||
|
||||
function handleChartClick(params: any) {
|
||||
if (props.options?.activeTool !== 'mark' || !chart) return
|
||||
|
||||
const event = params?.event?.event || params?.event || params
|
||||
const offsetX = Number(event?.offsetX)
|
||||
const offsetY = Number(event?.offsetY)
|
||||
|
||||
if (!Number.isFinite(offsetX) || !Number.isFinite(offsetY)) return
|
||||
if (!chart.containPixel?.({ gridIndex: 0 }, [offsetX, offsetY])) return
|
||||
|
||||
const convertedValue = chart.convertFromPixel?.({ xAxisIndex: 0 }, [offsetX, offsetY])
|
||||
const rawAxisValue = Array.isArray(convertedValue) ? convertedValue[0] : convertedValue
|
||||
const xAxisData = props.options?.xAxis?.data
|
||||
const dataIndex = getClosestAxisDataIndex(rawAxisValue, offsetX)
|
||||
const axisValue = Array.isArray(xAxisData) ? xAxisData[dataIndex] : dataIndex
|
||||
|
||||
if (!Number.isInteger(dataIndex) || dataIndex < 0 || axisValue === undefined) return
|
||||
|
||||
emit('chart-click', {
|
||||
dataIndex,
|
||||
axisValue
|
||||
})
|
||||
}
|
||||
|
||||
function handlePanCursorMouseDown(event: { offsetX: number; offsetY: number }) {
|
||||
isPanPointerDown = true
|
||||
updatePanCursor(event)
|
||||
}
|
||||
|
||||
function handlePanCursorMouseUp(event: { offsetX: number; offsetY: number }) {
|
||||
isPanPointerDown = false
|
||||
updatePanCursor(event)
|
||||
}
|
||||
|
||||
const resizeHandler = () => {
|
||||
// 不在视野中的时候不进行resize
|
||||
if (!chartRef.value) return
|
||||
@@ -41,8 +200,8 @@ const resizeHandler = () => {
|
||||
})
|
||||
}
|
||||
const initChart = () => {
|
||||
|
||||
if (!props.isInterVal && !props.pieInterVal) {
|
||||
unbindPanCursorEvents()
|
||||
chart?.dispose()
|
||||
}
|
||||
// chart?.dispose()
|
||||
@@ -120,6 +279,15 @@ const initChart = () => {
|
||||
end: 100
|
||||
}
|
||||
],
|
||||
toolbox: {
|
||||
show: false,
|
||||
feature: {
|
||||
dataZoom: {
|
||||
yAxisIndex: 'none'
|
||||
}
|
||||
},
|
||||
...(props.options?.toolbox || null)
|
||||
},
|
||||
color: props.options?.color || color,
|
||||
series: props.options?.series,
|
||||
...props.options?.options
|
||||
@@ -127,8 +295,26 @@ const initChart = () => {
|
||||
// console.log(options.series,"获取x轴");
|
||||
handlerBar(options)
|
||||
|
||||
// 处理柱状图
|
||||
chart.setOption(options, true)
|
||||
chart.off('datazoom')
|
||||
chart.on('datazoom', (params: any) => {
|
||||
const zoomPayload = Array.isArray(params.batch) ? params.batch[0] : params
|
||||
const start = Number(zoomPayload?.start)
|
||||
const end = Number(zoomPayload?.end)
|
||||
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end)) return
|
||||
|
||||
emit('chart-data-zoom', {
|
||||
start,
|
||||
end
|
||||
})
|
||||
})
|
||||
chart.dispatchAction({
|
||||
type: 'takeGlobalCursor',
|
||||
key: 'dataZoomSelect',
|
||||
dataZoomSelectActive: props.options?.activeTool === 'box-zoom'
|
||||
})
|
||||
bindPanCursorEvents()
|
||||
|
||||
setTimeout(() => {
|
||||
chart.resize()
|
||||
@@ -176,7 +362,7 @@ const handlerYAxis = () => {
|
||||
axisLabel: {
|
||||
color: '#000',
|
||||
fontSize: 14,
|
||||
formatter: function (value) {
|
||||
formatter: function (value: number) {
|
||||
return value.toFixed(0) // 格式化显示为一位小数
|
||||
}
|
||||
},
|
||||
@@ -244,14 +430,14 @@ const handlerXAxis = () => {
|
||||
let throttle: ReturnType<typeof setTimeout>
|
||||
// 动态计算table高度
|
||||
const resizeObserver = new ResizeObserver(entries => {
|
||||
for (const entry of entries) {
|
||||
if (throttle) {
|
||||
clearTimeout(throttle)
|
||||
}
|
||||
throttle = setTimeout(() => {
|
||||
resizeHandler()
|
||||
}, 100)
|
||||
if (!entries.length) return
|
||||
|
||||
if (throttle) {
|
||||
clearTimeout(throttle)
|
||||
}
|
||||
throttle = setTimeout(() => {
|
||||
resizeHandler()
|
||||
}, 100)
|
||||
})
|
||||
onMounted(() => {
|
||||
initChart()
|
||||
@@ -260,11 +446,12 @@ onMounted(() => {
|
||||
defineExpose({ initChart })
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver.unobserve(chartRef.value!)
|
||||
unbindPanCursorEvents()
|
||||
chart?.dispose()
|
||||
})
|
||||
watch(
|
||||
() => props.options,
|
||||
(newVal, oldVal) => {
|
||||
() => {
|
||||
initChart()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -7,31 +7,31 @@
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="refresh">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
{{ $t('tabs.refresh') }}
|
||||
{{ t('tabs.refresh') }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item @click="maximize">
|
||||
<el-icon><FullScreen /></el-icon>
|
||||
{{ $t('tabs.maximize') }}
|
||||
{{ t('tabs.maximize') }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided @click="closeCurrentTab">
|
||||
<el-icon><Remove /></el-icon>
|
||||
{{ $t('tabs.closeCurrent') }}
|
||||
{{ t('tabs.closeCurrent') }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item @click="tabStore.closeTabsOnSide(route.fullPath, 'left')">
|
||||
<el-dropdown-item @click="tabStore.closeTabsOnSide(currentTabPath, 'left')">
|
||||
<el-icon><DArrowLeft /></el-icon>
|
||||
{{ $t('tabs.closeLeft') }}
|
||||
{{ t('tabs.closeLeft') }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item @click="tabStore.closeTabsOnSide(route.fullPath, 'right')">
|
||||
<el-dropdown-item @click="tabStore.closeTabsOnSide(currentTabPath, 'right')">
|
||||
<el-icon><DArrowRight /></el-icon>
|
||||
{{ $t('tabs.closeRight') }}
|
||||
{{ t('tabs.closeRight') }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided @click="tabStore.closeMultipleTab(route.fullPath)">
|
||||
<el-dropdown-item divided @click="tabStore.closeMultipleTab(currentTabPath)">
|
||||
<el-icon><CircleClose /></el-icon>
|
||||
{{ $t('tabs.closeOther') }}
|
||||
{{ t('tabs.closeOther') }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item @click="closeAllTab">
|
||||
<el-icon><FolderDelete /></el-icon>
|
||||
{{ $t('tabs.closeAll') }}
|
||||
{{ t('tabs.closeAll') }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
@@ -39,7 +39,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { inject, nextTick } from 'vue'
|
||||
import { computed, inject, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { HOME_URL } from '@/config'
|
||||
import { useTabsStore } from '@/stores/modules/tabs'
|
||||
import { useGlobalStore } from '@/stores/modules/global'
|
||||
@@ -48,10 +49,20 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const tabStore = useTabsStore()
|
||||
const globalStore = useGlobalStore()
|
||||
const keepAliveStore = useKeepAliveStore()
|
||||
|
||||
const currentTabPath = computed(() => {
|
||||
const parentPath = route.meta.parentPath as string | undefined
|
||||
return route.meta.hideTab ? parentPath || route.fullPath : route.fullPath
|
||||
})
|
||||
|
||||
const currentTabRoute = computed(() => {
|
||||
return router.getRoutes().find(item => item.path === currentTabPath.value)
|
||||
})
|
||||
|
||||
// refresh current page
|
||||
const refreshCurrentPage: Function = inject('refresh') as Function
|
||||
const refresh = () => {
|
||||
@@ -72,8 +83,8 @@ const maximize = () => {
|
||||
|
||||
// Close Current
|
||||
const closeCurrentTab = () => {
|
||||
if (route.meta.isAffix) return
|
||||
tabStore.removeTabs(route.fullPath)
|
||||
if (currentTabRoute.value?.meta.isAffix) return
|
||||
tabStore.removeTabs(currentTabPath.value)
|
||||
}
|
||||
|
||||
// Close All
|
||||
|
||||
@@ -26,12 +26,16 @@
|
||||
import Sortable from 'sortablejs'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { TabPaneName, TabsPaneContext } from 'element-plus'
|
||||
import type { TabPaneName, TabsPaneContext } from 'element-plus'
|
||||
import { HOME_URL } from '@/config'
|
||||
import { useGlobalStore } from '@/stores/modules/global'
|
||||
import { useTabsStore } from '@/stores/modules/tabs'
|
||||
import MoreButton from './components/MoreButton.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'LayoutTabs'
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const tabStore = useTabsStore()
|
||||
@@ -41,17 +45,41 @@ const tabsMenuValue = ref(route.fullPath)
|
||||
const tabsMenuList = computed(() => tabStore.tabsMenuList)
|
||||
const tabsIcon = computed(() => globalStore.tabsIcon)
|
||||
|
||||
const resolveCurrentTabPath = () => {
|
||||
const parentPath = route.meta.parentPath as string | undefined
|
||||
return route.meta.hideTab ? parentPath || route.fullPath : route.fullPath
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
tabsDrop()
|
||||
initTabs()
|
||||
})
|
||||
|
||||
const ensureParentTab = () => {
|
||||
const parentPath = resolveCurrentTabPath()
|
||||
if (!parentPath || tabStore.tabsMenuList.some(item => item.path === parentPath)) return
|
||||
|
||||
const parentRoute = router.getRoutes().find(item => item.path === parentPath && item.name)
|
||||
if (!parentRoute) return
|
||||
|
||||
// 直接打开隐藏子页时补齐父级主标签
|
||||
tabStore.addTabs({
|
||||
icon: parentRoute.meta.icon as string,
|
||||
title: parentRoute.meta.title as string,
|
||||
path: parentRoute.path,
|
||||
name: parentRoute.name as string,
|
||||
close: !parentRoute.meta.isAffix,
|
||||
isKeepAlive: parentRoute.meta.isKeepAlive as boolean
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
if (route.meta.isFull) return
|
||||
if (route.meta.hideTab) {
|
||||
tabsMenuValue.value = route.meta.parentPath as string
|
||||
ensureParentTab()
|
||||
tabsMenuValue.value = resolveCurrentTabPath()
|
||||
} else {
|
||||
tabsMenuValue.value = route.fullPath
|
||||
const tabsParams = {
|
||||
@@ -112,7 +140,7 @@ const tabClick = (tabItem: TabsPaneContext) => {
|
||||
}
|
||||
|
||||
const tabRemove = (fullPath: TabPaneName) => {
|
||||
tabStore.removeTabs(fullPath as string, fullPath == route.fullPath)
|
||||
tabStore.removeTabs(fullPath as string, fullPath === tabsMenuValue.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -5,14 +5,28 @@ import { ElNotification } from 'element-plus'
|
||||
import { useUserStore } from '@/stores/modules/user'
|
||||
import { useAuthStore } from '@/stores/modules/auth'
|
||||
|
||||
// 引入 views 文件夹下所有 vue 文件
|
||||
const modules = import.meta.glob('@/views/**/*.vue')
|
||||
const STATIC_ROUTE_NAMES = new Set(['layout', 'login', 'home', 'tools', 'toolWaveform', 'toolMmsMapping', '403', '404', '500'])
|
||||
const VIEWS_ALIAS_PREFIX = '@/views'
|
||||
const VIEWS_SRC_PREFIX = '/src/views'
|
||||
const STATIC_ROUTE_NAMES = new Set([
|
||||
'layout',
|
||||
'login',
|
||||
'home',
|
||||
'tools',
|
||||
'toolWaveform',
|
||||
'toolMmsMapping',
|
||||
'toolAddData',
|
||||
'systemMonitor',
|
||||
'diskMonitor',
|
||||
'403',
|
||||
'404',
|
||||
'500'
|
||||
])
|
||||
|
||||
let isInitializing = false
|
||||
|
||||
/**
|
||||
* 清除已有的动态路由
|
||||
* 清除已有的动态路由,避免重复注入。
|
||||
*/
|
||||
const clearDynamicRoutes = () => {
|
||||
const routes = router.getRoutes()
|
||||
@@ -24,13 +38,19 @@ const clearDynamicRoutes = () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据菜单 component 路径查找对应的页面模块。
|
||||
* 兼容两种仓库写法:
|
||||
* 1. /foo/bar.vue
|
||||
* 2. /foo/bar/index.vue
|
||||
* 统一菜单 component 配置格式,只允许映射到 views 目录。
|
||||
*/
|
||||
const resolveComponentModule = (path: string) => {
|
||||
let normalizedPath = path.trim()
|
||||
const normalizeComponentPath = (path: string) => {
|
||||
let normalizedPath = path.trim().replace(/\\/g, '/')
|
||||
|
||||
if (normalizedPath.startsWith(VIEWS_ALIAS_PREFIX)) {
|
||||
normalizedPath = normalizedPath.slice(VIEWS_ALIAS_PREFIX.length)
|
||||
} else if (normalizedPath.startsWith(VIEWS_SRC_PREFIX)) {
|
||||
normalizedPath = normalizedPath.slice(VIEWS_SRC_PREFIX.length)
|
||||
} else if (normalizedPath.startsWith('src/views')) {
|
||||
normalizedPath = normalizedPath.slice('src/views'.length)
|
||||
}
|
||||
|
||||
if (!normalizedPath.startsWith('/')) {
|
||||
normalizedPath = '/' + normalizedPath
|
||||
}
|
||||
@@ -41,13 +61,31 @@ const resolveComponentModule = (path: string) => {
|
||||
normalizedPath = normalizedPath.slice(0, -1)
|
||||
}
|
||||
|
||||
const candidatePaths = [`/src/views${normalizedPath}.vue`, `/src/views${normalizedPath}/index.vue`]
|
||||
return normalizedPath
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据菜单 component 路径查找对应页面模块。
|
||||
* 兼容菜单资源里常见的多种写法,避免因为前缀或 index 差异导致误判。
|
||||
*/
|
||||
const resolveComponentModule = (path: string) => {
|
||||
const normalizedPath = normalizeComponentPath(path)
|
||||
const viewPath = normalizedPath.endsWith('/index') ? normalizedPath.slice(0, -'/index'.length) : normalizedPath
|
||||
const candidatePaths = Array.from(
|
||||
new Set([
|
||||
`${VIEWS_ALIAS_PREFIX}${normalizedPath}.vue`,
|
||||
`${VIEWS_ALIAS_PREFIX}${normalizedPath}/index.vue`,
|
||||
`${VIEWS_ALIAS_PREFIX}${viewPath}/index.vue`,
|
||||
`${VIEWS_SRC_PREFIX}${normalizedPath}.vue`,
|
||||
`${VIEWS_SRC_PREFIX}${normalizedPath}/index.vue`,
|
||||
`${VIEWS_SRC_PREFIX}${viewPath}/index.vue`
|
||||
])
|
||||
)
|
||||
|
||||
for (const candidatePath of candidatePaths) {
|
||||
const moduleLoader = modules[candidatePath]
|
||||
if (moduleLoader) {
|
||||
if (candidatePath in modules) {
|
||||
return {
|
||||
moduleLoader,
|
||||
moduleLoader: modules[candidatePath],
|
||||
resolvedPath: candidatePath
|
||||
}
|
||||
}
|
||||
@@ -60,7 +98,7 @@ const resolveComponentModule = (path: string) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 初始化动态路由
|
||||
* 初始化动态路由。
|
||||
*/
|
||||
export const initDynamicRouter = async () => {
|
||||
if (isInitializing) return Promise.reject(new Error('Dynamic router initialization in progress'))
|
||||
@@ -71,15 +109,13 @@ export const initDynamicRouter = async () => {
|
||||
const unresolvedRoutes: Array<{ name?: string; path?: string; component?: string; candidates: string[] }> = []
|
||||
|
||||
try {
|
||||
// 1. 获取菜单列表 && 按钮权限列表
|
||||
await authStore.getAuthMenuList()
|
||||
await authStore.getAuthButtonList()
|
||||
|
||||
// 2. 判断当前用户有没有菜单权限
|
||||
if (!authStore.authMenuListGet.length) {
|
||||
ElNotification({
|
||||
title: '无权限访问',
|
||||
message: '当前账号无任何菜单权限,请联系系统管理员!',
|
||||
message: '当前账号无任何菜单权限,请联系系统管理员',
|
||||
type: 'warning',
|
||||
duration: 3000
|
||||
})
|
||||
@@ -90,21 +126,17 @@ export const initDynamicRouter = async () => {
|
||||
return Promise.reject('No permission')
|
||||
}
|
||||
|
||||
// 3. 清理之前的动态路由
|
||||
clearDynamicRoutes()
|
||||
|
||||
// 4. 添加动态路由
|
||||
for (const item of authStore.flatMenuListGet) {
|
||||
// 删除 children 避免冗余嵌套
|
||||
if (item.children) delete item.children
|
||||
|
||||
// 处理组件映射
|
||||
// 动态菜单组件必须先映射成真实页面模块,否则 addRoute 后会直接落到 404。
|
||||
if (item.component && typeof item.component === 'string') {
|
||||
const { moduleLoader, resolvedPath } = resolveComponentModule(item.component)
|
||||
if (moduleLoader) {
|
||||
item.component = moduleLoader
|
||||
} else {
|
||||
// 动态路由组件一旦解析失败,对应菜单会落入 404,这里必须打印清楚候选路径。
|
||||
unresolvedRoutes.push({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
@@ -115,7 +147,6 @@ export const initDynamicRouter = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 类型守卫:确保满足 RouteRecordRaw 接口要求
|
||||
if (
|
||||
typeof item.path === 'string' &&
|
||||
(typeof item.component === 'function' || typeof item.redirect === 'string')
|
||||
@@ -135,7 +166,6 @@ export const initDynamicRouter = async () => {
|
||||
console.error('[dynamic-router] unresolved route components', unresolvedRoutes)
|
||||
}
|
||||
} catch (error) {
|
||||
// 当按钮 || 菜单请求出错时,重定向到登陆页
|
||||
userStore.setAccessToken('')
|
||||
userStore.setRefreshToken('')
|
||||
userStore.setExp(0)
|
||||
|
||||
@@ -59,6 +59,14 @@ export const staticRouter: RouteRecordRaw[] = [
|
||||
title: 'MMS 映射'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/tools/addData',
|
||||
name: 'toolAddData',
|
||||
component: () => import('@/views/tools/addData/index.vue'),
|
||||
meta: {
|
||||
title: '模拟数据'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/403',
|
||||
name: '403',
|
||||
@@ -83,6 +91,26 @@ export const staticRouter: RouteRecordRaw[] = [
|
||||
title: '500'
|
||||
}
|
||||
},
|
||||
{
|
||||
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: {
|
||||
// 磁盘监控页复用系统监控主标签
|
||||
activeMenu: '/systemMonitor',
|
||||
hideTab: true,
|
||||
parentPath: '/systemMonitor',
|
||||
title: '磁盘监控'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
component: () => import('@/components/ErrorMessage/404.vue')
|
||||
|
||||
2
frontend/src/types/global.d.ts
vendored
2
frontend/src/types/global.d.ts
vendored
@@ -12,6 +12,8 @@ declare namespace Menu {
|
||||
icon: string;
|
||||
title: string;
|
||||
activeMenu?: string;
|
||||
hideTab?: boolean;
|
||||
parentPath?: string;
|
||||
isLink?: string;
|
||||
isHide: boolean;
|
||||
isFull: boolean;
|
||||
|
||||
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
@@ -0,0 +1,346 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
class="job-detail-drawer"
|
||||
:model-value="props.visible"
|
||||
size="70%"
|
||||
title="任务详情"
|
||||
destroy-on-close
|
||||
@close="emit('update:visible', false)"
|
||||
>
|
||||
<div v-loading="props.loading" class="job-detail">
|
||||
<template v-if="props.detail">
|
||||
<section class="card job-detail-meta-card">
|
||||
<div class="meta-grid">
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">任务编号</span>
|
||||
<span class="meta-value">{{ props.detail.job.jobNo }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">任务来源</span>
|
||||
<span class="meta-value">{{ getSourceLabel(props.detail.job.jobSource) }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">任务状态</span>
|
||||
<span class="meta-value">{{ getJobStatusLabel(props.detail.job.jobStatus) }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">预警数量</span>
|
||||
<span class="meta-value">{{ props.detail.job.warningCount }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">告警数量</span>
|
||||
<span class="meta-value">{{ props.detail.job.alarmCount }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">开始时间</span>
|
||||
<span class="meta-value">{{ formatTime(props.detail.job.startedAt) }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">结束时间</span>
|
||||
<span class="meta-value">{{ formatTime(props.detail.job.finishedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="job-detail-table-sections">
|
||||
<section class="table-section">
|
||||
<h4 class="section-title">盘符结果</h4>
|
||||
<div class="table-main card job-detail-table-card">
|
||||
<el-table :data="props.detail.results" class="job-detail-el-table" border stripe height="100%">
|
||||
<el-table-column prop="driveLetter" label="盘符" min-width="100" align="center" />
|
||||
<el-table-column label="使用率" min-width="110" align="center">
|
||||
<template #default="{ row }">{{ row.usedPercent }}%</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="当前状态" min-width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getMonitorStatusType(row.currentStatus)" effect="light">
|
||||
{{ getMonitorStatusLabel(row.currentStatus) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="上次状态" min-width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getMonitorStatusType(row.previousStatus)" effect="light">
|
||||
{{ getMonitorStatusLabel(row.previousStatus) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态变化" min-width="100" align="center">
|
||||
<template #default="{ row }">{{ formatBoolean(row.statusChanged) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="是否通知" min-width="100" align="center">
|
||||
<template #default="{ row }">{{ formatBoolean(row.shouldNotify) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="通知原因" min-width="130" align="center">
|
||||
<template #default="{ row }">{{ getNotifyReasonLabel(row.notifyReason) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="扫描时间" min-width="170" align="center">
|
||||
<template #default="{ row }">{{ formatTime(row.scanTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="message"
|
||||
label="说明"
|
||||
min-width="180"
|
||||
align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
</el-table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="table-section">
|
||||
<h4 class="section-title">通知日志</h4>
|
||||
<div class="table-main card job-detail-table-card">
|
||||
<el-table :data="props.detail.notifyLogs" class="job-detail-el-table" border stripe height="100%">
|
||||
<el-table-column prop="driveLetter" label="盘符" min-width="100" align="center" />
|
||||
<el-table-column label="通知级别" min-width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getNotifyLevelType(row.notifyLevel)" effect="light">
|
||||
{{ getNotifyLevelLabel(row.notifyLevel) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="通知通道" min-width="120" align="center">
|
||||
<template #default="{ row }">{{ getChannelTypeLabel(row.channelType) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="channelTarget"
|
||||
label="通知目标"
|
||||
min-width="220"
|
||||
align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column label="发送状态" min-width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getSendStatusType(row.sendStatus)" effect="light">
|
||||
{{ getSendStatusLabel(row.sendStatus) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="responseMessage"
|
||||
label="响应信息"
|
||||
min-width="220"
|
||||
align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column label="发送时间" min-width="170" align="center">
|
||||
<template #default="{ row }">{{ formatTime(row.sentAt) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty v-else description="暂无详情数据" />
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs'
|
||||
import type { DiskMonitor } from '@/api/system/diskMonitor/interface'
|
||||
|
||||
defineOptions({
|
||||
name: 'DiskMonitorJobDetailDrawer'
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
detail: DiskMonitor.JobDetailData | null
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
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')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.job-detail-drawer :deep(.el-drawer__body) {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.job-detail {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.job-detail-meta-card {
|
||||
flex: none;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.job-detail-table-sections {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.job-detail-table-card {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.job-detail-el-table {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.job-detail-el-table :deep(.el-table__inner-wrapper) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.job-detail-el-table :deep(.el-table__body-wrapper) {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.job-detail-el-table :deep(.el-scrollbar),
|
||||
.job-detail-el-table :deep(.el-scrollbar__wrap),
|
||||
.job-detail-el-table :deep(.el-scrollbar__view) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.job-detail-el-table :deep(.el-table__empty-block) {
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.job-detail-el-table :deep(.el-table__empty-text) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 14px 16px;
|
||||
background: var(--el-fill-color-lighter);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
color: var(--el-text-color-primary);
|
||||
font-weight: 500;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.table-section {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.meta-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.meta-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,254 @@
|
||||
<template>
|
||||
<div class="table-main card disk-monitor-table-card">
|
||||
<div class="table-header">
|
||||
<div class="header-button-ri table-tools">
|
||||
<el-button circle :icon="Refresh" :loading="loading" @click="emit('refresh')" />
|
||||
<el-popover trigger="click" placement="bottom-end" :width="220">
|
||||
<div class="column-setting-panel">
|
||||
<el-checkbox v-for="column in columnOptions" :key="column.key" v-model="column.visible">
|
||||
{{ column.label }}
|
||||
</el-checkbox>
|
||||
</div>
|
||||
<template #reference>
|
||||
<el-button circle :icon="Operation" />
|
||||
</template>
|
||||
</el-popover>
|
||||
<el-button circle :icon="Search" @click="showSearch = !showSearch" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="showSearch" class="table-search">
|
||||
<el-form label-width="80px" class="disk-monitor-search-form">
|
||||
<div class="search-grid">
|
||||
<el-form-item label="任务编号">
|
||||
<el-input v-model="searchForm.jobNo" clearable placeholder="请输入任务编号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="任务来源">
|
||||
<el-select v-model="searchForm.jobSource" clearable placeholder="请选择来源">
|
||||
<el-option label="应用启动" value="APP_START" />
|
||||
<el-option label="定时任务" value="DAILY_SCHEDULE" />
|
||||
<el-option label="手动触发" value="MANUAL" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="任务状态">
|
||||
<el-select v-model="searchForm.jobStatus" clearable placeholder="请选择状态">
|
||||
<el-option label="成功" value="SUCCESS" />
|
||||
<el-option label="部分成功" value="PARTIAL_SUCCESS" />
|
||||
<el-option label="失败" value="FAILED" />
|
||||
<el-option label="运行中" value="RUNNING" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="operation">
|
||||
<el-button @click="resetSearch">重置</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="disk-monitor-table-body">
|
||||
<el-table v-loading="loading" class="disk-monitor-el-table" :data="filteredRows" border stripe height="100%">
|
||||
<el-table-column v-if="isColumnVisible('jobNo')" prop="jobNo" label="任务编号" min-width="180" align="center" />
|
||||
<el-table-column v-if="isColumnVisible('jobSource')" label="来源" min-width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ getSourceLabel(row.jobSource) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="isColumnVisible('startedAt')" label="开始时间" min-width="170" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.startedAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="isColumnVisible('finishedAt')" label="结束时间" min-width="170" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.finishedAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="isColumnVisible('jobStatus')" label="状态" min-width="130" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusType(row.jobStatus)" effect="light">
|
||||
{{ getStatusLabel(row.jobStatus) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="isColumnVisible('warningCount')"
|
||||
prop="warningCount"
|
||||
label="预警数量"
|
||||
min-width="100"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="isColumnVisible('alarmCount')"
|
||||
prop="alarmCount"
|
||||
label="告警数量"
|
||||
min-width="100"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column label="操作" width="160" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" :icon="View" @click="emit('detail', row)">查看详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { Operation, Refresh, Search, View } from '@element-plus/icons-vue'
|
||||
import dayjs from 'dayjs'
|
||||
import type { DiskMonitor } from '@/api/system/diskMonitor/interface'
|
||||
|
||||
defineOptions({
|
||||
name: 'DiskMonitorJobTable'
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
rows: DiskMonitor.JobListItem[]
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: []
|
||||
detail: [row: DiskMonitor.JobListItem]
|
||||
}>()
|
||||
|
||||
type JobColumnKey = 'jobNo' | 'jobSource' | 'startedAt' | 'finishedAt' | 'jobStatus' | 'warningCount' | 'alarmCount'
|
||||
|
||||
const showSearch = ref(false)
|
||||
const searchForm = reactive({
|
||||
jobNo: '',
|
||||
jobSource: '',
|
||||
jobStatus: ''
|
||||
})
|
||||
const columnOptions = reactive<{ key: JobColumnKey; label: string; visible: boolean }[]>([
|
||||
{ key: 'jobNo', label: '任务编号', visible: true },
|
||||
{ key: 'jobSource', label: '来源', visible: true },
|
||||
{ key: 'startedAt', label: '开始时间', visible: true },
|
||||
{ key: 'finishedAt', label: '结束时间', visible: true },
|
||||
{ key: 'jobStatus', label: '状态', visible: true },
|
||||
{ key: 'warningCount', label: '预警数量', visible: true },
|
||||
{ key: 'alarmCount', label: '告警数量', visible: true }
|
||||
])
|
||||
|
||||
const includesKeyword = (value?: string | number | null, keyword?: string) => {
|
||||
const normalizedKeyword = String(keyword || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (!normalizedKeyword) return true
|
||||
return String(value ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.includes(normalizedKeyword)
|
||||
}
|
||||
|
||||
const isColumnVisible = (key: JobColumnKey) => {
|
||||
return columnOptions.find(column => column.key === key)?.visible ?? true
|
||||
}
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
return props.rows.filter(row => {
|
||||
const matchesJobNo = includesKeyword(row.jobNo, searchForm.jobNo)
|
||||
const matchesSource = !searchForm.jobSource || row.jobSource === searchForm.jobSource
|
||||
const matchesStatus = !searchForm.jobStatus || row.jobStatus === searchForm.jobStatus
|
||||
return matchesJobNo && matchesSource && matchesStatus
|
||||
})
|
||||
})
|
||||
|
||||
const resetSearch = () => {
|
||||
searchForm.jobNo = ''
|
||||
searchForm.jobSource = ''
|
||||
searchForm.jobStatus = ''
|
||||
}
|
||||
|
||||
const getSourceLabel = (source: DiskMonitor.JobSource) => {
|
||||
if (source === 'APP_START') return '应用启动'
|
||||
if (source === 'DAILY_SCHEDULE') return '定时任务'
|
||||
return '手动触发'
|
||||
}
|
||||
|
||||
const getStatusType = (status: DiskMonitor.JobStatus) => {
|
||||
if (status === 'SUCCESS') return 'success'
|
||||
if (status === 'PARTIAL_SUCCESS') return 'warning'
|
||||
if (status === 'FAILED') return 'danger'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
const getStatusLabel = (status: DiskMonitor.JobStatus) => {
|
||||
if (status === 'SUCCESS') return '成功'
|
||||
if (status === 'PARTIAL_SUCCESS') return '部分成功'
|
||||
if (status === 'FAILED') return '失败'
|
||||
return '运行中'
|
||||
}
|
||||
|
||||
const formatTime = (value?: string | null) => {
|
||||
if (!value) return '--'
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.disk-monitor-table-card {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: flow-root;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.table-tools {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.column-setting-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.search-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0 12px;
|
||||
}
|
||||
|
||||
.disk-monitor-table-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.disk-monitor-el-table {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.disk-monitor-table-body :deep(.el-table__inner-wrapper) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.table-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-button-ri {
|
||||
float: none;
|
||||
}
|
||||
|
||||
.search-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<el-dialog class="disk-monitor-dialog" :model-value="props.visible" title="全局策略" width="880px" @close="closeDialog">
|
||||
<div class="dialog-section dialog-section--plain">
|
||||
<el-alert
|
||||
class="policy-alert"
|
||||
title="通知规则"
|
||||
description="预警按状态变化通知,告警每次命中都通知"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="dialog-section">
|
||||
<div class="section-title">基础配置</div>
|
||||
<el-form label-width="130px" class="policy-form">
|
||||
<el-form-item label="启用监控">
|
||||
<el-switch
|
||||
:model-value="props.modelValue.monitorEnabled"
|
||||
:disabled="props.disabled"
|
||||
@update:model-value="value => patchPolicy({ monitorEnabled: Boolean(value) })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="启动即监控">
|
||||
<el-switch
|
||||
:model-value="props.modelValue.runOnAppStart"
|
||||
:disabled="props.disabled"
|
||||
@update:model-value="value => patchPolicy({ runOnAppStart: Boolean(value) })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="每日执行时间">
|
||||
<el-time-picker
|
||||
:model-value="props.modelValue.dailyRunTime"
|
||||
:disabled="props.disabled"
|
||||
format="HH:mm:ss"
|
||||
value-format="HH:mm:ss"
|
||||
placeholder="选择时间"
|
||||
@update:model-value="value => patchPolicy({ dailyRunTime: typeof value === 'string' ? value : '' })"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button :disabled="props.disabled" @click="closeDialog">取消</el-button>
|
||||
<el-button type="primary" plain :loading="props.runLoading" :disabled="props.disabled" @click="emit('run')">
|
||||
立即执行监控
|
||||
</el-button>
|
||||
<el-button type="primary" :loading="props.saveLoading" :disabled="props.disabled" @click="emit('confirm')">
|
||||
保存配置
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { DiskMonitor } from '@/api/system/diskMonitor/interface'
|
||||
|
||||
defineOptions({
|
||||
name: 'DiskMonitorPolicyDialog'
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
visible: boolean
|
||||
modelValue: DiskMonitor.PolicyItem
|
||||
disabled?: boolean
|
||||
saveLoading?: boolean
|
||||
runLoading?: boolean
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
saveLoading: false,
|
||||
runLoading: false
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean]
|
||||
'update:modelValue': [value: DiskMonitor.PolicyItem]
|
||||
confirm: []
|
||||
run: []
|
||||
}>()
|
||||
|
||||
const closeDialog = () => {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
const patchPolicy = (patch: Partial<DiskMonitor.PolicyItem>) => {
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
...patch
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.disk-monitor-dialog :deep(.el-dialog__body) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dialog-section {
|
||||
padding: 16px;
|
||||
background: var(--el-fill-color-lighter);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.dialog-section--plain {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin-bottom: 14px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.policy-alert {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.policy-form :deep(.el-form-item) {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.policy-form :deep(.el-form-item:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.policy-form :deep(.el-form-item__content) {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.policy-form :deep(.el-time-picker) {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<div class="card disk-monitor-summary-card">
|
||||
<div class="summary-grid">
|
||||
<div class="summary-item">
|
||||
<div class="summary-label">监控状态</div>
|
||||
<div class="summary-value">{{ policy.monitorEnabled ? '已启用' : '已停用' }}</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="summary-label">启动即监控</div>
|
||||
<div class="summary-value">{{ policy.runOnAppStart ? '是' : '否' }}</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="summary-label">每日执行时间</div>
|
||||
<div class="summary-value">{{ policy.dailyRunTime || '--' }}</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="summary-label">监控目标数量</div>
|
||||
<div class="summary-value">{{ monitorTargetCount }}</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="summary-label">当前告警目标</div>
|
||||
<div class="summary-value">{{ alarmCount }}</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="summary-label">最近执行时间</div>
|
||||
<div class="summary-value">{{ latestRunTime }}</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="summary-label">最近执行状态</div>
|
||||
<div class="summary-value">{{ latestJobStatus }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs'
|
||||
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 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">
|
||||
.disk-monitor-summary-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
padding: 14px 16px;
|
||||
background: var(--el-fill-color-lighter);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 20px;
|
||||
line-height: 1.2;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.summary-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.summary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
class="disk-monitor-dialog"
|
||||
:model-value="props.visible"
|
||||
:title="props.title"
|
||||
:show-close="!props.disabled"
|
||||
:close-on-click-modal="!props.disabled"
|
||||
:close-on-press-escape="!props.disabled"
|
||||
width="880px"
|
||||
@close="closeDialog"
|
||||
>
|
||||
<el-form :disabled="props.disabled" label-width="120px" class="target-form">
|
||||
<div class="dialog-section">
|
||||
<div class="section-title">监控基础信息</div>
|
||||
<el-form-item label="盘符">
|
||||
<el-input
|
||||
:model-value="props.modelValue.driveLetter"
|
||||
placeholder="例如 C:"
|
||||
maxlength="2"
|
||||
@update:model-value="value => patchTarget({ driveLetter: String(value).trim().toUpperCase() })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="启用监控">
|
||||
<el-switch
|
||||
:model-value="props.modelValue.monitorEnabled"
|
||||
@update:model-value="value => patchTarget({ monitorEnabled: Boolean(value) })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="预警阈值">
|
||||
<div class="threshold-field">
|
||||
<el-input-number
|
||||
:model-value="props.modelValue.warningUsagePercent"
|
||||
:min="1"
|
||||
:max="100"
|
||||
:controls="false"
|
||||
@update:model-value="handleWarningChange($event)"
|
||||
/>
|
||||
<span class="suffix-text">%</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="告警阈值">
|
||||
<div class="threshold-field">
|
||||
<el-input-number
|
||||
:model-value="props.modelValue.alarmUsagePercent"
|
||||
:min="1"
|
||||
:max="100"
|
||||
:controls="false"
|
||||
@update:model-value="handleAlarmChange($event)"
|
||||
/>
|
||||
<span class="suffix-text">%</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="dialog-section">
|
||||
<div class="section-title">通知配置</div>
|
||||
<el-form-item label="路径通知">
|
||||
<el-switch
|
||||
:model-value="props.modelValue.notifyPathEnabled"
|
||||
@update:model-value="value => patchTarget({ notifyPathEnabled: Boolean(value) })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="props.modelValue.notifyPathEnabled" class="form-item--editor" label="路径通知配置">
|
||||
<NotificationPathEditor
|
||||
:model-value="props.modelValue.notifyPathList"
|
||||
@update:model-value="value => patchTarget({ notifyPathList: value })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="HTTP 通知">
|
||||
<el-switch
|
||||
:model-value="props.modelValue.notifyHttpEnabled"
|
||||
@update:model-value="value => patchTarget({ notifyHttpEnabled: Boolean(value) })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="props.modelValue.notifyHttpEnabled" class="form-item--editor" label="HTTP 通知配置">
|
||||
<NotificationHttpEditor
|
||||
:model-value="props.modelValue.notifyHttpList"
|
||||
@update:model-value="value => patchTarget({ notifyHttpList: value })"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="dialog-section">
|
||||
<div class="section-title">补充说明</div>
|
||||
<el-form-item class="form-item--remark" label="备注">
|
||||
<el-input
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:model-value="props.modelValue.remark"
|
||||
placeholder="可选"
|
||||
@update:model-value="value => patchTarget({ remark: String(value) })"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button :disabled="props.disabled" @click="closeDialog">取消</el-button>
|
||||
<el-button type="primary" :loading="props.confirmLoading" :disabled="props.disabled" @click="emit('confirm')">
|
||||
确定
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { DiskMonitor } from '@/api/system/diskMonitor/interface'
|
||||
import NotificationHttpEditor from './NotificationHttpEditor.vue'
|
||||
import NotificationPathEditor from './NotificationPathEditor.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'DiskMonitorTargetDialog'
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
visible: boolean
|
||||
modelValue: DiskMonitor.TargetItem
|
||||
title: string
|
||||
disabled?: boolean
|
||||
confirmLoading?: boolean
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
confirmLoading: false
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean]
|
||||
'update:modelValue': [value: DiskMonitor.TargetItem]
|
||||
confirm: []
|
||||
}>()
|
||||
|
||||
const closeDialog = () => {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
const handleWarningChange = (value: number | undefined) => {
|
||||
patchTarget({
|
||||
warningUsagePercent: Number(value || 0)
|
||||
})
|
||||
}
|
||||
|
||||
const handleAlarmChange = (value: number | undefined) => {
|
||||
patchTarget({
|
||||
alarmUsagePercent: Number(value || 0)
|
||||
})
|
||||
}
|
||||
|
||||
const patchTarget = (patch: Partial<DiskMonitor.TargetItem>) => {
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
...patch
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.disk-monitor-dialog :deep(.el-dialog__body) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.target-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dialog-section {
|
||||
padding: 16px;
|
||||
background: var(--el-fill-color-lighter);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin-bottom: 14px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.target-form :deep(.el-form-item__content) {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.target-form :deep(.el-form-item) {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.target-form :deep(.el-form-item:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.threshold-field {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.threshold-field :deep(.el-input-number) {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.suffix-text {
|
||||
margin-left: 8px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.form-item--editor :deep(.el-form-item__content),
|
||||
.form-item--remark :deep(.el-form-item__content) {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.form-item--editor :deep(.el-form-item__content > *),
|
||||
.form-item--remark :deep(.el-form-item__content > *) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,284 @@
|
||||
<template>
|
||||
<div class="table-main card disk-monitor-table-card">
|
||||
<div class="table-header">
|
||||
<div class="header-button-lf">
|
||||
<el-button type="primary" :icon="CirclePlus" :disabled="props.disabled" @click="emit('add')">
|
||||
新增目标
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="header-button-ri table-tools">
|
||||
<el-button circle :icon="Refresh" :disabled="props.disabled" @click="emit('refresh')" />
|
||||
<el-popover trigger="click" placement="bottom-end" :width="220">
|
||||
<div class="column-setting-panel">
|
||||
<el-checkbox v-for="column in columnOptions" :key="column.key" v-model="column.visible">
|
||||
{{ column.label }}
|
||||
</el-checkbox>
|
||||
</div>
|
||||
<template #reference>
|
||||
<el-button circle :icon="Operation" />
|
||||
</template>
|
||||
</el-popover>
|
||||
<el-button circle :icon="Search" @click="showSearch = !showSearch" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="showSearch" class="table-search">
|
||||
<el-form label-width="80px" class="disk-monitor-search-form">
|
||||
<div class="search-grid">
|
||||
<el-form-item label="盘符">
|
||||
<el-input v-model="searchForm.driveLetter" clearable placeholder="请输入盘符" />
|
||||
</el-form-item>
|
||||
<el-form-item label="当前状态">
|
||||
<el-select v-model="searchForm.lastStatus" clearable placeholder="请选择状态">
|
||||
<el-option label="正常" value="NORMAL" />
|
||||
<el-option label="预警" value="WARNING" />
|
||||
<el-option label="告警" value="ALARM" />
|
||||
<el-option label="未知" value="UNKNOWN" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="operation">
|
||||
<el-button @click="resetSearch">重置</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="disk-monitor-table-body">
|
||||
<el-table class="disk-monitor-el-table" :data="filteredRows" border stripe height="100%">
|
||||
<el-table-column
|
||||
v-if="isColumnVisible('driveLetter')"
|
||||
prop="driveLetter"
|
||||
label="盘符"
|
||||
min-width="90"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column v-if="isColumnVisible('monitorEnabled')" label="是否监控" min-width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.monitorEnabled ? '是' : '否' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="isColumnVisible('warningUsagePercent')" label="预警使用率" min-width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.warningUsagePercent }}%
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="isColumnVisible('alarmUsagePercent')" label="告警使用率" min-width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.alarmUsagePercent }}%
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="isColumnVisible('lastStatus')" label="当前状态" min-width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusType(row.lastStatus)" effect="light">
|
||||
{{ getStatusLabel(row.lastStatus) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="isColumnVisible('lastScanTime')" label="最近扫描时间" min-width="170" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ formatScanTime(row.lastScanTime) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="isColumnVisible('lastUsedPercent')" label="最近使用率" min-width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ formatUsedPercent(row.lastUsedPercent) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right" align="center">
|
||||
<template #default="{ row, $index }">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
:icon="EditPen"
|
||||
:disabled="props.disabled"
|
||||
@click="emit('edit', row, $index)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
:icon="Delete"
|
||||
:disabled="props.disabled"
|
||||
@click="emit('remove', $index)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { CirclePlus, Delete, EditPen, Operation, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import dayjs from 'dayjs'
|
||||
import type { DiskMonitor } from '@/api/system/diskMonitor/interface'
|
||||
|
||||
defineOptions({
|
||||
name: 'DiskMonitorTargetTable'
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
rows: DiskMonitor.TargetItem[]
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{
|
||||
disabled: false
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
add: []
|
||||
edit: [row: DiskMonitor.TargetItem, index: number]
|
||||
remove: [index: number]
|
||||
refresh: []
|
||||
}>()
|
||||
|
||||
type TargetColumnKey =
|
||||
| 'driveLetter'
|
||||
| 'monitorEnabled'
|
||||
| 'warningUsagePercent'
|
||||
| 'alarmUsagePercent'
|
||||
| 'lastStatus'
|
||||
| 'lastScanTime'
|
||||
| 'lastUsedPercent'
|
||||
|
||||
const showSearch = ref(false)
|
||||
const searchForm = reactive({
|
||||
driveLetter: '',
|
||||
lastStatus: ''
|
||||
})
|
||||
const columnOptions = reactive<{ key: TargetColumnKey; label: string; visible: boolean }[]>([
|
||||
{ key: 'driveLetter', label: '盘符', visible: true },
|
||||
{ key: 'monitorEnabled', label: '是否监控', visible: true },
|
||||
{ key: 'warningUsagePercent', label: '预警使用率', visible: true },
|
||||
{ key: 'alarmUsagePercent', label: '告警使用率', visible: true },
|
||||
{ key: 'lastStatus', label: '当前状态', visible: true },
|
||||
{ key: 'lastScanTime', label: '最近扫描时间', visible: true },
|
||||
{ key: 'lastUsedPercent', label: '最近使用率', visible: true }
|
||||
])
|
||||
|
||||
const includesKeyword = (value?: string | number | null, keyword?: string) => {
|
||||
const normalizedKeyword = String(keyword || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (!normalizedKeyword) return true
|
||||
return String(value ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.includes(normalizedKeyword)
|
||||
}
|
||||
|
||||
const isColumnVisible = (key: TargetColumnKey) => {
|
||||
return columnOptions.find(column => column.key === key)?.visible ?? true
|
||||
}
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
return props.rows.filter(row => {
|
||||
const matchesDriveLetter = includesKeyword(row.driveLetter, searchForm.driveLetter)
|
||||
const selectedStatus = searchForm.lastStatus
|
||||
const actualStatus = row.lastStatus || 'UNKNOWN'
|
||||
const matchesStatus = !selectedStatus || actualStatus === selectedStatus
|
||||
return matchesDriveLetter && matchesStatus
|
||||
})
|
||||
})
|
||||
|
||||
const resetSearch = () => {
|
||||
searchForm.driveLetter = ''
|
||||
searchForm.lastStatus = ''
|
||||
}
|
||||
|
||||
const getStatusType = (status: DiskMonitor.MonitorStatus) => {
|
||||
if (status === 'NORMAL') return 'success'
|
||||
if (status === 'WARNING') return 'warning'
|
||||
if (status === 'ALARM') return 'danger'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
const getStatusLabel = (status: DiskMonitor.MonitorStatus) => {
|
||||
if (status === 'NORMAL') return '正常'
|
||||
if (status === 'WARNING') return '预警'
|
||||
if (status === 'ALARM') return '告警'
|
||||
return '未知'
|
||||
}
|
||||
|
||||
const formatScanTime = (value?: string | null) => {
|
||||
if (!value) return '--'
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
const formatUsedPercent = (value?: number | null) => {
|
||||
if (value === null || value === undefined) return '--'
|
||||
return `${value}%`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.disk-monitor-table-card {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: flow-root;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.table-tools {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.column-setting-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.search-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 12px;
|
||||
}
|
||||
|
||||
.disk-monitor-table-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.disk-monitor-el-table {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.disk-monitor-table-body :deep(.el-table__inner-wrapper) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.table-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-button-lf,
|
||||
.header-button-ri {
|
||||
float: none;
|
||||
}
|
||||
|
||||
.search-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<div class="notification-editor">
|
||||
<div class="editor-header">
|
||||
<span class="editor-title">HTTP 通知目标</span>
|
||||
<el-button type="primary" plain size="small" :icon="CirclePlus" @click="handleAdd">新增 HTTP 目标</el-button>
|
||||
</div>
|
||||
<div v-if="!props.modelValue.length" class="empty-text">暂无 HTTP 通知目标</div>
|
||||
<div v-else class="editor-list">
|
||||
<div v-for="(item, index) in props.modelValue" :key="index" class="editor-row">
|
||||
<el-input
|
||||
:model-value="item.name"
|
||||
placeholder="名称"
|
||||
@update:model-value="value => patchRow(index, 'name', String(value))"
|
||||
/>
|
||||
<el-input
|
||||
:model-value="item.url"
|
||||
placeholder="通知 URL"
|
||||
@update:model-value="value => patchRow(index, 'url', String(value))"
|
||||
/>
|
||||
<el-select
|
||||
:model-value="item.method"
|
||||
@update:model-value="value => patchRow(index, 'method', value as DiskMonitor.NotifyHttpTarget['method'])"
|
||||
>
|
||||
<el-option label="POST" value="POST" />
|
||||
</el-select>
|
||||
<el-input-number
|
||||
:model-value="item.timeoutMs"
|
||||
:min="100"
|
||||
:step="100"
|
||||
:controls="false"
|
||||
@update:model-value="handleTimeoutChange(index, $event)"
|
||||
/>
|
||||
<el-switch
|
||||
:model-value="item.enabled"
|
||||
@update:model-value="value => patchRow(index, 'enabled', Boolean(value))"
|
||||
/>
|
||||
<el-button type="primary" link :icon="Delete" @click="handleRemove(index)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CirclePlus, Delete } from '@element-plus/icons-vue'
|
||||
import type { DiskMonitor } from '@/api/system/diskMonitor/interface'
|
||||
import { createEmptyHttpTarget } from '../utils/form'
|
||||
|
||||
defineOptions({
|
||||
name: 'NotificationHttpEditor'
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: DiskMonitor.NotifyHttpTarget[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: DiskMonitor.NotifyHttpTarget[]]
|
||||
}>()
|
||||
|
||||
const updateList = (nextList: DiskMonitor.NotifyHttpTarget[]) => {
|
||||
emit('update:modelValue', nextList)
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
updateList([...props.modelValue, createEmptyHttpTarget()])
|
||||
}
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
updateList(props.modelValue.filter((_, rowIndex) => rowIndex !== index))
|
||||
}
|
||||
|
||||
const handleTimeoutChange = (index: number, value: number | undefined) => {
|
||||
const nextTimeout = typeof value === 'number' && value >= 100 ? value : 100
|
||||
patchRow(index, 'timeoutMs', nextTimeout)
|
||||
}
|
||||
|
||||
const patchRow = <K extends keyof DiskMonitor.NotifyHttpTarget>(
|
||||
index: number,
|
||||
key: K,
|
||||
value: DiskMonitor.NotifyHttpTarget[K]
|
||||
) => {
|
||||
const nextList = props.modelValue.map((item, rowIndex) =>
|
||||
rowIndex === index
|
||||
? {
|
||||
...item,
|
||||
[key]: value
|
||||
}
|
||||
: item
|
||||
)
|
||||
updateList(nextList)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.notification-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.editor-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
padding: 14px 16px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
background: var(--el-fill-color-lighter);
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.editor-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.editor-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.6fr) 100px 120px auto auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background: var(--el-fill-color-lighter);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.editor-row :deep(.el-input),
|
||||
.editor-row :deep(.el-select),
|
||||
.editor-row :deep(.el-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.editor-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<div class="notification-editor">
|
||||
<div class="editor-header">
|
||||
<span class="editor-title">路径通知目标</span>
|
||||
<el-button type="primary" plain size="small" :icon="CirclePlus" @click="handleAdd">新增路径</el-button>
|
||||
</div>
|
||||
<div v-if="!props.modelValue.length" class="empty-text">暂无路径通知目标</div>
|
||||
<div v-else class="editor-list">
|
||||
<div v-for="(item, index) in props.modelValue" :key="index" class="editor-row">
|
||||
<el-input
|
||||
:model-value="item.name"
|
||||
placeholder="名称"
|
||||
@update:model-value="value => patchRow(index, 'name', String(value))"
|
||||
/>
|
||||
<el-input
|
||||
:model-value="item.path"
|
||||
placeholder="通知路径"
|
||||
@update:model-value="value => patchRow(index, 'path', String(value))"
|
||||
/>
|
||||
<el-switch
|
||||
:model-value="item.enabled"
|
||||
@update:model-value="value => patchRow(index, 'enabled', Boolean(value))"
|
||||
/>
|
||||
<el-button type="primary" link :icon="Delete" @click="handleRemove(index)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CirclePlus, Delete } from '@element-plus/icons-vue'
|
||||
import type { DiskMonitor } from '@/api/system/diskMonitor/interface'
|
||||
import { createEmptyPathTarget } from '../utils/form'
|
||||
|
||||
defineOptions({
|
||||
name: 'NotificationPathEditor'
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: DiskMonitor.NotifyPathTarget[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: DiskMonitor.NotifyPathTarget[]]
|
||||
}>()
|
||||
|
||||
const updateList = (nextList: DiskMonitor.NotifyPathTarget[]) => {
|
||||
emit('update:modelValue', nextList)
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
updateList([...props.modelValue, createEmptyPathTarget()])
|
||||
}
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
updateList(props.modelValue.filter((_, rowIndex) => rowIndex !== index))
|
||||
}
|
||||
|
||||
const patchRow = <K extends keyof DiskMonitor.NotifyPathTarget>(
|
||||
index: number,
|
||||
key: K,
|
||||
value: DiskMonitor.NotifyPathTarget[K]
|
||||
) => {
|
||||
const nextList = props.modelValue.map((item, rowIndex) =>
|
||||
rowIndex === index
|
||||
? {
|
||||
...item,
|
||||
[key]: value
|
||||
}
|
||||
: item
|
||||
)
|
||||
updateList(nextList)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.notification-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.editor-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
padding: 14px 16px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
background: var(--el-fill-color-lighter);
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.editor-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.editor-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) auto auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background: var(--el-fill-color-lighter);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.editor-row :deep(.el-input) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.editor-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
468
frontend/src/views/systemMonitor/diskMonitor/index.vue
Normal file
468
frontend/src/views/systemMonitor/diskMonitor/index.vue
Normal file
@@ -0,0 +1,468 @@
|
||||
<template>
|
||||
<div v-loading="loading.init" class="table-box disk-monitor-page">
|
||||
<div class="disk-monitor-summary-section">
|
||||
<DiskMonitorSummary :policy="policyForm" :targets="targetList" :latest-job="latestJob" />
|
||||
|
||||
<div class="disk-monitor-actions">
|
||||
<el-button type="primary" plain :icon="Setting" :disabled="formBusy" @click="openPolicyDialog">
|
||||
全局策略
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DiskMonitorPolicyDialog
|
||||
v-model:visible="policyDialogVisible"
|
||||
v-model="editingPolicy"
|
||||
:disabled="formBusy"
|
||||
:save-loading="loading.save"
|
||||
:run-loading="loading.run"
|
||||
@confirm="confirmPolicy"
|
||||
@run="handleRun"
|
||||
/>
|
||||
|
||||
<DiskMonitorTargetDialog
|
||||
v-model:visible="targetDialogVisible"
|
||||
v-model="editingTarget"
|
||||
:title="editingTargetIndex >= 0 ? '编辑监控目标' : '新增监控目标'"
|
||||
:disabled="formBusy"
|
||||
:confirm-loading="loading.save"
|
||||
@confirm="confirmTarget"
|
||||
/>
|
||||
|
||||
<section class="disk-monitor-tabs-card">
|
||||
<el-tabs v-model="activeTab" class="disk-monitor-tabs">
|
||||
<el-tab-pane label="监控记录" name="jobs">
|
||||
<div class="disk-monitor-tab-panel">
|
||||
<DiskMonitorJobTable
|
||||
:rows="jobList"
|
||||
:loading="loading.jobs"
|
||||
@refresh="loadJobList"
|
||||
@detail="openJobDetail"
|
||||
/>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="监测目标" name="targets">
|
||||
<div class="disk-monitor-tab-panel">
|
||||
<DiskMonitorTargetTable
|
||||
:rows="targetList"
|
||||
:disabled="formBusy"
|
||||
@add="openAddTarget"
|
||||
@edit="openEditTarget"
|
||||
@remove="removeTarget"
|
||||
@refresh="loadPageData"
|
||||
/>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</section>
|
||||
|
||||
<DiskMonitorJobDetailDrawer
|
||||
:visible="jobDetailVisible"
|
||||
:detail="jobDetail"
|
||||
:loading="detailLoading"
|
||||
@update:visible="handleJobDetailVisibleChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Setting } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getDiskMonitorJobDetail,
|
||||
getDiskMonitorJobList,
|
||||
getDiskMonitorPolicyDetail,
|
||||
runDiskMonitorJob,
|
||||
saveDiskMonitorPolicy
|
||||
} from '@/api/system/diskMonitor'
|
||||
import type { DiskMonitor } from '@/api/system/diskMonitor/interface'
|
||||
import DiskMonitorJobDetailDrawer from './components/DiskMonitorJobDetailDrawer.vue'
|
||||
import DiskMonitorJobTable from './components/DiskMonitorJobTable.vue'
|
||||
import DiskMonitorPolicyDialog from './components/DiskMonitorPolicyDialog.vue'
|
||||
import DiskMonitorSummary from './components/DiskMonitorSummary.vue'
|
||||
import DiskMonitorTargetDialog from './components/DiskMonitorTargetDialog.vue'
|
||||
import DiskMonitorTargetTable from './components/DiskMonitorTargetTable.vue'
|
||||
import {
|
||||
createDefaultPolicy,
|
||||
createEmptyTarget,
|
||||
normalizeTargetItem,
|
||||
validatePolicy,
|
||||
validateTarget,
|
||||
validateTargetList,
|
||||
validateTargetNotifications
|
||||
} from './utils/form'
|
||||
|
||||
defineOptions({
|
||||
name: 'DiskMonitorPage'
|
||||
})
|
||||
|
||||
type DiskMonitorTab = 'targets' | 'jobs'
|
||||
|
||||
const policyForm = ref<DiskMonitor.PolicyItem>(createDefaultPolicy())
|
||||
const policyDialogVisible = ref(false)
|
||||
const editingPolicy = ref<DiskMonitor.PolicyItem>(createDefaultPolicy())
|
||||
const targetList = ref<DiskMonitor.TargetItem[]>([])
|
||||
const targetDialogVisible = ref(false)
|
||||
const editingTargetIndex = ref(-1)
|
||||
const editingTarget = ref<DiskMonitor.TargetItem>(createEmptyTarget())
|
||||
const latestJob = ref<DiskMonitor.JobListItem | null>(null)
|
||||
const jobList = ref<DiskMonitor.JobListItem[]>([])
|
||||
const jobDetailVisible = ref(false)
|
||||
const jobDetail = ref<DiskMonitor.JobDetailData | null>(null)
|
||||
const detailLoading = ref(false)
|
||||
const jobDetailRequestSeq = ref(0)
|
||||
const activeTab = ref<DiskMonitorTab>('jobs')
|
||||
const loading = reactive({
|
||||
init: false,
|
||||
save: false,
|
||||
run: false,
|
||||
jobs: false
|
||||
})
|
||||
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 clonePolicy = (policy: DiskMonitor.PolicyItem): DiskMonitor.PolicyItem => ({
|
||||
...createDefaultPolicy(),
|
||||
...policy
|
||||
})
|
||||
|
||||
const cloneTarget = (target: DiskMonitor.TargetItem): DiskMonitor.TargetItem => {
|
||||
const normalized = normalizeTargetItem(target)
|
||||
return {
|
||||
...normalized,
|
||||
notifyPathList: normalized.notifyPathList.map(item => ({ ...item })),
|
||||
notifyHttpList: normalized.notifyHttpList.map(item => ({ ...item }))
|
||||
}
|
||||
}
|
||||
|
||||
const openPolicyDialog = () => {
|
||||
// 打开弹窗时复制当前策略,避免取消时污染页面已加载状态。
|
||||
editingPolicy.value = clonePolicy(policyForm.value)
|
||||
policyDialogVisible.value = true
|
||||
}
|
||||
|
||||
const openAddTarget = () => {
|
||||
editingTargetIndex.value = -1
|
||||
editingTarget.value = createEmptyTarget()
|
||||
targetDialogVisible.value = true
|
||||
}
|
||||
|
||||
const openEditTarget = (row: DiskMonitor.TargetItem, index: number) => {
|
||||
editingTargetIndex.value = index
|
||||
// 编辑时克隆当前行,避免未确认前直接改动表格数据。
|
||||
editingTarget.value = cloneTarget(normalizeTargetItem(row))
|
||||
targetDialogVisible.value = true
|
||||
}
|
||||
|
||||
const confirmTarget = () => {
|
||||
if (formBusy.value) return
|
||||
|
||||
const normalizedDriveLetter = editingTarget.value.driveLetter.trim().toUpperCase()
|
||||
const payload: DiskMonitor.TargetItem = {
|
||||
...normalizeTargetItem(editingTarget.value),
|
||||
driveLetter: normalizedDriveLetter
|
||||
}
|
||||
const exists = targetList.value
|
||||
.filter((_, index) => index !== editingTargetIndex.value)
|
||||
.map(item => item.driveLetter.trim().toUpperCase())
|
||||
|
||||
const targetErrorMessage = validateTarget(payload, exists)
|
||||
if (targetErrorMessage) {
|
||||
ElMessage.warning(targetErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
const notifyErrorMessage = validateTargetNotifications(payload)
|
||||
if (notifyErrorMessage) {
|
||||
ElMessage.warning(notifyErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
const nextTargetList = targetList.value.map(item => cloneTarget(item))
|
||||
if (editingTargetIndex.value >= 0) {
|
||||
nextTargetList.splice(editingTargetIndex.value, 1, payload)
|
||||
} else {
|
||||
nextTargetList.push(payload)
|
||||
}
|
||||
|
||||
// 目标配置先保留在页面本地状态里,统一通过“全局策略”弹窗中的保存入口提交。
|
||||
targetList.value = nextTargetList.map(item => cloneTarget(item))
|
||||
targetDialogVisible.value = false
|
||||
}
|
||||
|
||||
const removeTarget = (index: number) => {
|
||||
if (formBusy.value) return
|
||||
|
||||
// 删除目标时仅更新本地暂存列表,避免样式调整顺带改成“操作即落库”。
|
||||
targetList.value = targetList.value
|
||||
.filter((_, currentIndex) => currentIndex !== index)
|
||||
.map(item => cloneTarget(item))
|
||||
}
|
||||
|
||||
const loadPolicyDetail = async () => {
|
||||
const response = await getDiskMonitorPolicyDetail()
|
||||
const detail = response.data
|
||||
if (!detail) return
|
||||
|
||||
policyForm.value = {
|
||||
...createDefaultPolicy(),
|
||||
...(detail.policy || {})
|
||||
}
|
||||
// 后端列表字段允许为空,这里统一归一化为数组,避免编辑器和克隆流程出现空引用。
|
||||
targetList.value = (detail.targets || []).map(item => normalizeTargetItem(item))
|
||||
}
|
||||
|
||||
const loadJobList = async () => {
|
||||
loading.jobs = true
|
||||
try {
|
||||
// 统一按 startedAt 倒序拉取任务,确保顶部摘要展示最新一条记录。
|
||||
const response = await getDiskMonitorJobList({
|
||||
pageNum: 1,
|
||||
pageSize: 100,
|
||||
sortField: 'startedAt',
|
||||
sortOrder: 'desc'
|
||||
})
|
||||
const records = (response.data?.records || []).map(item => ({
|
||||
...item,
|
||||
id: resolveJobId(item)
|
||||
}))
|
||||
const sortedRecords = [...records].sort((a, b) => {
|
||||
return getTimeValue(b.startedAt) - getTimeValue(a.startedAt)
|
||||
})
|
||||
jobList.value = sortedRecords.slice(0, 10)
|
||||
latestJob.value = jobList.value[0] || null
|
||||
} finally {
|
||||
loading.jobs = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleJobDetailVisibleChange = (visible: boolean) => {
|
||||
jobDetailVisible.value = visible
|
||||
if (!visible) {
|
||||
// 抽屉关闭时使旧请求失效,避免晚到数据回写已关闭的详情面板。
|
||||
jobDetailRequestSeq.value += 1
|
||||
detailLoading.value = false
|
||||
jobDetail.value = null
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
jobDetail.value = null
|
||||
detailLoading.value = true
|
||||
|
||||
try {
|
||||
// 仅允许最后一次详情请求回写,避免快速切换任务时详情串台。
|
||||
const response = await getDiskMonitorJobDetail(jobId)
|
||||
if (currentSeq !== jobDetailRequestSeq.value || !jobDetailVisible.value) return
|
||||
jobDetail.value = response.data || null
|
||||
} finally {
|
||||
if (currentSeq === jobDetailRequestSeq.value) {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loadPageData = async () => {
|
||||
loading.init = true
|
||||
try {
|
||||
// 页面刷新统一并行拉取策略与任务列表,减少顶部和 tab 区的状态抖动。
|
||||
await Promise.all([loadPolicyDetail(), loadJobList()])
|
||||
} finally {
|
||||
loading.init = false
|
||||
}
|
||||
}
|
||||
|
||||
const persistPolicyAndTargets = async (
|
||||
policy: DiskMonitor.PolicyItem,
|
||||
targets: DiskMonitor.TargetItem[] = targetList.value,
|
||||
successMessage: string | null = '配置保存成功'
|
||||
) => {
|
||||
const errorMessage = validatePolicy(policy)
|
||||
if (errorMessage) {
|
||||
ElMessage.warning(errorMessage)
|
||||
return false
|
||||
}
|
||||
|
||||
const normalizedPolicy = clonePolicy(policy)
|
||||
const normalizedTargets = targets.map(item => ({
|
||||
...normalizeTargetItem(item),
|
||||
driveLetter: item.driveLetter.trim().toUpperCase()
|
||||
}))
|
||||
const targetListErrorMessage = validateTargetList(normalizedTargets)
|
||||
if (targetListErrorMessage) {
|
||||
ElMessage.warning(targetListErrorMessage)
|
||||
return false
|
||||
}
|
||||
|
||||
loading.save = true
|
||||
try {
|
||||
await saveDiskMonitorPolicy({
|
||||
policy: normalizedPolicy,
|
||||
targets: normalizedTargets
|
||||
})
|
||||
// 仅在服务端保存成功后回写本地状态,避免页面误以为已持久化。
|
||||
policyForm.value = normalizedPolicy
|
||||
targetList.value = normalizedTargets.map(item => cloneTarget(item))
|
||||
if (successMessage) {
|
||||
ElMessage.success(successMessage)
|
||||
}
|
||||
try {
|
||||
await loadPageData()
|
||||
} catch {
|
||||
// 保存已成功时保留当前状态,后续刷新失败交由全局请求错误处理。
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
loading.save = false
|
||||
}
|
||||
}
|
||||
|
||||
const confirmPolicy = async () => {
|
||||
if (formBusy.value) return
|
||||
|
||||
const saved = await persistPolicyAndTargets(clonePolicy(editingPolicy.value))
|
||||
if (saved) {
|
||||
policyDialogVisible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleRun = async () => {
|
||||
if (formBusy.value) return
|
||||
|
||||
if (policyDialogVisible.value) {
|
||||
// 弹窗内执行监控前先落当前策略草稿,避免运行的还是上一次已保存配置。
|
||||
const saved = await persistPolicyAndTargets(clonePolicy(editingPolicy.value), targetList.value, null)
|
||||
if (!saved) return
|
||||
policyDialogVisible.value = false
|
||||
}
|
||||
|
||||
loading.run = true
|
||||
try {
|
||||
await runDiskMonitorJob({
|
||||
jobSource: 'MANUAL'
|
||||
})
|
||||
ElMessage.success('监控任务已启动')
|
||||
await loadPageData()
|
||||
} finally {
|
||||
loading.run = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadPageData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.disk-monitor-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
// 页面根节点跟随主内容区边距,不再额外叠加页面级外边距。
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.disk-monitor-summary-section {
|
||||
position: relative;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.disk-monitor-summary-section :deep(.disk-monitor-summary-card) {
|
||||
padding-bottom: 36px;
|
||||
}
|
||||
|
||||
.disk-monitor-actions {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
top: calc(50% + 44px);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.disk-monitor-tabs-card {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
overflow: hidden;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.disk-monitor-tabs {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.disk-monitor-tabs :deep(.el-tabs__header) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.disk-monitor-tabs :deep(.el-tabs__nav-wrap::after) {
|
||||
background-color: var(--el-border-color-light);
|
||||
}
|
||||
|
||||
.disk-monitor-tabs :deep(.el-tabs__content) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.disk-monitor-tabs :deep(.el-tab-pane) {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.disk-monitor-tabs :deep(.el-tabs__item) {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.disk-monitor-tab-panel {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.disk-monitor-tabs :deep(.el-tabs__nav) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.disk-monitor-tabs :deep(.el-tabs__item) {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
120
frontend/src/views/systemMonitor/diskMonitor/utils/form.ts
Normal file
120
frontend/src/views/systemMonitor/diskMonitor/utils/form.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
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: '默认磁盘监控策略',
|
||||
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 '每日统一执行时间不能为空'
|
||||
if (!TIME_PATTERN.test(policy.dailyRunTime)) return '每日统一执行时间格式必须为 HH:mm:ss'
|
||||
return ''
|
||||
}
|
||||
|
||||
export const validateTarget = (target: DiskMonitor.TargetItem, exists: string[]) => {
|
||||
if (!target.driveLetter) return '盘符不能为空'
|
||||
if (!DRIVE_LETTER_PATTERN.test(target.driveLetter)) return '盘符格式必须为类似 C: 的单个盘符'
|
||||
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 ''
|
||||
}
|
||||
|
||||
export const validateTargetNotifications = (target: DiskMonitor.TargetItem) => {
|
||||
if (target.notifyPathEnabled) {
|
||||
const enabledPathTargets = (target.notifyPathList || []).filter(item => item.enabled)
|
||||
if (!enabledPathTargets.length) return '路径通知至少需要一个启用的通知目标'
|
||||
|
||||
const hasInvalidPath = enabledPathTargets.some(item => !item.path?.trim())
|
||||
if (hasInvalidPath) return '路径通知目标路径不能为空'
|
||||
}
|
||||
|
||||
if (target.notifyHttpEnabled) {
|
||||
const enabledHttpTargets = (target.notifyHttpList || []).filter(item => item.enabled)
|
||||
if (!enabledHttpTargets.length) return 'HTTP 通知至少需要一个启用的通知目标'
|
||||
|
||||
const hasInvalidUrl = enabledHttpTargets.some(item => {
|
||||
const url = item.url?.trim()
|
||||
return !url || !/^https?:\/\/\S+$/i.test(url)
|
||||
})
|
||||
if (hasInvalidUrl) return 'HTTP 通知目标 URL 需要为有效的 HTTP/HTTPS 地址'
|
||||
|
||||
const hasInvalidTimeout = enabledHttpTargets.some(
|
||||
item => !Number.isFinite(item.timeoutMs) || item.timeoutMs < 100
|
||||
)
|
||||
if (hasInvalidTimeout) return 'HTTP 通知超时时间不能小于 100 毫秒'
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
export const validateTargetList = (targets: DiskMonitor.TargetItem[]) => {
|
||||
for (let index = 0; index < targets.length; index += 1) {
|
||||
const target = targets[index]
|
||||
const normalizedTarget: DiskMonitor.TargetItem = {
|
||||
...normalizeTargetItem(target),
|
||||
driveLetter: target.driveLetter.trim().toUpperCase()
|
||||
}
|
||||
const exists = targets
|
||||
.filter((_, targetIndex) => targetIndex !== index)
|
||||
.map(item => item.driveLetter.trim().toUpperCase())
|
||||
|
||||
const targetErrorMessage = validateTarget(normalizedTarget, exists)
|
||||
if (targetErrorMessage) {
|
||||
const label = normalizedTarget.driveLetter || `第 ${index + 1} 个监控目标`
|
||||
return `${label}:${targetErrorMessage}`
|
||||
}
|
||||
|
||||
const notifyErrorMessage = validateTargetNotifications(normalizedTarget)
|
||||
if (notifyErrorMessage) {
|
||||
const label = normalizedTarget.driveLetter || `第 ${index + 1} 个监控目标`
|
||||
return `${label}:${notifyErrorMessage}`
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
export const normalizeTargetItem = (target: DiskMonitor.TargetItem): DiskMonitor.TargetItem => ({
|
||||
...target,
|
||||
notifyPathList: Array.isArray(target.notifyPathList) ? target.notifyPathList : [],
|
||||
notifyHttpList: Array.isArray(target.notifyHttpList) ? target.notifyHttpList : []
|
||||
})
|
||||
99
frontend/src/views/systemMonitor/index.vue
Normal file
99
frontend/src/views/systemMonitor/index.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<div class="system-monitor-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">系统监控</h2>
|
||||
<p class="page-description">从这里进入各类运行监控能力,目前已接入磁盘监控配置入口。</p>
|
||||
</div>
|
||||
|
||||
<div class="monitor-grid">
|
||||
<section class="monitor-card">
|
||||
<div class="card-content">
|
||||
<h3 class="card-title">磁盘监控</h3>
|
||||
<p class="card-description">
|
||||
配置多盘符监控、预警与告警阈值、启动监控、定时监控以及通知目标。
|
||||
</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="openDiskMonitor">进入配置</el-button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
defineOptions({
|
||||
name: 'SystemMonitorPage'
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const openDiskMonitor = async () => {
|
||||
// 系统监控页作为模块入口,保持磁盘监控返回链路闭环。
|
||||
await router.push('/systemMonitor/diskMonitor')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.system-monitor-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.monitor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.monitor-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-height: 180px;
|
||||
padding: 20px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
|
||||
}
|
||||
|
||||
.card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.card-description {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: #4b5563;
|
||||
}
|
||||
</style>
|
||||
526
frontend/src/views/tools/addData/components/AddDataTaskPanel.vue
Normal file
526
frontend/src/views/tools/addData/components/AddDataTaskPanel.vue
Normal file
@@ -0,0 +1,526 @@
|
||||
<template>
|
||||
<section class="card add-data-card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<div class="section-title">补数任务</div>
|
||||
<div class="section-description">
|
||||
手工输入监测点 ID,先预估本次写入规模,再发起异步补数任务。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<el-alert
|
||||
class="task-alert"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="时间步长仅影响 10 张基础实时类表,data_flicker / data_fluc 固定 10 分钟,data_plt 固定 2 小时。"
|
||||
/>
|
||||
|
||||
<el-form ref="formRef" :model="localForm" :rules="formRules" label-width="108px" class="task-form">
|
||||
<div class="form-row form-row-first">
|
||||
<el-form-item class="form-item-line-ids" label="监测点 ID" prop="lineIds">
|
||||
<div class="line-id-input-group">
|
||||
<el-input
|
||||
v-model="lineIdsText"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
resize="vertical"
|
||||
:placeholder="lineIdsPlaceholder"
|
||||
/>
|
||||
<div class="line-id-actions">
|
||||
<el-button type="primary" plain :icon="CirclePlus" @click="handleOpenGuidDialog">
|
||||
新增监测点
|
||||
</el-button>
|
||||
<el-button type="primary" plain :icon="Delete" @click="handleClearLineIds">
|
||||
清空监测点
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="form-row form-row-second">
|
||||
<el-form-item label="开始时间" prop="startTime">
|
||||
<el-date-picker
|
||||
v-model="localForm.startTime"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
placeholder="请选择开始时间"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="结束时间" prop="endTime">
|
||||
<el-date-picker
|
||||
v-model="localForm.endTime"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
placeholder="请选择结束时间"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="form-row form-row-third">
|
||||
<el-form-item class="form-item-interval" label="时间步长" prop="intervalMinutes">
|
||||
<el-radio-group v-model="localForm.intervalMinutes">
|
||||
<el-radio-button v-for="item in intervalOptions" :key="item" :label="item">
|
||||
{{ item }} 分钟
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<div class="form-actions">
|
||||
<el-button type="primary" plain :icon="Histogram" :loading="previewLoading" @click="emit('preview')">
|
||||
预估写入量
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:icon="Promotion"
|
||||
:loading="submitLoading"
|
||||
:disabled="taskRunning"
|
||||
@click="emit('submit')"
|
||||
>
|
||||
开始补数
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<div class="preview-section">
|
||||
<div class="preview-header">
|
||||
<div class="preview-title">预估结果</div>
|
||||
<div class="preview-text">参数发生变化后需要重新预估,才能继续创建任务。</div>
|
||||
</div>
|
||||
|
||||
<div v-if="preview" class="preview-content">
|
||||
<el-descriptions :column="3" border size="small">
|
||||
<el-descriptions-item label="监测点数量">{{ preview.lineCount }}</el-descriptions-item>
|
||||
<el-descriptions-item label="时间步长">{{ preview.intervalMinutes }} 分钟</el-descriptions-item>
|
||||
<el-descriptions-item label="总预计条数">{{ preview.totalRowCount }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-table class="preview-table" :data="preview.tableStats" border stripe :max-height="320">
|
||||
<el-table-column prop="tableName" label="数据表" min-width="180" />
|
||||
<el-table-column prop="timePointCount" label="时间点数量" min-width="120" align="right" />
|
||||
<el-table-column prop="phaseCount" label="相别数量" min-width="120" align="right" />
|
||||
<el-table-column prop="rowCount" label="预计条数" min-width="120" align="right" />
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-block">暂无预估结果,请先填写参数并点击“预估写入量”。</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="guidDialogVisible" title="新增 guid" width="420px" @closed="handleGuidDialogClosed">
|
||||
<el-form label-width="96px">
|
||||
<el-form-item label="guid 数量">
|
||||
<el-input-number v-model="guidCount" :min="1" :step="1" :precision="0" controls-position="right" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="guidDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleAppendGuids">确认</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CirclePlus, Delete, Histogram, Promotion } from '@element-plus/icons-vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import type { AddData } from '@/api/tools/addData/interface'
|
||||
|
||||
defineOptions({
|
||||
name: 'AddDataTaskPanel'
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
form: AddData.TaskFormModel
|
||||
preview: AddData.NormalizedPreview | null
|
||||
previewLoading: boolean
|
||||
submitLoading: boolean
|
||||
taskRunning: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
preview: []
|
||||
submit: []
|
||||
'update:form': [form: AddData.TaskFormModel]
|
||||
}>()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const syncingFromProp = ref(false)
|
||||
const guidDialogVisible = ref(false)
|
||||
const guidCount = ref(1)
|
||||
const intervalOptions: AddData.IntervalMinutes[] = [1, 3, 5, 10]
|
||||
const localForm = reactive<AddData.TaskFormModel>({
|
||||
lineMode: 'multiple',
|
||||
lineIds: [...props.form.lineIds],
|
||||
startTime: props.form.startTime,
|
||||
endTime: props.form.endTime,
|
||||
intervalMinutes: props.form.intervalMinutes
|
||||
})
|
||||
|
||||
const syncLocalForm = (form: AddData.TaskFormModel) => {
|
||||
localForm.lineMode = 'multiple'
|
||||
localForm.lineIds = [...form.lineIds]
|
||||
localForm.startTime = form.startTime
|
||||
localForm.endTime = form.endTime
|
||||
localForm.intervalMinutes = form.intervalMinutes
|
||||
}
|
||||
|
||||
const normalizeLineIds = (lineIds: string[]) => {
|
||||
return Array.from(
|
||||
new Set(
|
||||
(lineIds || [])
|
||||
.map(item => item?.trim())
|
||||
.filter((item): item is string => Boolean(item))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const splitLineIdsText = (value: string) => {
|
||||
return value
|
||||
.split(/[\s,\uFF0C]+/)
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
const isValidLineId = (value: string) => {
|
||||
const normalizedValue = value.trim()
|
||||
return Boolean(normalizedValue) && normalizedValue.length <= 32
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.form,
|
||||
value => {
|
||||
syncingFromProp.value = true
|
||||
syncLocalForm(value)
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
localForm,
|
||||
value => {
|
||||
if (syncingFromProp.value) {
|
||||
syncingFromProp.value = false
|
||||
return
|
||||
}
|
||||
|
||||
emit('update:form', {
|
||||
lineMode: 'multiple',
|
||||
lineIds: [...value.lineIds],
|
||||
startTime: value.startTime,
|
||||
endTime: value.endTime,
|
||||
intervalMinutes: value.intervalMinutes
|
||||
})
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const setLineIds = (lineIds: string[]) => {
|
||||
localForm.lineIds = normalizeLineIds(lineIds)
|
||||
}
|
||||
|
||||
const handleClearLineIds = () => {
|
||||
setLineIds([])
|
||||
}
|
||||
|
||||
const lineIdsText = computed({
|
||||
get: () => localForm.lineIds.join(','),
|
||||
set: value => {
|
||||
setLineIds(splitLineIdsText(value))
|
||||
}
|
||||
})
|
||||
|
||||
const lineIdsPlaceholder = computed(
|
||||
() => '请输入一个或多个监测点 ID,多个值可用英文逗号、中文逗号、空格或换行分隔,单个长度不超过 32'
|
||||
)
|
||||
|
||||
const generateGuidText = () => {
|
||||
return window.crypto.randomUUID().replace(/-/g, '')
|
||||
}
|
||||
|
||||
const handleOpenGuidDialog = () => {
|
||||
guidCount.value = 1
|
||||
guidDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleGuidDialogClosed = () => {
|
||||
guidCount.value = 1
|
||||
}
|
||||
|
||||
const handleAppendGuids = () => {
|
||||
const count = Number(guidCount.value)
|
||||
if (!Number.isInteger(count) || count <= 0) {
|
||||
ElMessage.warning('请输入大于 0 的整数 guid 数量')
|
||||
return
|
||||
}
|
||||
|
||||
// 前端补充的 guid 仍需满足后端 lineIds 非空且长度不超过 32 的契约。
|
||||
const nextLineIds = [...localForm.lineIds, ...Array.from({ length: count }, () => generateGuidText())]
|
||||
setLineIds(nextLineIds)
|
||||
guidDialogVisible.value = false
|
||||
}
|
||||
|
||||
const formRules: FormRules<AddData.TaskFormModel> = {
|
||||
lineIds: [
|
||||
{
|
||||
validator: (_rule, value: string[], callback) => {
|
||||
const validLineIds = normalizeLineIds(value)
|
||||
if (!validLineIds.length) {
|
||||
callback(new Error('请至少输入一个监测点 ID'))
|
||||
return
|
||||
}
|
||||
|
||||
if (validLineIds.some(item => !isValidLineId(item))) {
|
||||
callback(new Error('监测点 ID 不能为空,且长度不能超过 32'))
|
||||
return
|
||||
}
|
||||
|
||||
callback()
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
],
|
||||
startTime: [{ required: true, message: '请选择开始时间', trigger: 'change' }],
|
||||
endTime: [
|
||||
{ required: true, message: '请选择结束时间', trigger: 'change' },
|
||||
{
|
||||
validator: (_rule, value: string, callback) => {
|
||||
if (!value || !localForm.startTime) {
|
||||
callback()
|
||||
return
|
||||
}
|
||||
|
||||
if (new Date(value).getTime() < new Date(localForm.startTime).getTime()) {
|
||||
callback(new Error('开始时间不能大于结束时间'))
|
||||
return
|
||||
}
|
||||
|
||||
callback()
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
],
|
||||
intervalMinutes: [{ required: true, message: '请选择时间步长', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const validateTaskForm = async () => {
|
||||
const result = await formRef.value?.validate().catch(() => false)
|
||||
return Boolean(result)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
validateTaskForm
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.add-data-card {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 16px 12px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.section-description {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.task-alert,
|
||||
.task-form {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
gap: 0 16px;
|
||||
}
|
||||
|
||||
.form-row + .form-row {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.form-row-first {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.form-row-second {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.form-row-third {
|
||||
grid-template-columns: minmax(280px, auto) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.form-item-line-ids,
|
||||
.form-item-interval {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-item-line-ids :deep(.el-form-item__content),
|
||||
.form-item-interval :deep(.el-form-item__content) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.task-form :deep(.el-date-editor) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.task-form :deep(.el-radio-group) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.line-id-input-group {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.line-id-input-group :deep(.el-textarea) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.line-id-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.line-id-actions :deep(.el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.preview-section {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.preview-text {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.preview-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.preview-table :deep(.el-table__body-wrapper) {
|
||||
max-height: 320px;
|
||||
}
|
||||
|
||||
.empty-block {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
color: var(--el-text-color-secondary);
|
||||
background: var(--cn-color-canvas-bg);
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.form-row,
|
||||
.form-row-second,
|
||||
.form-row-third {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.line-id-input-group {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.line-id-input-group :deep(.el-button) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.line-id-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.form-actions :deep(.el-button) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<section class="card add-data-card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<div class="section-title">任务状态</div>
|
||||
<div class="section-description">创建任务后自动轮询状态,直到任务成功或失败。</div>
|
||||
</div>
|
||||
|
||||
<el-tag v-if="status" :type="statusMeta.type" effect="light">{{ statusMeta.label }}</el-tag>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="card-body">
|
||||
<div v-if="status" class="status-content">
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="任务 ID">{{ taskId || status.taskId || '--' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="当前状态">{{ statusMeta.label }}</el-descriptions-item>
|
||||
<el-descriptions-item label="当前表名">{{ status.currentTableName || '--' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="当前批次">{{ status.currentBatchInfo || '--' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已写入数量">{{ status.insertedCount }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已跳过数量">{{ status.skippedCount }}</el-descriptions-item>
|
||||
<el-descriptions-item label="失败数量">{{ status.failedCount }}</el-descriptions-item>
|
||||
<el-descriptions-item label="失败原因">
|
||||
<span class="failure-text">{{ status.failureReason || '--' }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="开始时间">{{ status.startTime || '--' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="结束时间">{{ status.endTime || '--' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="hourly-block">
|
||||
<div class="hourly-title">业务时刻</div>
|
||||
<div v-if="status.hourlyTimeResults.length" class="hourly-scroll">
|
||||
<div class="hourly-list">
|
||||
<el-tag v-for="item in status.hourlyTimeResults" :key="item" effect="plain" type="info">
|
||||
{{ item }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="hourly-empty">当前接口未返回业务时刻。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-block">暂无补数任务,创建任务后会在这里持续展示执行进度。</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { AddData } from '@/api/tools/addData/interface'
|
||||
|
||||
defineOptions({
|
||||
name: 'AddDataTaskStatusCard'
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
status: AddData.NormalizedTaskStatus | null
|
||||
taskId: string
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const statusMeta = computed(() => {
|
||||
const status = props.status?.status
|
||||
|
||||
if (status === 'SUCCESS') {
|
||||
return { label: '成功', type: 'success' as const }
|
||||
}
|
||||
|
||||
if (status === 'FAILED') {
|
||||
return { label: '失败', type: 'danger' as const }
|
||||
}
|
||||
|
||||
if (status === 'RUNNING') {
|
||||
return { label: '执行中', type: 'warning' as const }
|
||||
}
|
||||
|
||||
if (status === 'WAITING') {
|
||||
return { label: '等待中', type: 'info' as const }
|
||||
}
|
||||
|
||||
return {
|
||||
label: status || '未知状态',
|
||||
type: 'info' as const
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.add-data-card {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 16px 12px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.section-description {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.status-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.failure-text {
|
||||
word-break: break-all;
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.hourly-block {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
background: var(--cn-color-canvas-bg);
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.hourly-title {
|
||||
margin-bottom: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.hourly-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.hourly-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-content: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.hourly-empty {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.empty-block {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
color: var(--el-text-color-secondary);
|
||||
background: var(--cn-color-canvas-bg);
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<section class="table-main card add-data-template-card">
|
||||
<div class="table-header">
|
||||
<div class="header-title-group">
|
||||
<div class="section-title">参数规则</div>
|
||||
<div class="section-description">模板规则来自后端配置,用于说明参数展示字段、相别映射和统计规则。</div>
|
||||
</div>
|
||||
|
||||
<div class="header-tools">
|
||||
<el-button circle :icon="Refresh" :loading="loading" @click="emit('refresh')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-body">
|
||||
<el-table v-loading="loading" :data="rows" border stripe height="100%">
|
||||
<el-table-column prop="parameterName" label="电能质量参数名称" min-width="220" fixed="left" />
|
||||
<el-table-column prop="tableName" label="落库表" min-width="140" />
|
||||
<el-table-column prop="phaseDisplay" label="展示相别" min-width="110" align="center" />
|
||||
<el-table-column prop="phaseCodesText" label="落库相别" min-width="140" align="center" />
|
||||
<el-table-column prop="displayText" label="是否展示" min-width="110" align="center" />
|
||||
<el-table-column prop="showQualifiedText" label="是否展示合格" min-width="140" align="center" />
|
||||
<el-table-column prop="maxValueRule" label="最大值规则" min-width="160" />
|
||||
<el-table-column prop="minValueRule" label="最小值规则" min-width="160" />
|
||||
<el-table-column prop="averageValueRule" label="平均值规则" min-width="160" />
|
||||
<el-table-column prop="cp95ValueRule" label="95% 概率大值规则" min-width="180" />
|
||||
<el-table-column prop="decimalScaleText" label="小数位数" min-width="120" align="center" />
|
||||
</el-table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import type { AddData } from '@/api/tools/addData/interface'
|
||||
|
||||
defineOptions({
|
||||
name: 'AddDataTemplateTable'
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
rows: AddData.NormalizedTemplateItem[]
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.add-data-template-card {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.header-title-group {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.section-description {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.header-tools {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.table-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin-top: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-body :deep(.el-table__inner-wrapper) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.table-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
465
frontend/src/views/tools/addData/index.vue
Normal file
465
frontend/src/views/tools/addData/index.vue
Normal file
@@ -0,0 +1,465 @@
|
||||
<template>
|
||||
<div class="table-box add-data-page">
|
||||
<div class="add-data-layout">
|
||||
<div class="add-data-main-column">
|
||||
<AddDataTaskPanel
|
||||
ref="taskPanelRef"
|
||||
:form="taskForm"
|
||||
:preview="previewSummary"
|
||||
:preview-loading="loading.preview"
|
||||
:submit-loading="loading.create"
|
||||
:task-running="taskRunning"
|
||||
@update:form="handleTaskFormChange"
|
||||
@preview="handlePreview"
|
||||
@submit="handleCreateTask"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section class="add-data-side-panel">
|
||||
<el-tabs v-model="activeTab" class="add-data-tabs">
|
||||
<el-tab-pane label="任务状态" name="taskStatus">
|
||||
<AddDataTaskStatusCard :status="taskStatus" :task-id="currentTaskId" :loading="loading.status" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="参数规则" name="templateRules">
|
||||
<AddDataTemplateTable :rows="templateRows" :loading="loading.template" @refresh="loadTemplateList" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
createAddDataTask,
|
||||
getAddDataPreview,
|
||||
getAddDataTaskStatus,
|
||||
getAddDataTemplateList
|
||||
} from '@/api/tools/addData'
|
||||
import type { AddData } from '@/api/tools/addData/interface'
|
||||
import AddDataTaskPanel from './components/AddDataTaskPanel.vue'
|
||||
import AddDataTaskStatusCard from './components/AddDataTaskStatusCard.vue'
|
||||
import AddDataTemplateTable from './components/AddDataTemplateTable.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'AddDataView'
|
||||
})
|
||||
|
||||
type AddDataTaskPanelExpose = {
|
||||
validateTaskForm: () => Promise<boolean>
|
||||
}
|
||||
|
||||
const taskPanelRef = ref<AddDataTaskPanelExpose | null>(null)
|
||||
const activeTab = ref('taskStatus')
|
||||
const templateRows = ref<AddData.NormalizedTemplateItem[]>([])
|
||||
const previewSummary = ref<AddData.NormalizedPreview | null>(null)
|
||||
const taskStatus = ref<AddData.NormalizedTaskStatus | null>(null)
|
||||
const currentTaskId = ref('')
|
||||
const previewSignature = ref('')
|
||||
const pollTimer = ref<number | null>(null)
|
||||
const pollingBusy = ref(false)
|
||||
const loading = reactive({
|
||||
template: false,
|
||||
preview: false,
|
||||
create: false,
|
||||
status: false
|
||||
})
|
||||
const taskForm = reactive<AddData.TaskFormModel>({
|
||||
lineMode: 'multiple',
|
||||
lineIds: [],
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
intervalMinutes: 1
|
||||
})
|
||||
|
||||
const handleTaskFormChange = (nextForm: AddData.TaskFormModel) => {
|
||||
taskForm.lineMode = 'multiple'
|
||||
taskForm.lineIds = [...nextForm.lineIds]
|
||||
taskForm.startTime = nextForm.startTime
|
||||
taskForm.endTime = nextForm.endTime
|
||||
taskForm.intervalMinutes = nextForm.intervalMinutes
|
||||
}
|
||||
|
||||
const normalizeLineIds = (lineIds: string[]) => {
|
||||
return Array.from(
|
||||
new Set(
|
||||
(lineIds || [])
|
||||
.map(item => item?.trim())
|
||||
.filter((item): item is string => Boolean(item))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const parseLineIds = (lineIds: string[]) => {
|
||||
return normalizeLineIds(lineIds)
|
||||
}
|
||||
|
||||
const resetPreview = () => {
|
||||
previewSummary.value = null
|
||||
previewSignature.value = ''
|
||||
}
|
||||
|
||||
const resolveNumber = (...values: unknown[]) => {
|
||||
for (const value of values) {
|
||||
if (value === null || value === undefined || value === '') continue
|
||||
const parsed = Number(value)
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
const resolveText = (...values: unknown[]) => {
|
||||
for (const value of values) {
|
||||
if (value === null || value === undefined) continue
|
||||
const text = String(value).trim()
|
||||
if (text) {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const resolveDisplayRule = (value: unknown, fallback = '--') => {
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? '显示' : '不显示'
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return value ? '显示' : '不显示'
|
||||
}
|
||||
|
||||
const text = resolveText(value)
|
||||
return text || fallback
|
||||
}
|
||||
|
||||
const normalizePreview = (data?: AddData.PreviewResponse | null): AddData.NormalizedPreview => {
|
||||
const tableStats = (Array.isArray(data?.tableStats) ? data.tableStats : [])
|
||||
.map(item => ({
|
||||
tableName: resolveText(item.tableName) || '--',
|
||||
timePointCount: resolveNumber(item.timePointCount),
|
||||
phaseCount: resolveNumber(item.phaseCount),
|
||||
rowCount: resolveNumber(item.rowCount)
|
||||
}))
|
||||
.sort((left, right) => right.rowCount - left.rowCount)
|
||||
|
||||
return {
|
||||
lineCount: resolveNumber(data?.lineCount),
|
||||
intervalMinutes: resolveNumber(data?.intervalMinutes),
|
||||
totalRowCount: resolveNumber(data?.totalRowCount),
|
||||
tableStats
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeTaskStatus = (data?: AddData.TaskStatusResponse | null): AddData.NormalizedTaskStatus => {
|
||||
return {
|
||||
taskId: resolveText(data?.taskId),
|
||||
status: (resolveText(data?.status) || 'WAITING') as AddData.TaskStatus,
|
||||
currentTableName: resolveText(data?.currentTableName),
|
||||
currentBatchInfo: resolveText(data?.currentBatchInfo),
|
||||
insertedCount: resolveNumber(data?.insertedCount),
|
||||
skippedCount: resolveNumber(data?.skippedCount),
|
||||
failedCount: resolveNumber(data?.failedCount),
|
||||
failureReason: resolveText(data?.failureReason),
|
||||
hourlyTimeResults: Array.isArray(data?.hourlyTimeResults) ? data.hourlyTimeResults.filter(Boolean) : [],
|
||||
startTime: resolveText(data?.startTime),
|
||||
endTime: resolveText(data?.endTime)
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeTemplateItem = (item: AddData.TemplateItem): AddData.NormalizedTemplateItem => {
|
||||
const decimalScale = resolveText(item.decimalScale)
|
||||
|
||||
return {
|
||||
parameterName: resolveText(item.parameterName) || '--',
|
||||
tableName: resolveText(item.tableName) || '--',
|
||||
phaseDisplay: resolveText(item.phaseDisplay) || '--',
|
||||
phaseCodesText: Array.isArray(item.phaseCodes) && item.phaseCodes.length ? item.phaseCodes.join(' / ') : '--',
|
||||
displayText: resolveDisplayRule(item.display),
|
||||
showQualifiedText: resolveDisplayRule(item.showQualified),
|
||||
maxValueRule: resolveText(item.maxValueRule) || '--',
|
||||
minValueRule: resolveText(item.minValueRule) || '--',
|
||||
averageValueRule: resolveText(item.averageValueRule) || '--',
|
||||
cp95ValueRule: resolveText(item.cp95ValueRule) || '--',
|
||||
decimalScaleText: decimalScale ? `${decimalScale} 位小数` : '--'
|
||||
}
|
||||
}
|
||||
|
||||
const buildTaskPayload = (): AddData.TaskRequestParams => {
|
||||
return {
|
||||
lineIds: parseLineIds(taskForm.lineIds),
|
||||
startTime: taskForm.startTime,
|
||||
endTime: taskForm.endTime,
|
||||
intervalMinutes: taskForm.intervalMinutes
|
||||
}
|
||||
}
|
||||
|
||||
const buildPayloadSignature = (payload: AddData.TaskRequestParams) => {
|
||||
return JSON.stringify(payload)
|
||||
}
|
||||
|
||||
const buildPreviewDependencySignature = () => {
|
||||
return buildPayloadSignature(buildTaskPayload())
|
||||
}
|
||||
|
||||
const isTerminalStatus = (status?: AddData.TaskStatus) => {
|
||||
return status === 'SUCCESS' || status === 'FAILED'
|
||||
}
|
||||
|
||||
const taskRunning = computed(() => {
|
||||
const status = taskStatus.value?.status
|
||||
return Boolean(currentTaskId.value && (status === 'WAITING' || status === 'RUNNING'))
|
||||
})
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollTimer.value !== null) {
|
||||
window.clearInterval(pollTimer.value)
|
||||
pollTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const loadTemplateList = async () => {
|
||||
loading.template = true
|
||||
try {
|
||||
const response = await getAddDataTemplateList()
|
||||
const rows = Array.isArray(response.data) ? response.data : []
|
||||
templateRows.value = rows.map(item => normalizeTemplateItem(item))
|
||||
} finally {
|
||||
loading.template = false
|
||||
}
|
||||
}
|
||||
|
||||
const getValidatedPayload = async () => {
|
||||
const isValid = await taskPanelRef.value?.validateTaskForm()
|
||||
if (!isValid) return null
|
||||
|
||||
const payload = buildTaskPayload()
|
||||
if (!payload.lineIds.length) {
|
||||
ElMessage.warning('请至少输入一个合法的监测点 ID')
|
||||
return null
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
const loadTaskStatus = async (taskId = currentTaskId.value, silent = false) => {
|
||||
if (!taskId || pollingBusy.value) return
|
||||
|
||||
pollingBusy.value = true
|
||||
if (!silent) {
|
||||
loading.status = true
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getAddDataTaskStatus(taskId)
|
||||
// 状态接口是补数任务唯一的进度来源,统一在这里按正式契约归一化,避免页面层分散兼容字段。
|
||||
const normalizedStatus = normalizeTaskStatus(response.data)
|
||||
taskStatus.value = normalizedStatus
|
||||
currentTaskId.value = normalizedStatus.taskId || taskId
|
||||
|
||||
if (isTerminalStatus(normalizedStatus.status)) {
|
||||
stopPolling()
|
||||
}
|
||||
} catch (error) {
|
||||
// 轮询失败时立即停止定时器,避免请求持续堆积并反复弹出相同错误。
|
||||
stopPolling()
|
||||
throw error
|
||||
} finally {
|
||||
if (!silent) {
|
||||
loading.status = false
|
||||
}
|
||||
pollingBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const startPolling = (taskId: string) => {
|
||||
stopPolling()
|
||||
currentTaskId.value = taskId
|
||||
|
||||
// 创建任务后先立即拉一次状态,再进入固定轮询,避免页面长时间停留在初始态。
|
||||
void loadTaskStatus(taskId).catch(() => null)
|
||||
|
||||
pollTimer.value = window.setInterval(() => {
|
||||
void loadTaskStatus(taskId, true).catch(() => null)
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
const handlePreview = async () => {
|
||||
const payload = await getValidatedPayload()
|
||||
if (!payload) return
|
||||
|
||||
loading.preview = true
|
||||
try {
|
||||
const response = await getAddDataPreview(payload)
|
||||
// preview 是 create 前的唯一准入检查,必须严格按正式契约读取 totalRowCount 和 tableStats。
|
||||
previewSummary.value = normalizePreview(response.data)
|
||||
previewSignature.value = buildPayloadSignature(payload)
|
||||
ElMessage.success('写入规模预估完成')
|
||||
} finally {
|
||||
loading.preview = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateTask = async () => {
|
||||
if (taskRunning.value) {
|
||||
ElMessage.warning('当前补数任务仍在执行,请等待结束后再创建新任务')
|
||||
return
|
||||
}
|
||||
|
||||
const payload = await getValidatedPayload()
|
||||
if (!payload) return
|
||||
|
||||
const currentSignature = buildPayloadSignature(payload)
|
||||
if (!previewSummary.value || previewSignature.value !== currentSignature) {
|
||||
ElMessage.warning('参数已变化,请先重新预估写入量')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`预计写入 ${previewSummary.value.totalRowCount} 条数据,涉及 ${payload.lineIds.length} 个监测点,确认开始补数?`,
|
||||
'开始补数',
|
||||
{
|
||||
type: 'warning',
|
||||
confirmButtonText: '开始补数',
|
||||
cancelButtonText: '取消'
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
loading.create = true
|
||||
try {
|
||||
const response = await createAddDataTask(payload)
|
||||
const taskId = resolveText(response.data?.taskId)
|
||||
|
||||
taskStatus.value = normalizeTaskStatus({
|
||||
taskId,
|
||||
status: response.data?.status
|
||||
})
|
||||
|
||||
if (!taskId) {
|
||||
ElMessage.warning('任务已创建,但接口未返回 taskId,无法继续轮询状态')
|
||||
return
|
||||
}
|
||||
|
||||
startPolling(taskId)
|
||||
ElMessage.success('补数任务已创建,正在轮询状态')
|
||||
} finally {
|
||||
loading.create = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
// 预估失效判断必须与 preview/create 的正式请求参数保持同一口径,避免仅切换界面模式也误判为参数变化。
|
||||
() => buildPreviewDependencySignature(),
|
||||
() => {
|
||||
resetPreview()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadTemplateList()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.add-data-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
gap: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.add-data-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.05fr) minmax(360px, 0.95fr);
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.add-data-main-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.add-data-main-column > * {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.add-data-side-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.add-data-side-panel :deep(.el-tabs) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.add-data-side-panel :deep(.el-tabs__header) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.add-data-side-panel :deep(.el-tabs__nav-wrap::after) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.add-data-side-panel :deep(.el-tabs__content) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.add-data-tabs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.add-data-tabs :deep(.el-tab-pane) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.add-data-tabs :deep(.el-tab-pane > *) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.add-data-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -18,6 +18,11 @@
|
||||
<div class="tool-name">MMS 映射</div>
|
||||
<div class="tool-text">进入 MMS 映射页面,后续可继续补充映射配置和预览能力。</div>
|
||||
</button>
|
||||
|
||||
<button class="tool-item" type="button" @click="handleNavigate('/tools/addData')">
|
||||
<div class="tool-name">addData</div>
|
||||
<div class="tool-text">进入 addData 页面壳子,后续在此扩展补录数据能力和业务交互。</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<div class="json-mapping-tree-viewer">
|
||||
<div class="json-tree-toolbar">
|
||||
<div class="json-tree-meta">{{ metaText }}</div>
|
||||
<div class="json-tree-actions">
|
||||
<slot name="actions" />
|
||||
<el-button type="primary" plain size="small" :disabled="!rootNode" @click="expandAll">全部展开</el-button>
|
||||
<el-button plain size="small" :disabled="!rootNode" @click="collapseAll">全部收起</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="rootNode" class="json-tree-body">
|
||||
<JsonTreeNode :node="rootNode" :depth="0" :is-last="true" :expanded-keys="expandedKeys" @toggle="toggleNode" />
|
||||
</div>
|
||||
<pre v-else class="mapping-json-text">{{ source }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import JsonTreeNode, { type JsonTreeNodeModel, type JsonValueType } from './JsonMappingTreeNode.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'JsonMappingTree'
|
||||
})
|
||||
|
||||
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
|
||||
const props = defineProps<{
|
||||
source: string
|
||||
metaText?: string
|
||||
}>()
|
||||
|
||||
const expandedKeys = ref<Set<string>>(new Set())
|
||||
|
||||
const parsedJson = computed<{ valid: true; value: JsonValue } | { valid: false }>(() => {
|
||||
try {
|
||||
return {
|
||||
valid: true,
|
||||
value: JSON.parse(props.source) as JsonValue
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
valid: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const rootNode = computed(() => {
|
||||
if (!parsedJson.value.valid) return null
|
||||
|
||||
return buildJsonNode(undefined, parsedJson.value.value, '$')
|
||||
})
|
||||
|
||||
watch(
|
||||
rootNode,
|
||||
node => {
|
||||
expandedKeys.value = new Set(node ? collectContainerKeys(node) : [])
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function buildJsonNode(keyName: string | undefined, source: JsonValue, path: string): JsonTreeNodeModel {
|
||||
if (Array.isArray(source)) {
|
||||
return {
|
||||
id: path,
|
||||
keyName,
|
||||
openToken: '[',
|
||||
closeToken: ']',
|
||||
summary: ` ${source.length} 项`,
|
||||
children: source.map((item, index) => buildJsonNode(undefined, item, `${path}/${index}`))
|
||||
}
|
||||
}
|
||||
|
||||
if (source && typeof source === 'object') {
|
||||
const entries = Object.entries(source)
|
||||
|
||||
return {
|
||||
id: path,
|
||||
keyName,
|
||||
openToken: '{',
|
||||
closeToken: '}',
|
||||
summary: ` ${entries.length} 项`,
|
||||
children: entries.map(([key, value]) => buildJsonNode(key, value, `${path}/${escapePathKey(key)}`))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: path,
|
||||
keyName,
|
||||
valueText: formatPrimitiveValue(source),
|
||||
valueType: getPrimitiveType(source)
|
||||
}
|
||||
}
|
||||
|
||||
function collectContainerKeys(node: JsonTreeNodeModel): string[] {
|
||||
if (!node.children) return []
|
||||
|
||||
return [node.id, ...node.children.flatMap(collectContainerKeys)]
|
||||
}
|
||||
|
||||
function escapePathKey(key: string) {
|
||||
return key.replace(/~/g, '~0').replace(/\//g, '~1')
|
||||
}
|
||||
|
||||
function formatPrimitiveValue(source: JsonValue) {
|
||||
if (typeof source === 'string') return JSON.stringify(source)
|
||||
if (source === null) return 'null'
|
||||
return String(source)
|
||||
}
|
||||
|
||||
function getPrimitiveType(source: JsonValue): JsonValueType {
|
||||
if (source === null) return 'null'
|
||||
if (typeof source === 'boolean') return 'boolean'
|
||||
if (typeof source === 'number') return 'number'
|
||||
return 'string'
|
||||
}
|
||||
|
||||
function toggleNode(id: string) {
|
||||
const nextKeys = new Set(expandedKeys.value)
|
||||
|
||||
if (nextKeys.has(id)) {
|
||||
nextKeys.delete(id)
|
||||
} else {
|
||||
nextKeys.add(id)
|
||||
}
|
||||
|
||||
expandedKeys.value = nextKeys
|
||||
}
|
||||
|
||||
function expandAll() {
|
||||
if (!rootNode.value) return
|
||||
|
||||
expandedKeys.value = new Set(collectContainerKeys(rootNode.value))
|
||||
}
|
||||
|
||||
function collapseAll() {
|
||||
expandedKeys.value = new Set()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.json-mapping-tree-viewer {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.json-tree-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
min-height: 28px;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.json-tree-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.json-tree-actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.json-tree-body,
|
||||
.mapping-json-text {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
border: 1px solid #dbe3f0;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
overflow: auto;
|
||||
font-family: Consolas, 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.mapping-json-text {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<div v-if="!node.children" class="json-tree-line" :style="indentStyle">
|
||||
<span class="json-tree-spacer" />
|
||||
<template v-if="node.keyName !== undefined">
|
||||
<span class="json-tree-key">{{ JSON.stringify(node.keyName) }}</span>
|
||||
<span class="json-tree-colon">: </span>
|
||||
</template>
|
||||
<span :class="['json-tree-value', `is-${node.valueType}`]">{{ node.valueText }}</span>
|
||||
<span v-if="!isLast" class="json-tree-comma">,</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="json-tree-node">
|
||||
<div class="json-tree-line is-container" :style="indentStyle">
|
||||
<button class="json-tree-toggle" type="button" :title="isExpanded ? '收起' : '展开'" @click="emit('toggle', node.id)">
|
||||
{{ isExpanded ? '⌄' : '›' }}
|
||||
</button>
|
||||
<template v-if="node.keyName !== undefined">
|
||||
<span class="json-tree-key">{{ JSON.stringify(node.keyName) }}</span>
|
||||
<span class="json-tree-colon">: </span>
|
||||
</template>
|
||||
<span class="json-tree-token">{{ node.openToken }}</span>
|
||||
<span v-if="!isExpanded" class="json-tree-ellipsis"> ... </span>
|
||||
<span v-if="!isExpanded" class="json-tree-token">{{ node.closeToken }}</span>
|
||||
<span class="json-tree-summary">{{ node.summary }}</span>
|
||||
<span v-if="!isExpanded && !isLast" class="json-tree-comma">,</span>
|
||||
</div>
|
||||
|
||||
<template v-if="isExpanded">
|
||||
<JsonMappingTreeNode
|
||||
v-for="(child, index) in node.children"
|
||||
:key="child.id"
|
||||
:node="child"
|
||||
:depth="depth + 1"
|
||||
:is-last="index === node.children.length - 1"
|
||||
:expanded-keys="expandedKeys"
|
||||
@toggle="emit('toggle', $event)"
|
||||
/>
|
||||
<div class="json-tree-line is-close" :style="indentStyle">
|
||||
<span class="json-tree-spacer" />
|
||||
<span class="json-tree-token">{{ node.closeToken }}</span>
|
||||
<span v-if="!isLast" class="json-tree-comma">,</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'JsonMappingTreeNode'
|
||||
})
|
||||
|
||||
export type JsonValueType = 'string' | 'number' | 'boolean' | 'null'
|
||||
|
||||
export interface JsonTreeNodeModel {
|
||||
id: string
|
||||
keyName?: string
|
||||
openToken?: '{' | '['
|
||||
closeToken?: '}' | ']'
|
||||
summary?: string
|
||||
children?: JsonTreeNodeModel[]
|
||||
valueText?: string
|
||||
valueType?: JsonValueType
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
node: JsonTreeNodeModel
|
||||
depth: number
|
||||
isLast: boolean
|
||||
expandedKeys: Set<string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'toggle', id: string): void
|
||||
}>()
|
||||
|
||||
const isExpanded = computed(() => props.expandedKeys.has(props.node.id))
|
||||
const indentStyle = computed(() => ({
|
||||
paddingLeft: `${props.depth * 18}px`
|
||||
}))
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.json-tree-line {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
min-height: 24px;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.json-tree-line.is-container {
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.json-tree-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 18px;
|
||||
width: 18px;
|
||||
height: 22px;
|
||||
margin: 0 2px 0 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.json-tree-toggle:hover {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.json-tree-spacer {
|
||||
flex: 0 0 20px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.json-tree-key {
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.json-tree-colon,
|
||||
.json-tree-comma,
|
||||
.json-tree-token {
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.json-tree-ellipsis,
|
||||
.json-tree-summary {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.json-tree-value.is-string {
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.json-tree-value.is-number {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.json-tree-value.is-boolean {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.json-tree-value.is-null {
|
||||
color: #64748b;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,615 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
title="人工索引配置"
|
||||
width="960px"
|
||||
destroy-on-close
|
||||
top="6vh"
|
||||
class="mapping-confirm-dialog"
|
||||
@close="emit('update:visible', false)"
|
||||
>
|
||||
<div class="dialog-description">
|
||||
这里展示 ICD 候选索引的人工确认结果。请按分组确认每个标签是否启用,并为已启用标签选择合法的
|
||||
lnInst,确认后会自动回填到索引配置。
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!draftGroups.length" description="当前没有可确认的索引分组。" />
|
||||
|
||||
<template v-else>
|
||||
<div class="dialog-search-bar">
|
||||
<el-input
|
||||
v-model="indexSearchKeyword"
|
||||
:prefix-icon="Search"
|
||||
clearable
|
||||
placeholder="按分组、标签、目标报告、数据集或 lnInst 检索"
|
||||
/>
|
||||
<span class="dialog-search-count">{{ filteredLabelCount }} / {{ totalLabelCount }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredDraftGroups.length" class="dialog-content">
|
||||
<section v-for="group in filteredDraftGroups" :key="group.groupKey" class="group-card">
|
||||
<div class="group-header">
|
||||
<div>
|
||||
<h3 class="group-title">{{ group.groupDesc || group.groupKey }}</h3>
|
||||
<p class="group-key">{{ group.groupKey }}</p>
|
||||
</div>
|
||||
<el-tag type="info" effect="light">{{ group.labelItems.length }} 个标签</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="label-list">
|
||||
<article v-for="item in group.labelItems" :key="item.itemKey" class="label-card">
|
||||
<div class="label-main">
|
||||
<div class="label-meta">
|
||||
<div class="label-title-row">
|
||||
<span class="label-title">{{ item.label }}</span>
|
||||
<el-tag v-if="item.required" type="danger" effect="light" size="small">必选</el-tag>
|
||||
<el-tag v-if="item.configurableOnce" type="success" effect="light" size="small">
|
||||
共享 lnInst
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="label-options">
|
||||
<span class="label-hint">共同可选值</span>
|
||||
<template v-if="item.commonLnInstValues.length">
|
||||
<el-tag
|
||||
v-for="value in item.commonLnInstValues"
|
||||
:key="`${item.label}-${value}`"
|
||||
size="small"
|
||||
effect="plain"
|
||||
>
|
||||
{{ value }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<span v-else class="label-hint">当前没有共同 lnInst</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="label-actions">
|
||||
<el-switch
|
||||
v-model="item.enabled"
|
||||
:disabled="item.required"
|
||||
inline-prompt
|
||||
active-text="启用"
|
||||
inactive-text="停用"
|
||||
/>
|
||||
<el-select
|
||||
v-model="item.lnInst"
|
||||
class="lninst-select"
|
||||
placeholder="请选择 lnInst"
|
||||
clearable
|
||||
:disabled="!item.enabled || !item.commonLnInstValues.length"
|
||||
>
|
||||
<el-option
|
||||
v-for="value in item.commonLnInstValues"
|
||||
:key="`${item.label}-option-${value}`"
|
||||
:label="value"
|
||||
:value="value"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="item.enabled && !item.lnInst"
|
||||
title="已启用的标签必须选择 lnInst"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
class="label-alert"
|
||||
/>
|
||||
|
||||
<div class="target-list">
|
||||
<div v-for="target in item.targets" :key="target.targetKey" class="target-item">
|
||||
<div class="target-name-row">
|
||||
<span class="target-name">{{ target.reportDesc || target.reportName || '--' }}</span>
|
||||
<span class="target-code">
|
||||
{{ target.reportName || '--' }} / {{ target.dataSetName || '--' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="target-lninst-row">
|
||||
<span class="label-hint">目标报告可选值</span>
|
||||
<template v-if="target.availableLnInstValues.length">
|
||||
<el-tag
|
||||
v-for="value in target.availableLnInstValues"
|
||||
:key="`${target.reportName}-${target.dataSetName}-${value}`"
|
||||
size="small"
|
||||
effect="plain"
|
||||
>
|
||||
{{ value }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<span v-else class="label-hint">当前没有可用 lnInst</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<el-empty v-else description="当前检索条件下没有匹配的索引配置。" />
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<div v-if="validationMessage" class="footer-message">{{ validationMessage }}</div>
|
||||
<div class="footer-actions">
|
||||
<el-button :disabled="submitting" @click="emit('update:visible', false)">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" :disabled="Boolean(validationMessage)" @click="handleConfirm">
|
||||
确认并生成索引配置
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { MmsMapping } from '@/api/tools/mmsmapping/interface'
|
||||
|
||||
defineOptions({
|
||||
name: 'MappingConfirmDialog'
|
||||
})
|
||||
|
||||
interface ConfirmDialogDraftTarget {
|
||||
targetKey: string
|
||||
reportName: string
|
||||
dataSetName: string
|
||||
reportDesc: string
|
||||
availableLnInstValues: string[]
|
||||
}
|
||||
|
||||
interface ConfirmDialogDraftLabelItem {
|
||||
itemKey: string
|
||||
label: string
|
||||
required: boolean
|
||||
configurableOnce: boolean
|
||||
enabled: boolean
|
||||
lnInst: string
|
||||
commonLnInstValues: string[]
|
||||
targets: ConfirmDialogDraftTarget[]
|
||||
}
|
||||
|
||||
interface ConfirmDialogDraftGroup {
|
||||
groupKey: string
|
||||
groupDesc: string
|
||||
labelItems: ConfirmDialogDraftLabelItem[]
|
||||
}
|
||||
|
||||
interface PreparedConfirmDialogDraftLabelItem {
|
||||
itemKey: string
|
||||
label: string
|
||||
required: boolean
|
||||
configurableOnce: boolean
|
||||
defaultLnInst: string
|
||||
commonLnInstValues: string[]
|
||||
targets: ConfirmDialogDraftTarget[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
submitting: boolean
|
||||
confirmData: MmsMapping.IndexConfirmGroup[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:visible', value: boolean): void
|
||||
(event: 'confirm', value: MmsMapping.ConfirmedIndexGroup[]): void
|
||||
}>()
|
||||
|
||||
const normalizeStringArray = (values?: string[]) => (values || []).map(value => value?.trim() || '').filter(Boolean)
|
||||
|
||||
const sortLnInstValues = (values: string[]) =>
|
||||
[...values].sort((left, right) => {
|
||||
const leftNumber = Number(left)
|
||||
const rightNumber = Number(right)
|
||||
const bothNumeric = !Number.isNaN(leftNumber) && !Number.isNaN(rightNumber)
|
||||
|
||||
if (bothNumeric && leftNumber !== rightNumber) {
|
||||
return leftNumber - rightNumber
|
||||
}
|
||||
|
||||
return left.localeCompare(right, 'zh-CN', { numeric: true })
|
||||
})
|
||||
|
||||
const buildLnInstCluster = (items: PreparedConfirmDialogDraftLabelItem[]) => {
|
||||
const clusters: PreparedConfirmDialogDraftLabelItem[][] = []
|
||||
|
||||
items.forEach(item => {
|
||||
const itemValueSet = new Set(item.commonLnInstValues)
|
||||
const matchedCluster = clusters.find(cluster =>
|
||||
cluster.some(clusterItem => clusterItem.commonLnInstValues.some(value => itemValueSet.has(value)))
|
||||
)
|
||||
|
||||
if (matchedCluster) {
|
||||
matchedCluster.push(item)
|
||||
return
|
||||
}
|
||||
|
||||
clusters.push([item])
|
||||
})
|
||||
|
||||
return clusters
|
||||
}
|
||||
|
||||
const resolveDefaultLnInst = (commonLnInstValues: string[], expectedLnInst: string) => {
|
||||
if (!commonLnInstValues.length) return ''
|
||||
if (!expectedLnInst) return ''
|
||||
if (!commonLnInstValues.includes(expectedLnInst)) return ''
|
||||
return expectedLnInst
|
||||
}
|
||||
|
||||
const buildInitialDraftGroups = (groups: MmsMapping.IndexConfirmGroup[]): ConfirmDialogDraftGroup[] =>
|
||||
groups
|
||||
.map(group => {
|
||||
const preparedItems = (group.labelItems || [])
|
||||
.map<PreparedConfirmDialogDraftLabelItem | null>((item, itemIndex) => {
|
||||
const commonLnInstValues = sortLnInstValues(normalizeStringArray(item.commonLnInstValues))
|
||||
const defaultLnInst = resolveDefaultLnInst(commonLnInstValues, item.defaultLnInst?.trim() || '')
|
||||
const itemKey = `${group.groupKey?.trim() || 'group'}-${itemIndex}-${item.label?.trim() || 'label'}`
|
||||
|
||||
return {
|
||||
itemKey,
|
||||
label: item.label?.trim() || '',
|
||||
required: Boolean(item.required),
|
||||
configurableOnce: Boolean(item.configurableOnce),
|
||||
defaultLnInst,
|
||||
commonLnInstValues,
|
||||
targets: (item.targets || []).map((target, targetIndex) => ({
|
||||
targetKey: `${itemKey}-target-${targetIndex}-${target.reportName?.trim() || ''}-${target.dataSetName?.trim() || ''}`,
|
||||
reportName: target.reportName?.trim() || '',
|
||||
dataSetName: target.dataSetName?.trim() || '',
|
||||
reportDesc: target.reportDesc?.trim() || '',
|
||||
availableLnInstValues: sortLnInstValues(normalizeStringArray(target.availableLnInstValues))
|
||||
}))
|
||||
}
|
||||
})
|
||||
.filter((item): item is PreparedConfirmDialogDraftLabelItem => Boolean(item?.label))
|
||||
|
||||
const clusters = buildLnInstCluster(preparedItems)
|
||||
const defaultStateMap = new Map<
|
||||
string,
|
||||
{
|
||||
enabled: boolean
|
||||
lnInst: string
|
||||
}
|
||||
>()
|
||||
|
||||
clusters.forEach(cluster => {
|
||||
const clusterValues = sortLnInstValues(
|
||||
Array.from(new Set(cluster.flatMap(item => item.commonLnInstValues)))
|
||||
)
|
||||
|
||||
cluster.forEach((item, index) => {
|
||||
const expectedLnInst = index < clusterValues.length ? clusterValues[index] : ''
|
||||
const defaultLnInst = item.defaultLnInst || resolveDefaultLnInst(item.commonLnInstValues, expectedLnInst)
|
||||
const enabled = item.required || Boolean(defaultLnInst)
|
||||
|
||||
defaultStateMap.set(item.itemKey, {
|
||||
enabled,
|
||||
lnInst: defaultLnInst
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
groupKey: group.groupKey?.trim() || '',
|
||||
groupDesc: group.groupDesc?.trim() || '',
|
||||
labelItems: preparedItems.map(item => {
|
||||
const defaultState = defaultStateMap.get(item.itemKey) || {
|
||||
enabled: item.required,
|
||||
lnInst: ''
|
||||
}
|
||||
|
||||
return {
|
||||
itemKey: item.itemKey,
|
||||
label: item.label,
|
||||
required: item.required,
|
||||
configurableOnce: item.configurableOnce,
|
||||
enabled: defaultState.enabled,
|
||||
lnInst: defaultState.lnInst,
|
||||
commonLnInstValues: item.commonLnInstValues,
|
||||
targets: item.targets
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
.filter(group => group.groupKey)
|
||||
|
||||
const draftGroups = ref<ConfirmDialogDraftGroup[]>([])
|
||||
const indexSearchKeyword = ref('')
|
||||
|
||||
const totalLabelCount = computed(() => draftGroups.value.reduce((total, group) => total + group.labelItems.length, 0))
|
||||
|
||||
const normalizedIndexSearchKeyword = computed(() => indexSearchKeyword.value.trim().toLowerCase())
|
||||
|
||||
const matchText = (values: string[], keyword: string) => values.some(value => value.toLowerCase().includes(keyword))
|
||||
|
||||
const isLabelItemMatched = (item: ConfirmDialogDraftLabelItem, keyword: string) =>
|
||||
matchText([item.label, item.lnInst, ...item.commonLnInstValues], keyword) ||
|
||||
item.targets.some(target =>
|
||||
matchText(
|
||||
[target.reportDesc, target.reportName, target.dataSetName, ...target.availableLnInstValues],
|
||||
keyword
|
||||
)
|
||||
)
|
||||
|
||||
const filteredDraftGroups = computed<ConfirmDialogDraftGroup[]>(() => {
|
||||
const keyword = normalizedIndexSearchKeyword.value
|
||||
|
||||
if (!keyword) return draftGroups.value
|
||||
|
||||
return draftGroups.value
|
||||
.map(group => {
|
||||
const groupMatched = matchText([group.groupDesc, group.groupKey], keyword)
|
||||
|
||||
return {
|
||||
...group,
|
||||
labelItems: groupMatched
|
||||
? group.labelItems
|
||||
: group.labelItems.filter(item => isLabelItemMatched(item, keyword))
|
||||
}
|
||||
})
|
||||
.filter(group => group.labelItems.length)
|
||||
})
|
||||
|
||||
const filteredLabelCount = computed(() =>
|
||||
filteredDraftGroups.value.reduce((total, group) => total + group.labelItems.length, 0)
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [props.confirmData, props.visible] as const,
|
||||
([confirmData, visible]) => {
|
||||
if (!visible) return
|
||||
// 关键业务节点:弹窗每次打开都基于最新 confirmData 重新生成草稿,避免不同 ICD 的确认状态串用。
|
||||
draftGroups.value = buildInitialDraftGroups(confirmData)
|
||||
indexSearchKeyword.value = ''
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const validationMessage = computed(() => {
|
||||
for (const group of draftGroups.value) {
|
||||
for (const item of group.labelItems) {
|
||||
if (!item.enabled) continue
|
||||
if (!item.lnInst) return `分组“${group.groupDesc || group.groupKey}”中的标签“${item.label}”必须选择 lnInst`
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
|
||||
const buildConfirmedGroups = (): MmsMapping.ConfirmedIndexGroup[] =>
|
||||
draftGroups.value.map(group => ({
|
||||
groupKey: group.groupKey,
|
||||
labelItems: group.labelItems.map(item => ({
|
||||
label: item.label,
|
||||
enabled: item.enabled,
|
||||
lnInst: item.enabled ? item.lnInst : ''
|
||||
}))
|
||||
}))
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (validationMessage.value) return
|
||||
emit('confirm', buildConfirmedGroups())
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dialog-description {
|
||||
margin-bottom: 16px;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.dialog-search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.dialog-search-count {
|
||||
flex: 0 0 auto;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #64748b;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-height: 68vh;
|
||||
padding-right: 4px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.group-card {
|
||||
padding: 20px;
|
||||
border: 1px solid #dbe3f0;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.group-key {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: #64748b;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.label-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.label-card {
|
||||
padding: 16px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.label-main {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.label-meta,
|
||||
.label-actions,
|
||||
.target-list {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.label-meta {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.label-title-row,
|
||||
.label-options,
|
||||
.target-lninst-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.label-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.label-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.lninst-select {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.label-hint {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.label-alert {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.target-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.target-item {
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.target-name-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.target-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.6;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.target-code {
|
||||
font-family: Consolas, 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: #64748b;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.footer-message {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #d97706;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.footer-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dialog-content {
|
||||
max-height: 62vh;
|
||||
}
|
||||
|
||||
.group-card,
|
||||
.label-card {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.group-header,
|
||||
.label-main,
|
||||
.dialog-search-bar,
|
||||
.dialog-footer {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.label-actions,
|
||||
.footer-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.lninst-select {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,43 +1,680 @@
|
||||
<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"
|
||||
:is-parsing="isParsing"
|
||||
:icd-file-accept="icdFileAccept"
|
||||
:request-status-text="requestStatusText"
|
||||
:request-status-tag-type="requestStatusTagType"
|
||||
:can-parse="canParseIcd"
|
||||
:can-reset="canResetPage"
|
||||
@file-change="handleIcdFileChange"
|
||||
@parse="handleParseIcd"
|
||||
@reset="resetPage"
|
||||
/>
|
||||
|
||||
<MappingConfigPanel
|
||||
v-if="showConfigPanel"
|
||||
v-model:index-selection-json="indexSelectionJsonText"
|
||||
:is-submitting="isSubmitting"
|
||||
:is-generating="isGenerating"
|
||||
:can-generate="canGenerate"
|
||||
:json-error="indexSelectionError"
|
||||
:show-generate-button="showGenerateButton"
|
||||
:show-confirm-button="Boolean(confirmData.length)"
|
||||
confirm-button-text="人工索引配置"
|
||||
:can-confirm="!isSubmitting"
|
||||
:has-default-json="Boolean(indexSelectionJsonText.trim())"
|
||||
:empty-description="configEmptyDescription"
|
||||
@confirm-config="confirmDialogVisible = true"
|
||||
@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"
|
||||
:xml-meta-text="xmlMetaText"
|
||||
:xml-mapping-preview="xmlMappingPreview"
|
||||
:xml-empty-text="xmlEmptyText"
|
||||
:problem-tab-label="problemTabLabel"
|
||||
:problem-list="problemList"
|
||||
:problem-empty-text="problemEmptyText"
|
||||
:method-describe="methodDescribe"
|
||||
:can-export-json-mapping="canExportJsonMapping"
|
||||
:can-export-xml-mapping="canExportXmlMapping"
|
||||
:can-generate-xml-mapping="canGenerateXmlMapping"
|
||||
:is-generating-xml="isGeneratingXml"
|
||||
:show-xml-mapping-tab="showXmlMappingTab"
|
||||
@export-mapping="handleExportMapping"
|
||||
@generate-xml-mapping="handleGenerateXmlMapping"
|
||||
@update-mapping-json="handleUpdateMappingJson"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MappingConfirmDialog
|
||||
:visible="confirmDialogVisible"
|
||||
:submitting="isConfirmingSelection"
|
||||
:confirm-data="confirmData"
|
||||
@update:visible="confirmDialogVisible = $event"
|
||||
@confirm="handleConfirmIndexSelection"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { ResultData } from '@/api/interface'
|
||||
import {
|
||||
buildIndexConfirmDataApi,
|
||||
buildIndexSelectionApi,
|
||||
getIcdApi,
|
||||
getIcdMmsJsonApi,
|
||||
getXmlFromJsonApi
|
||||
} 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 MappingConfirmDialog from './components/MappingConfirmDialog.vue'
|
||||
import { formatIndexSelectionJson, parseIndexSelectionJson } from './utils/indexSelection'
|
||||
import { createBaseRequestPayload } from './utils/requestPayload'
|
||||
|
||||
defineOptions({
|
||||
name: 'MmsMappingView'
|
||||
})
|
||||
|
||||
type ResultTab = 'json' | 'xml' | 'problem'
|
||||
type TagType = 'success' | 'warning' | 'info' | 'primary' | 'danger'
|
||||
type ExportMappingType = 'json' | 'xml'
|
||||
|
||||
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 xmlResponsePayload = ref<MmsMapping.MappingTaskResponse | null>(null)
|
||||
const activeResultTab = ref<ResultTab>('json')
|
||||
const parsedCandidates = ref<MmsMapping.IndexCandidateGroup[]>([])
|
||||
const confirmData = ref<MmsMapping.IndexConfirmGroup[]>([])
|
||||
const indexSelectionJsonText = ref('')
|
||||
const confirmDialogVisible = ref(false)
|
||||
const isParsing = ref(false)
|
||||
const isConfirmingSelection = ref(false)
|
||||
const isGenerating = ref(false)
|
||||
const isGeneratingXml = 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 && error.message) return error.message
|
||||
return '接口调用失败,请检查后端服务和请求参数'
|
||||
}
|
||||
|
||||
const logConfirmDataDiagnostics = (groups: MmsMapping.IndexConfirmGroup[]) => {
|
||||
const interharmonicGroups = groups
|
||||
.map(group => ({
|
||||
groupKey: group.groupKey?.trim() || '',
|
||||
groupDesc: group.groupDesc?.trim() || '',
|
||||
labelItems: (group.labelItems || [])
|
||||
.map(item => ({
|
||||
label: item.label?.trim() || '',
|
||||
defaultLnInst: item.defaultLnInst?.trim() || '',
|
||||
commonLnInstValues: item.commonLnInstValues || [],
|
||||
targets: (item.targets || []).map(target => ({
|
||||
reportName: target.reportName?.trim() || '',
|
||||
dataSetName: target.dataSetName?.trim() || '',
|
||||
availableLnInstValues: target.availableLnInstValues || []
|
||||
}))
|
||||
}))
|
||||
.filter(item =>
|
||||
[item.label, ...item.commonLnInstValues, ...item.targets.map(target => target.dataSetName)].some(
|
||||
value => String(value || '').includes('间谐波')
|
||||
)
|
||||
)
|
||||
}))
|
||||
.filter(group => group.labelItems.length)
|
||||
|
||||
// 关键业务节点:人工确认弹窗是否缺少某个 lnInst,首先取决于 build-index-confirm-data 的返回内容,这里保留诊断日志便于核对接口是否漏数。
|
||||
console.info('[mmsMapping] build-index-confirm-data result', {
|
||||
groupCount: groups.length,
|
||||
interharmonicGroups
|
||||
})
|
||||
|
||||
const missingFiveItems = interharmonicGroups.flatMap(group =>
|
||||
group.labelItems
|
||||
.filter(item => item.commonLnInstValues.length && !item.commonLnInstValues.includes('5'))
|
||||
.map(item => ({
|
||||
groupKey: group.groupKey,
|
||||
label: item.label,
|
||||
defaultLnInst: item.defaultLnInst,
|
||||
commonLnInstValues: item.commonLnInstValues
|
||||
}))
|
||||
)
|
||||
|
||||
if (missingFiveItems.length) {
|
||||
console.warn('[mmsMapping] interharmonic lnInst missing "5"', missingFiveItems)
|
||||
}
|
||||
}
|
||||
|
||||
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: ''
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
value: parseIndexSelectionJson(source),
|
||||
error: ''
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
value: [] as MmsMapping.IndexSelectionGroup[],
|
||||
error: getErrorMessage(error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const isSubmitting = computed(
|
||||
() => isParsing.value || isConfirmingSelection.value || isGenerating.value || isGeneratingXml.value
|
||||
)
|
||||
const canResetPage = computed(() =>
|
||||
Boolean(
|
||||
selectedIcdFile.value ||
|
||||
responsePayload.value ||
|
||||
xmlResponsePayload.value ||
|
||||
confirmData.value.length ||
|
||||
indexSelectionJsonText.value.trim()
|
||||
)
|
||||
)
|
||||
const indexSelectionError = computed(() => parsedIndexSelectionState.value.error)
|
||||
const canParseIcd = computed(() => Boolean(selectedIcdFile.value && !isSubmitting.value))
|
||||
const canGenerate = computed(() =>
|
||||
Boolean(selectedIcdFile.value && indexSelectionJsonText.value.trim() && !indexSelectionError.value && !isSubmitting.value)
|
||||
)
|
||||
// 关键业务节点:请求配置区只在用户已经选择 ICD 后展示,避免初始态暴露无效的请求编辑区。
|
||||
const showConfigPanel = computed(() => Boolean(selectedIcdFile.value))
|
||||
const showGenerateButton = computed(() => Boolean(selectedIcdFile.value))
|
||||
const selectedIcdFileName = computed(() => selectedIcdFile.value?.name || '')
|
||||
|
||||
const configEmptyDescription = computed(() => {
|
||||
if (isParsing.value) return '正在获取 ICD 候选数据并准备人工确认,请稍候。'
|
||||
if (isConfirmingSelection.value) return '正在根据人工索引配置生成索引配置,请稍候。'
|
||||
if (confirmDialogVisible.value || confirmData.value.length) {
|
||||
return '请先在弹窗中完成人工索引配置,确认后会自动回填索引配置。'
|
||||
}
|
||||
if (selectedIcdFile.value) return '已选择 ICD 文件,请先点击“解析 ICD”进入人工确认流程。'
|
||||
return '当前 ICD 暂未生成可编辑的索引配置。'
|
||||
})
|
||||
|
||||
const requestStatusText = computed(() => {
|
||||
if (isParsing.value) return '解析中'
|
||||
if (isConfirmingSelection.value) return '确认中'
|
||||
if (isGeneratingXml.value) return 'XML转换中'
|
||||
if (isGenerating.value) return '生成中'
|
||||
if (confirmDialogVisible.value) return '待人工确认'
|
||||
if (selectedIcdFile.value && indexSelectionJsonText.value.trim()) return '已确认'
|
||||
if (selectedIcdFile.value && parsedCandidates.value.length) return '待确认'
|
||||
if (selectedIcdFile.value) return '待解析'
|
||||
return '未选择文件'
|
||||
})
|
||||
|
||||
const requestStatusTagType = computed<TagType>(() => {
|
||||
if (isParsing.value || isConfirmingSelection.value || isGenerating.value || isGeneratingXml.value) return 'warning'
|
||||
if (confirmDialogVisible.value) return 'primary'
|
||||
if (selectedIcdFile.value && indexSelectionJsonText.value.trim()) return 'success'
|
||||
if (selectedIcdFile.value) return 'primary'
|
||||
return 'info'
|
||||
})
|
||||
|
||||
const responseStatusText = computed(() => {
|
||||
if (isGenerating.value) return 'JSON生成中'
|
||||
if (isGeneratingXml.value) return 'XML生成中'
|
||||
if (isParsing.value || isConfirmingSelection.value) return '处理中'
|
||||
if (xmlResponsePayload.value?.status === 'FAILED') return 'XML失败'
|
||||
if (responsePayload.value?.status === 'FAILED') return '失败'
|
||||
if (responsePayload.value?.status === 'NEED_INDEX_SELECTION') return '待配置'
|
||||
if (xmlResponsePayload.value) return 'XML已生成'
|
||||
if (mappingJsonPreview.value) return 'JSON已生成'
|
||||
if (responsePayload.value) return '已解析'
|
||||
return '未生成'
|
||||
})
|
||||
|
||||
const responseStatusTagType = computed<TagType>(() => {
|
||||
if (isSubmitting.value) return 'warning'
|
||||
if (xmlResponsePayload.value?.status === 'FAILED') return 'danger'
|
||||
if (responsePayload.value?.status === 'FAILED') return 'danger'
|
||||
if (responsePayload.value?.status === 'NEED_INDEX_SELECTION') return 'warning'
|
||||
if (xmlResponsePayload.value || 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} 字符`
|
||||
})
|
||||
|
||||
// 关键业务节点:getXmlFromJson 标准响应把 XML 文本放在 xmlFile.content,旧字段仅保留为兼容兜底。
|
||||
const xmlContentForExport = computed(
|
||||
() =>
|
||||
xmlResponsePayload.value?.xmlFile?.content?.trim() ||
|
||||
xmlResponsePayload.value?.mappingXml?.trim() ||
|
||||
xmlResponsePayload.value?.xmlContent?.trim() ||
|
||||
xmlResponsePayload.value?.xmlText?.trim() ||
|
||||
''
|
||||
)
|
||||
const canExportJsonMapping = computed(() => Boolean(mappingJsonPreview.value && !isSubmitting.value))
|
||||
const canExportXmlMapping = computed(() => Boolean(xmlContentForExport.value && !isSubmitting.value))
|
||||
const canGenerateXmlMapping = computed(() => Boolean(mappingJsonPreview.value && !isSubmitting.value))
|
||||
const showXmlMappingTab = computed(() =>
|
||||
Boolean(xmlResponsePayload.value && xmlResponsePayload.value.status !== 'FAILED')
|
||||
)
|
||||
|
||||
const xmlMappingPreview = computed(() => {
|
||||
const source = xmlContentForExport.value
|
||||
|
||||
if (source) return source
|
||||
|
||||
const savedPath = xmlResponsePayload.value?.savedPath?.trim()
|
||||
if (savedPath) return `XML 文件已生成:\n${savedPath}`
|
||||
|
||||
return ''
|
||||
})
|
||||
|
||||
const xmlMetaText = computed(() => {
|
||||
const xmlFile = xmlResponsePayload.value?.xmlFile
|
||||
|
||||
if (xmlFile?.fileName) {
|
||||
const encoding = xmlFile.encoding?.trim()
|
||||
const contentType = xmlFile.contentType?.trim()
|
||||
const suffixParts = [encoding, contentType].filter(Boolean)
|
||||
|
||||
return suffixParts.length ? `${xmlFile.fileName}(${suffixParts.join(',')})` : xmlFile.fileName
|
||||
}
|
||||
|
||||
if (xmlResponsePayload.value?.savedPath) return `XML 文件路径:${xmlResponsePayload.value.savedPath}`
|
||||
if (xmlMappingPreview.value) return `XML映射 ${xmlMappingPreview.value.length} 字符`
|
||||
return '当前未生成 XML 映射'
|
||||
})
|
||||
|
||||
const xmlEmptyText = computed(() => {
|
||||
if (isGeneratingXml.value) return '正在根据 JSON 映射生成 XML 映射'
|
||||
if (xmlResponsePayload.value && xmlResponsePayload.value.status !== 'FAILED') {
|
||||
return '当前接口返回未包含 xmlFile.content'
|
||||
}
|
||||
if (mappingJsonPreview.value) return '当前接口返回未包含 XML 内容或文件路径'
|
||||
return '请先生成 JSON 映射'
|
||||
})
|
||||
|
||||
const problemList = computed(() => [
|
||||
...(responsePayload.value?.problems?.filter(Boolean) || []),
|
||||
...(xmlResponsePayload.value?.problems?.filter(Boolean) || [])
|
||||
])
|
||||
|
||||
const methodDescribe = computed(() => xmlResponsePayload.value?.methodDescribe?.trim() || '')
|
||||
|
||||
const problemTabLabel = computed(() => {
|
||||
if (!problemList.value.length) return '问题列表'
|
||||
return `问题列表(${problemList.value.length})`
|
||||
})
|
||||
|
||||
const resolveResultTab = (payload: MmsMapping.MappingTaskResponse | null): ResultTab => {
|
||||
if (payload?.mappingJson?.trim()) return 'json'
|
||||
if (payload?.problems?.filter(Boolean).length) return 'problem'
|
||||
return 'json'
|
||||
}
|
||||
|
||||
const handleGenerateXmlMapping = async () => {
|
||||
const mappingJson = responsePayload.value?.mappingJson?.trim()
|
||||
|
||||
if (!mappingJson) {
|
||||
ElMessage.warning('请先生成 JSON 映射')
|
||||
return
|
||||
}
|
||||
|
||||
isGeneratingXml.value = true
|
||||
activeResultTab.value = 'json'
|
||||
xmlResponsePayload.value = null
|
||||
|
||||
try {
|
||||
// 关键业务节点:XML 映射依赖本次接口返回的完整 mappingJson,避免使用旧结果生成不一致的 XML 文件。
|
||||
const response = await getXmlFromJsonApi({
|
||||
request: {
|
||||
mappingJson
|
||||
}
|
||||
})
|
||||
const payload = unwrapApiPayload<MmsMapping.MappingTaskResponse>(response)
|
||||
|
||||
xmlResponsePayload.value = payload
|
||||
|
||||
if (payload.status === 'FAILED') {
|
||||
ElMessage.warning(payload.message || 'XML 映射生成失败')
|
||||
activeResultTab.value = payload.problems?.filter(Boolean).length ? 'problem' : 'json'
|
||||
return
|
||||
}
|
||||
|
||||
activeResultTab.value = 'xml'
|
||||
ElMessage.success(payload.message || 'XML 映射生成完成')
|
||||
} catch (error) {
|
||||
xmlResponsePayload.value = {
|
||||
status: 'FAILED',
|
||||
message: getErrorMessage(error),
|
||||
problems: [getErrorMessage(error)]
|
||||
}
|
||||
activeResultTab.value = 'problem'
|
||||
ElMessage.warning(getErrorMessage(error))
|
||||
} finally {
|
||||
isGeneratingXml.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const stripProblemsFromIcdPayload = (payload: MmsMapping.MappingTaskResponse): MmsMapping.MappingTaskResponse => {
|
||||
// 关键业务节点:解析 ICD 阶段只消费候选数据和文档结构,不把后端问题列表直接绑定到结果区。
|
||||
const sanitizedPayload = { ...payload }
|
||||
|
||||
delete sanitizedPayload.problems
|
||||
|
||||
return sanitizedPayload
|
||||
}
|
||||
|
||||
const resetParsedState = () => {
|
||||
responsePayload.value = null
|
||||
xmlResponsePayload.value = null
|
||||
parsedCandidates.value = []
|
||||
confirmData.value = []
|
||||
indexSelectionJsonText.value = ''
|
||||
confirmDialogVisible.value = false
|
||||
activeResultTab.value = 'json'
|
||||
}
|
||||
|
||||
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 文件后立即清空旧确认结果和旧请求配置,避免不同文件的索引配置串用。
|
||||
selectedIcdFile.value = file
|
||||
resetParsedState()
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
const handleParseIcd = async () => {
|
||||
if (!selectedIcdFile.value) {
|
||||
ElMessage.warning('请先选择 ICD 文件')
|
||||
return
|
||||
}
|
||||
|
||||
isParsing.value = true
|
||||
responsePayload.value = null
|
||||
xmlResponsePayload.value = null
|
||||
confirmDialogVisible.value = false
|
||||
confirmData.value = []
|
||||
indexSelectionJsonText.value = ''
|
||||
|
||||
try {
|
||||
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 = []
|
||||
ElMessage.error(payload.message || 'ICD 解析失败')
|
||||
return
|
||||
}
|
||||
|
||||
parsedCandidates.value = candidateGroups
|
||||
|
||||
// 关键业务节点:拿到 ICD 候选结果后必须先走 buildIndexConfirmData,生成人工确认弹窗所需模型。
|
||||
const confirmResponse = await buildIndexConfirmDataApi(candidateGroups)
|
||||
const confirmGroups = unwrapApiPayload<MmsMapping.IndexConfirmGroup[]>(confirmResponse) || []
|
||||
|
||||
confirmData.value = confirmGroups
|
||||
logConfirmDataDiagnostics(confirmGroups)
|
||||
|
||||
if (!confirmGroups.length) {
|
||||
indexSelectionJsonText.value = formatIndexSelectionJson([])
|
||||
ElMessage.success(payload.message || 'ICD 解析完成,当前没有待确认的索引配置')
|
||||
return
|
||||
}
|
||||
|
||||
confirmDialogVisible.value = true
|
||||
ElMessage.success(payload.message || 'ICD 解析完成,请在弹窗中完成人工确认')
|
||||
} catch (error) {
|
||||
resetParsedState()
|
||||
ElMessage.error(getErrorMessage(error))
|
||||
} finally {
|
||||
isParsing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirmIndexSelection = async (confirmedData: MmsMapping.ConfirmedIndexGroup[]) => {
|
||||
if (!confirmData.value.length) {
|
||||
ElMessage.warning('当前没有可确认的索引配置')
|
||||
return
|
||||
}
|
||||
|
||||
isConfirmingSelection.value = true
|
||||
|
||||
try {
|
||||
const response = await buildIndexSelectionApi({
|
||||
confirmData: confirmData.value,
|
||||
confirmedData
|
||||
})
|
||||
const indexSelection = unwrapApiPayload<MmsMapping.IndexSelectionGroup[]>(response) || []
|
||||
|
||||
// 关键业务节点:只有 buildIndexSelection 返回的正式结果才能进入请求配置区,避免前端自行拼装绑定关系。
|
||||
indexSelectionJsonText.value = formatIndexSelectionJson(indexSelection)
|
||||
confirmDialogVisible.value = false
|
||||
ElMessage.success('人工索引配置完成,已回填索引配置')
|
||||
} catch (error) {
|
||||
ElMessage.error(getErrorMessage(error))
|
||||
} finally {
|
||||
isConfirmingSelection.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerateMapping = async () => {
|
||||
if (!selectedIcdFile.value) {
|
||||
ElMessage.warning('请先选择 ICD 文件')
|
||||
return
|
||||
}
|
||||
|
||||
if (!indexSelectionJsonText.value.trim()) {
|
||||
if (confirmData.value.length) {
|
||||
ElMessage.warning('请先完成人工索引配置并生成索引配置')
|
||||
return
|
||||
}
|
||||
|
||||
ElMessage.warning('请先解析 ICD,并在弹窗中完成人工确认')
|
||||
return
|
||||
}
|
||||
|
||||
const { error, value } = parsedIndexSelectionState.value
|
||||
if (error) {
|
||||
responsePayload.value = {
|
||||
status: 'NEED_INDEX_SELECTION',
|
||||
message: '索引配置格式有误,请继续修正',
|
||||
problems: [error]
|
||||
}
|
||||
activeResultTab.value = 'problem'
|
||||
ElMessage.warning(error)
|
||||
return
|
||||
}
|
||||
|
||||
isGenerating.value = true
|
||||
responsePayload.value = null
|
||||
xmlResponsePayload.value = null
|
||||
|
||||
try {
|
||||
// 关键业务节点:正式生成阶段只消费当前请求配置区里的索引配置,确保导出的映射与最终确认结果一致。
|
||||
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
|
||||
xmlResponsePayload.value = null
|
||||
ElMessage.error(getErrorMessage(error))
|
||||
} finally {
|
||||
isGenerating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const buildExportFileName = (type: ExportMappingType) => {
|
||||
const baseFileName = selectedIcdFile.value?.name.replace(/\.[^.]+$/, '')
|
||||
const suffix = `${type}-mapping.${type}`
|
||||
|
||||
return baseFileName ? `${baseFileName}-${suffix}` : suffix
|
||||
}
|
||||
|
||||
const downloadTextFile = (content: string, fileName: string, mimeType: string) => {
|
||||
const blob = new Blob([content], { type: mimeType })
|
||||
const objectUrl = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
|
||||
link.href = objectUrl
|
||||
link.download = fileName
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
|
||||
const handleExportMapping = (type: ExportMappingType) => {
|
||||
if (type === 'json' && !mappingJsonPreview.value) {
|
||||
ElMessage.warning('当前没有可导出的 JSON 映射')
|
||||
return
|
||||
}
|
||||
|
||||
if (type === 'xml' && !xmlContentForExport.value) {
|
||||
ElMessage.warning('当前没有可导出的 XML 映射')
|
||||
return
|
||||
}
|
||||
|
||||
if (type === 'json') {
|
||||
downloadTextFile(mappingJsonPreview.value, buildExportFileName('json'), 'application/json;charset=utf-8')
|
||||
ElMessage.success('JSON 映射已导出')
|
||||
return
|
||||
}
|
||||
|
||||
downloadTextFile(xmlContentForExport.value, buildExportFileName('xml'), 'application/xml;charset=utf-8')
|
||||
ElMessage.success('XML 映射已导出')
|
||||
}
|
||||
|
||||
const handleUpdateMappingJson = (mappingJson: string) => {
|
||||
if (!responsePayload.value) return
|
||||
|
||||
// 关键业务节点:序列配置修改的是当前 JSON 映射结果,XML 结果需要清空,避免继续展示旧 JSON 转换出的 XML。
|
||||
responsePayload.value = {
|
||||
...responsePayload.value,
|
||||
mappingJson
|
||||
}
|
||||
xmlResponsePayload.value = null
|
||||
activeResultTab.value = 'json'
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
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,192 @@
|
||||
<template>
|
||||
<section class="mapping-panel config-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2 class="panel-title">人工索引配置</h2>
|
||||
<p class="panel-description">展示现有的人工索引配置,并允许继续编辑。</p>
|
||||
</div>
|
||||
<div class="panel-actions">
|
||||
<el-button
|
||||
v-if="showConfirmButton"
|
||||
type="primary"
|
||||
:icon="EditPen"
|
||||
:disabled="!canConfirm"
|
||||
@click="emit('confirm-config')"
|
||||
>
|
||||
{{ confirmButtonText }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="showGenerateButton"
|
||||
type="primary"
|
||||
:icon="Connection"
|
||||
:loading="isGenerating"
|
||||
:disabled="!canGenerate"
|
||||
@click="emit('generate')"
|
||||
>
|
||||
生成JSON映射
|
||||
</el-button>
|
||||
</div>
|
||||
</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="人工索引配置完成后,这里会自动回填索引配置,仍可继续直接编辑。"
|
||||
@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, EditPen } from '@element-plus/icons-vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'MappingConfigPanel'
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
indexSelectionJson: string
|
||||
isSubmitting: boolean
|
||||
isGenerating: boolean
|
||||
canGenerate: boolean
|
||||
jsonError: string
|
||||
showGenerateButton: boolean
|
||||
showConfirmButton: boolean
|
||||
confirmButtonText: string
|
||||
canConfirm: boolean
|
||||
hasDefaultJson: boolean
|
||||
emptyDescription: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:indexSelectionJson', value: string): void
|
||||
(event: 'confirm-config'): 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-actions {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.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: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.result-card {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.index-selection-textarea {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.json-alert + .index-selection-textarea {
|
||||
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;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,182 @@
|
||||
<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"
|
||||
plain
|
||||
:icon="FolderOpened"
|
||||
:disabled="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"
|
||||
:icon="Search"
|
||||
:loading="isParsing"
|
||||
:disabled="!canParse"
|
||||
@click="emit('parse')"
|
||||
>
|
||||
解析 ICD
|
||||
</el-button>
|
||||
<el-button type="danger" plain :icon="Delete" :disabled="!canReset || isSubmitting" @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
|
||||
isParsing: boolean
|
||||
icdFileAccept: string
|
||||
requestStatusText: string
|
||||
requestStatusTagType: TagType
|
||||
canParse: boolean
|
||||
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,907 @@
|
||||
<template>
|
||||
<section class="mapping-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2 class="panel-title">映射结果</h2>
|
||||
<p class="panel-description">展示和导出JSON与XML的映射结果,以及JSON的映射序列配置。</p>
|
||||
</div>
|
||||
<div class="panel-actions">
|
||||
<el-button
|
||||
type="primary"
|
||||
:icon="Connection"
|
||||
:loading="isGeneratingXml"
|
||||
:disabled="!canGenerateXmlMapping"
|
||||
@click="emit('generate-xml-mapping')"
|
||||
>
|
||||
生成XML映射
|
||||
</el-button>
|
||||
<div class="export-actions">
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
:icon="Download"
|
||||
:disabled="!canExportActiveMapping"
|
||||
@click="emit('export-mapping', activeExportType)"
|
||||
>
|
||||
{{ exportButtonText }}
|
||||
</el-button>
|
||||
<el-dropdown trigger="click" :disabled="!canExportAnyMapping" @command="handleExportCommand">
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
class="export-menu-button"
|
||||
:icon="ArrowDown"
|
||||
:disabled="!canExportAnyMapping"
|
||||
/>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="json" :disabled="!canExportJsonMapping">
|
||||
导出JSON映射
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="xml" :disabled="!canExportXmlMapping">
|
||||
导出XML映射
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
<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="JSON映射" name="json">
|
||||
<div class="mapping-json-scroll">
|
||||
<JsonMappingTree
|
||||
v-if="mappingJsonPreview"
|
||||
:source="mappingJsonPreview"
|
||||
:meta-text="mappingMetaText"
|
||||
>
|
||||
<template #actions>
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
size="small"
|
||||
:icon="Setting"
|
||||
:disabled="!mappingJsonPreview"
|
||||
@click="openSequenceDialog"
|
||||
>
|
||||
序列配置
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
size="small"
|
||||
:icon="Warning"
|
||||
@click="problemDialogVisible = true"
|
||||
>
|
||||
{{ problemButtonText }}
|
||||
</el-button>
|
||||
</template>
|
||||
</JsonMappingTree>
|
||||
<el-empty v-else description="当前返回未包含 mappingJson" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane v-if="showXmlMappingTab" label="XML映射" name="xml">
|
||||
<div class="mapping-json-scroll">
|
||||
<div class="match-result-actions">
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
:icon="Document"
|
||||
:disabled="!methodDescribe"
|
||||
@click="matchResultDialogVisible = true"
|
||||
>
|
||||
匹配结果展示
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-if="xmlMappingPreview" class="xml-file-viewer">
|
||||
<div class="xml-file-header">
|
||||
<span class="xml-file-name">XML 文件</span>
|
||||
<span class="xml-file-meta">{{ xmlMetaText }}</span>
|
||||
</div>
|
||||
<pre class="xml-file-content">{{ xmlMappingPreview }}</pre>
|
||||
</div>
|
||||
<el-empty v-else :description="xmlEmptyText" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="problemDialogVisible"
|
||||
title="问题列表"
|
||||
width="720px"
|
||||
destroy-on-close
|
||||
top="8vh"
|
||||
class="mapping-problem-dialog"
|
||||
>
|
||||
<div v-if="problemList.length" class="problem-dialog-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" />
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="matchResultDialogVisible"
|
||||
title="匹配结果展示"
|
||||
width="640px"
|
||||
destroy-on-close
|
||||
top="12vh"
|
||||
>
|
||||
<div class="match-result-detail">
|
||||
{{ methodDescribe || '当前接口返回未包含 methodDescribe' }}
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="sequenceDialogVisible"
|
||||
title="序列配置"
|
||||
width="920px"
|
||||
destroy-on-close
|
||||
top="8vh"
|
||||
class="sequence-config-dialog"
|
||||
>
|
||||
<template v-if="sequenceConfigRows.length">
|
||||
<div class="dialog-search-bar">
|
||||
<el-input
|
||||
v-model="sequenceSearchKeyword"
|
||||
:prefix-icon="Search"
|
||||
clearable
|
||||
placeholder="按顶层、类型、上层描述、name 或路径检索"
|
||||
/>
|
||||
<span class="dialog-search-count">
|
||||
{{ filteredSequenceRows.length }} / {{ sequenceConfigRows.length }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="sequenceConfigGroups.length" class="sequence-config-list">
|
||||
<section v-for="group in sequenceConfigGroups" :key="group.topKey" class="sequence-top-group">
|
||||
<div class="sequence-top-header">
|
||||
<div>
|
||||
<h3 class="sequence-top-title">{{ group.topDesc || group.topKey }}</h3>
|
||||
<p class="sequence-top-key">{{ group.topKey }}</p>
|
||||
</div>
|
||||
<el-tag type="info" effect="light">{{ group.rowCount }} 项</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="sequence-type-list">
|
||||
<article
|
||||
v-for="typeGroup in group.typeGroups"
|
||||
:key="typeGroup.typeName"
|
||||
class="sequence-type-card"
|
||||
>
|
||||
<div class="sequence-type-header">
|
||||
<div class="sequence-type-title">{{ typeGroup.typeName }}</div>
|
||||
<el-tag type="primary" effect="plain" size="small">{{ typeGroup.rows.length }} 项</el-tag>
|
||||
</div>
|
||||
|
||||
<div v-for="row in typeGroup.rows" :key="row.id" class="sequence-config-item">
|
||||
<div class="sequence-config-info">
|
||||
<div class="sequence-config-title">{{ row.parentDesc }}</div>
|
||||
<div class="sequence-config-subtitle">
|
||||
{{ row.name }}
|
||||
<span class="sequence-config-path">{{ row.pathText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-form label-position="top" size="small" class="sequence-config-form">
|
||||
<el-form-item label="start">
|
||||
<el-input v-model="row.start" />
|
||||
</el-form-item>
|
||||
<el-form-item label="end">
|
||||
<el-input v-model="row.end" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<el-empty v-else description="当前检索条件下没有匹配的序列。" />
|
||||
</template>
|
||||
<el-empty v-else description="当前 JSON 映射中未分析到包含 start 和 end 的序列。" />
|
||||
<template #footer>
|
||||
<el-button @click="sequenceDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :disabled="!sequenceConfigRows.length" @click="confirmSequenceConfig">
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ArrowDown, Connection, Document, Download, Search, Setting, Warning } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { computed, ref } from 'vue'
|
||||
import JsonMappingTree from './JsonMappingTree.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'MappingResultPanel'
|
||||
})
|
||||
|
||||
type TagType = 'success' | 'warning' | 'info' | 'primary' | 'danger'
|
||||
type ResultTab = 'json' | 'xml' | 'problem'
|
||||
type ExportMappingType = 'json' | 'xml'
|
||||
type JsonObject = Record<string, unknown>
|
||||
type JsonPath = Array<string | number>
|
||||
|
||||
interface SequenceConfigRow {
|
||||
id: string
|
||||
path: JsonPath
|
||||
pathText: string
|
||||
topKey: string
|
||||
topDesc: string
|
||||
parentDesc: string
|
||||
desc: string
|
||||
name: string
|
||||
start: string
|
||||
end: string
|
||||
startValueType: string
|
||||
endValueType: string
|
||||
}
|
||||
|
||||
interface SequenceConfigTypeGroup {
|
||||
typeName: string
|
||||
rows: SequenceConfigRow[]
|
||||
}
|
||||
|
||||
interface SequenceConfigGroup {
|
||||
topKey: string
|
||||
topDesc: string
|
||||
rowCount: number
|
||||
typeGroups: SequenceConfigTypeGroup[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
responseStatusText: string
|
||||
responseStatusTagType: TagType
|
||||
activeResultTab: ResultTab
|
||||
mappingMetaText: string
|
||||
mappingJsonPreview: string
|
||||
xmlMetaText: string
|
||||
xmlMappingPreview: string
|
||||
xmlEmptyText: string
|
||||
problemTabLabel: string
|
||||
problemList: string[]
|
||||
problemEmptyText: string
|
||||
methodDescribe: string
|
||||
canExportJsonMapping: boolean
|
||||
canExportXmlMapping: boolean
|
||||
canGenerateXmlMapping: boolean
|
||||
isGeneratingXml: boolean
|
||||
showXmlMappingTab: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:activeResultTab', value: ResultTab): void
|
||||
(event: 'export-mapping', value: ExportMappingType): void
|
||||
(event: 'generate-xml-mapping'): void
|
||||
(event: 'update-mapping-json', value: string): void
|
||||
}>()
|
||||
|
||||
const activeTabProxy = computed({
|
||||
get: () => (props.activeResultTab === 'problem' ? 'json' : props.activeResultTab),
|
||||
set: value => emit('update:activeResultTab', value)
|
||||
})
|
||||
|
||||
const canExportAnyMapping = computed(() => props.canExportJsonMapping || props.canExportXmlMapping)
|
||||
const activeExportType = computed<ExportMappingType>(() => {
|
||||
if (props.activeResultTab === 'xml') return 'xml'
|
||||
if (props.canExportJsonMapping) return 'json'
|
||||
if (props.canExportXmlMapping) return 'xml'
|
||||
return 'json'
|
||||
})
|
||||
const canExportActiveMapping = computed(() =>
|
||||
activeExportType.value === 'json' ? props.canExportJsonMapping : props.canExportXmlMapping
|
||||
)
|
||||
const exportButtonText = computed(() => (activeExportType.value === 'xml' ? '导出XML映射' : '导出JSON映射'))
|
||||
const problemButtonText = computed(() =>
|
||||
props.problemList.length ? `问题列表(${props.problemList.length})` : '问题列表'
|
||||
)
|
||||
const problemDialogVisible = ref(false)
|
||||
const matchResultDialogVisible = ref(false)
|
||||
const sequenceDialogVisible = ref(false)
|
||||
const sequenceConfigRows = ref<SequenceConfigRow[]>([])
|
||||
const sequenceSearchKeyword = ref('')
|
||||
const normalizedSequenceSearchKeyword = computed(() => sequenceSearchKeyword.value.trim().toLowerCase())
|
||||
const filteredSequenceRows = computed(() => {
|
||||
const keyword = normalizedSequenceSearchKeyword.value
|
||||
|
||||
if (!keyword) return sequenceConfigRows.value
|
||||
|
||||
return sequenceConfigRows.value.filter(row =>
|
||||
[row.topKey, row.topDesc, row.desc, row.parentDesc, row.name, row.pathText, row.start, row.end].some(value =>
|
||||
value.toLowerCase().includes(keyword)
|
||||
)
|
||||
)
|
||||
})
|
||||
const sequenceConfigGroups = computed<SequenceConfigGroup[]>(() => {
|
||||
const groupMap = new Map<string, SequenceConfigRow[]>()
|
||||
|
||||
filteredSequenceRows.value.forEach(row => {
|
||||
const groupRows = groupMap.get(row.topKey) || []
|
||||
|
||||
groupRows.push(row)
|
||||
groupMap.set(row.topKey, groupRows)
|
||||
})
|
||||
|
||||
return Array.from(groupMap.entries()).map(([topKey, rows]) => {
|
||||
const typeMap = new Map<string, SequenceConfigRow[]>()
|
||||
|
||||
rows.forEach(row => {
|
||||
const typeRows = typeMap.get(row.desc) || []
|
||||
|
||||
typeRows.push(row)
|
||||
typeMap.set(row.desc, typeRows)
|
||||
})
|
||||
|
||||
return {
|
||||
topKey,
|
||||
topDesc: rows[0]?.topDesc || '',
|
||||
rowCount: rows.length,
|
||||
typeGroups: Array.from(typeMap.entries()).map(([typeName, typeRows]) => ({
|
||||
typeName,
|
||||
rows: typeRows
|
||||
}))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const handleExportCommand = (command: string | number | object) => {
|
||||
if (command === 'json' || command === 'xml') {
|
||||
emit('export-mapping', command)
|
||||
}
|
||||
}
|
||||
|
||||
const isRecord = (value: unknown): value is JsonObject => Boolean(value && typeof value === 'object' && !Array.isArray(value))
|
||||
|
||||
const toDisplayText = (value: unknown, fallback: string) => {
|
||||
if (typeof value === 'string' && value.trim()) return value.trim()
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
||||
return fallback
|
||||
}
|
||||
|
||||
const isZeroValue = (value: unknown) => {
|
||||
if (typeof value === 'number') return value === 0
|
||||
if (typeof value === 'string') return Number(value.trim()) === 0
|
||||
return false
|
||||
}
|
||||
|
||||
const getPathText = (path: JsonPath) => (path.length ? path.map(item => String(item)).join(' / ') : '$')
|
||||
|
||||
const getTopKey = (path: JsonPath) => (path.length ? String(path[0]) : '$')
|
||||
|
||||
const resolveTopDesc = (source: unknown, path: JsonPath) => {
|
||||
if (!path.length || !isRecord(source)) return ''
|
||||
|
||||
const topValue = source[String(path[0])]
|
||||
|
||||
return isRecord(topValue) ? toDisplayText(topValue.desc, '') : ''
|
||||
}
|
||||
|
||||
const collectSequenceRows = (source: unknown, path: JsonPath = [], parentDesc = '', rootSource = source): SequenceConfigRow[] => {
|
||||
if (Array.isArray(source)) {
|
||||
return source.flatMap((item, index) => collectSequenceRows(item, [...path, index], parentDesc, rootSource))
|
||||
}
|
||||
|
||||
if (!isRecord(source)) return []
|
||||
|
||||
const currentDesc = toDisplayText(source.desc, '')
|
||||
const nextParentDesc = currentDesc || parentDesc
|
||||
const childrenRows = Object.entries(source).flatMap(([key, value]) =>
|
||||
collectSequenceRows(value, [...path, key], nextParentDesc, rootSource)
|
||||
)
|
||||
const hasStart = Object.prototype.hasOwnProperty.call(source, 'start')
|
||||
const hasEnd = Object.prototype.hasOwnProperty.call(source, 'end')
|
||||
|
||||
if (!hasStart || !hasEnd) return childrenRows
|
||||
if (isZeroValue(source.start) && isZeroValue(source.end)) return childrenRows
|
||||
|
||||
return [
|
||||
{
|
||||
id: path.length ? path.join('/') : '$',
|
||||
path,
|
||||
pathText: getPathText(path),
|
||||
topKey: getTopKey(path),
|
||||
topDesc: resolveTopDesc(rootSource, path),
|
||||
parentDesc: parentDesc || '未配置上层描述',
|
||||
desc: toDisplayText(source.desc, '未命名序列'),
|
||||
name: toDisplayText(source.name, '未配置 name'),
|
||||
start: source.start === undefined || source.start === null ? '' : String(source.start),
|
||||
end: source.end === undefined || source.end === null ? '' : String(source.end),
|
||||
startValueType: typeof source.start,
|
||||
endValueType: typeof source.end
|
||||
},
|
||||
...childrenRows
|
||||
]
|
||||
}
|
||||
|
||||
const getObjectByPath = (source: unknown, path: JsonPath) => {
|
||||
return path.reduce<unknown>((target, key) => {
|
||||
if (!target || typeof target !== 'object') return undefined
|
||||
return (target as Record<string | number, unknown>)[key]
|
||||
}, source)
|
||||
}
|
||||
|
||||
const normalizeSequenceValue = (value: string, valueType: string) => {
|
||||
if (valueType !== 'number') return value
|
||||
|
||||
const numericValue = Number(value)
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
throw new Error('start 和 end 需要填写有效数字')
|
||||
}
|
||||
|
||||
return numericValue
|
||||
}
|
||||
|
||||
const openSequenceDialog = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(props.mappingJsonPreview) as unknown
|
||||
|
||||
sequenceConfigRows.value = collectSequenceRows(parsed)
|
||||
sequenceSearchKeyword.value = ''
|
||||
sequenceDialogVisible.value = true
|
||||
} catch {
|
||||
ElMessage.warning('当前 JSON 映射内容无法解析,不能配置序列')
|
||||
}
|
||||
}
|
||||
|
||||
const confirmSequenceConfig = () => {
|
||||
try {
|
||||
const nextJson = JSON.parse(props.mappingJsonPreview) as unknown
|
||||
|
||||
sequenceConfigRows.value.forEach(row => {
|
||||
const target = getObjectByPath(nextJson, row.path)
|
||||
|
||||
if (!isRecord(target)) return
|
||||
|
||||
// 关键业务节点:序列配置只回写 start/end,避免弹窗编辑影响映射 JSON 的其他字段结构。
|
||||
target.start = normalizeSequenceValue(row.start, row.startValueType)
|
||||
target.end = normalizeSequenceValue(row.end, row.endValueType)
|
||||
})
|
||||
|
||||
emit('update-mapping-json', JSON.stringify(nextJson, null, 4))
|
||||
sequenceDialogVisible.value = false
|
||||
ElMessage.success('序列配置已同步到 JSON 映射')
|
||||
} catch (error) {
|
||||
ElMessage.warning(error instanceof Error ? error.message : '序列配置同步失败')
|
||||
}
|
||||
}
|
||||
</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;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.export-actions {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.export-menu-button {
|
||||
width: 32px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.mapping-json-scroll {
|
||||
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;
|
||||
}
|
||||
|
||||
.xml-file-viewer {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
border: 1px solid #dbe3f0;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xml-file-header {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-height: 40px;
|
||||
padding: 0 14px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: #f8fafc;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xml-file-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.xml-file-meta {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.xml-file-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
font-family: Consolas, 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: #172033;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.problem-dialog-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-height: 62vh;
|
||||
padding-right: 4px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.match-result-actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.match-result-detail {
|
||||
max-height: 58vh;
|
||||
padding: 14px 16px;
|
||||
overflow: auto;
|
||||
border: 1px solid #dbe3f0;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: #334155;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.dialog-search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.dialog-search-count {
|
||||
flex: 0 0 auto;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #64748b;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sequence-config-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-height: 62vh;
|
||||
padding-right: 4px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.sequence-top-group {
|
||||
padding: 16px;
|
||||
border: 1px solid #dbe3f0;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.sequence-top-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.sequence-top-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.sequence-top-key {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: #64748b;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.sequence-type-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sequence-type-card {
|
||||
padding: 14px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.sequence-type-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.sequence-type-title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
color: #111827;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sequence-config-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 300px;
|
||||
gap: 16px;
|
||||
padding: 8px 0;
|
||||
border: 1px solid #dbe3f0;
|
||||
border-width: 1px 0 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sequence-config-item:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.sequence-config-info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sequence-config-title {
|
||||
overflow: hidden;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
color: #1f2937;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sequence-config-subtitle {
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #64748b;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sequence-config-path {
|
||||
margin-left: 8px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.sequence-config-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sequence-config-form :deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.sequence-config-form :deep(.el-form-item__label) {
|
||||
margin-bottom: 2px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mapping-panel {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.panel-header,
|
||||
.panel-actions {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.sequence-config-item {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sequence-config-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
75
frontend/src/views/tools/mmsmapping/utils/indexSelection.ts
Normal file
75
frontend/src/views/tools/mmsmapping/utils/indexSelection.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
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(`索引配置第 ${groupIndex + 1} 组的 bindings 必须是非空数组`)
|
||||
}
|
||||
|
||||
return value.map((binding, bindingIndex) => {
|
||||
if (!isRecord(binding)) {
|
||||
throw new Error(`索引配置第 ${groupIndex + 1} 组第 ${bindingIndex + 1} 项必须是对象`)
|
||||
}
|
||||
|
||||
return {
|
||||
reportName: normalizeRequiredString(
|
||||
binding.reportName,
|
||||
`索引配置第 ${groupIndex + 1} 组第 ${bindingIndex + 1} 项的 reportName`
|
||||
),
|
||||
dataSetName: normalizeRequiredString(
|
||||
binding.dataSetName,
|
||||
`索引配置第 ${groupIndex + 1} 组第 ${bindingIndex + 1} 项的 dataSetName`
|
||||
),
|
||||
label: normalizeRequiredString(
|
||||
binding.label,
|
||||
`索引配置第 ${groupIndex + 1} 组第 ${bindingIndex + 1} 项的 label`
|
||||
),
|
||||
lnInst: normalizeRequiredString(
|
||||
binding.lnInst,
|
||||
`索引配置第 ${groupIndex + 1} 组第 ${bindingIndex + 1} 项的 lnInst`
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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('索引配置不是合法 JSON')
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error('索引配置必须是数组')
|
||||
}
|
||||
|
||||
return parsed.map((group, groupIndex) => {
|
||||
if (!isRecord(group)) {
|
||||
throw new Error(`索引配置第 ${groupIndex + 1} 组必须是对象`)
|
||||
}
|
||||
|
||||
const groupDesc = normalizeOptionalString(group.groupDesc)
|
||||
|
||||
return {
|
||||
groupKey: normalizeRequiredString(group.groupKey, `索引配置第 ${groupIndex + 1} 组的 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,22 @@
|
||||
<div class="summary-value">{{ item.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="marker-data-block">
|
||||
<div class="marker-data-title">标记数据</div>
|
||||
<div v-if="markerDataItems.length" class="marker-data-list">
|
||||
<div v-for="marker in markerDataItems" :key="marker.name" class="marker-data-group">
|
||||
<div class="marker-data-name">{{ marker.name }}:{{ marker.axisValueText }}</div>
|
||||
<div class="marker-data-rows">
|
||||
<div v-for="row in marker.rows" :key="`${marker.name}-${row.label}`" class="marker-data-row">
|
||||
<span class="marker-data-label">{{ row.label }}:</span>
|
||||
<span class="marker-data-value">{{ row.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="marker-data-empty">暂无标记数据</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="向量信息" name="vector">
|
||||
@@ -23,27 +39,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,17 +58,17 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { Waveform } from '@/api/tools/waveform/interface'
|
||||
import type { FeatureCardItem, SummaryItem } from './types'
|
||||
import type { MarkerDataItem, SummaryItem } from './types'
|
||||
import WaveformVectorInfo from './WaveformVectorInfo.vue'
|
||||
|
||||
defineProps<{
|
||||
hasParsedWaveform: boolean
|
||||
summaryItems: SummaryItem[]
|
||||
featureCards: FeatureCardItem[]
|
||||
vectorParseResult: Waveform.WaveComtradeVectorResultVO | null
|
||||
lastParseErrorMessage: string
|
||||
lastVectorParseErrorMessage: string
|
||||
activeVectorChannelName: string
|
||||
markerDataItems: MarkerDataItem[]
|
||||
}>()
|
||||
|
||||
// 右侧信息区仅切换展示内容,不影响波形与向量解析结果的联动状态。
|
||||
@@ -143,18 +148,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 +192,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,25 +214,77 @@ const activeInfoTab = ref('waveform')
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.feature-header {
|
||||
margin-top: 2px;
|
||||
.marker-data-block {
|
||||
padding: 10px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 4px;
|
||||
background: var(--cn-color-canvas-bg);
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
.marker-data-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.marker-data-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.feature-row {
|
||||
.marker-data-group + .marker-data-group {
|
||||
padding-top: 10px;
|
||||
border-top: 1px dashed var(--el-border-color-light);
|
||||
}
|
||||
|
||||
.marker-data-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.6;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.marker-data-rows {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.marker-data-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
min-width: fit-content;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--el-text-color-regular);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.marker-data-label {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.marker-data-value {
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.marker-data-empty {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.summary-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -221,9 +292,8 @@ const activeInfoTab = ref('waveform')
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.summary-grid,
|
||||
.feature-grid {
|
||||
.summary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<template>
|
||||
<div class="page-header">
|
||||
|
||||
<div class="action-row">
|
||||
<div class="file-select-row">
|
||||
<el-input
|
||||
@@ -9,7 +8,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"
|
||||
@@ -29,7 +30,12 @@
|
||||
placeholder="选择通道"
|
||||
@update:model-value="handleChannelChange"
|
||||
>
|
||||
<el-option v-for="item in channelOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
<el-option
|
||||
v-for="item in channelOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
@@ -52,42 +58,43 @@
|
||||
|
||||
<div class="toolbar-item">
|
||||
<div class="toolbar-label">数值类型</div>
|
||||
<el-radio-group :model-value="activeValueMode" class="value-mode-switch" @update:model-value="handleValueModeChange">
|
||||
<el-radio-group
|
||||
:model-value="activeValueMode"
|
||||
class="value-mode-switch"
|
||||
@update:model-value="handleValueModeChange"
|
||||
>
|
||||
<el-radio-button v-for="item in valueModeOptions" :key="item.value" :label="item.value">
|
||||
{{ item.label }}
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<el-button type="primary" :disabled="!hasWaveformData" @click="emit('download')">下载数据</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FolderOpened } from '@element-plus/icons-vue'
|
||||
import { ref } from 'vue'
|
||||
import type { DisplayMode, LabelValueOption, ValueMode, WaveformDetailOption } from './types'
|
||||
import type { ChannelSelectValue, DisplayMode, LabelValueOption, ValueMode, WaveformDetailOption } from './types'
|
||||
|
||||
defineProps<{
|
||||
selectedWaveformFileName: string
|
||||
isParsing: boolean
|
||||
waveformFileAccept: string
|
||||
channelOptions: WaveformDetailOption[]
|
||||
activeChannelIndex: number
|
||||
activeChannelIndex: ChannelSelectValue
|
||||
displayModeOptions: LabelValueOption<DisplayMode>[]
|
||||
activeDisplayMode: DisplayMode
|
||||
valueModeOptions: LabelValueOption<ValueMode>[]
|
||||
activeValueMode: ValueMode
|
||||
hasWaveformData: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:activeChannelIndex': [value: number]
|
||||
'update:activeChannelIndex': [value: ChannelSelectValue]
|
||||
'update:activeDisplayMode': [value: DisplayMode]
|
||||
'update:activeValueMode': [value: ValueMode]
|
||||
'waveform-file-change': [event: Event]
|
||||
download: []
|
||||
}>()
|
||||
|
||||
const waveformFileInputRef = ref<HTMLInputElement>()
|
||||
@@ -103,7 +110,7 @@ const handleWaveformFileChange = (event: Event) => {
|
||||
emit('waveform-file-change', event)
|
||||
}
|
||||
|
||||
const handleChannelChange = (value: number) => {
|
||||
const handleChannelChange = (value: ChannelSelectValue) => {
|
||||
emit('update:activeChannelIndex', value)
|
||||
}
|
||||
|
||||
@@ -197,6 +204,4 @@ const handleValueModeChange = (value: string | number | boolean | undefined) =>
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<div class="panel-body">
|
||||
<div v-if="hasWaveformData && isAllChannelsActive" class="all-channel-list">
|
||||
<template v-for="group in allChannelTrendGroups" :key="group.key">
|
||||
<div
|
||||
v-if="activeDisplayMode === 'multi-channel'"
|
||||
class="all-channel-chart"
|
||||
:class="{ 'trend-chart-block--with-axis': group.isLastChart }"
|
||||
>
|
||||
<LineChart
|
||||
:options="group.multiChannelOptions"
|
||||
:group="group.group"
|
||||
@chart-data-zoom="handleChartDataZoom"
|
||||
@chart-click="handleChartClick"
|
||||
/>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="item in group.singleChannelOptionsList"
|
||||
:key="item.key"
|
||||
class="single-channel-card"
|
||||
:class="{ 'trend-chart-block--with-axis': item.isLastChart }"
|
||||
>
|
||||
<div class="single-channel-chart">
|
||||
<LineChart
|
||||
:options="item.options"
|
||||
:group="item.group"
|
||||
@chart-data-zoom="handleChartDataZoom"
|
||||
@chart-click="handleChartClick"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else-if="hasWaveformData && activeDisplayMode === 'multi-channel'" class="chart-container">
|
||||
<LineChart
|
||||
:options="activeTrendOptions"
|
||||
@chart-data-zoom="handleChartDataZoom"
|
||||
@chart-click="handleChartClick"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="hasWaveformData" class="single-channel-list">
|
||||
<div
|
||||
v-for="item in singleChannelTrendOptionsList"
|
||||
:key="item.key"
|
||||
class="single-channel-card"
|
||||
:class="{ 'trend-chart-block--with-axis': item.isLastChart }"
|
||||
>
|
||||
<div class="single-channel-chart">
|
||||
<LineChart
|
||||
:options="item.options"
|
||||
:group="item.group"
|
||||
@chart-data-zoom="handleChartDataZoom"
|
||||
@chart-click="handleChartClick"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-block">
|
||||
<div class="empty-title">暂无波形数据</div>
|
||||
<div class="empty-text">请选择同一组 `.cfg` 和 `.dat` 文件后自动解析并展示。</div>
|
||||
<div v-if="lastParseErrorMessage" class="empty-text error-text">
|
||||
最近一次解析失败:{{ lastParseErrorMessage }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import LineChart from '@/components/echarts/line/index.vue'
|
||||
import type {
|
||||
AllChannelTrendGroup,
|
||||
DisplayMode,
|
||||
SingleChannelTrendOption,
|
||||
TrendChartClickPayload,
|
||||
TrendChartZoomPayload
|
||||
} from './types'
|
||||
|
||||
defineProps<{
|
||||
hasWaveformData: boolean
|
||||
isAllChannelsActive: boolean
|
||||
activeDisplayMode: DisplayMode
|
||||
activeTrendOptions: Record<string, unknown>
|
||||
singleChannelTrendOptionsList: SingleChannelTrendOption[]
|
||||
allChannelTrendGroups: AllChannelTrendGroup[]
|
||||
lastParseErrorMessage: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'chart-data-zoom': [value: TrendChartZoomPayload]
|
||||
'chart-click': [value: TrendChartClickPayload]
|
||||
}>()
|
||||
|
||||
const handleChartDataZoom = (value: TrendChartZoomPayload) => {
|
||||
emit('chart-data-zoom', value)
|
||||
}
|
||||
|
||||
const handleChartClick = (value: TrendChartClickPayload) => {
|
||||
emit('chart-click', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.panel-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chart-container,
|
||||
.empty-block {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 6px 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.empty-block {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
background: var(--cn-color-canvas-bg);
|
||||
}
|
||||
|
||||
.single-channel-list {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.all-channel-list {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-height: 0;
|
||||
padding: 6px 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 4px;
|
||||
background: var(--cn-color-canvas-bg);
|
||||
}
|
||||
|
||||
.all-channel-chart {
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.single-channel-card {
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-height: 0;
|
||||
padding: 6px 8px;
|
||||
overflow: hidden;
|
||||
background: var(--cn-color-canvas-bg);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.trend-chart-block--with-axis {
|
||||
flex-basis: 17px;
|
||||
}
|
||||
|
||||
.single-channel-chart {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: var(--el-color-danger);
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
@@ -1,57 +1,194 @@
|
||||
<template>
|
||||
<template>
|
||||
<section class="waveform-panel">
|
||||
<div class="panel-header">
|
||||
<el-tabs :model-value="activeTrendTab" class="trend-tabs" @update:model-value="handleTrendTabChange">
|
||||
<el-tab-pane v-for="item in trendTabs" :key="item.value" :label="item.label" :name="item.value" />
|
||||
</el-tabs>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<div v-if="hasWaveformData && activeDisplayMode === 'multi-channel'" class="chart-container">
|
||||
<LineChart :options="activeTrendOptions" />
|
||||
</div>
|
||||
<div v-else-if="hasWaveformData" class="single-channel-list">
|
||||
<div
|
||||
v-for="item in singleChannelTrendOptionsList"
|
||||
:key="item.key"
|
||||
class="single-channel-card"
|
||||
:class="{ 'single-channel-card--with-axis': item.isLastChart }"
|
||||
>
|
||||
<div class="single-channel-chart">
|
||||
<LineChart :options="item.options" :group="item.group" />
|
||||
</div>
|
||||
<div class="trend-tool-groups">
|
||||
<div v-for="group in trendToolGroups" :key="group.key" class="trend-tool-group">
|
||||
<el-tooltip
|
||||
v-for="item in group.items"
|
||||
:key="item.action"
|
||||
:content="getTrendToolTooltip(item)"
|
||||
placement="top"
|
||||
>
|
||||
<el-button
|
||||
:type="isTrendToolActive(item.action) ? 'primary' : 'default'"
|
||||
:icon="item.icon"
|
||||
:disabled="isTrendToolDisabled(item.action)"
|
||||
circle
|
||||
@click="handleTrendToolClick(item.action)"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-block">
|
||||
<div class="empty-title">暂无波形数据</div>
|
||||
<div class="empty-text">请选择同一组 `.cfg` 和 `.dat` 文件后自动解析并展示。</div>
|
||||
<div v-if="lastParseErrorMessage" class="empty-text error-text">最近一次解析失败:{{ lastParseErrorMessage }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WaveformTrendChartArea
|
||||
class="waveform-trend-export-target"
|
||||
:has-waveform-data="hasWaveformData"
|
||||
:active-display-mode="activeDisplayMode"
|
||||
:active-trend-options="activeTrendOptions"
|
||||
:single-channel-trend-options-list="singleChannelTrendOptionsList"
|
||||
:all-channel-trend-groups="allChannelTrendGroups"
|
||||
:is-all-channels-active="isAllChannelsActive"
|
||||
:last-parse-error-message="lastParseErrorMessage"
|
||||
@chart-data-zoom="handleChartDataZoom"
|
||||
@chart-click="handleChartClick"
|
||||
/>
|
||||
|
||||
<el-dialog
|
||||
v-model="fullscreenVisible"
|
||||
class="waveform-fullscreen-dialog"
|
||||
title="波形全屏展示"
|
||||
fullscreen
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
>
|
||||
<WaveformTrendChartArea
|
||||
class="fullscreen-chart-body"
|
||||
:has-waveform-data="hasWaveformData"
|
||||
:active-display-mode="activeDisplayMode"
|
||||
:active-trend-options="activeTrendOptions"
|
||||
:single-channel-trend-options-list="singleChannelTrendOptionsList"
|
||||
:all-channel-trend-groups="allChannelTrendGroups"
|
||||
:is-all-channels-active="isAllChannelsActive"
|
||||
:last-parse-error-message="lastParseErrorMessage"
|
||||
@chart-data-zoom="handleChartDataZoom"
|
||||
@chart-click="handleChartClick"
|
||||
/>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import LineChart from '@/components/echarts/line/index.vue'
|
||||
import type { DisplayMode, LabelValueOption, SingleChannelTrendOption, TrendTabValue } from './types'
|
||||
import {
|
||||
ArrowDownBold,
|
||||
ArrowLeftBold,
|
||||
ArrowRightBold,
|
||||
ArrowUpBold,
|
||||
Crop,
|
||||
Download,
|
||||
FullScreen,
|
||||
Location,
|
||||
Picture,
|
||||
Pointer,
|
||||
RefreshLeft
|
||||
} from '@element-plus/icons-vue'
|
||||
import { ref } from 'vue'
|
||||
import WaveformTrendChartArea from './WaveformTrendChartArea.vue'
|
||||
import type {
|
||||
AllChannelTrendGroup,
|
||||
DisplayMode,
|
||||
LabelValueOption,
|
||||
SingleChannelTrendOption,
|
||||
TrendChartClickPayload,
|
||||
TrendChartZoomPayload,
|
||||
TrendToolAction,
|
||||
TrendTabValue
|
||||
} from './types'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
type TrendPanelToolAction = TrendToolAction | 'fullscreen'
|
||||
|
||||
const props = defineProps<{
|
||||
hasWaveformData: boolean
|
||||
isAllChannelsActive: boolean
|
||||
activeDisplayMode: DisplayMode
|
||||
activeTrendTab: TrendTabValue
|
||||
trendTabs: LabelValueOption<TrendTabValue>[]
|
||||
activeTrendOptions: Record<string, unknown>
|
||||
singleChannelTrendOptionsList: SingleChannelTrendOption[]
|
||||
allChannelTrendGroups: AllChannelTrendGroup[]
|
||||
lastParseErrorMessage: string
|
||||
activeTrendToolStates: Partial<Record<TrendToolAction, boolean>>
|
||||
disabledTrendToolStates?: Partial<Record<TrendToolAction, boolean>>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:activeTrendTab': [value: TrendTabValue]
|
||||
'trend-tool-action': [value: TrendToolAction]
|
||||
'chart-data-zoom': [value: TrendChartZoomPayload]
|
||||
'chart-click': [value: TrendChartClickPayload]
|
||||
}>()
|
||||
|
||||
const fullscreenVisible = ref(false)
|
||||
|
||||
const trendToolGroups: Array<{
|
||||
key: string
|
||||
items: Array<{
|
||||
action: TrendPanelToolAction
|
||||
label: string
|
||||
icon: Component
|
||||
}>
|
||||
}> = [
|
||||
{
|
||||
key: 'scale',
|
||||
items: [
|
||||
{ action: 'x-zoom-in', label: 'X坐标放大', icon: ArrowRightBold },
|
||||
{ action: 'x-zoom-out', label: 'X坐标缩小', icon: ArrowLeftBold },
|
||||
{ action: 'y-zoom-in', label: 'Y坐标放大', icon: ArrowUpBold },
|
||||
{ action: 'y-zoom-out', label: 'Y坐标缩小', icon: ArrowDownBold },
|
||||
{ action: 'box-zoom', label: '框选放大', icon: Crop },
|
||||
{ action: 'mark', label: '标记', icon: Location },
|
||||
{ action: 'reset', label: '恢复', icon: RefreshLeft },
|
||||
{ action: 'pan', label: '平移', icon: Pointer }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'export',
|
||||
items: [
|
||||
{ action: 'fullscreen', label: '全屏展示', icon: FullScreen },
|
||||
{ action: 'download-image', label: '下载图片', icon: Picture },
|
||||
{ action: 'download-data', label: '下载数据', icon: Download }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const handleTrendTabChange = (value: string | number) => {
|
||||
emit('update:activeTrendTab', value as TrendTabValue)
|
||||
}
|
||||
|
||||
const isTrendToolActive = (action: TrendPanelToolAction) => {
|
||||
if (action === 'fullscreen') return fullscreenVisible.value
|
||||
|
||||
return props.activeTrendToolStates[action]
|
||||
}
|
||||
|
||||
const isTrendToolDisabled = (action: TrendPanelToolAction) => {
|
||||
if (!props.hasWaveformData) return true
|
||||
if (action === 'fullscreen') return false
|
||||
|
||||
return !!props.disabledTrendToolStates?.[action]
|
||||
}
|
||||
|
||||
const getTrendToolTooltip = (item: { action: TrendPanelToolAction; label: string }) => {
|
||||
if (item.action === 'pan' && isTrendToolDisabled(item.action) && props.hasWaveformData) {
|
||||
return '请先放大 X 轴或框选局部区域后再平移'
|
||||
}
|
||||
|
||||
return item.label
|
||||
}
|
||||
|
||||
const handleTrendToolClick = (action: TrendPanelToolAction) => {
|
||||
if (isTrendToolDisabled(action)) return
|
||||
|
||||
if (action === 'fullscreen') {
|
||||
fullscreenVisible.value = true
|
||||
return
|
||||
}
|
||||
|
||||
emit('trend-tool-action', action)
|
||||
}
|
||||
|
||||
const handleChartDataZoom = (value: TrendChartZoomPayload) => {
|
||||
emit('chart-data-zoom', value)
|
||||
}
|
||||
|
||||
const handleChartClick = (value: TrendChartClickPayload) => {
|
||||
emit('chart-click', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -68,15 +205,44 @@ const handleTrendTabChange = (value: string | number) => {
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.trend-tabs {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.trend-tool-groups {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.trend-tool-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.trend-tool-group + .trend-tool-group {
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.trend-tool-group :deep(.el-button.is-circle) {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.trend-tabs :deep(.el-tabs__header) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@@ -89,86 +255,37 @@ const handleTrendTabChange = (value: string | number) => {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
:global(.waveform-fullscreen-dialog) {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chart-container,
|
||||
.empty-block {
|
||||
:global(.waveform-fullscreen-dialog .el-dialog__body) {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.empty-block {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
background: var(--cn-color-canvas-bg);
|
||||
}
|
||||
|
||||
.single-channel-list {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
.fullscreen-chart-body {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.single-channel-card {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-height: 0;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 4px;
|
||||
background: var(--cn-color-canvas-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.single-channel-card--with-axis {
|
||||
flex: 1.1;
|
||||
}
|
||||
|
||||
.single-channel-chart {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: var(--el-color-danger);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.panel-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.trend-tabs {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.trend-tool-groups {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.trend-tabs :deep(.el-tabs__nav) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
export type TrendTabValue = 'instant' | 'rms'
|
||||
export type TrendTabValue = 'instant' | 'rms'
|
||||
export type ValueMode = 'primary' | 'secondary'
|
||||
export type DisplayMode = 'single-channel' | 'multi-channel'
|
||||
export type ChannelSelectValue = number | 'all'
|
||||
export type TrendToolAction =
|
||||
| 'x-zoom-in'
|
||||
| 'x-zoom-out'
|
||||
| 'y-zoom-in'
|
||||
| 'y-zoom-out'
|
||||
| 'box-zoom'
|
||||
| 'mark'
|
||||
| 'pan'
|
||||
| 'reset'
|
||||
| 'download-image'
|
||||
| 'download-data'
|
||||
|
||||
export interface LabelValueOption<T extends string | number = string | number> {
|
||||
label: string
|
||||
@@ -9,7 +21,7 @@ export interface LabelValueOption<T extends string | number = string | number> {
|
||||
|
||||
export interface WaveformDetailOption {
|
||||
label: string
|
||||
value: number
|
||||
value: ChannelSelectValue
|
||||
}
|
||||
|
||||
export interface SingleChannelTrendOption {
|
||||
@@ -19,11 +31,29 @@ export interface SingleChannelTrendOption {
|
||||
options: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface AllChannelTrendGroup {
|
||||
key: string
|
||||
title: string
|
||||
group: string
|
||||
isLastChart?: boolean
|
||||
singleChannelOptionsList: SingleChannelTrendOption[]
|
||||
multiChannelOptions: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface SummaryItem {
|
||||
label: string
|
||||
value: string | number
|
||||
}
|
||||
|
||||
export interface MarkerDataItem {
|
||||
name: string
|
||||
axisValueText: string
|
||||
rows: Array<{
|
||||
label: string
|
||||
value: string
|
||||
}>
|
||||
}
|
||||
|
||||
export interface FeatureCardItem {
|
||||
title: string
|
||||
rows: Array<{
|
||||
@@ -31,3 +61,13 @@ export interface FeatureCardItem {
|
||||
value: string
|
||||
}>
|
||||
}
|
||||
|
||||
export interface TrendChartZoomPayload {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export interface TrendChartClickPayload {
|
||||
dataIndex: number
|
||||
axisValue: string | number
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user