Files
CN_Tool_client/frontend/src/views/tools/waveform/index.vue

690 lines
24 KiB
Vue
Raw Normal View History

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