波形解析相关

This commit is contained in:
2026-04-16 20:20:52 +08:00
parent 9b43f45808
commit 649418a51c
9 changed files with 1213 additions and 460 deletions

View File

@@ -8,6 +8,7 @@
- 先整理执行方案,说明目标、涉及模块、预计修改点和验证方式,待用户评审确认后再执行。
- 不要想当然;如果需求存在歧义、前提不清或有多种实现路径,先说清假设与取舍,再继续。
- 默认先确认任务是否位于 `frontend/src/`,再按 `views/``components/``api/``stores/``routers/``hooks/` 的调用关系向下分析;只有涉及桌面壳层或启动问题时,再继续查看 `electron/``scripts/``build/``public/`
-`views/**/index.vue` 内容过大时,优先按功能块抽到当前功能目录下的 `components/`,页面入口只保留编排、状态组织和事件分发,不把大段展示结构继续堆在 `index.vue`
- 涉及 preload 桥接、主进程配置、自动更新、窗口生命周期、端口检测或打包启动链路时,先核对已有实现和 `doc/` 中的说明,避免只看局部代码就下结论。
- 回复风格保持简洁、直接,优先给出可执行结果;如果存在限制、风险或未验证部分,需要明确说明。
@@ -57,3 +58,5 @@ PR 应包含:
## 安全与配置提示
`public/ssl/``build/extraResources/``electron/config/` 包含敏感运行资源。不要硬编码新的密钥或口令;凡是影响打包或启动的本地 `.env`、端口或运行配置调整,都应同步记录到 `doc/`

View File

@@ -0,0 +1,212 @@
# 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 @@

View File

@@ -0,0 +1,266 @@
# 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

@@ -0,0 +1,183 @@
<template>
<section class="waveform-panel">
<div class="panel-header">
<div class="section-title">波形信息</div>
</div>
<div v-if="hasParsedWaveform" class="panel-body info-body">
<div class="panel-tip">当前接口未返回向量图坐标右侧展示解析摘要与特征值结果</div>
<div class="summary-grid">
<div v-for="item in summaryItems" :key="item.label" class="summary-item">
<div class="summary-label">{{ item.label }}</div>
<div class="summary-value">{{ item.value }}</div>
</div>
</div>
<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 v-if="lastParseErrorMessage" class="empty-text error-text">最近一次解析失败{{ lastParseErrorMessage }}</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import type { FeatureCardItem, SummaryItem } from './types'
defineProps<{
hasParsedWaveform: boolean
summaryItems: SummaryItem[]
featureCards: FeatureCardItem[]
lastParseErrorMessage: string
}>()
</script>
<style scoped lang="scss">
.waveform-panel {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
padding: 16px;
overflow: hidden;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 4px;
}
.panel-header {
margin-bottom: 16px;
flex-shrink: 0;
}
.panel-body {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.empty-block {
display: flex;
flex: 1;
min-height: 0;
padding: 12px;
overflow: hidden;
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
background: var(--cn-color-canvas-bg);
}
.empty-title {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.empty-text,
.empty-inline,
.panel-tip {
font-size: 13px;
line-height: 1.6;
color: var(--el-text-color-regular);
}
.error-text {
color: var(--el-color-danger);
word-break: break-all;
}
.info-body {
gap: 12px;
overflow: auto;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.summary-item,
.feature-card {
padding: 12px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
background: var(--cn-color-canvas-bg);
}
.summary-label,
.feature-card-title,
.feature-header {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.summary-value {
margin-top: 6px;
font-size: 13px;
line-height: 1.6;
color: var(--el-text-color-regular);
word-break: break-all;
}
.feature-header {
margin-top: 4px;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.feature-card {
display: flex;
flex-direction: column;
gap: 8px;
}
.feature-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-size: 13px;
color: var(--el-text-color-regular);
}
@media (max-width: 992px) {
.summary-grid,
.feature-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,215 @@
<template>
<div class="page-header">
<div class="page-title">波形查看</div>
<div class="action-row">
<div class="file-select-row">
<el-input
:model-value="selectedWaveformFileName"
readonly
placeholder="请选择同一组.cfg和.dat文件"
class="file-input"
/>
<el-button type="primary" :loading="isParsing" @click="openWaveformFilePicker">选择波形</el-button>
<input
ref="waveformFileInputRef"
type="file"
:accept="waveformFileAccept"
multiple
class="waveform-file-input"
@change="handleWaveformFileChange"
/>
</div>
<div class="toolbar-actions">
<div v-if="channelOptions.length" class="toolbar-item">
<div class="toolbar-label">波形通道</div>
<el-select
:model-value="activeChannelIndex"
class="channel-select"
placeholder="选择通道"
@update:model-value="handleChannelChange"
>
<el-option v-for="item in channelOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</div>
<div class="toolbar-item">
<div class="toolbar-label">功能显示</div>
<el-select
:model-value="activeDisplayMode"
class="display-mode-select"
placeholder="选择显示模式"
@update:model-value="handleDisplayModeChange"
>
<el-option
v-for="item in displayModeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</div>
<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-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 { ref } from 'vue'
import type { DisplayMode, LabelValueOption, ValueMode, WaveformDetailOption } from './types'
defineProps<{
selectedWaveformFileName: string
isParsing: boolean
waveformFileAccept: string
channelOptions: WaveformDetailOption[]
activeChannelIndex: number
displayModeOptions: LabelValueOption<DisplayMode>[]
activeDisplayMode: DisplayMode
valueModeOptions: LabelValueOption<ValueMode>[]
activeValueMode: ValueMode
hasWaveformData: boolean
}>()
const emit = defineEmits<{
'update:activeChannelIndex': [value: number]
'update:activeDisplayMode': [value: DisplayMode]
'update:activeValueMode': [value: ValueMode]
'waveform-file-change': [event: Event]
download: []
}>()
const waveformFileInputRef = ref<HTMLInputElement>()
const openWaveformFilePicker = () => {
if (!waveformFileInputRef.value) return
waveformFileInputRef.value.value = ''
waveformFileInputRef.value.click()
}
const handleWaveformFileChange = (event: Event) => {
emit('waveform-file-change', event)
}
const handleChannelChange = (value: number) => {
emit('update:activeChannelIndex', value)
}
const handleDisplayModeChange = (value: DisplayMode) => {
emit('update:activeDisplayMode', value)
}
const handleValueModeChange = (value: string | number | boolean | undefined) => {
if (value === 'primary' || value === 'secondary') {
emit('update:activeValueMode', value)
}
}
</script>
<style scoped lang="scss">
.page-header {
display: flex;
flex-direction: column;
gap: 12px;
flex-shrink: 0;
}
.page-title {
font-size: 18px;
font-weight: 600;
line-height: 1.5;
color: var(--el-text-color-primary);
}
.action-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.file-select-row,
.toolbar-actions,
.toolbar-item {
display: flex;
gap: 12px;
align-items: center;
min-width: 0;
}
.toolbar-actions {
flex-shrink: 0;
}
.toolbar-label,
.file-select-row :deep(.el-input__inner),
.file-select-row :deep(.el-button),
.toolbar-actions :deep(.el-radio-button__inner),
.toolbar-actions :deep(.el-button),
.toolbar-actions :deep(.el-input__inner),
.toolbar-actions :deep(.el-select__placeholder),
.toolbar-actions :deep(.el-select__selected-item) {
font-size: 13px;
}
.file-input {
width: 360px;
max-width: 100%;
}
.channel-select {
width: 220px;
}
.display-mode-select {
width: 160px;
}
.value-mode-switch {
min-width: 150px;
}
.waveform-file-input {
display: none;
}
@media (max-width: 992px) {
.action-row,
.toolbar-actions {
flex-direction: column;
align-items: stretch;
}
.toolbar-item,
.file-select-row {
width: 100%;
}
.channel-select,
.display-mode-select,
.file-input,
.value-mode-switch {
width: 100%;
max-width: none;
}
}
@media (max-width: 768px) {
.page-title {
font-size: 17px;
}
}
</style>

View File

@@ -0,0 +1,181 @@
<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>
</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>
</section>
</template>
<script setup lang="ts">
import LineChart from '@/components/echarts/line/index.vue'
import type { DisplayMode, LabelValueOption, SingleChannelTrendOption, TrendTabValue } from './types'
defineProps<{
hasWaveformData: boolean
activeDisplayMode: DisplayMode
activeTrendTab: TrendTabValue
trendTabs: LabelValueOption<TrendTabValue>[]
activeTrendOptions: Record<string, unknown>
singleChannelTrendOptionsList: SingleChannelTrendOption[]
lastParseErrorMessage: string
}>()
const emit = defineEmits<{
'update:activeTrendTab': [value: TrendTabValue]
}>()
const handleTrendTabChange = (value: string | number) => {
emit('update:activeTrendTab', value as TrendTabValue)
}
</script>
<style scoped lang="scss">
.waveform-panel {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
padding: 16px;
overflow: hidden;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 4px;
}
.panel-header {
margin-bottom: 16px;
flex-shrink: 0;
}
.trend-tabs {
width: 100%;
flex-shrink: 0;
}
.trend-tabs :deep(.el-tabs__header) {
margin-bottom: 0;
}
.trend-tabs :deep(.el-tabs__nav-wrap::after) {
background-color: var(--el-border-color-light);
}
.trend-tabs :deep(.el-tabs__item) {
font-size: 13px;
}
.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: 12px;
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: 8px;
background: var(--cn-color-canvas-bg);
}
.single-channel-list {
display: flex;
flex: 1;
flex-direction: column;
gap: 12px;
min-height: 0;
overflow: hidden;
}
.single-channel-card {
display: flex;
flex: 1;
flex-direction: column;
gap: 8px;
min-height: 0;
padding: 12px;
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.18;
}
.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) {
.trend-tabs {
width: 100%;
}
.trend-tabs :deep(.el-tabs__nav) {
width: 100%;
}
.trend-tabs :deep(.el-tabs__item) {
flex: 1;
justify-content: center;
}
}
</style>

View File

@@ -0,0 +1,33 @@
export type TrendTabValue = 'instant' | 'rms'
export type ValueMode = 'primary' | 'secondary'
export type DisplayMode = 'single-channel' | 'multi-channel'
export interface LabelValueOption<T extends string | number = string | number> {
label: string
value: T
}
export interface WaveformDetailOption {
label: string
value: number
}
export interface SingleChannelTrendOption {
key: string
group: string
isLastChart?: boolean
options: Record<string, unknown>
}
export interface SummaryItem {
label: string
value: string | number
}
export interface FeatureCardItem {
title: string
rows: Array<{
label: string
value: string
}>
}

View File

@@ -1,147 +1,41 @@
<template>
<div class="table-box waveform-page">
<div class="page-header">
<div class="page-title">波形查看</div>
<div class="action-row">
<div class="file-select-row">
<el-input
:model-value="selectedWaveformFileName"
readonly
placeholder="请选择同一组.cfg和.dat文件"
class="file-input"
/>
<el-button type="primary" :loading="isParsing" @click="openWaveformFilePicker">选择波形</el-button>
<input
ref="waveformFileInputRef"
type="file"
:accept="waveformFileAccept"
multiple
class="waveform-file-input"
@change="handleWaveformFileChange"
/>
</div>
<div class="toolbar-actions">
<div v-if="channelOptions.length" class="toolbar-item">
<div class="toolbar-label">波形通道</div>
<el-select v-model="activeChannelIndex" class="channel-select" placeholder="选择通道">
<el-option
v-for="item in channelOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</div>
<div class="toolbar-item">
<div class="toolbar-label">功能显示</div>
<el-select v-model="activeDisplayMode" class="display-mode-select" placeholder="选择显示模式">
<el-option
v-for="item in displayModeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</div>
<div class="toolbar-item">
<div class="toolbar-label">数值类型</div>
<el-radio-group v-model="activeValueMode" class="value-mode-switch">
<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" :icon="Download" :disabled="!hasWaveformData" @click="downloadTrendData">
下载数据
</el-button>
</div>
</div>
</div>
<WaveformToolbar
:selected-waveform-file-name="selectedWaveformFileName"
:is-parsing="isParsing"
:waveform-file-accept="waveformFileAccept"
:channel-options="channelOptions"
:active-channel-index="activeChannelIndex"
:display-mode-options="displayModeOptions"
:active-display-mode="activeDisplayMode"
:value-mode-options="valueModeOptions"
:active-value-mode="activeValueMode"
:has-waveform-data="hasWaveformData"
@update:active-channel-index="activeChannelIndex = $event"
@update:active-display-mode="activeDisplayMode = $event"
@update:active-value-mode="activeValueMode = $event"
@waveform-file-change="handleWaveformFileChange"
@download="downloadTrendData"
/>
<div class="waveform-layout">
<section class="waveform-panel">
<div class="panel-header">
<el-tabs v-model="activeTrendTab" class="trend-tabs">
<el-tab-pane v-for="item in trendTabs" :key="item.value" :label="item.label" :name="item.value" />
</el-tabs>
</div>
<WaveformTrendPanel
:has-waveform-data="hasWaveformData"
:active-display-mode="activeDisplayMode"
:active-trend-tab="activeTrendTab"
:trend-tabs="trendTabs"
:active-trend-options="activeTrendOptions"
:single-channel-trend-options-list="singleChannelTrendOptionsList"
:last-parse-error-message="lastParseErrorMessage"
@update:active-trend-tab="activeTrendTab = $event"
/>
<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">
<div class="single-channel-chart">
<LineChart :options="item.options" :group="item.group" />
</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>
</section>
<section class="waveform-panel">
<div class="panel-header">
<div class="section-title">波形信息</div>
</div>
<div v-if="hasParsedWaveform" class="panel-body info-body">
<div class="panel-tip">当前接口未返回向量图坐标右侧展示解析摘要与特征值结果</div>
<div class="summary-grid">
<div v-for="item in summaryItems" :key="item.label" class="summary-item">
<div class="summary-label">{{ item.label }}</div>
<div class="summary-value">{{ item.value }}</div>
</div>
</div>
<div class="feature-header">特征值</div>
<div v-if="eigenvalueList.length" class="feature-grid">
<div v-for="(item, index) in eigenvalueList" :key="index" class="feature-card">
<div class="feature-card-title">特征值 {{ index + 1 }}</div>
<div class="feature-row">
<span>幅值占比</span>
<span>{{ formatPercentage(item.amplitude) }}</span>
</div>
<div class="feature-row">
<span>残余电压</span>
<span>{{ formatNumber(item.residualVoltage) }}</span>
</div>
<div class="feature-row">
<span>额定电压</span>
<span>{{ formatNumber(item.ratedVoltage) }}</span>
</div>
<div class="feature-row">
<span>持续时间</span>
<span>{{ formatDuration(item.durationTime) }}</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 v-if="lastParseErrorMessage" class="empty-text error-text">最近一次解析失败{{ lastParseErrorMessage }}</div>
</div>
</div>
</section>
<WaveformInfoPanel
:has-parsed-waveform="hasParsedWaveform"
:summary-items="summaryItems"
:feature-cards="featureCards"
:last-parse-error-message="lastParseErrorMessage"
/>
</div>
</div>
</template>
@@ -149,24 +43,26 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Download } from '@element-plus/icons-vue'
import LineChart from '@/components/echarts/line/index.vue'
import { parseComtradeApi } from '@/api/tools/waveform'
import type { Waveform } from '@/api/tools/waveform/interface'
import WaveformInfoPanel from './components/WaveformInfoPanel.vue'
import WaveformToolbar from './components/WaveformToolbar.vue'
import WaveformTrendPanel from './components/WaveformTrendPanel.vue'
import type {
DisplayMode,
FeatureCardItem,
LabelValueOption,
SingleChannelTrendOption,
SummaryItem,
TrendTabValue,
ValueMode,
WaveformDetailOption
} from './components/types'
defineOptions({
name: 'WaveformView'
})
type TrendTabValue = 'instant' | 'rms'
type ValueMode = 'primary' | 'secondary'
type DisplayMode = 'single-channel' | 'multi-channel'
interface WaveformDetailOption {
label: string
value: number
}
interface WaveformSeriesItem {
name: string
data: number[]
@@ -190,37 +86,36 @@ const activeDisplayMode = ref<DisplayMode>('single-channel')
const activeChannelIndex = ref(0)
const singleChannelTrendChartGroup = 'waveform-single-channel-sync'
const isParsing = ref(false)
const waveformFileInputRef = ref<HTMLInputElement>()
const selectedCfgFile = ref<File | null>(null)
const selectedDatFile = ref<File | null>(null)
const waveformParseResult = ref<Waveform.WaveComtradeResultVO | null>(null)
const lastParseErrorMessage = ref('')
const waveformFileAccept = '.cfg,.dat'
const trendTabs = [
{ value: 'instant' as const, label: '瞬时波形' },
{ value: 'rms' as const, label: 'RMS 波形' }
const trendTabs: LabelValueOption<TrendTabValue>[] = [
{ value: 'instant', label: '瞬时波形' },
{ value: 'rms', label: 'RMS 波形' }
]
const valueModeOptions = [
const valueModeOptions: LabelValueOption<ValueMode>[] = [
{ label: '一次值', value: 'primary' },
{ label: '二次值', value: 'secondary' }
]
const displayModeOptions = [
{ label: '单通道', value: 'single-channel' as const },
{ label: '多通道', value: 'multi-channel' as const }
const displayModeOptions: LabelValueOption<DisplayMode>[] = [
{ label: '单通道', value: 'single-channel' },
{ label: '多通道', value: 'multi-channel' }
]
const trendLabelMap = {
const trendLabelMap: Record<TrendTabValue, string> = {
instant: '瞬时波形',
rms: 'RMS 波形'
} as const
}
const valueModeLabelMap = {
const valueModeLabelMap: Record<ValueMode, string> = {
primary: '一次值',
secondary: '二次值'
} as const
}
const readThemeColor = (name: string, fallback: string) => {
if (typeof window === 'undefined') return fallback
@@ -235,6 +130,8 @@ const phaseColors = {
}
const defaultPhaseColors = [phaseColors.a, phaseColors.b, phaseColors.c]
const axisTextColor = readThemeColor('--el-text-color-regular', '#606266')
const axisLineColor = readThemeColor('--el-border-color', '#dcdfe6')
const selectedWaveformFileName = computed(() => {
const fileNames = [selectedCfgFile.value?.name, selectedDatFile.value?.name].filter(Boolean)
@@ -429,6 +326,16 @@ const buildTrendAxisConfig = (trendPayload: WaveformTrendPayload) => {
}
}
const buildTimeAxisLabelFormatter = (timeLabels: string[]) => {
const lastIndex = timeLabels.length - 1
return (value: string | number, index: number) => {
// 横坐标只保留首尾标签,避免波形点位过多时底部文字拥挤。
if (index !== 0 && index !== lastIndex) return ''
return formatNumber(value, 2)
}
}
const buildTrendChartOptions = (
trendPayload: WaveformTrendPayload,
seriesList: WaveformSeriesItem[],
@@ -439,6 +346,15 @@ const buildTrendChartOptions = (
return {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'line',
snap: true,
lineStyle: {
color: 'rgba(24, 144, 255, 0.55)',
width: 1
}
},
valueFormatter: (value: number) => {
return trendPayload.unit ? `${formatNumber(value)} ${trendPayload.unit}` : formatNumber(value)
}
@@ -456,18 +372,32 @@ const buildTrendChartOptions = (
xAxis: {
data: trendPayload.timeLabels,
boundaryGap: false,
// 仅最后一张图显示时间轴,前两张图不再额外预留占位,尽量放大趋势内容区。
name: showTimeAxis ? 'ms' : '',
nameLocation: 'end',
nameGap: showTimeAxis ? 10 : 0,
nameTextStyle: {
color: axisTextColor
},
axisLine: {
show: showTimeAxis,
lineStyle: {
color: axisLineColor
}
},
axisLabel: {
show: showTimeAxis,
hideOverlap: true,
interval: 'auto',
hideOverlap: false,
interval: 0,
margin: showTimeAxis ? 6 : 0,
formatter: (value: string | number) => formatNumber(value, 2)
color: axisTextColor,
formatter: buildTimeAxisLabelFormatter(trendPayload.timeLabels)
},
axisTick: {
show: showTimeAxis
show: showTimeAxis,
lineStyle: {
color: axisLineColor
}
}
},
yAxis: buildTrendAxisConfig(trendPayload),
@@ -483,32 +413,33 @@ const buildTrendChartOptions = (
}
}
const activeTrendOptions = computed(() => {
const activeTrendOptions = computed<Record<string, unknown>>(() => {
const trendPayload = activeTrendPayload.value
return buildTrendChartOptions(trendPayload, trendPayload.series)
})
// 单通道模式按相别拆成多个图,便于分别观察各通道波形。
const singleChannelTrendOptionsList = computed(() => {
const singleChannelTrendOptionsList = computed<SingleChannelTrendOption[]>(() => {
const trendPayload = activeTrendPayload.value
// 单通道下每张图只保留一个 series需要单独指定对应相色。
return trendPayload.series.map((item, index) => ({
key: item.name,
group: singleChannelTrendChartGroup,
isLastChart: index === trendPayload.series.length - 1,
options: buildTrendChartOptions(
trendPayload,
[item],
[currentTrendColors.value[index] || defaultPhaseColors[index]],
{
// 仅最后一张图显示时间轴,既保留时间信息,又避免多图重复挤占高度
// 仅最后一张图显示时间轴,并通过卡片高度微调补偿其额外占用空间
showTimeAxis: index === trendPayload.series.length - 1
}
)
}))
})
const summaryItems = computed(() => {
const summaryItems = computed<SummaryItem[]>(() => {
const waveData = activeWaveData.value
const cfgData = waveData?.comtradeCfgDTO
const detail = activeWaveDetail.value
@@ -529,12 +460,17 @@ const summaryItems = computed(() => {
]
})
const openWaveformFilePicker = () => {
if (!waveformFileInputRef.value) return
waveformFileInputRef.value.value = ''
waveformFileInputRef.value.click()
}
const featureCards = computed<FeatureCardItem[]>(() => {
return eigenvalueList.value.map((item, index) => ({
title: `特征值 ${index + 1}`,
rows: [
{ label: '幅值占比', value: formatPercentage(item.amplitude) },
{ label: '残余电压', value: formatNumber(item.residualVoltage) },
{ label: '额定电压', value: formatNumber(item.ratedVoltage) },
{ label: '持续时间', value: formatDuration(item.durationTime) }
]
}))
})
const getFileBaseName = (fileName: string) => {
return fileName.replace(/\.[^.]+$/, '').toLowerCase()
@@ -648,73 +584,6 @@ const downloadTrendData = () => {
overflow: hidden;
}
.page-header {
display: flex;
flex-direction: column;
gap: 12px;
flex-shrink: 0;
}
.page-title {
font-size: 18px;
font-weight: 600;
line-height: 1.5;
color: var(--el-text-color-primary);
}
.action-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.file-select-row,
.toolbar-actions,
.toolbar-item {
display: flex;
gap: 12px;
align-items: center;
min-width: 0;
}
.toolbar-actions {
flex-shrink: 0;
}
.toolbar-label,
.file-select-row :deep(.el-input__inner),
.file-select-row :deep(.el-button),
.toolbar-actions :deep(.el-radio-button__inner),
.toolbar-actions :deep(.el-button),
.toolbar-actions :deep(.el-input__inner),
.toolbar-actions :deep(.el-select__placeholder),
.toolbar-actions :deep(.el-select__selected-item),
.trend-tabs :deep(.el-tabs__item) {
font-size: 13px;
}
.file-input {
width: 360px;
max-width: 100%;
}
.channel-select {
width: 220px;
}
.display-mode-select {
width: 160px;
}
.value-mode-switch {
min-width: 150px;
}
.waveform-file-input {
display: none;
}
.waveform-layout {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(360px, 0.95fr);
@@ -724,227 +593,17 @@ const downloadTrendData = () => {
overflow: hidden;
}
.waveform-panel {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
padding: 16px;
overflow: hidden;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 4px;
}
.panel-header {
margin-bottom: 16px;
flex-shrink: 0;
}
.trend-tabs {
width: 100%;
flex-shrink: 0;
}
.trend-tabs :deep(.el-tabs__header) {
margin-bottom: 0;
}
.trend-tabs :deep(.el-tabs__nav-wrap::after) {
background-color: var(--el-border-color-light);
}
.panel-body {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.chart-container,
.empty-block {
display: flex;
flex: 1;
min-height: 0;
padding: 12px;
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: 8px;
background: var(--cn-color-canvas-bg);
}
.single-channel-list {
display: flex;
flex: 1;
flex-direction: column;
gap: 12px;
min-height: 0;
overflow: hidden;
}
.single-channel-card {
display: flex;
flex: 1;
flex-direction: column;
gap: 8px;
min-height: 0;
padding: 12px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
background: var(--cn-color-canvas-bg);
overflow: hidden;
}
.single-channel-chart {
flex: 1;
min-height: 0;
}
.empty-title {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.empty-text,
.empty-inline,
.panel-tip {
font-size: 13px;
line-height: 1.6;
color: var(--el-text-color-regular);
}
.error-text {
color: var(--el-color-danger);
word-break: break-all;
}
.info-body {
gap: 12px;
overflow: auto;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.summary-item,
.feature-card {
padding: 12px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
background: var(--cn-color-canvas-bg);
}
.summary-label,
.feature-card-title,
.feature-header {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.summary-value {
margin-top: 6px;
font-size: 13px;
line-height: 1.6;
color: var(--el-text-color-regular);
word-break: break-all;
}
.feature-header {
margin-top: 4px;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.feature-card {
display: flex;
flex-direction: column;
gap: 8px;
}
.feature-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-size: 13px;
color: var(--el-text-color-regular);
}
@media (max-width: 1280px) {
.waveform-layout {
grid-template-columns: 1fr;
}
}
@media (max-width: 992px) {
.action-row,
.toolbar-actions {
flex-direction: column;
align-items: stretch;
}
.toolbar-item,
.file-select-row {
width: 100%;
}
.channel-select,
.display-mode-select,
.file-input,
.value-mode-switch {
width: 100%;
max-width: none;
}
.summary-grid,
.feature-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.page-title {
font-size: 17px;
}
.trend-tabs {
width: 100%;
}
.trend-tabs :deep(.el-tabs__nav) {
width: 100%;
}
.trend-tabs :deep(.el-tabs__item) {
flex: 1;
justify-content: center;
}
}
</style>