新建监控功能

This commit is contained in:
2026-04-23 11:09:06 +08:00
parent 7dee9092dc
commit 287de846a6
28 changed files with 6263 additions and 703 deletions

View File

@@ -14,6 +14,7 @@
<div class="summary-value">{{ item.value }}</div>
</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="向量信息" name="vector">
@@ -23,27 +24,16 @@
:last-vector-parse-error-message="lastVectorParseErrorMessage"
:active-vector-channel-name="activeVectorChannelName"
/>
</div>
</el-tab-pane>
</el-tabs>
<div class="feature-header">特征值</div>
<div v-if="featureCards.length" class="feature-grid">
<div v-for="item in featureCards" :key="item.title" class="feature-card">
<div class="feature-card-title">{{ item.title }}</div>
<div v-for="row in item.rows" :key="row.label" class="feature-row">
<span>{{ row.label }}</span>
<span>{{ row.value }}</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 class="empty-text">接口联调完成后右侧会展示波形信息和向量信息</div>
<div v-if="lastParseErrorMessage" class="empty-text error-text">最近一次解析失败{{ lastParseErrorMessage }}</div>
</div>
</div>
@@ -53,13 +43,12 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { Waveform } from '@/api/tools/waveform/interface'
import type { FeatureCardItem, SummaryItem } from './types'
import type { SummaryItem } from './types'
import WaveformVectorInfo from './WaveformVectorInfo.vue'
defineProps<{
hasParsedWaveform: boolean
summaryItems: SummaryItem[]
featureCards: FeatureCardItem[]
vectorParseResult: Waveform.WaveComtradeVectorResultVO | null
lastParseErrorMessage: string
lastVectorParseErrorMessage: string
@@ -143,18 +132,40 @@ const activeInfoTab = ref('waveform')
}
.info-body {
overflow: auto;
overflow: hidden;
}
.info-tabs {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
flex-direction: column;
width: 100%;
flex-shrink: 0;
}
.info-tabs :deep(.el-tabs__header) {
margin-bottom: 8px;
}
.info-tabs :deep(.el-tabs__content) {
flex: 1;
min-height: 0;
overflow: hidden;
}
.info-tabs :deep(.el-tab-pane) {
height: 100%;
}
.info-tab-panel {
flex: 1;
height: 100%;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
}
.info-tabs :deep(.el-tabs__item) {
font-size: 13px;
}
@@ -165,23 +176,15 @@ const activeInfoTab = ref('waveform')
gap: 8px;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.summary-item,
.feature-card {
.summary-item {
padding: 10px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
background: var(--cn-color-canvas-bg);
}
.summary-label,
.feature-card-title,
.feature-header {
.summary-label {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
@@ -195,24 +198,7 @@ const activeInfoTab = ref('waveform')
word-break: break-all;
}
.feature-header {
margin-top: 2px;
}
.feature-card {
display: flex;
flex-direction: column;
gap: 6px;
}
.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: 1200px) {
.summary-grid {
@@ -221,8 +207,7 @@ const activeInfoTab = ref('waveform')
}
@media (max-width: 992px) {
.summary-grid,
.feature-grid {
.summary-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -9,7 +9,9 @@
placeholder="请选择同一组.cfg和.dat文件"
class="file-input"
/>
<el-button type="primary" :loading="isParsing" @click="openWaveformFilePicker">选择波形</el-button>
<el-button type="primary" :icon="FolderOpened" :loading="isParsing" @click="openWaveformFilePicker">
选择波形
</el-button>
<input
ref="waveformFileInputRef"
type="file"
@@ -59,13 +61,16 @@
</el-radio-group>
</div>
<el-button type="primary" :disabled="!hasWaveformData" @click="emit('download')">下载数据</el-button>
<el-button type="primary" :icon="Download" :disabled="!hasWaveformData" @click="emit('download')">
下载数据
</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Download, FolderOpened } from '@element-plus/icons-vue'
import { ref } from 'vue'
import type { DisplayMode, LabelValueOption, ValueMode, WaveformDetailOption } from './types'

View File

@@ -28,6 +28,7 @@
</el-tabs>
<div v-if="activeCycle" class="vector-tab-content">
<div class="feature-header feature-header--nested">基础指标</div>
<div v-if="phaseMetricColumns.length" class="phase-metric-table">
<div class="phase-metric-row phase-metric-row--header" :style="phaseMetricGridStyle">
<span class="phase-metric-cell phase-metric-cell--label">指标</span>
@@ -83,23 +84,8 @@
/>
</el-tabs>
<div v-if="activeHarmonicRows.length" class="harmonic-list">
<div class="harmonic-row harmonic-row--header">
<span>次数</span>
<span>幅值</span>
<span>有效值</span>
<span>占比</span>
</div>
<div
v-for="item in activeHarmonicRows"
:key="`${activePhaseKey}-${item.harmonicOrder}`"
class="harmonic-row"
>
<span>{{ item.harmonicOrder ?? '--' }}</span>
<span>{{ formatWaveValue(item.amplitude, activeVectorGroup?.unit) }}</span>
<span>{{ formatWaveValue(item.rms, activeVectorGroup?.unit) }}</span>
<span>{{ formatPercentValue(item.rate) }}</span>
</div>
<div v-if="activeHarmonicRows.length" class="harmonic-chart-card">
<div ref="harmonicChartRef" class="harmonic-chart" />
</div>
<div v-else class="empty-inline">当前相未返回谐波结果</div>
</div>
@@ -112,8 +98,9 @@
</template>
<script setup lang="ts">
import { computed, ref, watch, type CSSProperties } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch, type CSSProperties } from 'vue'
import dayjs from 'dayjs'
import * as echarts from 'echarts'
import type { Waveform } from '@/api/tools/waveform/interface'
import type { SummaryItem } from './types'
@@ -163,26 +150,12 @@ const formatWaveformTime = (value?: string) => {
return parsedValue.isValid() ? parsedValue.format('YYYY-MM-DD HH:mm:ss.SSS') : value
}
const formatWaveValue = (value: unknown, unit?: string) => {
const formatWaveValue = (value: unknown) => {
const formattedValue = formatNumber(value)
if (formattedValue === '--') return '--'
return unit ? `${formattedValue} ${unit}` : formattedValue
return formattedValue
}
const formatPhaseAngle = (value: unknown) => {
const formattedValue = formatNumber(value)
return formattedValue === '--' ? '--' : `${formattedValue} °`
}
const formatPercentValue = (value: unknown) => {
const formattedValue = formatNumber(value)
return formattedValue === '--' ? '--' : `${formattedValue}%`
}
const formatCycleTime = (value: unknown) => {
const formattedValue = formatNumber(value)
return formattedValue === '--' ? '--' : `${formattedValue} ms`
}
const buildMetricLabel = (label: string, unit?: string) => {
return unit ? `${label} (${unit})` : label
@@ -190,13 +163,15 @@ const buildMetricLabel = (label: string, unit?: string) => {
const phaseMetricConfigs: Array<{
label: string
getValue: (phase: Waveform.WavePhaseVectorDTO, unit?: string) => string
unit?: string
useGroupUnit?: boolean
getValue: (phase: Waveform.WavePhaseVectorDTO) => string
}> = [
{ label: '总有效值', getValue: (phase, unit) => formatWaveValue(phase.totalRms, unit) },
{ label: '基波幅值', getValue: (phase, unit) => formatWaveValue(phase.fundamentalAmplitude, unit) },
{ label: '基波有效值', getValue: (phase, unit) => formatWaveValue(phase.fundamentalRms, unit) },
{ label: '基波相角', getValue: phase => formatPhaseAngle(phase.fundamentalPhaseAngle) },
{ label: '谐波畸变率', getValue: phase => formatPercentValue(phase.harmonicDistortionRate) }
{ label: '总有效值', useGroupUnit: true, getValue: phase => formatWaveValue(phase.totalRms) },
{ label: '基波幅值', useGroupUnit: true, getValue: phase => formatWaveValue(phase.fundamentalAmplitude) },
{ label: '基波有效值', useGroupUnit: true, getValue: phase => formatWaveValue(phase.fundamentalRms) },
{ label: '基波相角', unit: '°', getValue: phase => formatWaveValue(phase.fundamentalPhaseAngle) },
{ label: '谐波畸变率', unit: '%', getValue: phase => formatWaveValue(phase.harmonicDistortionRate) }
]
const buildVectorGroupKey = (group: Waveform.WaveVectorGroupDTO, index: number) => {
@@ -299,15 +274,15 @@ const phaseMetricColumns = computed<PhaseMetricColumn[]>(() => {
})
const phaseMetricGridStyle = computed<CSSProperties>(() => ({
gridTemplateColumns: `96px repeat(${Math.max(phaseMetricColumns.value.length, 1)}, minmax(96px, 1fr))`
gridTemplateColumns: `128px repeat(${Math.max(phaseMetricColumns.value.length, 1)}, minmax(96px, 1fr))`
}))
const sequenceMetricGridStyle = computed<CSSProperties>(() => ({
gridTemplateColumns: `132px repeat(${Math.max(sequenceMetricColumns.value.length, 1)}, minmax(0, 1fr))`
gridTemplateColumns: `88px repeat(${Math.max(sequenceMetricColumns.value.length, 1)}, minmax(64px, 1fr))`
}))
const unbalanceMetricGridStyle = computed<CSSProperties>(() => ({
gridTemplateColumns: '156px minmax(0, 1fr)'
gridTemplateColumns: '128px minmax(72px, 1fr)'
}))
const phaseMetricRows = computed<PhaseMetricRow[]>(() => {
@@ -315,11 +290,11 @@ const phaseMetricRows = computed<PhaseMetricRow[]>(() => {
// 相量基础指标按“指标为行、相别为列”转置,减少 A/B/C 三相重复标签。
return phaseMetricConfigs.map(config => ({
label: config.label,
label: buildMetricLabel(config.label, config.useGroupUnit ? activeGroup?.unit : config.unit),
values: activePhaseVectors.value.reduce<Record<string, string>>((result, phase, index) => {
const column = phaseMetricColumns.value[index]
if (column) {
result[column.key] = config.getValue(phase, activeGroup?.unit)
result[column.key] = config.getValue(phase)
}
return result
}, {})
@@ -380,11 +355,158 @@ const unbalanceMetricRows = computed<MetricRow[]>(() => {
]
})
const activeHarmonicRows = computed(() => {
const activeHarmonicRows = computed<Waveform.WaveHarmonicDTO[]>(() => {
if (!activePhaseVector.value) return []
return activePhaseVector.value.harmonicVoltageContentRates || activePhaseVector.value.harmonicCurrentAmplitudes || []
})
const harmonicChartRef = ref<HTMLDivElement>()
let harmonicChart: echarts.ECharts | null = null
let harmonicResizeObserver: ResizeObserver | null = null
const normalizeChartValue = (value: unknown) => {
const numberValue = Number(value)
return Number.isFinite(numberValue) ? Number(numberValue.toFixed(3)) : null
}
const harmonicChartOption = computed<echarts.EChartsOption>(() => {
const unit = activeVectorGroup.value?.unit || ''
const categories = activeHarmonicRows.value.map(item => `${item.harmonicOrder ?? '--'}`)
const hasRate = activeHarmonicRows.value.some(item => Number.isFinite(Number(item.rate)))
const valueAxisName = unit ? `幅值 / 有效值 (${unit})` : '幅值 / 有效值'
const series: echarts.SeriesOption[] = [
{
name: unit ? `幅值 (${unit})` : '幅值',
type: 'bar',
barMaxWidth: 16,
data: activeHarmonicRows.value.map(item => normalizeChartValue(item.amplitude))
},
{
name: unit ? `有效值 (${unit})` : '有效值',
type: 'bar',
barMaxWidth: 16,
data: activeHarmonicRows.value.map(item => normalizeChartValue(item.rms))
}
]
if (hasRate) {
series.push({
name: '占比 (%)',
type: 'bar',
yAxisIndex: 1,
barMaxWidth: 16,
data: activeHarmonicRows.value.map(item => normalizeChartValue(item.rate))
})
}
return {
color: ['#2f80ed', '#07ccca', '#ffbf00'],
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
confine: true
},
legend: {
top: 0,
right: 8,
itemWidth: 12,
itemHeight: 8,
textStyle: { color: '#606266', fontSize: 12 }
},
grid: {
top: 36,
right: hasRate ? 52 : 16,
bottom: 42,
left: 12,
containLabel: true
},
xAxis: {
type: 'category',
name: '谐波次数',
nameGap: 22,
data: categories,
axisTick: { show: false },
axisLabel: { color: '#606266', fontSize: 11 },
axisLine: { lineStyle: { color: '#dcdfe6' } }
},
yAxis: [
{
type: 'value',
name: valueAxisName,
nameTextStyle: { color: '#606266', fontSize: 11 },
axisLabel: { color: '#606266', fontSize: 11 },
splitLine: { lineStyle: { color: '#ebeef5', type: 'dashed' } }
},
{
type: 'value',
name: '%',
show: hasRate,
nameTextStyle: { color: '#606266', fontSize: 11 },
axisLabel: { color: '#606266', fontSize: 11 },
splitLine: { show: false }
}
],
dataZoom: [
{
type: 'inside',
start: 0,
end: Math.min(100, activeHarmonicRows.value.length > 18 ? 36 : 100)
},
{
type: 'slider',
height: 12,
bottom: 12,
start: 0,
end: Math.min(100, activeHarmonicRows.value.length > 18 ? 36 : 100)
}
],
series
}
})
const resizeHarmonicChart = () => {
if (!harmonicChart || !harmonicChartRef.value || harmonicChartRef.value.offsetHeight === 0) return
harmonicChart.resize()
}
const renderHarmonicChart = async () => {
await nextTick()
if (!harmonicChartRef.value || !activeHarmonicRows.value.length) {
harmonicChart?.dispose()
harmonicChart = null
harmonicResizeObserver?.disconnect()
harmonicResizeObserver = null
return
}
if (!harmonicChart) {
harmonicChart = echarts.init(harmonicChartRef.value)
}
harmonicChart.setOption(harmonicChartOption.value, true)
if (!harmonicResizeObserver) {
harmonicResizeObserver = new ResizeObserver(() => resizeHarmonicChart())
harmonicResizeObserver.observe(harmonicChartRef.value)
}
resizeHarmonicChart()
}
onMounted(() => {
renderHarmonicChart()
})
onBeforeUnmount(() => {
harmonicResizeObserver?.disconnect()
harmonicChart?.dispose()
})
watch(harmonicChartOption, () => {
renderHarmonicChart()
})
</script>
<style scoped lang="scss">
@@ -397,6 +519,12 @@ const activeHarmonicRows = computed(() => {
gap: 8px;
}
.waveform-vector-info {
flex: none;
min-height: auto;
overflow: visible;
}
.empty-text,
.empty-inline,
.vector-placeholder {
@@ -425,7 +553,7 @@ const activeHarmonicRows = computed(() => {
.summary-item,
.feature-card,
.vector-placeholder,
.harmonic-list {
.harmonic-chart-card {
padding: 10px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
@@ -488,6 +616,11 @@ const activeHarmonicRows = computed(() => {
border-bottom: 1px solid var(--el-border-color-lighter);
}
.phase-metric-table--fit .phase-metric-cell {
padding-right: 8px;
padding-left: 8px;
}
.phase-metric-table--fit .phase-metric-row {
width: 100%;
min-width: 0;
@@ -509,12 +642,20 @@ const activeHarmonicRows = computed(() => {
font-size: 12px;
line-height: 1.4;
color: var(--el-text-color-regular);
text-align: right;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
border-right: 1px solid var(--el-border-color-lighter);
}
.phase-metric-cell--label {
overflow: visible;
text-align: left;
text-overflow: clip;
white-space: normal;
word-break: keep-all;
}
.phase-metric-row--header .phase-metric-cell {
color: var(--el-text-color-primary);
}
@@ -523,9 +664,6 @@ const activeHarmonicRows = computed(() => {
border-right: 0;
}
.phase-metric-cell--label {
text-align: left;
}
.vector-tabs {
width: 100%;
@@ -544,36 +682,14 @@ const activeHarmonicRows = computed(() => {
font-size: 13px;
}
.harmonic-list {
display: flex;
flex-direction: column;
gap: 0;
max-height: 240px;
overflow: auto;
padding: 0;
.harmonic-chart-card {
height: 280px;
padding: 10px 8px 4px;
}
.harmonic-row {
display: grid;
grid-template-columns: 52px repeat(3, minmax(0, 1fr));
gap: 8px;
padding: 8px 10px;
font-size: 12px;
color: var(--el-text-color-regular);
border-bottom: 1px solid var(--el-border-color-lighter);
}
.harmonic-row:last-child {
border-bottom: 0;
}
.harmonic-row--header {
position: sticky;
top: 0;
z-index: 1;
font-weight: 600;
color: var(--el-text-color-primary);
background: var(--el-bg-color);
.harmonic-chart {
width: 100%;
height: 100%;
}
@media (max-width: 1200px) {
@@ -585,7 +701,7 @@ const activeHarmonicRows = computed(() => {
@media (min-width: 993px) {
.vector-tab-content {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: minmax(0, 1.35fr) minmax(200px, 0.65fr);
align-items: start;
}
@@ -594,29 +710,30 @@ const activeHarmonicRows = computed(() => {
}
.vector-tab-content > :first-child,
.vector-tab-content > :nth-child(n + 6) {
.vector-tab-content > :nth-child(2),
.vector-tab-content > :nth-child(n + 7) {
grid-column: 1 / -1;
}
.vector-tab-content > :nth-child(2) {
grid-column: 1;
grid-row: 2;
}
.vector-tab-content > :nth-child(3) {
grid-column: 1;
grid-row: 3;
}
.vector-tab-content > :nth-child(4) {
grid-column: 2;
grid-row: 2;
grid-column: 1;
grid-row: 4;
}
.vector-tab-content > :nth-child(5) {
grid-column: 2;
grid-row: 3;
}
.vector-tab-content > :nth-child(6) {
grid-column: 2;
grid-row: 4;
}
}
@media (max-width: 992px) {
@@ -626,8 +743,6 @@ const activeHarmonicRows = computed(() => {
grid-template-columns: 1fr;
}
.harmonic-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>