波形解析相关

This commit is contained in:
2026-04-16 08:15:46 +08:00
parent 5596d57409
commit 9b43f45808
5 changed files with 1219 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
import http from '@/api'
import type { Waveform } from './interface'
export const parseComtradeApi = (params: Waveform.ParseComtradeParams) => {
const formData = new FormData()
formData.append('cfgFile', params.cfgFile)
formData.append('datFile', params.datFile)
return http.post<Waveform.WaveComtradeResultVO>(`/wave/parseComtrade`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}

View File

@@ -0,0 +1,79 @@
export namespace Waveform {
export interface ParseComtradeParams {
cfgFile: File
datFile: File
parseType?: number
ptType?: number
pt?: number
ct?: number
monitorName?: string
calculateRms?: boolean
buildDetails?: boolean
calculateEigenvalue?: boolean
dynamicThreshold?: boolean
}
export interface WaveTrendData {
max?: number
min?: number
aValue?: number[][]
bValue?: number[][]
cValue?: number[][]
}
export interface WaveDataDetail {
instantData?: WaveTrendData
rmsData?: WaveTrendData
a?: string
b?: string
c?: string
channelName?: string
unit?: string
isOpen?: boolean
title?: string
colors?: string[]
}
export interface ComtradeCfgDTO {
nChannelNum?: number
nPhasic?: number
nAnalogNum?: number
nDigitalNum?: number
timeStart?: string
timeTrige?: string
nRates?: number
firstMs?: number
nPush?: number
finalSampleRate?: number
nAllWaveNum?: number
strBinType?: string
}
export interface WaveDataDTO {
comtradeCfgDTO?: ComtradeCfgDTO
waveTitle?: string[]
channelNames?: string[]
listWaveData?: number[][]
listRmsData?: number[][]
listRmsMinData?: number[][]
iPhasic?: number
ptType?: number
pt?: number
ct?: number
time?: string
monitorName?: string
}
export interface EigenvalueDTO {
amplitude?: number
residualVoltage?: number
ratedVoltage?: number
durationTime?: number
}
export interface WaveComtradeResultVO {
waveData?: WaveDataDTO
waveDataDetails?: WaveDataDetail[]
eigenvalues?: EigenvalueDTO[]
}
}

View File

@@ -0,0 +1,134 @@
<template>
<div class="tools-view">
<div class="tools-card">
<div class="page-header">
<div>
<h1 class="tools-title">工具中心</h1>
<p class="tools-description">当前工具页面统一从这里进入后续新增工具可继续在此扩展</p>
</div>
</div>
<div class="tool-grid">
<button class="tool-item" type="button" @click="handleNavigate('/tools/waveform')">
<div class="tool-name">波形查看</div>
<div class="tool-text">选择同名 cfg/dat 文件并查看波形RMS 和摘要信息</div>
</button>
<button class="tool-item" type="button" @click="handleNavigate('/tools/mmsMapping')">
<div class="tool-name">MMS 映射</div>
<div class="tool-text">进入 MMS 映射页面后续可继续补充映射配置和预览能力</div>
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
defineOptions({
name: 'ToolsView'
})
const router = useRouter()
const handleNavigate = async (path: string) => {
if (!path) return
await router.push(path)
}
</script>
<style scoped lang="scss">
.tools-view {
min-height: 100%;
padding: 24px;
background: #f5f7fa;
}
.tools-card {
display: flex;
flex-direction: column;
gap: 24px;
padding: 32px;
border-radius: 12px;
background: #ffffff;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.tools-title {
margin: 0 0 12px;
font-size: 24px;
font-weight: 600;
color: #1f2937;
}
.tools-description {
margin: 0;
font-size: 14px;
line-height: 1.6;
color: #4b5563;
}
.tool-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
}
.tool-item {
display: flex;
flex-direction: column;
gap: 10px;
padding: 20px;
border: 1px solid #dbe3f0;
border-radius: 12px;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
cursor: pointer;
text-align: left;
transition:
transform 0.2s ease,
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.tool-item:hover {
border-color: #94a3b8;
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.08);
transform: translateY(-2px);
}
.tool-name {
font-size: 18px;
font-weight: 600;
color: #172033;
}
.tool-text {
font-size: 13px;
line-height: 1.7;
color: #64748b;
}
@media (max-width: 992px) {
.tool-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.tools-view {
padding: 16px;
}
.tools-card {
padding: 20px;
}
}
</style>

View File

@@ -0,0 +1,43 @@
<template>
<div class="mms-mapping-view">
<div class="mms-mapping-card">
<h1 class="page-title">MMS 映射</h1>
<p class="page-description">当前页面已创建后续可在这里接入 MMS 映射配置映射预览和导入导出能力</p>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({
name: 'MmsMappingView'
})
</script>
<style scoped lang="scss">
.mms-mapping-view {
min-height: 100%;
padding: 24px;
background: #f5f7fa;
}
.mms-mapping-card {
padding: 32px;
border-radius: 12px;
background: #ffffff;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
}
.page-title {
margin: 0 0 12px;
font-size: 24px;
font-weight: 600;
color: #1f2937;
}
.page-description {
margin: 0;
font-size: 14px;
line-height: 1.6;
color: #4b5563;
}
</style>

View File

@@ -0,0 +1,950 @@
<template>
<div class="table-box waveform-page">
<div class="page-header">
<div class="page-title">波形查看</div>
<div class="action-row">
<div class="file-select-row">
<el-input
:model-value="selectedWaveformFileName"
readonly
placeholder="请选择同一组.cfg和.dat文件"
class="file-input"
/>
<el-button type="primary" :loading="isParsing" @click="openWaveformFilePicker">选择波形</el-button>
<input
ref="waveformFileInputRef"
type="file"
:accept="waveformFileAccept"
multiple
class="waveform-file-input"
@change="handleWaveformFileChange"
/>
</div>
<div class="toolbar-actions">
<div v-if="channelOptions.length" class="toolbar-item">
<div class="toolbar-label">波形通道</div>
<el-select v-model="activeChannelIndex" class="channel-select" placeholder="选择通道">
<el-option
v-for="item in channelOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</div>
<div class="toolbar-item">
<div class="toolbar-label">功能显示</div>
<el-select v-model="activeDisplayMode" class="display-mode-select" placeholder="选择显示模式">
<el-option
v-for="item in displayModeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</div>
<div class="toolbar-item">
<div class="toolbar-label">数值类型</div>
<el-radio-group v-model="activeValueMode" class="value-mode-switch">
<el-radio-button
v-for="item in valueModeOptions"
:key="item.value"
:label="item.value"
>
{{ item.label }}
</el-radio-button>
</el-radio-group>
</div>
<el-button type="primary" :icon="Download" :disabled="!hasWaveformData" @click="downloadTrendData">
下载数据
</el-button>
</div>
</div>
</div>
<div class="waveform-layout">
<section class="waveform-panel">
<div class="panel-header">
<el-tabs v-model="activeTrendTab" class="trend-tabs">
<el-tab-pane v-for="item in trendTabs" :key="item.value" :label="item.label" :name="item.value" />
</el-tabs>
</div>
<div class="panel-body">
<div v-if="hasWaveformData && activeDisplayMode === 'multi-channel'" class="chart-container">
<LineChart :options="activeTrendOptions" />
</div>
<div v-else-if="hasWaveformData" class="single-channel-list">
<div v-for="item in singleChannelTrendOptionsList" :key="item.key" class="single-channel-card">
<div class="single-channel-chart">
<LineChart :options="item.options" :group="item.group" />
</div>
</div>
</div>
<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>
</div>
</section>
<section class="waveform-panel">
<div class="panel-header">
<div class="section-title">波形信息</div>
</div>
<div v-if="hasParsedWaveform" class="panel-body info-body">
<div class="panel-tip">当前接口未返回向量图坐标右侧展示解析摘要与特征值结果</div>
<div class="summary-grid">
<div v-for="item in summaryItems" :key="item.label" class="summary-item">
<div class="summary-label">{{ item.label }}</div>
<div class="summary-value">{{ item.value }}</div>
</div>
</div>
<div class="feature-header">特征值</div>
<div v-if="eigenvalueList.length" class="feature-grid">
<div v-for="(item, index) in eigenvalueList" :key="index" class="feature-card">
<div class="feature-card-title">特征值 {{ index + 1 }}</div>
<div class="feature-row">
<span>幅值占比</span>
<span>{{ formatPercentage(item.amplitude) }}</span>
</div>
<div class="feature-row">
<span>残余电压</span>
<span>{{ formatNumber(item.residualVoltage) }}</span>
</div>
<div class="feature-row">
<span>额定电压</span>
<span>{{ formatNumber(item.ratedVoltage) }}</span>
</div>
<div class="feature-row">
<span>持续时间</span>
<span>{{ formatDuration(item.durationTime) }}</span>
</div>
</div>
</div>
<div v-else class="empty-inline">当前文件未返回特征值结果</div>
</div>
<div v-else class="panel-body">
<div class="empty-block">
<div class="empty-title">暂无解析信息</div>
<div class="empty-text">接口联调完成后右侧会展示波形摘要和特征值</div>
<div v-if="lastParseErrorMessage" class="empty-text error-text">最近一次解析失败{{ lastParseErrorMessage }}</div>
</div>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Download } from '@element-plus/icons-vue'
import LineChart from '@/components/echarts/line/index.vue'
import { parseComtradeApi } from '@/api/tools/waveform'
import type { Waveform } from '@/api/tools/waveform/interface'
defineOptions({
name: 'WaveformView'
})
type TrendTabValue = 'instant' | 'rms'
type ValueMode = 'primary' | 'secondary'
type DisplayMode = 'single-channel' | 'multi-channel'
interface WaveformDetailOption {
label: string
value: number
}
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 waveformFileInputRef = ref<HTMLInputElement>()
const selectedCfgFile = ref<File | null>(null)
const selectedDatFile = ref<File | null>(null)
const waveformParseResult = ref<Waveform.WaveComtradeResultVO | null>(null)
const lastParseErrorMessage = ref('')
const waveformFileAccept = '.cfg,.dat'
const trendTabs = [
{ value: 'instant' as const, label: '瞬时波形' },
{ value: 'rms' as const, label: 'RMS 波形' }
]
const valueModeOptions = [
{ label: '一次值', value: 'primary' },
{ label: '二次值', value: 'secondary' }
]
const displayModeOptions = [
{ label: '单通道', value: 'single-channel' as const },
{ label: '多通道', value: 'multi-channel' as const }
]
const trendLabelMap = {
instant: '瞬时波形',
rms: 'RMS 波形'
} as const
const valueModeLabelMap = {
primary: '一次值',
secondary: '二次值'
} as const
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]
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 eigenvalueList = computed(() => waveformParseResult.value?.eigenvalues || [])
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))}`
}
const formatPercentage = (value?: number) => {
const numberValue = Number(value)
if (!Number.isFinite(numberValue)) return '--'
return `${Number((numberValue * 100).toFixed(2))}%`
}
const formatDuration = (value?: number) => {
const numberValue = Number(value)
if (!Number.isFinite(numberValue)) return '--'
return `${Number(numberValue.toFixed(3))} ms`
}
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)
const buildTrendAxisConfig = (trendPayload: WaveformTrendPayload) => {
const min = trendPayload.min
const max = trendPayload.max
const valueGap = Number.isFinite(min) && Number.isFinite(max) ? Math.max((max! - min!) * 0.1, 1) : undefined
return {
name: trendPayload.unit || '',
...(valueGap !== undefined && min !== undefined && max !== undefined
? {
min: min - valueGap,
max: max + valueGap
}
: {})
}
}
const buildTrendChartOptions = (
trendPayload: WaveformTrendPayload,
seriesList: WaveformSeriesItem[],
chartColors = currentTrendColors.value,
layoutOptions: TrendChartLayoutOptions = {}
) => {
const { showTimeAxis = true } = layoutOptions
return {
tooltip: {
valueFormatter: (value: number) => {
return trendPayload.unit ? `${formatNumber(value)} ${trendPayload.unit}` : formatNumber(value)
}
},
legend: {
top: 10,
right: 12
},
grid: {
top: '18px',
left: '24px',
right: showTimeAxis ? '32px' : '24px',
bottom: showTimeAxis ? '22px' : '12px'
},
xAxis: {
data: trendPayload.timeLabels,
boundaryGap: false,
name: showTimeAxis ? 'ms' : '',
nameLocation: 'end',
nameGap: showTimeAxis ? 10 : 0,
axisLabel: {
show: showTimeAxis,
hideOverlap: true,
interval: 'auto',
margin: showTimeAxis ? 6 : 0,
formatter: (value: string | number) => formatNumber(value, 2)
},
axisTick: {
show: showTimeAxis
}
},
yAxis: buildTrendAxisConfig(trendPayload),
dataZoom: [],
color: chartColors,
series: seriesList.map(item => ({
name: item.name,
type: 'line',
smooth: true,
symbol: 'none',
data: item.data
}))
}
}
const activeTrendOptions = computed(() => {
const trendPayload = activeTrendPayload.value
return buildTrendChartOptions(trendPayload, trendPayload.series)
})
// 单通道模式按相别拆成多个图,便于分别观察各通道波形。
const singleChannelTrendOptionsList = computed(() => {
const trendPayload = activeTrendPayload.value
// 单通道下每张图只保留一个 series需要单独指定对应相色。
return trendPayload.series.map((item, index) => ({
key: item.name,
group: singleChannelTrendChartGroup,
options: buildTrendChartOptions(
trendPayload,
[item],
[currentTrendColors.value[index] || defaultPhaseColors[index]],
{
// 仅最后一张图显示时间轴,既保留时间信息,又避免多图重复挤占高度。
showTimeAxis: index === trendPayload.series.length - 1
}
)
}))
})
const summaryItems = computed(() => {
const waveData = activeWaveData.value
const cfgData = waveData?.comtradeCfgDTO
const detail = activeWaveDetail.value
return [
{ label: '测点名称', value: waveData?.monitorName || '--' },
{ label: '事件时间', value: waveData?.time || '--' },
{ label: '录波开始', value: cfgData?.timeStart || '--' },
{ label: '触发时间', value: cfgData?.timeTrige || '--' },
{ label: '采样率', value: cfgData?.finalSampleRate ? `${cfgData.finalSampleRate} Hz` : '--' },
{ label: '总通道数', value: cfgData?.nChannelNum ?? '--' },
{ label: '当前通道', value: detail ? buildChannelLabel(detail, activeChannelIndex.value) : '--' },
{ label: '单位', value: detail?.unit || '--' },
{ label: '相别数量', value: waveData?.iPhasic ?? '--' },
{ label: 'PT / CT', value: `${formatNumber(waveData?.pt, 2)} / ${formatNumber(waveData?.ct, 2)}` },
{ label: '数据类型', value: valueModeLabelMap[activeValueMode.value] },
{ label: '文件编码', value: cfgData?.strBinType || '--' }
]
})
const openWaveformFilePicker = () => {
if (!waveformFileInputRef.value) return
waveformFileInputRef.value.value = ''
waveformFileInputRef.value.click()
}
const getFileBaseName = (fileName: string) => {
return fileName.replace(/\.[^.]+$/, '').toLowerCase()
}
const resetSelectedWaveformFiles = () => {
selectedCfgFile.value = null
selectedDatFile.value = null
waveformParseResult.value = null
lastParseErrorMessage.value = ''
}
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 = ''
const result = await parseComtradeApi({
cfgFile,
datFile
})
waveformParseResult.value = result.data
} catch (error) {
waveformParseResult.value = null
lastParseErrorMessage.value = getWaveformParseErrorMessage(error)
console.error('[waveform] parseComtrade failed', {
cfgFileName: cfgFile.name,
cfgFileSize: cfgFile.size,
datFileName: datFile.name,
datFileSize: datFile.size,
error
})
} 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;
}
.page-header {
display: flex;
flex-direction: column;
gap: 12px;
flex-shrink: 0;
}
.page-title {
font-size: 18px;
font-weight: 600;
line-height: 1.5;
color: var(--el-text-color-primary);
}
.action-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.file-select-row,
.toolbar-actions,
.toolbar-item {
display: flex;
gap: 12px;
align-items: center;
min-width: 0;
}
.toolbar-actions {
flex-shrink: 0;
}
.toolbar-label,
.file-select-row :deep(.el-input__inner),
.file-select-row :deep(.el-button),
.toolbar-actions :deep(.el-radio-button__inner),
.toolbar-actions :deep(.el-button),
.toolbar-actions :deep(.el-input__inner),
.toolbar-actions :deep(.el-select__placeholder),
.toolbar-actions :deep(.el-select__selected-item),
.trend-tabs :deep(.el-tabs__item) {
font-size: 13px;
}
.file-input {
width: 360px;
max-width: 100%;
}
.channel-select {
width: 220px;
}
.display-mode-select {
width: 160px;
}
.value-mode-switch {
min-width: 150px;
}
.waveform-file-input {
display: none;
}
.waveform-layout {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(360px, 0.95fr);
gap: 16px;
width: 100%;
height: 100%;
overflow: hidden;
}
.waveform-panel {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
padding: 16px;
overflow: hidden;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 4px;
}
.panel-header {
margin-bottom: 16px;
flex-shrink: 0;
}
.trend-tabs {
width: 100%;
flex-shrink: 0;
}
.trend-tabs :deep(.el-tabs__header) {
margin-bottom: 0;
}
.trend-tabs :deep(.el-tabs__nav-wrap::after) {
background-color: var(--el-border-color-light);
}
.panel-body {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.chart-container,
.empty-block {
display: flex;
flex: 1;
min-height: 0;
padding: 12px;
overflow: hidden;
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
}
.empty-block {
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
background: var(--cn-color-canvas-bg);
}
.single-channel-list {
display: flex;
flex: 1;
flex-direction: column;
gap: 12px;
min-height: 0;
overflow: hidden;
}
.single-channel-card {
display: flex;
flex: 1;
flex-direction: column;
gap: 8px;
min-height: 0;
padding: 12px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
background: var(--cn-color-canvas-bg);
overflow: hidden;
}
.single-channel-chart {
flex: 1;
min-height: 0;
}
.empty-title {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.empty-text,
.empty-inline,
.panel-tip {
font-size: 13px;
line-height: 1.6;
color: var(--el-text-color-regular);
}
.error-text {
color: var(--el-color-danger);
word-break: break-all;
}
.info-body {
gap: 12px;
overflow: auto;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.summary-item,
.feature-card {
padding: 12px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
background: var(--cn-color-canvas-bg);
}
.summary-label,
.feature-card-title,
.feature-header {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.summary-value {
margin-top: 6px;
font-size: 13px;
line-height: 1.6;
color: var(--el-text-color-regular);
word-break: break-all;
}
.feature-header {
margin-top: 4px;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.feature-card {
display: flex;
flex-direction: column;
gap: 8px;
}
.feature-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-size: 13px;
color: var(--el-text-color-regular);
}
@media (max-width: 1280px) {
.waveform-layout {
grid-template-columns: 1fr;
}
}
@media (max-width: 992px) {
.action-row,
.toolbar-actions {
flex-direction: column;
align-items: stretch;
}
.toolbar-item,
.file-select-row {
width: 100%;
}
.channel-select,
.display-mode-select,
.file-input,
.value-mode-switch {
width: 100%;
max-width: none;
}
.summary-grid,
.feature-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.page-title {
font-size: 17px;
}
.trend-tabs {
width: 100%;
}
.trend-tabs :deep(.el-tabs__nav) {
width: 100%;
}
.trend-tabs :deep(.el-tabs__item) {
flex: 1;
justify-content: center;
}
}
</style>