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

1225 lines
42 KiB
Vue
Raw Normal View History

<template>
2026-04-16 08:15:46 +08:00
<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"
@update:active-channel-index="activeChannelIndex = $event"
@update:active-display-mode="activeDisplayMode = $event"
@update:active-value-mode="activeValueMode = $event"
@waveform-file-change="handleWaveformFileChange"
/>
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"
:all-channel-trend-groups="allChannelTrendGroups"
:is-all-channels-active="isAllChannelsActive"
2026-04-16 20:20:52 +08:00
:last-parse-error-message="lastParseErrorMessage"
:active-trend-tool-states="activeTrendToolStates"
:disabled-trend-tool-states="disabledTrendToolStates"
2026-04-16 20:20:52 +08:00
@update:active-trend-tab="activeTrendTab = $event"
@trend-tool-action="handleTrendToolAction"
@chart-data-zoom="handleTrendChartDataZoom"
2026-04-16 20:20:52 +08:00
/>
<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, nextTick, ref, watch } from 'vue'
2026-04-17 08:10:46 +08:00
import dayjs from 'dayjs'
import html2canvas from 'html2canvas'
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 {
AllChannelTrendGroup,
ChannelSelectValue,
2026-04-16 20:20:52 +08:00
DisplayMode,
LabelValueOption,
SingleChannelTrendOption,
SummaryItem,
TrendChartZoomPayload,
TrendToolAction,
2026-04-16 20:20:52 +08:00
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
yAxisSplitCount?: number
}
interface TrendZoomRange {
start: number
end: number
}
2026-04-16 08:15:46 +08:00
const activeTrendTab = ref<TrendTabValue>('instant')
const activeValueMode = ref<ValueMode>('primary')
const activeDisplayMode = ref<DisplayMode>('single-channel')
const activeChannelIndex = ref<ChannelSelectValue>('all')
2026-04-16 08:15:46 +08:00
const singleChannelTrendChartGroup = 'waveform-single-channel-sync'
const allChannelTrendChartGroup = 'waveform-all-channel-sync'
2026-04-16 08:15:46 +08:00
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'
const trendXZoomRange = ref<TrendZoomRange>({ start: 0, end: 100 })
const trendYZoomScale = ref(1)
const activeTrendInteractionMode = ref<'none' | 'box-zoom' | 'pan'>('none')
2026-04-16 08:15:46 +08:00
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[]>(() => {
const detailOptions = normalizedWaveDetails.value.map((item, index) => ({
2026-04-16 08:15:46 +08:00
label: buildChannelLabel(item, index),
value: index
}))
return detailOptions.length ? [{ label: '全部', value: 'all' }, ...detailOptions] : []
2026-04-16 08:15:46 +08:00
})
const isAllChannelsActive = computed(() => activeChannelIndex.value === 'all')
2026-04-16 08:15:46 +08:00
const activeWaveDetail = computed(() => {
if (typeof activeChannelIndex.value !== 'number') return null
2026-04-16 08:15:46 +08:00
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 getValueScale = (detail: Waveform.WaveDataDetail | null) => {
2026-04-16 08:15:46 +08:00
if (activeValueMode.value === 'secondary') return 1
const waveData = activeWaveData.value
const ratio = isCurrentChannel(detail?.channelName) ? waveData?.ct : waveData?.pt
2026-04-16 08:15:46 +08:00
return normalizeRatio(ratio)
}
const activeValueScale = computed(() => getValueScale(activeWaveDetail.value))
2026-04-16 08:15:46 +08:00
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 => {
2026-04-16 08:15:46 +08:00
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 getTrendColors = (detail: Waveform.WaveDataDetail | null) => {
const customColors = detail?.colors?.filter(Boolean) || []
2026-04-16 08:15:46 +08:00
return customColors.length >= 3 ? customColors.slice(0, 3) : defaultPhaseColors
}
const currentTrendColors = computed(() => getTrendColors(activeWaveDetail.value))
const hasWaveformData = computed(() => {
if (isAllChannelsActive.value) return allChannelTrendGroups.value.length > 0
2026-04-16 08:15:46 +08:00
return activeTrendPayload.value.series.length > 0
})
2026-04-16 08:15:46 +08:00
const canPanTrendChart = computed(() => {
const { start, end } = trendXZoomRange.value
return hasWaveformData.value && (start > 0 || end < 100)
})
const canResetTrendChart = computed(() => {
const { start, end } = trendXZoomRange.value
return (
hasWaveformData.value &&
(start > 0 || end < 100 || trendYZoomScale.value !== 1 || activeTrendInteractionMode.value !== 'none')
)
})
const activeTrendToolStates = computed<Partial<Record<TrendToolAction, boolean>>>(() => ({
'box-zoom': activeTrendInteractionMode.value === 'box-zoom',
pan: activeTrendInteractionMode.value === 'pan'
}))
const disabledTrendToolStates = computed<Partial<Record<TrendToolAction, boolean>>>(() => ({
pan: !canPanTrendChart.value,
reset: !canResetTrendChart.value
}))
const TREND_AXIS_EXPAND_RATIO = 1.2
const TREND_AXIS_SHRINK_RATIO = 0.8
const TREND_AXIS_BALANCED_RATIO = 0.9
const TREND_AXIS_DEFAULT_SPLIT_COUNT = 4
const TREND_AXIS_COMPACT_SPLIT_COUNT = 2
const TREND_AXIS_SMALL_INTERVAL_THRESHOLD = 1
const TREND_AXIS_EXTRA_SPLIT_SCORE = 0.25
const TREND_AXIS_READABLE_INTERVAL_STEPS = [1, 2, 2.5, 5, 10]
const TREND_GRID_TOP = '6px'
const TREND_GRID_LEFT = '52px'
const TREND_GRID_RIGHT = '36px'
const TREND_GRID_BOTTOM = {
withTimeAxis: '34px',
withoutTimeAxis: '6px'
}
const TREND_LINE_MAX_WIDTH = 1.6
2026-04-17 08:10:46 +08:00
const getAxisPrecision = (step: number) => {
const absStep = Math.abs(step)
2026-04-17 08:10:46 +08:00
if (!Number.isFinite(absStep) || absStep >= 1) return 0
const stepText = `${absStep}`
2026-04-17 08:10:46 +08:00
if (stepText.includes('e-')) {
return Number(stepText.split('e-')[1] || 0)
}
return Math.min(stepText.split('.')[1]?.length || 0, 4)
}
const getAxisBoundaryPrecision = (axisMin: number, axisMax: number, interval: number) => {
const boundaryPrecision = Math.max(Math.abs(axisMin), Math.abs(axisMax)) < 1 ? 2 : 0
return Math.max(getAxisPrecision(interval), boundaryPrecision)
}
const getReadableAxisInterval = (value: number) => {
if (!Number.isFinite(value) || value <= 0) return 1
const magnitude = 10 ** Math.floor(Math.log10(value))
const normalizedValue = value / magnitude
const step = TREND_AXIS_READABLE_INTERVAL_STEPS.find(item => normalizedValue <= item) || 10
return step * magnitude
2026-04-17 08:10:46 +08:00
}
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 roundAxisValueUp = (value: number) => {
const absValue = Math.abs(value)
2026-04-17 08:10:46 +08:00
if (!Number.isFinite(absValue) || absValue === 0) return 0
2026-04-17 08:10:46 +08:00
const magnitude = 10 ** Math.floor(Math.log10(absValue))
const step = magnitude >= 10 ? magnitude / 10 : magnitude
const precision = getAxisPrecision(step)
2026-04-17 08:10:46 +08:00
return normalizeAxisValue(Math.ceil(absValue / step) * step, precision)
2026-04-17 08:10:46 +08:00
}
const formatAxisLabel = (value: number, precision: number) => {
if (!Number.isFinite(value)) return ''
return `${normalizeAxisValue(value, precision)}`
}
const clampPercent = (value: number) => Math.min(Math.max(value, 0), 100)
// 趋势图按当前可见点数调整线宽,避免大数据初始展示时线条过粗。
const resolveTrendVisiblePointCount = (pointCount: number) => {
const { start, end } = trendXZoomRange.value
const visibleRatio = Math.max((end - start) / 100, 0)
return Math.ceil(pointCount * visibleRatio)
}
const resolveTrendLineWidth = (pointCount: number) => {
if (pointCount >= 200000) return 0.35
if (pointCount >= 100000) return 0.45
if (pointCount >= 50000) return 0.55
if (pointCount >= 20000) return 0.65
if (pointCount >= 10000) return 0.75
if (pointCount >= 5000) return 0.9
if (pointCount >= 2000) return 1
if (pointCount >= 800) return 1.2
if (pointCount >= 200) return 1.4
return TREND_LINE_MAX_WIDTH
}
const resetTrendToolState = () => {
trendXZoomRange.value = { start: 0, end: 100 }
trendYZoomScale.value = 1
activeTrendInteractionMode.value = 'none'
}
const zoomTrendXAxis = (ratio: number) => {
const { start, end } = trendXZoomRange.value
const center = (start + end) / 2
const nextWidth = Math.min(Math.max((end - start) * ratio, 1), 100)
const nextStart = clampPercent(center - nextWidth / 2)
const nextEnd = clampPercent(center + nextWidth / 2)
if (nextStart === 0) {
trendXZoomRange.value = { start: 0, end: nextWidth }
return
}
if (nextEnd === 100) {
trendXZoomRange.value = { start: 100 - nextWidth, end: 100 }
return
}
trendXZoomRange.value = { start: nextStart, end: nextEnd }
}
const applyYAxisZoom = (yAxisConfig: Record<string, unknown>) => {
const axisMin = Number(yAxisConfig.min)
const axisMax = Number(yAxisConfig.max)
const scale = trendYZoomScale.value
if (!Number.isFinite(axisMin) || !Number.isFinite(axisMax) || axisMin === axisMax || scale === 1) {
return yAxisConfig
}
const center = (axisMin + axisMax) / 2
const halfRange = ((axisMax - axisMin) * scale) / 2
const nextMin = center - halfRange
const nextMax = center + halfRange
const splitNumber = Number(yAxisConfig.splitNumber) || TREND_AXIS_DEFAULT_SPLIT_COUNT
const interval = (nextMax - nextMin) / splitNumber
const precision = getAxisBoundaryPrecision(nextMin, nextMax, interval)
const normalizedInterval = normalizeAxisValue(interval, precision)
return {
...yAxisConfig,
min: normalizeAxisValue(nextMin, precision),
max: normalizeAxisValue(nextMax, precision),
interval: normalizedInterval,
minInterval: normalizedInterval,
maxInterval: normalizedInterval
}
}
const resolveReadableAxisRange = (axisMin: number, axisMax: number, splitCount: number) => {
const axisRange = axisMax - axisMin
const rawInterval = axisRange / splitCount
if (!Number.isFinite(axisRange) || axisRange <= 0 || rawInterval >= TREND_AXIS_SMALL_INTERVAL_THRESHOLD) {
return {
axisMin,
axisMax,
interval: rawInterval,
splitCount
}
}
const splitCountCandidates =
splitCount >= TREND_AXIS_DEFAULT_SPLIT_COUNT ? [splitCount, splitCount + 1] : [splitCount]
return splitCountCandidates.reduce(
(currentBest, currentSplitCount) => {
let interval = getReadableAxisInterval(axisRange / currentSplitCount)
let readableMin = axisMin
let readableMax = axisMax
for (let index = 0; index < TREND_AXIS_READABLE_INTERVAL_STEPS.length; index += 1) {
readableMin = Math.floor(axisMin / interval) * interval
readableMax = readableMin + interval * currentSplitCount
if (readableMax < axisMax) {
readableMax = Math.ceil(axisMax / interval) * interval
readableMin = readableMax - interval * currentSplitCount
}
if (readableMin <= axisMin && readableMax >= axisMax) break
interval = getReadableAxisInterval(interval * 1.01)
}
const precision = getAxisPrecision(interval)
const normalizedMin = normalizeAxisValue(readableMin, precision)
const normalizedMax = normalizeAxisValue(readableMax, precision)
const normalizedInterval = normalizeAxisValue(interval, precision)
const extraSplitCost = Math.max(currentSplitCount - splitCount, 0) * TREND_AXIS_EXTRA_SPLIT_SCORE
const wasteRatio = (normalizedMax - normalizedMin - axisRange) / axisRange
const score = getAxisPrecision(normalizedInterval) * 10 + extraSplitCost + wasteRatio
if (score >= currentBest.score) return currentBest
return {
axisMin: normalizedMin,
axisMax: normalizedMax,
interval: normalizedInterval,
splitCount: currentSplitCount,
score
}
},
{
axisMin,
axisMax,
interval: rawInterval,
splitCount,
score: Number.POSITIVE_INFINITY
}
)
}
const resolveExpandedAxisBoundary = (value: number, isMin: boolean) => {
if (value === 0) return 0
const ratio = isMin
? value < 0
? TREND_AXIS_EXPAND_RATIO
: TREND_AXIS_SHRINK_RATIO
: value > 0
? TREND_AXIS_EXPAND_RATIO
: TREND_AXIS_SHRINK_RATIO
return value * ratio
}
const resolveTrendAxisSplitCount = (layoutOptions: TrendChartLayoutOptions) => {
return layoutOptions.yAxisSplitCount || TREND_AXIS_DEFAULT_SPLIT_COUNT
}
const shouldUseBalancedAxisBoundary = (min: number, max: number) => {
if (min >= 0 || max <= 0) return false
const minAbs = Math.abs(min)
const maxAbs = Math.abs(max)
const smallerAbs = Math.min(minAbs, maxAbs)
const largerAbs = Math.max(minAbs, maxAbs)
return largerAbs > 0 && smallerAbs / largerAbs >= TREND_AXIS_BALANCED_RATIO
}
const buildTrendAxisConfig = (trendPayload: WaveformTrendPayload, splitCount = TREND_AXIS_DEFAULT_SPLIT_COUNT) => {
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 || ''
}
}
let axisMin = resolveExpandedAxisBoundary(min, true)
let axisMax = resolveExpandedAxisBoundary(max, false)
2026-04-17 08:10:46 +08:00
if (shouldUseBalancedAxisBoundary(min, max)) {
// 正负幅值接近时,对称边界必须基于已向外扩展后的范围,避免把峰值贴到坐标边界。
const axisBoundary = roundAxisValueUp(Math.max(Math.abs(axisMin), Math.abs(axisMax)))
axisMin = -axisBoundary
axisMax = axisBoundary
2026-04-17 08:10:46 +08:00
}
if (axisMin === axisMax) {
const fallbackBoundary = Math.max(Math.abs(axisMin), 1) * TREND_AXIS_EXPAND_RATIO
axisMin = axisMin - fallbackBoundary
axisMax = axisMax + fallbackBoundary
2026-04-17 08:10:46 +08:00
}
2026-04-16 08:15:46 +08:00
const safeSplitCount = Math.max(Math.round(splitCount), 1)
const readableAxisRange = resolveReadableAxisRange(axisMin, axisMax, safeSplitCount)
const precision = getAxisBoundaryPrecision(
readableAxisRange.axisMin,
readableAxisRange.axisMax,
readableAxisRange.interval
)
axisMin = normalizeAxisValue(readableAxisRange.axisMin, precision)
axisMax = normalizeAxisValue(readableAxisRange.axisMax, precision)
const interval = normalizeAxisValue(readableAxisRange.interval, precision)
const axisSplitCount = readableAxisRange.splitCount
2026-04-16 08:15:46 +08:00
return {
name: trendPayload.unit || '',
nameLocation: 'middle',
nameGap: 42,
nameTextStyle: {
color: axisTextColor,
fontSize: 12,
align: 'center'
},
2026-04-17 08:10:46 +08:00
min: axisMin,
max: axisMax,
interval,
splitNumber: axisSplitCount,
2026-04-17 08:10:46 +08:00
minInterval: interval,
maxInterval: interval,
// 纵坐标按数据极值留白后均分,小区间优先使用可读步长,避免标签出现冗长小数。
2026-04-17 08:10:46 +08:00
axisLabel: {
showMinLabel: true,
showMaxLabel: true,
2026-04-17 08:10:46 +08:00
hideOverlap: true,
formatter: (value: number) => formatAxisLabel(value, precision)
}
2026-04-16 08:15:46 +08:00
}
}
const buildTrendSeries = (seriesList: WaveformSeriesItem[]) => {
return seriesList.map(item => {
const visiblePointCount = resolveTrendVisiblePointCount(item.data.length)
return {
name: item.name,
type: 'line',
smooth: true,
symbol: 'none',
symbolSize: 3,
lineStyle: {
width: resolveTrendLineWidth(visiblePointCount)
},
data: item.data
}
})
}
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)
}
}
interface TrendTooltipParam {
axisValue?: string | number
marker?: string
seriesName?: string
value?: number | string
}
const buildTrendTooltipFormatter = (unit: string, showTime = true) => {
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`
if (!showTime) return valueRows
return `${valueRows}<div style="margin-top:4px;">时间<span style="float:right;margin-left:12px;">${timeText}</span></div>`
}
}
2026-04-16 08:15:46 +08:00
const buildTrendChartOptions = (
trendPayload: WaveformTrendPayload,
seriesList: WaveformSeriesItem[],
chartColors = currentTrendColors.value,
layoutOptions: TrendChartLayoutOptions = {}
) => {
const { showTimeAxis = true } = layoutOptions
const yAxisSplitCount = resolveTrendAxisSplitCount(layoutOptions)
const yAxisConfig = applyYAxisZoom(buildTrendAxisConfig(trendPayload, yAxisSplitCount))
2026-04-16 08:15:46 +08:00
return {
activeTool: activeTrendInteractionMode.value,
2026-04-16 08:15:46 +08:00
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
}
},
formatter: buildTrendTooltipFormatter(trendPayload.unit, showTimeAxis)
2026-04-16 08:15:46 +08:00
},
legend: {
show: false,
2026-04-17 08:10:46 +08:00
top: 0,
2026-04-16 08:15:46 +08:00
right: 12
},
grid: {
top: TREND_GRID_TOP,
// 多图趋势图固定绘图区左边界,避免纵坐标标签宽度不同导致 x=0 起点错位。
left: TREND_GRID_LEFT,
right: TREND_GRID_RIGHT,
bottom: showTimeAxis ? TREND_GRID_BOTTOM.withTimeAxis : TREND_GRID_BOTTOM.withoutTimeAxis,
containLabel: false
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 ? 8 : 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,
margin: showTimeAxis ? 10 : 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: yAxisConfig,
dataZoom: [
{
type: 'inside',
start: trendXZoomRange.value.start,
end: trendXZoomRange.value.end,
zoomOnMouseWheel: activeTrendInteractionMode.value !== 'pan',
moveOnMouseMove: activeTrendInteractionMode.value === 'pan',
moveOnMouseWheel: activeTrendInteractionMode.value === 'pan'
}
],
toolbox: {
// 外部工具栏通过 takeGlobalCursor 激活框选放大ECharts 仍需要注册 toolbox.dataZoom 才会创建框选控制器。
show: true,
showTitle: false,
itemSize: 0,
itemGap: 0,
left: -100,
top: -100,
feature: {
dataZoom: {
yAxisIndex: 'none',
brushStyle: {
color: 'rgba(64, 158, 255, 0.18)',
borderColor: 'rgba(64, 158, 255, 0.65)',
borderWidth: 1
}
}
}
},
2026-04-16 08:15:46 +08:00
color: chartColors,
series: buildTrendSeries(seriesList)
2026-04-16 08:15:46 +08:00
}
}
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)
})
// 单通道模式按相别拆成多个图,便于分别观察各通道波形。
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)
2026-04-16 08:15:46 +08:00
// 单通道下每张图只保留一个 series需要单独指定对应相色。
return trendPayload.series.map((item, index) => {
const showTimeAxis = resolveShowTimeAxis(index, trendPayload.series.length)
const itemTrendPayload = {
...trendPayload,
min: item.data.length ? Math.min(...item.data) : undefined,
max: item.data.length ? Math.max(...item.data) : undefined,
series: [item]
}
return {
key: `${detailIndex}-${item.name}`,
group: chartGroup,
isLastChart: showTimeAxis,
options: buildTrendChartOptions(
itemTrendPayload,
itemTrendPayload.series,
[trendColors[index] || defaultPhaseColors[index]],
{
showTimeAxis,
yAxisSplitCount: TREND_AXIS_COMPACT_SPLIT_COUNT
}
)
}
})
}
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
2026-04-16 08:15:46 +08:00
}
})
.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),
group: allChannelTrendChartGroup,
isLastChart: isLastGroup,
singleChannelOptionsList: buildSingleChannelTrendOptionsList(
item.detail,
item.index,
allChannelTrendChartGroup,
(seriesIndex, seriesCount) => isLastGroup && seriesIndex === seriesCount - 1
),
multiChannelOptions: buildTrendChartOptions(
item.trendPayload,
item.trendPayload.series,
getTrendColors(item.detail),
{
showTimeAxis: isLastGroup,
yAxisSplitCount: TREND_AXIS_COMPACT_SPLIT_COUNT
}
)
}
})
2026-04-16 08:15:46 +08:00
})
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: isAllChannelsActive.value
? '全部'
: detail
? buildChannelLabel(detail, activeChannelIndex.value as number)
: '--'
},
2026-04-16 08:15:46 +08:00
{ 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 handleTrendToolAction = async (action: TrendToolAction) => {
if (!hasWaveformData.value) return
switch (action) {
case 'x-zoom-in':
zoomTrendXAxis(0.8)
break
case 'x-zoom-out':
zoomTrendXAxis(1.25)
break
case 'y-zoom-in':
trendYZoomScale.value = Math.max(trendYZoomScale.value * 0.8, 0.1)
break
case 'y-zoom-out':
trendYZoomScale.value = Math.min(trendYZoomScale.value * 1.25, 10)
break
case 'box-zoom':
activeTrendInteractionMode.value = activeTrendInteractionMode.value === 'box-zoom' ? 'none' : 'box-zoom'
break
case 'pan':
if (!canPanTrendChart.value) {
ElMessage.info('请先放大 X 轴或框选局部区域后再平移')
activeTrendInteractionMode.value = 'none'
break
}
activeTrendInteractionMode.value = activeTrendInteractionMode.value === 'pan' ? 'none' : 'pan'
break
case 'reset':
resetTrendToolState()
break
case 'download-image':
await downloadTrendImage()
break
case 'download-data':
downloadTrendData()
break
default:
break
}
}
const handleTrendChartDataZoom = (value: TrendChartZoomPayload) => {
trendXZoomRange.value = {
start: clampPercent(value.start),
end: clampPercent(value.end)
}
if (!canPanTrendChart.value && activeTrendInteractionMode.value === 'pan') {
activeTrendInteractionMode.value = 'none'
}
}
watch([activeTrendTab, activeValueMode, activeDisplayMode, activeChannelIndex], () => {
resetTrendToolState()
})
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 = 'all'
resetTrendToolState()
2026-04-16 08:15:46 +08:00
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 buildTrendExportFileName = (extension: string) => {
const channelLabel = isAllChannelsActive.value
? '全部'
: activeWaveDetail.value
? buildChannelLabel(activeWaveDetail.value, activeChannelIndex.value as number)
: '波形'
return `波形查看_${channelLabel}_${valueModeLabelMap[activeValueMode.value]}_${trendLabelMap[activeTrendTab.value]}.${extension}`
}
const downloadTrendImage = async () => {
await nextTick()
const targetElement = document.querySelector('.waveform-trend-export-target') as HTMLElement | null
if (!targetElement) {
ElMessage.warning('未找到可导出的趋势图区域')
return
}
const canvas = await html2canvas(targetElement, {
backgroundColor: '#ffffff',
scale: window.devicePixelRatio || 1,
useCORS: true
})
const imageUrl = canvas.toDataURL('image/png')
const exportFile = document.createElement('a')
exportFile.style.display = 'none'
exportFile.download = buildTrendExportFileName('png')
exportFile.href = imageUrl
document.body.appendChild(exportFile)
exportFile.click()
document.body.removeChild(exportFile)
ElMessage.success('趋势图图片下载成功')
}
2026-04-16 08:15:46 +08:00
const downloadTrendData = () => {
if (!hasWaveformData.value) {
ElMessage.warning('暂无可导出的波形数据')
return
}
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
}
2026-04-16 08:15:46 +08:00
const header = ['时间', ...trendPayload.series.map(item => item.name)]
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] ?? ''))
]
}
2026-04-16 08:15:46 +08:00
return [time, ...trendPayload.series.map(item => item.data[index] ?? '')]
})
const csvContent = [isAllChannelsActive.value ? allChannelHeader : header, ...rows]
.map(row => row.join(','))
.join('\n')
2026-04-16 08:15:46 +08:00
const blob = new Blob([`\uFEFF${csvContent}`], { type: 'text/csv;charset=utf-8;' })
const blobUrl = URL.createObjectURL(blob)
const exportFile = document.createElement('a')
const fileName = buildTrendExportFileName('csv')
2026-04-16 08:15:46 +08:00
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>