波形解析相关
This commit is contained in:
13
frontend/src/api/tools/waveform/index.ts
Normal file
13
frontend/src/api/tools/waveform/index.ts
Normal 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' }
|
||||
})
|
||||
}
|
||||
79
frontend/src/api/tools/waveform/interface/index.ts
Normal file
79
frontend/src/api/tools/waveform/interface/index.ts
Normal 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[]
|
||||
}
|
||||
}
|
||||
134
frontend/src/views/tools/index.vue
Normal file
134
frontend/src/views/tools/index.vue
Normal 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>
|
||||
43
frontend/src/views/tools/mmsMapping/index.vue
Normal file
43
frontend/src/views/tools/mmsMapping/index.vue
Normal 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>
|
||||
950
frontend/src/views/tools/waveform/index.vue
Normal file
950
frontend/src/views/tools/waveform/index.vue
Normal 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>
|
||||
|
||||
Reference in New Issue
Block a user