Compare commits

...

24 Commits

Author SHA1 Message Date
a1e1fb124a feat(mmsmapping): 添加 XML 映射生成功能和波形标记功能
- 新增 getXmlFromJsonApi 接口用于从 JSON 生成 XML 映射
- 添加 XML 映射相关的数据结构定义和响应处理
- 实现 XML 映射生成功能,支持 JSON 到 XML 的转换
- 添加波形图表点击事件处理和标记功能
- 实现趋势图表的标记点显示和标签功能
- 更新界面以支持 XML 映射预览和导出
- 优化图表交互体验,添加标记工具模式
- 重构部分界面组件以支持新的映射功能
2026-05-08 09:54:52 +08:00
fe3ab1f679 refactor(waveform): 优化波形趋势图表交互功能
- 添加了 canResetTrendChart 计算属性用于判断是否可以重置图表
- 更新了工具栏状态控制逻辑,禁用状态下禁止重置操作
- 调整了图表边距配置,右侧留白从 18px 增加到 36px
- 时间轴底部留白从 30px 调整为 34px
- 时间轴标签间距从 6 调整为 8
- 集成了 ECharts 工具箱的数据缩放功能
- 替换了图标组件,使用箭头图标替代缩放图标
- 调整了工具栏项目顺序,将平移工具移到最后
2026-05-07 11:47:44 +08:00
32f324909d feat(waveform): 添加趋势图动态线宽和平移功能
- 实现趋势图按可见点数动态计算线宽,避免大数据量线条过粗
- 新增平移工具支持图表区域拖拽浏览
- 优化数据缩放事件处理,提升图表交互体验
- 添加线宽分档规则配置,支持不同数据量级的显示优化
- 移除原有的光标测量和峰值定位功能
- 更新图表点击事件为数据缩放事件处理
2026-05-07 11:16:51 +08:00
2babe9d99d feat(waveform): 添加波形图趋势工具和坐标轴规则
- 实现波形图纵坐标显示规则,包括最大最小值显示、刻度均分、对称边界等功能
- 添加多图趋势图对齐规则,确保绘图区左边界一致和标签宽度统一
- 集成图表点击事件发射器,支持时间标签、数值和系列名称的数据传递
- 实现波形图缩放、平移、测量、峰值标记等交互功能
- 添加坐标轴刻度精度控制和可读步长归一化处理
- 实现多图对齐的网格配置和纵坐标标签防重叠机制
- 添加波形图全屏展示和图片导出功能
- 实现趋势图峰值标记点和测量游标功能
2026-05-07 09:38:06 +08:00
407ab0a7f6 feat(waveform): 添加全通道波形数据显示功能
- 实现全通道模式下的波形数据展示
- 添加通道选择器支持全部/单个通道切换
- 新增全通道趋势分组数据结构
- 重构波形数据获取逻辑支持多通道模式
- 更新图表配置支持动态图例显示控制
- 完善波形数据导出功能支持全通道数据
- 优化工具栏界面适配新的通道选择功能
2026-05-06 16:35:48 +08:00
bf9f3719a4 feat(mmsMapping): 添加映射确认对话框的诊断日志和键值优化
- 实现 logConfirmDataDiagnostics 函数用于诊断间谐波相关数据
- 添加控制台日志输出用于调试构建索引确认数据的结果
- 为标签项目和目标项目添加唯一键值以优化列表渲染性能
- 在准备确认对话框草稿数据时生成默认实例编号
- 修复确认对话框中的默认状态映射逻辑
- 为间谐波相关的 lnInst 缺失情况添加警告日志
2026-05-06 11:41:20 +08:00
483b3d7ae4 feat(mmsmapping): 添加ICD索引配置人工确认功能
- 新增IndexConfirmTarget、IndexConfirmLabelItem、IndexConfirmGroup等接口定义
- 添加buildIndexConfirmDataApi和buildIndexSelectionApi两个API方法
- 实现MappingConfirmDialog组件用于人工确认索引配置
- 将解析ICD流程分为候选数据获取和人工确认两个步骤
- 添加确认弹窗的验证逻辑和状态管理
- 更新页面重置逻辑以清除确认相关状态
- 修改请求配置面板显示确认按钮和相应操作
- 移除原有的自动生成默认索引选择的工具函数
2026-05-06 08:47:39 +08:00
297f89ef52 docs(api): 删除MMS映射API调试文档
- 移除generateAndSubmitIndexSelection标准API调试文档
- 移除getIcdMmsJson标准API调试文档
- 清理相关API接口说明和调试示例内容
- 删除Postman和cURL调试方式说明
- 移除请求响应结构定义和常见问题解答
2026-04-30 11:30:59 +08:00
0dc0e4ecdc feat(tools): 新增数据补数功能模块
- 实现补数任务面板组件,支持监测点ID输入、时间范围选择和时间步长设置
- 添加任务状态卡片组件,实时展示任务执行进度和结果统计
- 集成参数规则表格组件,显示后端配置的模板规则信息
- 实现补数API接口服务,包括预估写入量、创建任务和查询状态功能
- 添加磁盘监控策略对话框组件,支持全局监控配置管理
- 完成补数功能页面布局设计,集成左右双栏界面结构
- 实现任务轮询机制,自动更新任务执行状态直到完成
- 添加表单验证逻辑,确保输入参数符合业务规则要求
2026-04-30 09:04:52 +08:00
398a2cf1dc style(diskMonitor): 统一磁盘监控页面样式规范
- 添加页面边距、表格样式和按钮样式约定到 AGENTS.md 文档
- 为任务详情抽屉组件添加卡片样式和表格结构优化
- 重构任务表格组件,增加搜索功能和列设置选项
- 移除独立的策略表单组件,整合到汇总页面
- 优化监控摘要组件的网格布局和样式
- 重新设计目标对话框的表单分组和禁用状态处理
- 统一所有组件使用 card、table-main 等标准类名
- 添加文件编码规范要求确保 UTF-8 编码一致性
2026-04-30 09:02:57 +08:00
287de846a6 新建监控功能 2026-04-23 11:09:06 +08:00
7dee9092dc fix: harden disk monitor configuration flows 2026-04-23 03:31:06 +08:00
c6e3662248 fix: harden disk monitor job interactions 2026-04-23 03:31:06 +08:00
10e6bd5151 feat: add disk monitor job views 2026-04-23 03:31:06 +08:00
e1cb4fb694 fix: validate and normalize disk monitor targets 2026-04-23 03:31:06 +08:00
edf0af7953 feat: add disk monitor target editors 2026-04-23 03:31:06 +08:00
63433f7f01 fix: prevent disk monitor action overlap 2026-04-23 03:31:06 +08:00
eb384e8eef feat: scaffold disk monitor page state 2026-04-23 03:31:06 +08:00
a6e32e0e19 fix: preserve disk monitor static routes 2026-04-23 03:31:06 +08:00
2314b03404 feat: add disk monitor contracts and sql 2026-04-23 03:31:06 +08:00
455d394682 docs: add disk monitor design spec 2026-04-22 20:27:30 +08:00
93d6416da1 chore: ignore local worktrees 2026-04-22 07:37:41 +08:00
9a388627a0 docs: refine mmsmapping config defaults 2026-04-22 01:48:45 +08:00
45ab5c9e84 docs: add mmsmapping redesign spec 2026-04-22 01:41:30 +08:00
57 changed files with 14580 additions and 902 deletions

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
node_modules
.worktrees/
out/
logs/
run/

View File

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

View File

@@ -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` 次。
- 如果单周波采样点数过低,高次谐波指标会受分辨率限制。

View 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='磁盘监控通知日志表';

View File

@@ -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` 等命名,没有前后不一致的接口名。

View File

@@ -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`.

File diff suppressed because it is too large Load Diff

View File

@@ -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`,但用户操作对象变为“基于候选生成的可编辑草稿”,从而兼顾接口约束、调试效率和页面可用性。

View 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)
}

View 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> {}
}

View 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')
}

View 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
}
}

View 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)
}

View 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
}
}

View File

@@ -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()
}
)

View File

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

View File

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

View File

@@ -5,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)

View File

@@ -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')

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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 : []
})

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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"
]
}
]
}
]
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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)
}
})
}

View 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
})

View File

@@ -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`,其他波形文本解析接口请单独编写。

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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%;
}

View File

@@ -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>

View File

@@ -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