feat(waveform): 添加全通道波形数据显示功能

- 实现全通道模式下的波形数据展示
- 添加通道选择器支持全部/单个通道切换
- 新增全通道趋势分组数据结构
- 重构波形数据获取逻辑支持多通道模式
- 更新图表配置支持动态图例显示控制
- 完善波形数据导出功能支持全通道数据
- 优化工具栏界面适配新的通道选择功能
This commit is contained in:
2026-05-06 16:35:48 +08:00
parent bf9f3719a4
commit 407ab0a7f6
4 changed files with 246 additions and 53 deletions

View File

@@ -1,6 +1,5 @@
<template>
<template>
<div class="page-header">
<div class="action-row">
<div class="file-select-row">
<el-input
@@ -31,7 +30,12 @@
placeholder="选择通道"
@update:model-value="handleChannelChange"
>
<el-option v-for="item in channelOptions" :key="item.value" :label="item.label" :value="item.value" />
<el-option
v-for="item in channelOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</div>
@@ -54,7 +58,11 @@
<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-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>
@@ -72,14 +80,14 @@
<script setup lang="ts">
import { Download, FolderOpened } from '@element-plus/icons-vue'
import { ref } from 'vue'
import type { DisplayMode, LabelValueOption, ValueMode, WaveformDetailOption } from './types'
import type { ChannelSelectValue, DisplayMode, LabelValueOption, ValueMode, WaveformDetailOption } from './types'
defineProps<{
selectedWaveformFileName: string
isParsing: boolean
waveformFileAccept: string
channelOptions: WaveformDetailOption[]
activeChannelIndex: number
activeChannelIndex: ChannelSelectValue
displayModeOptions: LabelValueOption<DisplayMode>[]
activeDisplayMode: DisplayMode
valueModeOptions: LabelValueOption<ValueMode>[]
@@ -88,7 +96,7 @@ defineProps<{
}>()
const emit = defineEmits<{
'update:activeChannelIndex': [value: number]
'update:activeChannelIndex': [value: ChannelSelectValue]
'update:activeDisplayMode': [value: DisplayMode]
'update:activeValueMode': [value: ValueMode]
'waveform-file-change': [event: Event]
@@ -108,7 +116,7 @@ const handleWaveformFileChange = (event: Event) => {
emit('waveform-file-change', event)
}
const handleChannelChange = (value: number) => {
const handleChannelChange = (value: ChannelSelectValue) => {
emit('update:activeChannelIndex', value)
}
@@ -202,6 +210,4 @@ const handleValueModeChange = (value: string | number | boolean | undefined) =>
max-width: none;
}
}
</style>

View File

@@ -1,4 +1,4 @@
<template>
<template>
<section class="waveform-panel">
<div class="panel-header">
<el-tabs :model-value="activeTrendTab" class="trend-tabs" @update:model-value="handleTrendTabChange">
@@ -7,7 +7,26 @@
</div>
<div class="panel-body">
<div v-if="hasWaveformData && activeDisplayMode === 'multi-channel'" class="chart-container">
<div v-if="hasWaveformData && isAllChannelsActive" class="all-channel-list">
<template v-for="group in allChannelTrendGroups" :key="group.key">
<div v-if="activeDisplayMode === 'multi-channel'" class="all-channel-chart">
<LineChart :options="group.multiChannelOptions" />
</div>
<template v-else>
<div
v-for="item in group.singleChannelOptionsList"
: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>
</template>
</template>
</div>
<div v-else-if="hasWaveformData && activeDisplayMode === 'multi-channel'" class="chart-container">
<LineChart :options="activeTrendOptions" />
</div>
<div v-else-if="hasWaveformData" class="single-channel-list">
@@ -25,7 +44,9 @@
<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 v-if="lastParseErrorMessage" class="empty-text error-text">
最近一次解析失败{{ lastParseErrorMessage }}
</div>
</div>
</div>
</section>
@@ -33,15 +54,23 @@
<script setup lang="ts">
import LineChart from '@/components/echarts/line/index.vue'
import type { DisplayMode, LabelValueOption, SingleChannelTrendOption, TrendTabValue } from './types'
import type {
AllChannelTrendGroup,
DisplayMode,
LabelValueOption,
SingleChannelTrendOption,
TrendTabValue
} from './types'
defineProps<{
hasWaveformData: boolean
isAllChannelsActive: boolean
activeDisplayMode: DisplayMode
activeTrendTab: TrendTabValue
trendTabs: LabelValueOption<TrendTabValue>[]
activeTrendOptions: Record<string, unknown>
singleChannelTrendOptionsList: SingleChannelTrendOption[]
allChannelTrendGroups: AllChannelTrendGroup[]
lastParseErrorMessage: string
}>()
@@ -125,6 +154,26 @@ const handleTrendTabChange = (value: string | number) => {
overflow: hidden;
}
.all-channel-list {
display: flex;
flex: 1;
flex-direction: column;
gap: 4px;
min-height: 0;
padding: 8px;
overflow: hidden;
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
background: var(--cn-color-canvas-bg);
}
.all-channel-chart {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
.single-channel-card {
display: flex;
flex: 1;

View File

@@ -1,6 +1,7 @@
export type TrendTabValue = 'instant' | 'rms'
export type TrendTabValue = 'instant' | 'rms'
export type ValueMode = 'primary' | 'secondary'
export type DisplayMode = 'single-channel' | 'multi-channel'
export type ChannelSelectValue = number | 'all'
export interface LabelValueOption<T extends string | number = string | number> {
label: string
@@ -9,7 +10,7 @@ export interface LabelValueOption<T extends string | number = string | number> {
export interface WaveformDetailOption {
label: string
value: number
value: ChannelSelectValue
}
export interface SingleChannelTrendOption {
@@ -19,6 +20,13 @@ export interface SingleChannelTrendOption {
options: Record<string, unknown>
}
export interface AllChannelTrendGroup {
key: string
title: string
singleChannelOptionsList: SingleChannelTrendOption[]
multiChannelOptions: Record<string, unknown>
}
export interface SummaryItem {
label: string
value: string | number

View File

@@ -1,4 +1,4 @@
<template>
<template>
<div class="table-box waveform-page">
<WaveformToolbar
:selected-waveform-file-name="selectedWaveformFileName"
@@ -26,6 +26,8 @@
:trend-tabs="trendTabs"
:active-trend-options="activeTrendOptions"
:single-channel-trend-options-list="singleChannelTrendOptionsList"
:all-channel-trend-groups="allChannelTrendGroups"
:is-all-channels-active="isAllChannelsActive"
:last-parse-error-message="lastParseErrorMessage"
@update:active-trend-tab="activeTrendTab = $event"
/>
@@ -52,6 +54,8 @@ import WaveformInfoPanel from './components/WaveformInfoPanel.vue'
import WaveformToolbar from './components/WaveformToolbar.vue'
import WaveformTrendPanel from './components/WaveformTrendPanel.vue'
import type {
AllChannelTrendGroup,
ChannelSelectValue,
DisplayMode,
LabelValueOption,
SingleChannelTrendOption,
@@ -80,13 +84,15 @@ interface WaveformTrendPayload {
interface TrendChartLayoutOptions {
showTimeAxis?: boolean
showLegend?: boolean
}
const activeTrendTab = ref<TrendTabValue>('instant')
const activeValueMode = ref<ValueMode>('primary')
const activeDisplayMode = ref<DisplayMode>('single-channel')
const activeChannelIndex = ref(0)
const activeChannelIndex = ref<ChannelSelectValue>('all')
const singleChannelTrendChartGroup = 'waveform-single-channel-sync'
const allChannelTrendChartGroup = 'waveform-all-channel-sync'
const isParsing = ref(false)
const selectedCfgFile = ref<File | null>(null)
const selectedDatFile = ref<File | null>(null)
@@ -205,13 +211,19 @@ const normalizedWaveDetails = computed<Waveform.WaveDataDetail[]>(() => {
})
const channelOptions = computed<WaveformDetailOption[]>(() => {
return normalizedWaveDetails.value.map((item, index) => ({
const detailOptions = normalizedWaveDetails.value.map((item, index) => ({
label: buildChannelLabel(item, index),
value: index
}))
return detailOptions.length ? [{ label: '全部', value: 'all' }, ...detailOptions] : []
})
const isAllChannelsActive = computed(() => activeChannelIndex.value === 'all')
const activeWaveDetail = computed(() => {
if (typeof activeChannelIndex.value !== 'number') return null
return normalizedWaveDetails.value[activeChannelIndex.value] || normalizedWaveDetails.value[0] || null
})
@@ -226,13 +238,15 @@ const isCurrentChannel = (channelName?: string) => {
return (channelName || '').toUpperCase().startsWith('I')
}
const activeValueScale = computed(() => {
const getValueScale = (detail: Waveform.WaveDataDetail | null) => {
if (activeValueMode.value === 'secondary') return 1
const waveData = activeWaveData.value
const ratio = isCurrentChannel(activeWaveDetail.value?.channelName) ? waveData?.ct : waveData?.pt
const ratio = isCurrentChannel(detail?.channelName) ? waveData?.ct : waveData?.pt
return normalizeRatio(ratio)
})
}
const activeValueScale = computed(() => getValueScale(activeWaveDetail.value))
const safeNumber = (value: unknown) => {
const numberValue = Number(value)
@@ -262,7 +276,11 @@ const buildChannelLabel = (detail: Waveform.WaveDataDetail, index: number) => {
return `通道 ${index + 1}`
}
const buildTrendPayload = (detail: Waveform.WaveDataDetail | null, trendTab: TrendTabValue, scale: number): WaveformTrendPayload => {
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相'
@@ -301,12 +319,18 @@ const activeTrendPayload = computed(() => {
return buildTrendPayload(activeWaveDetail.value, activeTrendTab.value, activeValueScale.value)
})
const currentTrendColors = computed(() => {
const customColors = activeWaveDetail.value?.colors?.filter(Boolean) || []
const getTrendColors = (detail: Waveform.WaveDataDetail | null) => {
const customColors = detail?.colors?.filter(Boolean) || []
return customColors.length >= 3 ? customColors.slice(0, 3) : defaultPhaseColors
})
}
const hasWaveformData = computed(() => activeTrendPayload.value.series.length > 0)
const currentTrendColors = computed(() => getTrendColors(activeWaveDetail.value))
const hasWaveformData = computed(() => {
if (isAllChannelsActive.value) return allChannelTrendGroups.value.length > 0
return activeTrendPayload.value.series.length > 0
})
const SYMMETRIC_AXIS_SPLIT_COUNT = 6
const REGULAR_AXIS_SPLIT_COUNT = 5
@@ -408,13 +432,40 @@ const buildTimeAxisLabelFormatter = (timeLabels: string[]) => {
}
}
interface TrendTooltipParam {
axisValue?: string | number
marker?: string
seriesName?: string
value?: number | string
}
const buildTrendTooltipFormatter = (unit: string) => {
return (params: TrendTooltipParam | TrendTooltipParam[]) => {
const paramList = Array.isArray(params) ? params : [params]
const firstParam = paramList[0]
const timeValue = firstParam?.axisValue
const valueRows = paramList
.map(item => {
const marker = item.marker || ''
const seriesName = item.seriesName || ''
const valueText = unit ? `${formatNumber(item.value)} ${unit}` : formatNumber(item.value)
return `<div>${marker}${seriesName}<span style="float:right;margin-left:12px;font-weight:600;">${valueText}</span></div>`
})
.join('')
const timeText = timeValue === undefined ? '--' : `${formatNumber(timeValue, 2)} ms`
return `${valueRows}<div style="margin-top:4px;">时间<span style="float:right;margin-left:12px;">${timeText}</span></div>`
}
}
const buildTrendChartOptions = (
trendPayload: WaveformTrendPayload,
seriesList: WaveformSeriesItem[],
chartColors = currentTrendColors.value,
layoutOptions: TrendChartLayoutOptions = {}
) => {
const { showTimeAxis = true } = layoutOptions
const { showTimeAxis = true, showLegend = true } = layoutOptions
return {
tooltip: {
@@ -427,16 +478,15 @@ const buildTrendChartOptions = (
width: 1
}
},
valueFormatter: (value: number) => {
return trendPayload.unit ? `${formatNumber(value)} ${trendPayload.unit}` : formatNumber(value)
}
formatter: buildTrendTooltipFormatter(trendPayload.unit)
},
legend: {
show: showLegend,
top: 0,
right: 12
},
grid: {
top: '18px',
top: showLegend ? '18px' : '6px',
left: '24px',
right: showTimeAxis ? '32px' : '24px',
bottom: showTimeAxis ? '16px' : '6px'
@@ -493,24 +543,68 @@ const activeTrendOptions = computed<Record<string, unknown>>(() => {
})
// 单通道模式按相别拆成多个图,便于分别观察各通道波形。
const singleChannelTrendOptionsList = computed<SingleChannelTrendOption[]>(() => {
const trendPayload = activeTrendPayload.value
const buildSingleChannelTrendOptionsList = (
detail: Waveform.WaveDataDetail | null,
detailIndex: number,
chartGroup: string,
resolveShowTimeAxis = (seriesIndex: number, seriesCount: number) => seriesIndex === seriesCount - 1
): SingleChannelTrendOption[] => {
const trendPayload = buildTrendPayload(detail, activeTrendTab.value, getValueScale(detail))
const trendColors = getTrendColors(detail)
// 单通道下每张图只保留一个 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
return trendPayload.series.map((item, index) => {
const showTimeAxis = resolveShowTimeAxis(index, trendPayload.series.length)
return {
key: `${detailIndex}-${item.name}`,
group: chartGroup,
isLastChart: showTimeAxis,
options: buildTrendChartOptions(trendPayload, [item], [trendColors[index] || defaultPhaseColors[index]], {
// 单通道下每张图只有一条曲线,图例信息与图表标题重复。
showTimeAxis,
showLegend: false
})
}
})
}
const singleChannelTrendOptionsList = computed<SingleChannelTrendOption[]>(() => {
return buildSingleChannelTrendOptionsList(activeWaveDetail.value, -1, singleChannelTrendChartGroup)
})
const allChannelTrendGroups = computed<AllChannelTrendGroup[]>(() => {
const availableDetails = normalizedWaveDetails.value
.map((detail, index) => {
const trendPayload = buildTrendPayload(detail, activeTrendTab.value, getValueScale(detail))
return {
detail,
index,
trendPayload
}
)
}))
})
.filter(item => item.trendPayload.series.length > 0)
return availableDetails.map((item, groupIndex) => {
const isLastGroup = groupIndex === availableDetails.length - 1
return {
key: `${item.index}-${buildChannelLabel(item.detail, item.index)}`,
title: buildChannelLabel(item.detail, item.index),
singleChannelOptionsList: buildSingleChannelTrendOptionsList(
item.detail,
item.index,
allChannelTrendChartGroup,
(seriesIndex, seriesCount) => isLastGroup && seriesIndex === seriesCount - 1
),
multiChannelOptions: buildTrendChartOptions(
item.trendPayload,
item.trendPayload.series,
getTrendColors(item.detail)
)
}
})
})
const summaryItems = computed<SummaryItem[]>(() => {
@@ -523,7 +617,14 @@ const summaryItems = computed<SummaryItem[]>(() => {
{ 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: isAllChannelsActive.value
? '全部'
: detail
? buildChannelLabel(detail, activeChannelIndex.value as number)
: '--'
},
{ label: '单位', value: detail?.unit || '--' },
{ label: '相别数量', value: waveData?.iPhasic ?? '--' },
{ label: 'PT / CT', value: `${formatNumber(waveData?.pt, 2)} / ${formatNumber(waveData?.ct, 2)}` },
@@ -583,7 +684,7 @@ const handleWaveformFileChange = async (event: Event) => {
const loadWaveformData = async (cfgFile: File, datFile: File) => {
try {
isParsing.value = true
activeChannelIndex.value = 0
activeChannelIndex.value = 'all'
lastParseErrorMessage.value = ''
lastVectorParseErrorMessage.value = ''
@@ -641,16 +742,46 @@ const downloadTrendData = () => {
}
const trendPayload = activeTrendPayload.value
const allChannelPayloads = normalizedWaveDetails.value
.map((detail, index) => ({
label: buildChannelLabel(detail, index),
payload: buildTrendPayload(detail, activeTrendTab.value, getValueScale(detail))
}))
.filter(item => item.payload.series.length > 0)
const exportPayload = isAllChannelsActive.value ? allChannelPayloads[0]?.payload : trendPayload
if (!exportPayload) {
ElMessage.warning('暂无可导出的波形数据')
return
}
const header = ['时间', ...trendPayload.series.map(item => item.name)]
const rows = trendPayload.timeLabels.map((time, index) => {
const allChannelHeader = [
'时间',
...allChannelPayloads.flatMap(item => item.payload.series.map(series => `${item.label}-${series.name}`))
]
const rows = exportPayload.timeLabels.map((time, index) => {
if (isAllChannelsActive.value) {
return [
time,
...allChannelPayloads.flatMap(item => item.payload.series.map(series => series.data[index] ?? ''))
]
}
return [time, ...trendPayload.series.map(item => item.data[index] ?? '')]
})
const csvContent = [header, ...rows].map(row => row.join(',')).join('\n')
const csvContent = [isAllChannelsActive.value ? allChannelHeader : 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 channelLabel = isAllChannelsActive.value
? '全部'
: activeWaveDetail.value
? buildChannelLabel(activeWaveDetail.value, activeChannelIndex.value as number)
: '波形'
const fileName = `波形查看_${channelLabel}_${valueModeLabelMap[activeValueMode.value]}_${trendLabelMap[activeTrendTab.value]}.csv`
exportFile.style.display = 'none'
@@ -686,4 +817,3 @@ const downloadTrendData = () => {
}
}
</style>