波形解析相关
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
import http from '@/api'
|
||||
import type { Waveform } from './interface'
|
||||
|
||||
const appendFormDataValue = (formData: FormData, key: string, value: File | string | number | boolean | undefined) => {
|
||||
if (value === undefined || value === null || value === '') return
|
||||
formData.append(key, value instanceof File ? value : `${value}`)
|
||||
}
|
||||
|
||||
export const parseComtradeApi = (params: Waveform.ParseComtradeParams) => {
|
||||
const formData = new FormData()
|
||||
|
||||
@@ -11,3 +16,19 @@ export const parseComtradeApi = (params: Waveform.ParseComtradeParams) => {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
export const parseComtradeVectorApi = (params: Waveform.ParseComtradeVectorParams) => {
|
||||
const formData = new FormData()
|
||||
|
||||
appendFormDataValue(formData, 'cfgFile', params.cfgFile)
|
||||
appendFormDataValue(formData, 'datFile', params.datFile)
|
||||
appendFormDataValue(formData, 'parseType', params.parseType)
|
||||
appendFormDataValue(formData, 'ptType', params.ptType)
|
||||
appendFormDataValue(formData, 'pt', params.pt)
|
||||
appendFormDataValue(formData, 'ct', params.ct)
|
||||
appendFormDataValue(formData, 'monitorName', params.monitorName)
|
||||
|
||||
return http.post<Waveform.WaveComtradeVectorResultVO>(`/wave/parseComtradeVector`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,6 +13,16 @@ export namespace Waveform {
|
||||
dynamicThreshold?: boolean
|
||||
}
|
||||
|
||||
export interface ParseComtradeVectorParams {
|
||||
cfgFile: File
|
||||
datFile: File
|
||||
parseType?: number
|
||||
ptType?: number
|
||||
pt?: number
|
||||
ct?: number
|
||||
monitorName?: string
|
||||
}
|
||||
|
||||
export interface WaveTrendData {
|
||||
max?: number
|
||||
min?: number
|
||||
@@ -71,6 +81,62 @@ export namespace Waveform {
|
||||
durationTime?: number
|
||||
}
|
||||
|
||||
export interface WaveHarmonicDTO {
|
||||
harmonicOrder?: number
|
||||
amplitude?: number
|
||||
rms?: number
|
||||
rate?: number
|
||||
}
|
||||
|
||||
export interface WavePhaseVectorDTO {
|
||||
phaseName?: string
|
||||
totalRms?: number
|
||||
fundamentalAmplitude?: number
|
||||
fundamentalRms?: number
|
||||
fundamentalPhaseAngle?: number
|
||||
harmonicVoltageContentRates?: WaveHarmonicDTO[]
|
||||
harmonicCurrentAmplitudes?: WaveHarmonicDTO[]
|
||||
harmonicDistortionRate?: number
|
||||
}
|
||||
|
||||
export interface WaveSequenceVectorDTO {
|
||||
sequenceName?: string
|
||||
amplitude?: number
|
||||
rms?: number
|
||||
phaseAngle?: number
|
||||
}
|
||||
|
||||
export interface WaveSequenceUnbalanceDTO {
|
||||
negativeUnbalanceRate?: number
|
||||
zeroUnbalanceRate?: number
|
||||
}
|
||||
|
||||
export interface WaveCycleVectorDTO {
|
||||
cycleIndex?: number
|
||||
time?: number
|
||||
phaseVectors?: WavePhaseVectorDTO[]
|
||||
positiveSequence?: WaveSequenceVectorDTO
|
||||
negativeSequence?: WaveSequenceVectorDTO
|
||||
zeroSequence?: WaveSequenceVectorDTO
|
||||
unbalance?: WaveSequenceUnbalanceDTO
|
||||
}
|
||||
|
||||
export interface WaveVectorGroupDTO {
|
||||
channelName?: string
|
||||
unit?: string
|
||||
phaseCount?: number
|
||||
phaseNames?: string[]
|
||||
vectorSeries?: WaveCycleVectorDTO[]
|
||||
}
|
||||
|
||||
export interface WaveComtradeVectorResultVO {
|
||||
monitorName?: string
|
||||
time?: string
|
||||
samplePerCycle?: number
|
||||
cycleCount?: number
|
||||
vectorGroups?: WaveVectorGroupDTO[]
|
||||
}
|
||||
|
||||
export interface WaveComtradeResultVO {
|
||||
waveData?: WaveDataDTO
|
||||
waveDataDetails?: WaveDataDetail[]
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
<template>
|
||||
<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>
|
||||
@@ -14,6 +12,97 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-section">
|
||||
<div class="feature-header">向量信息</div>
|
||||
<div v-if="hasVectorData" class="vector-content">
|
||||
<div class="vector-summary-grid">
|
||||
<div v-for="item in vectorSummaryItems" :key="item.label" class="summary-item vector-summary-item">
|
||||
<div class="summary-label">{{ item.label }}</div>
|
||||
<div class="summary-value">{{ item.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="activeVectorGroupKey" class="vector-tabs">
|
||||
<el-tab-pane
|
||||
v-for="(group, index) in vectorGroups"
|
||||
:key="buildVectorGroupKey(group, index)"
|
||||
:label="group.channelName || `分组 ${index + 1}`"
|
||||
:name="buildVectorGroupKey(group, index)"
|
||||
/>
|
||||
</el-tabs>
|
||||
|
||||
<div v-if="activeVectorGroup" class="vector-panel-body">
|
||||
<el-tabs v-model="activeCycleKey" class="vector-tabs vector-cycle-tabs">
|
||||
<el-tab-pane
|
||||
v-for="(cycle, index) in activeVectorSeries"
|
||||
:key="buildCycleKey(cycle, index)"
|
||||
:label="buildCycleLabel(cycle, index)"
|
||||
:name="buildCycleKey(cycle, index)"
|
||||
/>
|
||||
</el-tabs>
|
||||
|
||||
<div v-if="activeCycle" class="vector-tab-content">
|
||||
<div v-if="phaseVectorCards.length" class="vector-card-grid">
|
||||
<div v-for="item in phaseVectorCards" :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 class="feature-header feature-header--nested">序分量与不平衡度</div>
|
||||
<div class="vector-card-grid sequence-grid">
|
||||
<div v-for="item in sequenceCards" :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 class="feature-header feature-header--nested">谐波信息</div>
|
||||
<el-tabs v-if="activePhaseVectors.length" v-model="activePhaseKey" class="vector-tabs vector-phase-tabs">
|
||||
<el-tab-pane
|
||||
v-for="(phase, index) in activePhaseVectors"
|
||||
:key="buildPhaseKey(phase, index)"
|
||||
:label="phase.phaseName || `相别 ${index + 1}`"
|
||||
:name="buildPhaseKey(phase, index)"
|
||||
/>
|
||||
</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>
|
||||
<div v-else class="empty-inline">当前相未返回谐波结果。</div>
|
||||
</div>
|
||||
<div v-else class="empty-inline">当前分组未返回逐周波结果。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="vector-placeholder">向量信息待接口返回后展示。</div>
|
||||
<div v-if="lastVectorParseErrorMessage" class="empty-text error-text">
|
||||
最近一次向量解析失败:{{ lastVectorParseErrorMessage }}
|
||||
</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">
|
||||
@@ -30,7 +119,7 @@
|
||||
<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>
|
||||
@@ -38,14 +127,202 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import type { Waveform } from '@/api/tools/waveform/interface'
|
||||
import type { FeatureCardItem, SummaryItem } from './types'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
hasParsedWaveform: boolean
|
||||
summaryItems: SummaryItem[]
|
||||
featureCards: FeatureCardItem[]
|
||||
vectorParseResult: Waveform.WaveComtradeVectorResultVO | null
|
||||
lastParseErrorMessage: string
|
||||
lastVectorParseErrorMessage: string
|
||||
activeVectorChannelName: string
|
||||
}>()
|
||||
|
||||
const activeVectorGroupKey = ref('')
|
||||
const activeCycleKey = ref('')
|
||||
const activePhaseKey = ref('')
|
||||
|
||||
const formatNumber = (value: unknown, fractionDigits = 3) => {
|
||||
const numberValue = Number(value)
|
||||
|
||||
if (!Number.isFinite(numberValue)) return '--'
|
||||
if (Number.isInteger(numberValue)) return `${numberValue}`
|
||||
|
||||
return `${Number(numberValue.toFixed(fractionDigits))}`
|
||||
}
|
||||
|
||||
const formatWaveformTime = (value?: string) => {
|
||||
if (!value) return '--'
|
||||
|
||||
const parsedValue = dayjs(value)
|
||||
return parsedValue.isValid() ? parsedValue.format('YYYY-MM-DD HH:mm:ss.SSS') : value
|
||||
}
|
||||
|
||||
const formatWaveValue = (value: unknown, unit?: string) => {
|
||||
const formattedValue = formatNumber(value)
|
||||
if (formattedValue === '--') return '--'
|
||||
return unit ? `${formattedValue} ${unit}` : 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 buildVectorGroupKey = (group: Waveform.WaveVectorGroupDTO, index: number) => {
|
||||
return group.channelName || `group-${index}`
|
||||
}
|
||||
|
||||
const buildCycleKey = (cycle: Waveform.WaveCycleVectorDTO, index: number) => {
|
||||
return `${cycle.cycleIndex ?? index}`
|
||||
}
|
||||
|
||||
const buildPhaseKey = (phase: Waveform.WavePhaseVectorDTO, index: number) => {
|
||||
return phase.phaseName || `phase-${index}`
|
||||
}
|
||||
|
||||
const buildCycleLabel = (cycle: Waveform.WaveCycleVectorDTO, index: number) => {
|
||||
return `周波 ${Number(cycle.cycleIndex ?? index) + 1}`
|
||||
}
|
||||
|
||||
const vectorGroups = computed(() => props.vectorParseResult?.vectorGroups || [])
|
||||
const hasVectorData = computed(() => vectorGroups.value.length > 0)
|
||||
|
||||
watch(
|
||||
[vectorGroups, () => props.activeVectorChannelName],
|
||||
([groups, activeChannelName]) => {
|
||||
if (!groups.length) {
|
||||
activeVectorGroupKey.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const matchedIndex = groups.findIndex(item => item.channelName === activeChannelName)
|
||||
const nextIndex = matchedIndex >= 0 ? matchedIndex : 0
|
||||
activeVectorGroupKey.value = buildVectorGroupKey(groups[nextIndex], nextIndex)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const activeVectorGroup = computed(() => {
|
||||
return vectorGroups.value.find((item, index) => buildVectorGroupKey(item, index) === activeVectorGroupKey.value) || vectorGroups.value[0] || null
|
||||
})
|
||||
|
||||
const activeVectorSeries = computed(() => activeVectorGroup.value?.vectorSeries || [])
|
||||
|
||||
watch(
|
||||
activeVectorSeries,
|
||||
series => {
|
||||
if (!series.length) {
|
||||
activeCycleKey.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
if (series.some((item, index) => buildCycleKey(item, index) === activeCycleKey.value)) return
|
||||
activeCycleKey.value = buildCycleKey(series[0], 0)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const activeCycle = computed(() => {
|
||||
return activeVectorSeries.value.find((item, index) => buildCycleKey(item, index) === activeCycleKey.value) || activeVectorSeries.value[0] || null
|
||||
})
|
||||
|
||||
const activePhaseVectors = computed(() => activeCycle.value?.phaseVectors || [])
|
||||
|
||||
watch(
|
||||
activePhaseVectors,
|
||||
phaseVectors => {
|
||||
if (!phaseVectors.length) {
|
||||
activePhaseKey.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
if (phaseVectors.some((item, index) => buildPhaseKey(item, index) === activePhaseKey.value)) return
|
||||
activePhaseKey.value = buildPhaseKey(phaseVectors[0], 0)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const activePhaseVector = computed(() => {
|
||||
return activePhaseVectors.value.find((item, index) => buildPhaseKey(item, index) === activePhaseKey.value) || activePhaseVectors.value[0] || null
|
||||
})
|
||||
|
||||
const vectorSummaryItems = computed<SummaryItem[]>(() => {
|
||||
const activeGroup = activeVectorGroup.value
|
||||
const currentCycle = activeCycle.value
|
||||
|
||||
return [
|
||||
{ label: '测点名称', value: props.vectorParseResult?.monitorName || '--' },
|
||||
{ label: '事件时间', value: formatWaveformTime(props.vectorParseResult?.time) },
|
||||
{ label: '每周波采样点数', value: props.vectorParseResult?.samplePerCycle ?? '--' },
|
||||
{ label: '可计算周波数', value: props.vectorParseResult?.cycleCount ?? '--' },
|
||||
{ label: '当前分组', value: activeGroup?.channelName || '--' },
|
||||
{ label: '当前周波', value: currentCycle ? buildCycleLabel(currentCycle, Number(currentCycle.cycleIndex ?? 0)) : '--' }
|
||||
]
|
||||
})
|
||||
|
||||
const phaseVectorCards = computed<FeatureCardItem[]>(() => {
|
||||
const activeGroup = activeVectorGroup.value
|
||||
|
||||
return activePhaseVectors.value.map(item => ({
|
||||
title: item.phaseName || '相量',
|
||||
rows: [
|
||||
{ label: '总有效值', value: formatWaveValue(item.totalRms, activeGroup?.unit) },
|
||||
{ label: '基波幅值', value: formatWaveValue(item.fundamentalAmplitude, activeGroup?.unit) },
|
||||
{ label: '基波有效值', value: formatWaveValue(item.fundamentalRms, activeGroup?.unit) },
|
||||
{ label: '基波相角', value: formatPhaseAngle(item.fundamentalPhaseAngle) },
|
||||
{ label: '谐波畸变率', value: formatPercentValue(item.harmonicDistortionRate) }
|
||||
]
|
||||
}))
|
||||
})
|
||||
|
||||
const sequenceCards = computed<FeatureCardItem[]>(() => {
|
||||
const activeGroup = activeVectorGroup.value
|
||||
const currentCycle = activeCycle.value
|
||||
|
||||
const sequenceList = [currentCycle?.positiveSequence, currentCycle?.negativeSequence, currentCycle?.zeroSequence]
|
||||
.filter(Boolean)
|
||||
.map(item => ({
|
||||
title: item?.sequenceName || '序分量',
|
||||
rows: [
|
||||
{ label: '幅值', value: formatWaveValue(item?.amplitude, activeGroup?.unit) },
|
||||
{ label: '有效值', value: formatWaveValue(item?.rms, activeGroup?.unit) },
|
||||
{ label: '相角', value: formatPhaseAngle(item?.phaseAngle) }
|
||||
]
|
||||
}))
|
||||
|
||||
return [
|
||||
...sequenceList,
|
||||
{
|
||||
title: '不平衡度',
|
||||
rows: [
|
||||
{ label: '负序不平衡度', value: formatPercentValue(currentCycle?.unbalance?.negativeUnbalanceRate) },
|
||||
{ label: '零序不平衡度', value: formatPercentValue(currentCycle?.unbalance?.zeroUnbalanceRate) },
|
||||
{ label: '周波中点', value: formatCycleTime(currentCycle?.time) }
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const activeHarmonicRows = computed(() => {
|
||||
if (!activePhaseVector.value) return []
|
||||
|
||||
return activePhaseVector.value.harmonicVoltageContentRates || activePhaseVector.value.harmonicCurrentAmplitudes || []
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -54,7 +331,7 @@ defineProps<{
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
@@ -62,7 +339,7 @@ defineProps<{
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -103,7 +380,7 @@ defineProps<{
|
||||
|
||||
.empty-text,
|
||||
.empty-inline,
|
||||
.panel-tip {
|
||||
.vector-placeholder {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--el-text-color-regular);
|
||||
@@ -115,19 +392,39 @@ defineProps<{
|
||||
}
|
||||
|
||||
.info-body {
|
||||
gap: 12px;
|
||||
gap: 8px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.info-section,
|
||||
.vector-content,
|
||||
.vector-panel-body,
|
||||
.vector-tab-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vector-summary-grid,
|
||||
.vector-card-grid,
|
||||
.sequence-grid,
|
||||
.feature-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.summary-item,
|
||||
.feature-card {
|
||||
padding: 12px;
|
||||
.feature-card,
|
||||
.vector-placeholder,
|
||||
.harmonic-list {
|
||||
padding: 10px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 4px;
|
||||
background: var(--cn-color-canvas-bg);
|
||||
@@ -150,19 +447,17 @@ defineProps<{
|
||||
}
|
||||
|
||||
.feature-header {
|
||||
margin-top: 4px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.feature-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
.feature-header--nested {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.feature-row {
|
||||
@@ -174,10 +469,72 @@ defineProps<{
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.vector-tabs {
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vector-tabs :deep(.el-tabs__header) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.vector-tabs :deep(.el-tabs__nav-wrap::after) {
|
||||
background-color: var(--el-border-color-light);
|
||||
}
|
||||
|
||||
.vector-tabs :deep(.el-tabs__item) {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.harmonic-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.summary-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.summary-grid,
|
||||
.vector-summary-grid,
|
||||
.vector-card-grid,
|
||||
.sequence-grid,
|
||||
.feature-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.harmonic-row {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<div class="page-title">波形查看</div>
|
||||
|
||||
<div class="action-row">
|
||||
<div class="file-select-row">
|
||||
@@ -122,16 +121,8 @@ const handleValueModeChange = (value: string | number | boolean | undefined) =>
|
||||
<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);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
@@ -207,9 +198,5 @@ const handleValueModeChange = (value: string | number | boolean | undefined) =>
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-title {
|
||||
font-size: 17px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ const handleTrendTabChange = (value: string | number) => {
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ const handleTrendTabChange = (value: string | number) => {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
padding: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 4px;
|
||||
@@ -112,7 +112,7 @@ const handleTrendTabChange = (value: string | number) => {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
gap: 4px;
|
||||
background: var(--cn-color-canvas-bg);
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ const handleTrendTabChange = (value: string | number) => {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: 4px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -129,9 +129,9 @@ const handleTrendTabChange = (value: string | number) => {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 4px;
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 4px;
|
||||
background: var(--cn-color-canvas-bg);
|
||||
@@ -139,7 +139,7 @@ const handleTrendTabChange = (value: string | number) => {
|
||||
}
|
||||
|
||||
.single-channel-card--with-axis {
|
||||
flex: 1.18;
|
||||
flex: 1.1;
|
||||
}
|
||||
|
||||
.single-channel-chart {
|
||||
|
||||
@@ -34,7 +34,10 @@
|
||||
:has-parsed-waveform="hasParsedWaveform"
|
||||
:summary-items="summaryItems"
|
||||
:feature-cards="featureCards"
|
||||
:vector-parse-result="vectorParseResult"
|
||||
:last-parse-error-message="lastParseErrorMessage"
|
||||
:last-vector-parse-error-message="lastVectorParseErrorMessage"
|
||||
:active-vector-channel-name="activeWaveDetail?.channelName || ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -42,8 +45,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { parseComtradeApi } from '@/api/tools/waveform'
|
||||
import { parseComtradeApi, parseComtradeVectorApi } from '@/api/tools/waveform'
|
||||
import type { Waveform } from '@/api/tools/waveform/interface'
|
||||
import WaveformInfoPanel from './components/WaveformInfoPanel.vue'
|
||||
import WaveformToolbar from './components/WaveformToolbar.vue'
|
||||
@@ -89,7 +93,9 @@ const isParsing = ref(false)
|
||||
const selectedCfgFile = ref<File | null>(null)
|
||||
const selectedDatFile = ref<File | null>(null)
|
||||
const waveformParseResult = ref<Waveform.WaveComtradeResultVO | null>(null)
|
||||
const vectorParseResult = ref<Waveform.WaveComtradeVectorResultVO | null>(null)
|
||||
const lastParseErrorMessage = ref('')
|
||||
const lastVectorParseErrorMessage = ref('')
|
||||
const waveformFileAccept = '.cfg,.dat'
|
||||
|
||||
const trendTabs: LabelValueOption<TrendTabValue>[] = [
|
||||
@@ -257,6 +263,13 @@ const formatDuration = (value?: number) => {
|
||||
return `${Number(numberValue.toFixed(3))} ms`
|
||||
}
|
||||
|
||||
const formatWaveformTime = (value?: string) => {
|
||||
if (!value) return '--'
|
||||
|
||||
const parsedValue = dayjs(value)
|
||||
return parsedValue.isValid() ? parsedValue.format('YYYY-MM-DD HH:mm:ss.SSS') : value
|
||||
}
|
||||
|
||||
const buildChannelLabel = (detail: Waveform.WaveDataDetail, index: number) => {
|
||||
if (detail.title) return detail.title
|
||||
if (detail.channelName && detail.unit) return `${detail.channelName} (${detail.unit})`
|
||||
@@ -310,22 +323,96 @@ const currentTrendColors = computed(() => {
|
||||
|
||||
const hasWaveformData = computed(() => activeTrendPayload.value.series.length > 0)
|
||||
|
||||
const SYMMETRIC_AXIS_SPLIT_COUNT = 6
|
||||
const REGULAR_AXIS_SPLIT_COUNT = 5
|
||||
|
||||
const getAxisPrecision = (step: number) => {
|
||||
if (!Number.isFinite(step) || step >= 1) return 0
|
||||
|
||||
const stepText = `${step}`
|
||||
if (stepText.includes('e-')) {
|
||||
return Number(stepText.split('e-')[1] || 0)
|
||||
}
|
||||
|
||||
return stepText.split('.')[1]?.length || 0
|
||||
}
|
||||
|
||||
const normalizeAxisValue = (value: number, precision: number) => {
|
||||
const factor = 10 ** precision
|
||||
const normalizedValue = Math.round(value * factor) / factor
|
||||
return Object.is(normalizedValue, -0) ? 0 : normalizedValue
|
||||
}
|
||||
|
||||
const getNiceAxisInterval = (value: number) => {
|
||||
if (!Number.isFinite(value) || value <= 0) return 1
|
||||
|
||||
const exponent = Math.floor(Math.log10(value))
|
||||
const magnitude = 10 ** exponent
|
||||
const normalized = value / magnitude
|
||||
const candidates = [1, 2, 2.5, 5, 10]
|
||||
const closestCandidate = candidates.reduce((currentBest, currentValue) => {
|
||||
return Math.abs(currentValue - normalized) < Math.abs(currentBest - normalized) ? currentValue : currentBest
|
||||
}, candidates[0])
|
||||
|
||||
return closestCandidate * magnitude
|
||||
}
|
||||
|
||||
const roundAxisBoundary = (value: number, interval: number, isMin: boolean) => {
|
||||
const precision = getAxisPrecision(interval)
|
||||
const stepCount = isMin ? Math.floor(value / interval) : Math.ceil(value / interval)
|
||||
return normalizeAxisValue(stepCount * interval, precision)
|
||||
}
|
||||
|
||||
const formatAxisLabel = (value: number, precision: number) => {
|
||||
if (!Number.isFinite(value)) return ''
|
||||
return `${normalizeAxisValue(value, precision)}`
|
||||
}
|
||||
|
||||
const buildTrendAxisConfig = (trendPayload: WaveformTrendPayload) => {
|
||||
const min = trendPayload.min
|
||||
const max = trendPayload.max
|
||||
const valueGap = Number.isFinite(min) && Number.isFinite(max) ? Math.max((max! - min!) * 0.1, 1) : undefined
|
||||
const min = Number(trendPayload.min)
|
||||
const max = Number(trendPayload.max)
|
||||
|
||||
if (!Number.isFinite(min) || !Number.isFinite(max)) {
|
||||
return {
|
||||
name: trendPayload.unit || ''
|
||||
}
|
||||
}
|
||||
|
||||
const crossesZero = min < 0 && max > 0
|
||||
const splitCount = crossesZero ? SYMMETRIC_AXIS_SPLIT_COUNT : REGULAR_AXIS_SPLIT_COUNT
|
||||
const baseValue = crossesZero ? Math.max(Math.abs(min), Math.abs(max)) : max - min
|
||||
const interval = getNiceAxisInterval(baseValue / (crossesZero ? splitCount / 2 : splitCount))
|
||||
const precision = getAxisPrecision(interval)
|
||||
|
||||
let axisMin = roundAxisBoundary(min, interval, true)
|
||||
let axisMax = roundAxisBoundary(max, interval, false)
|
||||
|
||||
if (crossesZero) {
|
||||
const axisBound = Math.max(Math.abs(axisMin), Math.abs(axisMax))
|
||||
axisMin = normalizeAxisValue(-axisBound, precision)
|
||||
axisMax = normalizeAxisValue(axisBound, precision)
|
||||
}
|
||||
|
||||
if (axisMin === axisMax) {
|
||||
axisMin = normalizeAxisValue(axisMin - interval, precision)
|
||||
axisMax = normalizeAxisValue(axisMax + interval, precision)
|
||||
}
|
||||
|
||||
return {
|
||||
name: trendPayload.unit || '',
|
||||
...(valueGap !== undefined && min !== undefined && max !== undefined
|
||||
? {
|
||||
min: min - valueGap,
|
||||
max: max + valueGap
|
||||
}
|
||||
: {})
|
||||
min: axisMin,
|
||||
max: axisMax,
|
||||
interval,
|
||||
splitNumber: crossesZero ? SYMMETRIC_AXIS_SPLIT_COUNT : Math.max(Math.round((axisMax - axisMin) / interval), 1),
|
||||
minInterval: interval,
|
||||
maxInterval: interval,
|
||||
// 跨零波形按 0 对称出刻度,避免边界出现 164 / -165 这类不均匀标签。
|
||||
axisLabel: {
|
||||
hideOverlap: true,
|
||||
formatter: (value: number) => formatAxisLabel(value, precision)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const buildTimeAxisLabelFormatter = (timeLabels: string[]) => {
|
||||
const lastIndex = timeLabels.length - 1
|
||||
|
||||
@@ -360,14 +447,14 @@ const buildTrendChartOptions = (
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
top: 10,
|
||||
top: 0,
|
||||
right: 12
|
||||
},
|
||||
grid: {
|
||||
top: '18px',
|
||||
left: '24px',
|
||||
right: showTimeAxis ? '32px' : '24px',
|
||||
bottom: showTimeAxis ? '22px' : '12px'
|
||||
bottom: showTimeAxis ? '16px' : '6px'
|
||||
},
|
||||
xAxis: {
|
||||
data: trendPayload.timeLabels,
|
||||
@@ -381,6 +468,8 @@ const buildTrendChartOptions = (
|
||||
},
|
||||
axisLine: {
|
||||
show: showTimeAxis,
|
||||
// 时间轴固定在图底部,避免 y 轴包含 0 时横轴穿过波形区域显得过粗。
|
||||
onZero: false,
|
||||
lineStyle: {
|
||||
color: axisLineColor
|
||||
}
|
||||
@@ -389,7 +478,7 @@ const buildTrendChartOptions = (
|
||||
show: showTimeAxis,
|
||||
hideOverlap: false,
|
||||
interval: 0,
|
||||
margin: showTimeAxis ? 6 : 0,
|
||||
margin: showTimeAxis ? 9 : 0,
|
||||
color: axisTextColor,
|
||||
formatter: buildTimeAxisLabelFormatter(trendPayload.timeLabels)
|
||||
},
|
||||
@@ -445,18 +534,15 @@ const summaryItems = computed<SummaryItem[]>(() => {
|
||||
const detail = activeWaveDetail.value
|
||||
|
||||
return [
|
||||
{ label: '测点名称', value: waveData?.monitorName || '--' },
|
||||
{ label: '事件时间', value: waveData?.time || '--' },
|
||||
{ label: '录波开始', value: cfgData?.timeStart || '--' },
|
||||
{ label: '触发时间', value: cfgData?.timeTrige || '--' },
|
||||
{ label: '录波开始', value: formatWaveformTime(cfgData?.timeStart) },
|
||||
{ label: '触发时间', value: formatWaveformTime(cfgData?.timeTrige) },
|
||||
{ label: '采样率', value: cfgData?.finalSampleRate ? `${cfgData.finalSampleRate} Hz` : '--' },
|
||||
{ label: '总通道数', value: cfgData?.nChannelNum ?? '--' },
|
||||
{ label: '当前通道', value: detail ? buildChannelLabel(detail, activeChannelIndex.value) : '--' },
|
||||
{ label: '单位', value: detail?.unit || '--' },
|
||||
{ label: '相别数量', value: waveData?.iPhasic ?? '--' },
|
||||
{ label: 'PT / CT', value: `${formatNumber(waveData?.pt, 2)} / ${formatNumber(waveData?.ct, 2)}` },
|
||||
{ label: '数据类型', value: valueModeLabelMap[activeValueMode.value] },
|
||||
{ label: '文件编码', value: cfgData?.strBinType || '--' }
|
||||
{ label: '数据类型', value: valueModeLabelMap[activeValueMode.value] }
|
||||
]
|
||||
})
|
||||
|
||||
@@ -480,7 +566,9 @@ const resetSelectedWaveformFiles = () => {
|
||||
selectedCfgFile.value = null
|
||||
selectedDatFile.value = null
|
||||
waveformParseResult.value = null
|
||||
vectorParseResult.value = null
|
||||
lastParseErrorMessage.value = ''
|
||||
lastVectorParseErrorMessage.value = ''
|
||||
}
|
||||
|
||||
const handleWaveformFileChange = async (event: Event) => {
|
||||
@@ -524,24 +612,50 @@ const loadWaveformData = async (cfgFile: File, datFile: File) => {
|
||||
isParsing.value = true
|
||||
activeChannelIndex.value = 0
|
||||
lastParseErrorMessage.value = ''
|
||||
lastVectorParseErrorMessage.value = ''
|
||||
|
||||
const result = await parseComtradeApi({
|
||||
cfgFile,
|
||||
datFile
|
||||
})
|
||||
// 波形与向量结果共用同一组文件,这里并行请求以缩短右侧联动展示等待时间。
|
||||
const [waveformResult, vectorResult] = await Promise.allSettled([
|
||||
parseComtradeApi({
|
||||
cfgFile,
|
||||
datFile
|
||||
}),
|
||||
parseComtradeVectorApi({
|
||||
cfgFile,
|
||||
datFile,
|
||||
parseType: 3
|
||||
})
|
||||
])
|
||||
|
||||
waveformParseResult.value = result.data
|
||||
} catch (error) {
|
||||
waveformParseResult.value = null
|
||||
lastParseErrorMessage.value = getWaveformParseErrorMessage(error)
|
||||
if (waveformResult.status === 'fulfilled') {
|
||||
waveformParseResult.value = waveformResult.value.data
|
||||
} else {
|
||||
waveformParseResult.value = null
|
||||
lastParseErrorMessage.value = getWaveformParseErrorMessage(waveformResult.reason)
|
||||
|
||||
console.error('[waveform] parseComtrade failed', {
|
||||
cfgFileName: cfgFile.name,
|
||||
cfgFileSize: cfgFile.size,
|
||||
datFileName: datFile.name,
|
||||
datFileSize: datFile.size,
|
||||
error
|
||||
})
|
||||
console.error('[waveform] parseComtrade failed', {
|
||||
cfgFileName: cfgFile.name,
|
||||
cfgFileSize: cfgFile.size,
|
||||
datFileName: datFile.name,
|
||||
datFileSize: datFile.size,
|
||||
error: waveformResult.reason
|
||||
})
|
||||
}
|
||||
|
||||
if (vectorResult.status === 'fulfilled') {
|
||||
vectorParseResult.value = vectorResult.value.data
|
||||
} else {
|
||||
vectorParseResult.value = null
|
||||
lastVectorParseErrorMessage.value = getWaveformParseErrorMessage(vectorResult.reason)
|
||||
|
||||
console.error('[waveform] parseComtradeVector failed', {
|
||||
cfgFileName: cfgFile.name,
|
||||
cfgFileSize: cfgFile.size,
|
||||
datFileName: datFile.name,
|
||||
datFileSize: datFile.size,
|
||||
error: vectorResult.reason
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
isParsing.value = false
|
||||
}
|
||||
@@ -586,7 +700,7 @@ const downloadTrendData = () => {
|
||||
|
||||
.waveform-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(360px, 0.95fr);
|
||||
grid-template-columns: minmax(0, 1.5fr) minmax(300px, 0.72fr);
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -600,10 +714,3 @@ const downloadTrendData = () => {
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user