波形解析相关
This commit is contained in:
1
frontend/src/views/tools/mmsMapping/.gitkeep
Normal file
1
frontend/src/views/tools/mmsMapping/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
266
frontend/src/views/tools/waveform/PARSE_COMTRADE_API.md
Normal file
266
frontend/src/views/tools/waveform/PARSE_COMTRADE_API.md
Normal 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`,其他波形文本解析接口请单独编写。
|
||||
@@ -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>
|
||||
215
frontend/src/views/tools/waveform/components/WaveformToolbar.vue
Normal file
215
frontend/src/views/tools/waveform/components/WaveformToolbar.vue
Normal 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>
|
||||
@@ -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>
|
||||
33
frontend/src/views/tools/waveform/components/types.ts
Normal file
33
frontend/src/views/tools/waveform/components/types.ts
Normal 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
|
||||
}>
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user