2026-04-16 08:15:46 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<div class="table-box waveform-page">
|
2026-04-16 20:20:52 +08:00
|
|
|
|
<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"
|
|
|
|
|
|
/>
|
2026-04-16 08:15:46 +08:00
|
|
|
|
|
|
|
|
|
|
<div class="waveform-layout">
|
2026-04-16 20:20:52 +08:00
|
|
|
|
<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"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<WaveformInfoPanel
|
|
|
|
|
|
:has-parsed-waveform="hasParsedWaveform"
|
|
|
|
|
|
:summary-items="summaryItems"
|
2026-04-17 08:10:46 +08:00
|
|
|
|
:vector-parse-result="vectorParseResult"
|
2026-04-16 20:20:52 +08:00
|
|
|
|
:last-parse-error-message="lastParseErrorMessage"
|
2026-04-17 08:10:46 +08:00
|
|
|
|
:last-vector-parse-error-message="lastVectorParseErrorMessage"
|
|
|
|
|
|
:active-vector-channel-name="activeWaveDetail?.channelName || ''"
|
2026-04-16 20:20:52 +08:00
|
|
|
|
/>
|
2026-04-16 08:15:46 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { computed, ref } from 'vue'
|
2026-04-17 08:10:46 +08:00
|
|
|
|
import dayjs from 'dayjs'
|
2026-04-16 08:15:46 +08:00
|
|
|
|
import { ElMessage } from 'element-plus'
|
2026-04-17 08:10:46 +08:00
|
|
|
|
import { parseComtradeApi, parseComtradeVectorApi } from '@/api/tools/waveform'
|
2026-04-16 08:15:46 +08:00
|
|
|
|
import type { Waveform } from '@/api/tools/waveform/interface'
|
2026-04-16 20:20:52 +08:00
|
|
|
|
import WaveformInfoPanel from './components/WaveformInfoPanel.vue'
|
|
|
|
|
|
import WaveformToolbar from './components/WaveformToolbar.vue'
|
|
|
|
|
|
import WaveformTrendPanel from './components/WaveformTrendPanel.vue'
|
|
|
|
|
|
import type {
|
|
|
|
|
|
DisplayMode,
|
|
|
|
|
|
LabelValueOption,
|
|
|
|
|
|
SingleChannelTrendOption,
|
|
|
|
|
|
SummaryItem,
|
|
|
|
|
|
TrendTabValue,
|
|
|
|
|
|
ValueMode,
|
|
|
|
|
|
WaveformDetailOption
|
|
|
|
|
|
} from './components/types'
|
2026-04-16 08:15:46 +08:00
|
|
|
|
|
|
|
|
|
|
defineOptions({
|
|
|
|
|
|
name: 'WaveformView'
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
interface WaveformSeriesItem {
|
|
|
|
|
|
name: string
|
|
|
|
|
|
data: number[]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface WaveformTrendPayload {
|
|
|
|
|
|
timeLabels: string[]
|
|
|
|
|
|
unit: string
|
|
|
|
|
|
min?: number
|
|
|
|
|
|
max?: number
|
|
|
|
|
|
series: WaveformSeriesItem[]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface TrendChartLayoutOptions {
|
|
|
|
|
|
showTimeAxis?: boolean
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const activeTrendTab = ref<TrendTabValue>('instant')
|
|
|
|
|
|
const activeValueMode = ref<ValueMode>('primary')
|
|
|
|
|
|
const activeDisplayMode = ref<DisplayMode>('single-channel')
|
|
|
|
|
|
const activeChannelIndex = ref(0)
|
|
|
|
|
|
const singleChannelTrendChartGroup = 'waveform-single-channel-sync'
|
|
|
|
|
|
const isParsing = ref(false)
|
|
|
|
|
|
const selectedCfgFile = ref<File | null>(null)
|
|
|
|
|
|
const selectedDatFile = ref<File | null>(null)
|
|
|
|
|
|
const waveformParseResult = ref<Waveform.WaveComtradeResultVO | null>(null)
|
2026-04-17 08:10:46 +08:00
|
|
|
|
const vectorParseResult = ref<Waveform.WaveComtradeVectorResultVO | null>(null)
|
2026-04-16 08:15:46 +08:00
|
|
|
|
const lastParseErrorMessage = ref('')
|
2026-04-17 08:10:46 +08:00
|
|
|
|
const lastVectorParseErrorMessage = ref('')
|
2026-04-16 08:15:46 +08:00
|
|
|
|
const waveformFileAccept = '.cfg,.dat'
|
|
|
|
|
|
|
2026-04-16 20:20:52 +08:00
|
|
|
|
const trendTabs: LabelValueOption<TrendTabValue>[] = [
|
|
|
|
|
|
{ value: 'instant', label: '瞬时波形' },
|
|
|
|
|
|
{ value: 'rms', label: 'RMS 波形' }
|
2026-04-16 08:15:46 +08:00
|
|
|
|
]
|
|
|
|
|
|
|
2026-04-16 20:20:52 +08:00
|
|
|
|
const valueModeOptions: LabelValueOption<ValueMode>[] = [
|
2026-04-16 08:15:46 +08:00
|
|
|
|
{ label: '一次值', value: 'primary' },
|
|
|
|
|
|
{ label: '二次值', value: 'secondary' }
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-04-16 20:20:52 +08:00
|
|
|
|
const displayModeOptions: LabelValueOption<DisplayMode>[] = [
|
|
|
|
|
|
{ label: '单通道', value: 'single-channel' },
|
|
|
|
|
|
{ label: '多通道', value: 'multi-channel' }
|
2026-04-16 08:15:46 +08:00
|
|
|
|
]
|
|
|
|
|
|
|
2026-04-16 20:20:52 +08:00
|
|
|
|
const trendLabelMap: Record<TrendTabValue, string> = {
|
2026-04-16 08:15:46 +08:00
|
|
|
|
instant: '瞬时波形',
|
|
|
|
|
|
rms: 'RMS 波形'
|
2026-04-16 20:20:52 +08:00
|
|
|
|
}
|
2026-04-16 08:15:46 +08:00
|
|
|
|
|
2026-04-16 20:20:52 +08:00
|
|
|
|
const valueModeLabelMap: Record<ValueMode, string> = {
|
2026-04-16 08:15:46 +08:00
|
|
|
|
primary: '一次值',
|
|
|
|
|
|
secondary: '二次值'
|
2026-04-16 20:20:52 +08:00
|
|
|
|
}
|
2026-04-16 08:15:46 +08:00
|
|
|
|
|
|
|
|
|
|
const readThemeColor = (name: string, fallback: string) => {
|
|
|
|
|
|
if (typeof window === 'undefined') return fallback
|
|
|
|
|
|
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
|
|
|
|
|
return value || fallback
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const phaseColors = {
|
|
|
|
|
|
a: readThemeColor('--cn-color-phase-a', '#daa520'),
|
|
|
|
|
|
b: readThemeColor('--cn-color-phase-b', '#2e8b57'),
|
|
|
|
|
|
c: readThemeColor('--cn-color-phase-c', '#a52a2a')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const defaultPhaseColors = [phaseColors.a, phaseColors.b, phaseColors.c]
|
2026-04-16 20:20:52 +08:00
|
|
|
|
const axisTextColor = readThemeColor('--el-text-color-regular', '#606266')
|
|
|
|
|
|
const axisLineColor = readThemeColor('--el-border-color', '#dcdfe6')
|
2026-04-16 08:15:46 +08:00
|
|
|
|
|
|
|
|
|
|
const selectedWaveformFileName = computed(() => {
|
|
|
|
|
|
const fileNames = [selectedCfgFile.value?.name, selectedDatFile.value?.name].filter(Boolean)
|
|
|
|
|
|
return fileNames.join(' / ')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const getWaveformParseErrorMessage = (error: unknown) => {
|
|
|
|
|
|
if (!error || typeof error !== 'object') {
|
|
|
|
|
|
return '波形解析失败,请检查 cfg 和 dat 文件内容'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const businessError = error as {
|
|
|
|
|
|
message?: string
|
|
|
|
|
|
response?: {
|
|
|
|
|
|
data?: {
|
|
|
|
|
|
message?: string
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return businessError.response?.data?.message || businessError.message || '波形解析失败,请检查 cfg 和 dat 文件内容'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const hasParsedWaveform = computed(() => !!waveformParseResult.value?.waveData)
|
|
|
|
|
|
|
|
|
|
|
|
const buildSeriesPoints = (list: number[][] | undefined, valueIndex: number) => {
|
|
|
|
|
|
return (list || []).map(item => [Number(item[0]), Number(item[valueIndex] ?? 0)])
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const normalizedWaveDetails = computed<Waveform.WaveDataDetail[]>(() => {
|
|
|
|
|
|
const waveData = waveformParseResult.value?.waveData
|
|
|
|
|
|
const detailList = waveformParseResult.value?.waveDataDetails || []
|
|
|
|
|
|
const instantList = waveData?.listWaveData
|
|
|
|
|
|
const rmsList = waveData?.listRmsData
|
|
|
|
|
|
const waveTitles = waveData?.waveTitle || []
|
|
|
|
|
|
const groupCount = Math.max(Math.floor(Math.max(waveTitles.length - 1, 0) / 3), detailList.length)
|
|
|
|
|
|
|
|
|
|
|
|
if (!groupCount) return []
|
|
|
|
|
|
|
|
|
|
|
|
return Array.from({ length: groupCount }, (_, index) => {
|
|
|
|
|
|
const startColumnIndex = index * 3 + 1
|
|
|
|
|
|
const detail = detailList[index]
|
|
|
|
|
|
const titleSlice = waveTitles.slice(startColumnIndex, startColumnIndex + 3)
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
channelName: detail?.channelName || waveData?.channelNames?.[startColumnIndex] || `通道${index + 1}`,
|
|
|
|
|
|
title: detail?.title || titleSlice.join(' / ') || `三相波形 ${index + 1}`,
|
|
|
|
|
|
unit: detail?.unit || '',
|
|
|
|
|
|
a: detail?.a || waveTitles[startColumnIndex] || 'A相',
|
|
|
|
|
|
b: detail?.b || waveTitles[startColumnIndex + 1] || 'B相',
|
|
|
|
|
|
c: detail?.c || waveTitles[startColumnIndex + 2] || 'C相',
|
|
|
|
|
|
isOpen: detail?.isOpen || false,
|
|
|
|
|
|
// 三相颜色统一读取全局主题变量,避免被接口返回的局部颜色覆盖。
|
|
|
|
|
|
colors: [...defaultPhaseColors],
|
|
|
|
|
|
instantData: {
|
|
|
|
|
|
aValue: buildSeriesPoints(instantList, startColumnIndex),
|
|
|
|
|
|
bValue: buildSeriesPoints(instantList, startColumnIndex + 1),
|
|
|
|
|
|
cValue: buildSeriesPoints(instantList, startColumnIndex + 2)
|
|
|
|
|
|
},
|
|
|
|
|
|
rmsData: {
|
|
|
|
|
|
aValue: buildSeriesPoints(rmsList, startColumnIndex),
|
|
|
|
|
|
bValue: buildSeriesPoints(rmsList, startColumnIndex + 1),
|
|
|
|
|
|
cValue: buildSeriesPoints(rmsList, startColumnIndex + 2)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const channelOptions = computed<WaveformDetailOption[]>(() => {
|
|
|
|
|
|
return normalizedWaveDetails.value.map((item, index) => ({
|
|
|
|
|
|
label: buildChannelLabel(item, index),
|
|
|
|
|
|
value: index
|
|
|
|
|
|
}))
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const activeWaveDetail = computed(() => {
|
|
|
|
|
|
return normalizedWaveDetails.value[activeChannelIndex.value] || normalizedWaveDetails.value[0] || null
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const activeWaveData = computed(() => waveformParseResult.value?.waveData)
|
|
|
|
|
|
|
|
|
|
|
|
const normalizeRatio = (value?: number) => {
|
|
|
|
|
|
const ratio = Number(value)
|
|
|
|
|
|
return Number.isFinite(ratio) && ratio > 0 ? ratio : 1
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const isCurrentChannel = (channelName?: string) => {
|
|
|
|
|
|
return (channelName || '').toUpperCase().startsWith('I')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const activeValueScale = computed(() => {
|
|
|
|
|
|
if (activeValueMode.value === 'secondary') return 1
|
|
|
|
|
|
|
|
|
|
|
|
const waveData = activeWaveData.value
|
|
|
|
|
|
const ratio = isCurrentChannel(activeWaveDetail.value?.channelName) ? waveData?.ct : waveData?.pt
|
|
|
|
|
|
return normalizeRatio(ratio)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const safeNumber = (value: unknown) => {
|
|
|
|
|
|
const numberValue = Number(value)
|
|
|
|
|
|
return Number.isFinite(numberValue) ? numberValue : 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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))}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 08:10:46 +08:00
|
|
|
|
const formatWaveformTime = (value?: string) => {
|
|
|
|
|
|
if (!value) return '--'
|
|
|
|
|
|
|
|
|
|
|
|
const parsedValue = dayjs(value)
|
|
|
|
|
|
return parsedValue.isValid() ? parsedValue.format('YYYY-MM-DD HH:mm:ss.SSS') : value
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 08:15:46 +08:00
|
|
|
|
const buildChannelLabel = (detail: Waveform.WaveDataDetail, index: number) => {
|
|
|
|
|
|
if (detail.title) return detail.title
|
|
|
|
|
|
if (detail.channelName && detail.unit) return `${detail.channelName} (${detail.unit})`
|
|
|
|
|
|
if (detail.channelName) return detail.channelName
|
|
|
|
|
|
return `通道 ${index + 1}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const buildTrendPayload = (detail: Waveform.WaveDataDetail | null, trendTab: TrendTabValue, scale: number): WaveformTrendPayload => {
|
|
|
|
|
|
const trendData = trendTab === 'instant' ? detail?.instantData : detail?.rmsData
|
|
|
|
|
|
const aName = detail?.a || 'A相'
|
|
|
|
|
|
const bName = detail?.b || 'B相'
|
|
|
|
|
|
const cName = detail?.c || 'C相'
|
|
|
|
|
|
const aValue = trendData?.aValue || []
|
|
|
|
|
|
const bValue = trendData?.bValue || []
|
|
|
|
|
|
const cValue = trendData?.cValue || []
|
|
|
|
|
|
const timeSource = aValue.length ? aValue : bValue.length ? bValue : cValue
|
|
|
|
|
|
const seriesConfigs = [
|
|
|
|
|
|
{ name: aName, source: aValue },
|
|
|
|
|
|
{ name: bName, source: bValue },
|
|
|
|
|
|
{ name: cName, source: cValue }
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
const series = seriesConfigs
|
|
|
|
|
|
.filter(item => item.source.length)
|
|
|
|
|
|
.map(item => ({
|
|
|
|
|
|
name: item.name,
|
|
|
|
|
|
data: item.source.map(point => safeNumber(point[1]) * scale)
|
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
|
|
const flatSeriesData = series.flatMap(item => item.data)
|
|
|
|
|
|
const min = flatSeriesData.length ? Math.min(...flatSeriesData) : undefined
|
|
|
|
|
|
const max = flatSeriesData.length ? Math.max(...flatSeriesData) : undefined
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
timeLabels: timeSource.map(point => formatNumber(point[0])),
|
|
|
|
|
|
unit: detail?.unit || '',
|
|
|
|
|
|
min,
|
|
|
|
|
|
max,
|
|
|
|
|
|
series
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const activeTrendPayload = computed(() => {
|
|
|
|
|
|
return buildTrendPayload(activeWaveDetail.value, activeTrendTab.value, activeValueScale.value)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const currentTrendColors = computed(() => {
|
|
|
|
|
|
const customColors = activeWaveDetail.value?.colors?.filter(Boolean) || []
|
|
|
|
|
|
return customColors.length >= 3 ? customColors.slice(0, 3) : defaultPhaseColors
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const hasWaveformData = computed(() => activeTrendPayload.value.series.length > 0)
|
|
|
|
|
|
|
2026-04-17 08:10:46 +08:00
|
|
|
|
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)}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 08:15:46 +08:00
|
|
|
|
const buildTrendAxisConfig = (trendPayload: WaveformTrendPayload) => {
|
2026-04-17 08:10:46 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-04-16 08:15:46 +08:00
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
name: trendPayload.unit || '',
|
2026-04-17 08:10:46 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-04-16 08:15:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-16 20:20:52 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 08:15:46 +08:00
|
|
|
|
const buildTrendChartOptions = (
|
|
|
|
|
|
trendPayload: WaveformTrendPayload,
|
|
|
|
|
|
seriesList: WaveformSeriesItem[],
|
|
|
|
|
|
chartColors = currentTrendColors.value,
|
|
|
|
|
|
layoutOptions: TrendChartLayoutOptions = {}
|
|
|
|
|
|
) => {
|
|
|
|
|
|
const { showTimeAxis = true } = layoutOptions
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
tooltip: {
|
2026-04-16 20:20:52 +08:00
|
|
|
|
trigger: 'axis',
|
|
|
|
|
|
axisPointer: {
|
|
|
|
|
|
type: 'line',
|
|
|
|
|
|
snap: true,
|
|
|
|
|
|
lineStyle: {
|
|
|
|
|
|
color: 'rgba(24, 144, 255, 0.55)',
|
|
|
|
|
|
width: 1
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-04-16 08:15:46 +08:00
|
|
|
|
valueFormatter: (value: number) => {
|
|
|
|
|
|
return trendPayload.unit ? `${formatNumber(value)} ${trendPayload.unit}` : formatNumber(value)
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
legend: {
|
2026-04-17 08:10:46 +08:00
|
|
|
|
top: 0,
|
2026-04-16 08:15:46 +08:00
|
|
|
|
right: 12
|
|
|
|
|
|
},
|
|
|
|
|
|
grid: {
|
|
|
|
|
|
top: '18px',
|
|
|
|
|
|
left: '24px',
|
|
|
|
|
|
right: showTimeAxis ? '32px' : '24px',
|
2026-04-17 08:10:46 +08:00
|
|
|
|
bottom: showTimeAxis ? '16px' : '6px'
|
2026-04-16 08:15:46 +08:00
|
|
|
|
},
|
|
|
|
|
|
xAxis: {
|
|
|
|
|
|
data: trendPayload.timeLabels,
|
|
|
|
|
|
boundaryGap: false,
|
2026-04-16 20:20:52 +08:00
|
|
|
|
// 仅最后一张图显示时间轴,前两张图不再额外预留占位,尽量放大趋势内容区。
|
2026-04-16 08:15:46 +08:00
|
|
|
|
name: showTimeAxis ? 'ms' : '',
|
|
|
|
|
|
nameLocation: 'end',
|
|
|
|
|
|
nameGap: showTimeAxis ? 10 : 0,
|
2026-04-16 20:20:52 +08:00
|
|
|
|
nameTextStyle: {
|
|
|
|
|
|
color: axisTextColor
|
|
|
|
|
|
},
|
|
|
|
|
|
axisLine: {
|
|
|
|
|
|
show: showTimeAxis,
|
2026-04-17 08:10:46 +08:00
|
|
|
|
// 时间轴固定在图底部,避免 y 轴包含 0 时横轴穿过波形区域显得过粗。
|
|
|
|
|
|
onZero: false,
|
2026-04-16 20:20:52 +08:00
|
|
|
|
lineStyle: {
|
|
|
|
|
|
color: axisLineColor
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-04-16 08:15:46 +08:00
|
|
|
|
axisLabel: {
|
|
|
|
|
|
show: showTimeAxis,
|
2026-04-16 20:20:52 +08:00
|
|
|
|
hideOverlap: false,
|
|
|
|
|
|
interval: 0,
|
2026-04-17 08:10:46 +08:00
|
|
|
|
margin: showTimeAxis ? 9 : 0,
|
2026-04-16 20:20:52 +08:00
|
|
|
|
color: axisTextColor,
|
|
|
|
|
|
formatter: buildTimeAxisLabelFormatter(trendPayload.timeLabels)
|
2026-04-16 08:15:46 +08:00
|
|
|
|
},
|
|
|
|
|
|
axisTick: {
|
2026-04-16 20:20:52 +08:00
|
|
|
|
show: showTimeAxis,
|
|
|
|
|
|
lineStyle: {
|
|
|
|
|
|
color: axisLineColor
|
|
|
|
|
|
}
|
2026-04-16 08:15:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
yAxis: buildTrendAxisConfig(trendPayload),
|
|
|
|
|
|
dataZoom: [],
|
|
|
|
|
|
color: chartColors,
|
|
|
|
|
|
series: seriesList.map(item => ({
|
|
|
|
|
|
name: item.name,
|
|
|
|
|
|
type: 'line',
|
|
|
|
|
|
smooth: true,
|
|
|
|
|
|
symbol: 'none',
|
|
|
|
|
|
data: item.data
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 20:20:52 +08:00
|
|
|
|
const activeTrendOptions = computed<Record<string, unknown>>(() => {
|
2026-04-16 08:15:46 +08:00
|
|
|
|
const trendPayload = activeTrendPayload.value
|
|
|
|
|
|
return buildTrendChartOptions(trendPayload, trendPayload.series)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 单通道模式按相别拆成多个图,便于分别观察各通道波形。
|
2026-04-16 20:20:52 +08:00
|
|
|
|
const singleChannelTrendOptionsList = computed<SingleChannelTrendOption[]>(() => {
|
2026-04-16 08:15:46 +08:00
|
|
|
|
const trendPayload = activeTrendPayload.value
|
|
|
|
|
|
|
|
|
|
|
|
// 单通道下每张图只保留一个 series,需要单独指定对应相色。
|
|
|
|
|
|
return trendPayload.series.map((item, index) => ({
|
|
|
|
|
|
key: item.name,
|
|
|
|
|
|
group: singleChannelTrendChartGroup,
|
2026-04-16 20:20:52 +08:00
|
|
|
|
isLastChart: index === trendPayload.series.length - 1,
|
2026-04-16 08:15:46 +08:00
|
|
|
|
options: buildTrendChartOptions(
|
|
|
|
|
|
trendPayload,
|
|
|
|
|
|
[item],
|
|
|
|
|
|
[currentTrendColors.value[index] || defaultPhaseColors[index]],
|
|
|
|
|
|
{
|
2026-04-16 20:20:52 +08:00
|
|
|
|
// 仅最后一张图显示时间轴,并通过卡片高度微调补偿其额外占用空间。
|
2026-04-16 08:15:46 +08:00
|
|
|
|
showTimeAxis: index === trendPayload.series.length - 1
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
}))
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-04-16 20:20:52 +08:00
|
|
|
|
const summaryItems = computed<SummaryItem[]>(() => {
|
2026-04-16 08:15:46 +08:00
|
|
|
|
const waveData = activeWaveData.value
|
|
|
|
|
|
const cfgData = waveData?.comtradeCfgDTO
|
|
|
|
|
|
const detail = activeWaveDetail.value
|
|
|
|
|
|
|
|
|
|
|
|
return [
|
2026-04-17 08:10:46 +08:00
|
|
|
|
{ label: '录波开始', value: formatWaveformTime(cfgData?.timeStart) },
|
|
|
|
|
|
{ label: '触发时间', value: formatWaveformTime(cfgData?.timeTrige) },
|
2026-04-16 08:15:46 +08:00
|
|
|
|
{ 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)}` },
|
2026-04-17 08:10:46 +08:00
|
|
|
|
{ label: '数据类型', value: valueModeLabelMap[activeValueMode.value] }
|
2026-04-16 08:15:46 +08:00
|
|
|
|
]
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const getFileBaseName = (fileName: string) => {
|
|
|
|
|
|
return fileName.replace(/\.[^.]+$/, '').toLowerCase()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const resetSelectedWaveformFiles = () => {
|
|
|
|
|
|
selectedCfgFile.value = null
|
|
|
|
|
|
selectedDatFile.value = null
|
|
|
|
|
|
waveformParseResult.value = null
|
2026-04-17 08:10:46 +08:00
|
|
|
|
vectorParseResult.value = null
|
2026-04-16 08:15:46 +08:00
|
|
|
|
lastParseErrorMessage.value = ''
|
2026-04-17 08:10:46 +08:00
|
|
|
|
lastVectorParseErrorMessage.value = ''
|
2026-04-16 08:15:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleWaveformFileChange = async (event: Event) => {
|
|
|
|
|
|
const input = event.target as HTMLInputElement
|
|
|
|
|
|
const fileList = Array.from(input.files || [])
|
|
|
|
|
|
|
|
|
|
|
|
if (!fileList.length) return
|
|
|
|
|
|
|
|
|
|
|
|
const cfgFiles = fileList.filter(item => item.name.toLowerCase().endsWith('.cfg'))
|
|
|
|
|
|
const datFiles = fileList.filter(item => item.name.toLowerCase().endsWith('.dat'))
|
|
|
|
|
|
|
|
|
|
|
|
if (cfgFiles.length !== 1 || datFiles.length !== 1 || fileList.length !== 2) {
|
|
|
|
|
|
resetSelectedWaveformFiles()
|
|
|
|
|
|
ElMessage.warning('请选择一组同名的.cfg和.dat文件')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const [cfgFile] = cfgFiles
|
|
|
|
|
|
const [datFile] = datFiles
|
|
|
|
|
|
|
|
|
|
|
|
if (!cfgFile || !datFile) {
|
|
|
|
|
|
resetSelectedWaveformFiles()
|
|
|
|
|
|
ElMessage.warning('请选择同一组.cfg和.dat文件')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (getFileBaseName(cfgFile.name) !== getFileBaseName(datFile.name)) {
|
|
|
|
|
|
resetSelectedWaveformFiles()
|
|
|
|
|
|
ElMessage.warning('请选择同名的.cfg和.dat文件')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
selectedCfgFile.value = cfgFile
|
|
|
|
|
|
selectedDatFile.value = datFile
|
|
|
|
|
|
|
|
|
|
|
|
await loadWaveformData(cfgFile, datFile)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const loadWaveformData = async (cfgFile: File, datFile: File) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
isParsing.value = true
|
|
|
|
|
|
activeChannelIndex.value = 0
|
|
|
|
|
|
lastParseErrorMessage.value = ''
|
2026-04-17 08:10:46 +08:00
|
|
|
|
lastVectorParseErrorMessage.value = ''
|
|
|
|
|
|
|
|
|
|
|
|
// 波形与向量结果共用同一组文件,这里并行请求以缩短右侧联动展示等待时间。
|
|
|
|
|
|
const [waveformResult, vectorResult] = await Promise.allSettled([
|
|
|
|
|
|
parseComtradeApi({
|
|
|
|
|
|
cfgFile,
|
|
|
|
|
|
datFile
|
|
|
|
|
|
}),
|
|
|
|
|
|
parseComtradeVectorApi({
|
|
|
|
|
|
cfgFile,
|
|
|
|
|
|
datFile,
|
|
|
|
|
|
parseType: 3
|
|
|
|
|
|
})
|
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
|
|
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: waveformResult.reason
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
2026-04-16 08:15:46 +08:00
|
|
|
|
|
2026-04-17 08:10:46 +08:00
|
|
|
|
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
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
2026-04-16 08:15:46 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
isParsing.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const downloadTrendData = () => {
|
|
|
|
|
|
if (!hasWaveformData.value) {
|
|
|
|
|
|
ElMessage.warning('暂无可导出的波形数据')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const trendPayload = activeTrendPayload.value
|
|
|
|
|
|
const header = ['时间', ...trendPayload.series.map(item => item.name)]
|
|
|
|
|
|
const rows = trendPayload.timeLabels.map((time, index) => {
|
|
|
|
|
|
return [time, ...trendPayload.series.map(item => item.data[index] ?? '')]
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const csvContent = [header, ...rows].map(row => row.join(',')).join('\n')
|
|
|
|
|
|
const blob = new Blob([`\uFEFF${csvContent}`], { type: 'text/csv;charset=utf-8;' })
|
|
|
|
|
|
const blobUrl = URL.createObjectURL(blob)
|
|
|
|
|
|
const exportFile = document.createElement('a')
|
|
|
|
|
|
const channelLabel = activeWaveDetail.value ? buildChannelLabel(activeWaveDetail.value, activeChannelIndex.value) : '波形'
|
|
|
|
|
|
const fileName = `波形查看_${channelLabel}_${valueModeLabelMap[activeValueMode.value]}_${trendLabelMap[activeTrendTab.value]}.csv`
|
|
|
|
|
|
|
|
|
|
|
|
exportFile.style.display = 'none'
|
|
|
|
|
|
exportFile.download = fileName
|
|
|
|
|
|
exportFile.href = blobUrl
|
|
|
|
|
|
document.body.appendChild(exportFile)
|
|
|
|
|
|
exportFile.click()
|
|
|
|
|
|
document.body.removeChild(exportFile)
|
|
|
|
|
|
URL.revokeObjectURL(blobUrl)
|
|
|
|
|
|
|
|
|
|
|
|
ElMessage.success('趋势图数据下载成功')
|
|
|
|
|
|
}
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped lang="scss">
|
|
|
|
|
|
.waveform-page {
|
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.waveform-layout {
|
|
|
|
|
|
display: grid;
|
2026-04-17 08:10:46 +08:00
|
|
|
|
grid-template-columns: minmax(0, 1.5fr) minmax(300px, 0.72fr);
|
2026-04-16 08:15:46 +08:00
|
|
|
|
gap: 16px;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 1280px) {
|
|
|
|
|
|
.waveform-layout {
|
|
|
|
|
|
grid-template-columns: 1fr;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-16 20:20:52 +08:00
|
|
|
|
</style>
|
2026-04-16 08:15:46 +08:00
|
|
|
|
|