Files
pqs-9100_client/frontend/src/views/machine/freqConverter/components/freqConverterDipChart.vue

868 lines
21 KiB
Vue
Raw Normal View History

<template>
2026-04-17 09:15:58 +08:00
<el-card class="dip-chart-card" shadow="never">
<template #header>
<div class="card-header">
<div class="card-header-main">
<div class="card-title">耐受图</div>
<div class="card-subtitle">
{{ selectedMappingText }}
</div>
</div>
<div class="header-actions">
<el-button type="primary" plain :icon="Download" :disabled="!hasChartData" @click="downloadChartImage">
下载图片
</el-button>
<el-button type="primary" plain :icon="Document" :disabled="!hasChartData" @click="exportChartData">
导出数据
</el-button>
<el-button
2026-04-17 09:15:58 +08:00
type="primary"
plain
:loading="curveLoading"
class="draw-curve-button"
@click="drawCharacteristicCurve"
>
绘制特性曲线
</el-button>
</div>
2026-04-17 09:15:58 +08:00
</div>
</template>
<div class="chart-wrapper">
<MyEchart ref="chartRef" :options="chartOptions" />
2026-04-17 09:15:58 +08:00
</div>
</el-card>
</template>
<script lang="ts" setup>
import { computed, nextTick, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Document, Download } from '@element-plus/icons-vue'
import * as XLSX from 'xlsx'
2026-04-17 09:15:58 +08:00
import MyEchart from '@/components/echarts/line/index.vue'
import { getFreqConverterSCurve } from '@/api/device/freqConverter'
2026-04-17 09:15:58 +08:00
type ChartPointStatus = 'pass' | 'fail'
interface ChartPoint {
duration: number
residualVoltage: number
status: ChartPointStatus
2026-04-17 09:15:58 +08:00
}
interface NormalizedTolerantPoint {
duration: number
residualVoltage: number
tolerant: number | null
status: ChartPointStatus
2026-04-17 09:15:58 +08:00
}
const props = defineProps<{
selectedMapping?: Record<string, any> | null
webMsgSend?: any
resultData?: any
2026-04-17 09:15:58 +08:00
}>()
const STATUS_COLOR_MAP: Record<ChartPointStatus, string> = {
pass: '#1d4ed8',
fail: '#111111'
}
const chartPoints = ref<ChartPoint[]>([])
const characteristicCurveData = ref<Array<[number, number]>>([])
const curveLoading = ref(false)
const chartRef = ref<any>(null)
2026-04-17 09:15:58 +08:00
const selectedMappingText = computed(() => {
if (!props.selectedMapping) {
return '未选择变频器'
}
return `变频器:${props.selectedMapping.freqConverterName || '-'}`
})
const positiveDurations = computed(() => {
return [
...chartPoints.value.map(item => item.duration),
...characteristicCurveData.value.map(item => item[0])
].filter(item => Number.isFinite(item) && item > 0)
})
const xAxisMin = computed(() => {
if (!positiveDurations.value.length) {
return 0.001
}
const minValue = Math.min(...positiveDurations.value)
return Math.min(0.001, Number(minValue.toFixed(3)))
})
const xAxisMax = computed(() => {
if (!positiveDurations.value.length) {
return 60
}
const maxValue = Math.max(...positiveDurations.value)
return Math.max(Number((maxValue * 1.05).toFixed(3)), 60)
})
const sortedChartPoints = computed(() => {
return [...chartPoints.value].sort((a, b) => {
if (a.duration !== b.duration) {
return a.duration - b.duration
}
return a.residualVoltage - b.residualVoltage
})
})
const sortedCharacteristicCurveData = computed(() => {
return [...characteristicCurveData.value].sort((a, b) => {
if (a[0] !== b[0]) {
return a[0] - b[0]
}
return a[1] - b[1]
})
})
const clampValue = (value: number, min: number, max: number) => {
return Math.min(max, Math.max(min, value))
}
const buildExtendedCurvePoint = (points: Array<[number, number]>, targetDuration: number) => {
if (!points.length || !Number.isFinite(targetDuration) || targetDuration <= 0) {
return null as [number, number] | null
}
const lastPoint = points[points.length - 1]
if (!lastPoint || targetDuration === lastPoint[0]) {
return null as [number, number] | null
}
if (points.length === 1) {
return [targetDuration, lastPoint[1]] as [number, number]
}
const recentDistinctPoints: Array<[number, number]> = []
for (let index = points.length - 1; index >= 0 && recentDistinctPoints.length < 3; index -= 1) {
const point = points[index]
if (recentDistinctPoints.some(item => item[0] === point[0])) {
continue
}
recentDistinctPoints.unshift(point)
}
if (recentDistinctPoints.length < 2) {
return [targetDuration, lastPoint[1]] as [number, number]
}
const toLogDuration = (value: number) => Math.log10(value)
const getSlope = (start: [number, number], end: [number, number]) => {
const deltaX = toLogDuration(end[0]) - toLogDuration(start[0])
if (!Number.isFinite(deltaX) || deltaX === 0) {
return null
}
return (end[1] - start[1]) / deltaX
}
let slope = getSlope(
recentDistinctPoints[recentDistinctPoints.length - 2],
recentDistinctPoints[recentDistinctPoints.length - 1]
)
if (recentDistinctPoints.length >= 3) {
const previousSlope = getSlope(recentDistinctPoints[0], recentDistinctPoints[1])
if (previousSlope !== null && slope !== null) {
slope = previousSlope * 0.35 + slope * 0.65
}
}
if (slope === null) {
return [targetDuration, lastPoint[1]] as [number, number]
}
const deltaX = toLogDuration(targetDuration) - toLogDuration(lastPoint[0])
if (!Number.isFinite(deltaX)) {
return null as [number, number] | null
}
const predictedResidualVoltage = lastPoint[1] + slope * deltaX
const nearbyResidualVoltages = recentDistinctPoints.map(item => item[1])
const minResidualVoltage = Math.max(0, Math.min(...nearbyResidualVoltages) - 15)
const maxResidualVoltage = Math.min(100, Math.max(...nearbyResidualVoltages) + 15)
return [
targetDuration,
Number(clampValue(predictedResidualVoltage, minResidualVoltage, maxResidualVoltage).toFixed(2))
] as [number, number]
}
const inferredCharacteristicCurvePoint = computed(() => {
return buildExtendedCurvePoint(sortedCharacteristicCurveData.value, xAxisMax.value)
})
const solidCharacteristicCurveSeriesData = computed(() => {
return sortedCharacteristicCurveData.value.map(item => [item[0], item[1], 'Backend point'])
})
const dashedCharacteristicCurveSeriesData = computed(() => {
const lastPoint = sortedCharacteristicCurveData.value[sortedCharacteristicCurveData.value.length - 1]
const extensionPoint = inferredCharacteristicCurvePoint.value
if (!lastPoint || !extensionPoint) {
return []
}
return [
{
value: [lastPoint[0], lastPoint[1], 'Backend point'],
symbol: 'none'
},
{
value: [extensionPoint[0], extensionPoint[1], 'Inferred point']
}
]
})
const hasChartData = computed(() => {
return sortedChartPoints.value.length > 0 || sortedCharacteristicCurveData.value.length > 0
})
const formatLogDurationLabel = (value: number) => {
if (!Number.isFinite(value)) {
return ''
}
if (value >= 1) {
return `${Number(value.toFixed(2))}`
}
return `${Number(value.toFixed(3))}`
}
const sanitizeFileName = (value: string) => {
return value.replace(/[\\/:*?"<>|]/g, '_').trim() || '未命名变频器'
}
const buildFileName = (prefix: string, suffix: string) => {
const freqConverterName = sanitizeFileName(props.selectedMapping?.freqConverterName || '未命名变频器')
return `${prefix}_${freqConverterName}.${suffix}`
}
const triggerDownload = (url: string, fileName: string) => {
const link = document.createElement('a')
link.style.display = 'none'
link.href = url
link.download = fileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
const toNumber = (value: unknown) => {
const result = Number(value)
return Number.isFinite(result) ? result : null
}
2026-04-17 09:15:58 +08:00
const normalizeTolerantValue = (value: unknown) => {
if (value === undefined || value === null || value === '') {
return null
}
const result = Number(value)
if ([0, 1, 2].includes(result)) {
return result
}
return null
}
const normalizeDuration = (source: Record<string, any>) => {
return toNumber(
source.durationMs !== undefined && source.durationMs !== null
? Number(source.durationMs) / 1000
: source.duration ?? source.x ?? source.dipDuration ?? source.retainTime ?? source.durationValue
2026-04-17 09:15:58 +08:00
)
}
const normalizeResidualVoltageValue = (source: Record<string, any>) => {
return toNumber(source.residualVoltage ?? source.y ?? source.residual ?? source.voltage ?? source.residual_value)
}
const normalizeStatus = (value: unknown): ChartPointStatus => {
const rawValue = `${value ?? ''}`.trim().toLowerCase()
if (
value === 0 ||
rawValue === '0' ||
rawValue === 'false' ||
rawValue === 'fail' ||
rawValue === 'failed' ||
rawValue.includes('不耐受')
) {
return 'fail'
}
return 'pass'
}
const normalizeTolerantPoint = (source: Record<string, any>): NormalizedTolerantPoint | null => {
const duration = normalizeDuration(source)
const residualVoltage = normalizeResidualVoltageValue(source)
if (duration === null || residualVoltage === null) {
return null
}
if (duration <= 0 || residualVoltage < 0 || residualVoltage > 100) {
return null
}
const tolerant = normalizeTolerantValue(
source.tolerant ??
source.endure ??
source.isEndure ??
source.tolerable ??
source.isTolerable ??
source.status ??
source.pointStatus ??
source.result ??
source.state
2026-04-17 09:15:58 +08:00
)
return {
duration,
residualVoltage,
tolerant,
status:
tolerant === 0
? 'fail'
: tolerant === 1
? 'pass'
: normalizeStatus(
source.tolerant ??
source.endure ??
source.isEndure ??
source.tolerable ??
source.isTolerable ??
source.status ??
source.pointStatus ??
source.result ??
source.state
)
}
}
const getStatusText = (status: ChartPointStatus) => {
return status === 'fail' ? '不耐受' : '耐受'
}
const normalizePoint = (source: Record<string, any>): ChartPoint | null => {
const point = normalizeTolerantPoint(source)
if (!point || point.tolerant === 2) {
return null
}
return {
duration: point.duration,
residualVoltage: point.residualVoltage,
status: point.status
}
2026-04-17 09:15:58 +08:00
}
const normalizeCurveData = (source: unknown) => {
if (!Array.isArray(source)) {
return [] as Array<[number, number]>
}
return source
.map(item => {
if (Array.isArray(item) && item.length >= 2) {
const duration = Number(item[0])
const residualVoltage = Number(item[1])
if (Number.isFinite(duration) && Number.isFinite(residualVoltage) && duration > 0) {
return [duration, residualVoltage] as [number, number]
2026-04-17 09:15:58 +08:00
}
}
2026-04-17 09:15:58 +08:00
if (item && typeof item === 'object') {
const record = item as Record<string, any>
const tolerant = normalizeTolerantValue(record.tolerant)
if (tolerant !== null && tolerant !== 2) {
return null
}
2026-04-17 09:15:58 +08:00
const duration = normalizeDuration(record)
const residualVoltage = normalizeResidualVoltageValue(record)
if (duration !== null && residualVoltage !== null && duration > 0) {
return [duration, residualVoltage] as [number, number]
2026-04-17 09:15:58 +08:00
}
}
2026-04-17 09:15:58 +08:00
return null
})
.filter((item): item is [number, number] => !!item)
2026-04-17 09:15:58 +08:00
}
const extractCurveData = (payload: any) => {
if (!payload) {
return [] as Array<[number, number]>
}
const candidates = [
payload,
payload?.data,
payload?.data?.records,
payload?.data?.points,
payload?.points,
payload?.records,
payload?.list
]
for (const candidate of candidates) {
const normalized = normalizeCurveData(candidate)
if (normalized.length) {
return normalized
}
}
return [] as Array<[number, number]>
}
const extractCharacteristicCurvePoints = (payload: any) => {
const result: Array<[number, number]> = []
const seen = new Set<string>()
const rootPayload = payload?.data && typeof payload.data === 'object' ? payload.data : payload
const walk = (node: any) => {
if (!node) {
return
}
if (Array.isArray(node)) {
node.forEach(item => walk(item))
return
}
if (typeof node !== 'object') {
return
}
const point = normalizeTolerantPoint(node)
if (point?.tolerant === 2) {
const key = `${point.duration}|${point.residualVoltage}`
if (!seen.has(key)) {
seen.add(key)
result.push([point.duration, point.residualVoltage])
}
}
Object.values(node).forEach(item => {
if (item && typeof item === 'object') {
walk(item)
}
})
}
walk(rootPayload)
return result
}
const extractPoints = (payload: any) => {
const result: ChartPoint[] = []
const seen = new Set<string>()
const rootPayload = payload?.data && typeof payload.data === 'object' ? payload.data : payload
const walk = (node: any) => {
if (!node) {
return
}
if (Array.isArray(node)) {
node.forEach(item => walk(item))
return
}
if (typeof node !== 'object') {
return
}
const point = normalizePoint(node)
if (point) {
const key = `${point.duration}|${point.residualVoltage}`
if (!seen.has(key)) {
seen.add(key)
result.push(point)
}
}
Object.values(node).forEach(item => {
if (item && typeof item === 'object') {
walk(item)
}
})
}
walk(rootPayload)
return result
}
2026-04-17 09:15:58 +08:00
const drawCharacteristicCurve = async () => {
const freqConverterId = props.selectedMapping?.freqConverterId
if (!freqConverterId) {
ElMessage.warning('未获取到变频器ID')
return
}
curveLoading.value = true
try {
const result = await getFreqConverterSCurve({
converterId: freqConverterId
})
const normalizedCurveData = extractCurveData(result)
if (!normalizedCurveData.length) {
characteristicCurveData.value = []
ElMessage.warning('未获取到特性曲线数据')
return
}
characteristicCurveData.value = normalizedCurveData
} catch (error) {
console.error('draw characteristic curve failed:', error)
2026-04-17 09:15:58 +08:00
characteristicCurveData.value = []
} finally {
curveLoading.value = false
}
}
const downloadChartImage = async () => {
if (!hasChartData.value) {
ElMessage.warning('暂无可下载的图表数据')
return
}
await nextTick()
2026-04-17 09:15:58 +08:00
const chartInstance = chartRef.value?.getChartInstance?.()
if (!chartInstance) {
ElMessage.warning('图表尚未渲染完成')
return
}
const imageUrl = chartInstance.getDataURL({
type: 'png',
pixelRatio: 2,
backgroundColor: '#ffffff'
})
triggerDownload(imageUrl, buildFileName('变频器耐受图', 'png'))
ElMessage.success('图表图片导出成功')
}
const exportChartData = () => {
if (!hasChartData.value) {
ElMessage.warning('暂无可导出的点位数据')
return
}
const workbook = XLSX.utils.book_new()
if (sortedChartPoints.value.length) {
const pointSheet = XLSX.utils.json_to_sheet(
sortedChartPoints.value.map((item, index) => ({
'序号': index + 1,
'持续时间_s': item.duration,
'暂降幅值_%': item.residualVoltage,
'状态': getStatusText(item.status)
}))
)
XLSX.utils.book_append_sheet(workbook, pointSheet, '暂降点')
}
if (sortedCharacteristicCurveData.value.length) {
const curveSheet = XLSX.utils.json_to_sheet(
sortedCharacteristicCurveData.value.map((item, index) => ({
'序号': index + 1,
'持续时间_s': item[0],
'暂降幅值_%': item[1]
}))
)
XLSX.utils.book_append_sheet(workbook, curveSheet, '特性曲线')
}
const workbookBuffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' })
const blob = new Blob([workbookBuffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
})
const blobUrl = window.URL.createObjectURL(blob)
try {
triggerDownload(blobUrl, buildFileName('变频器耐受图数据', 'xlsx'))
ElMessage.success('点位数据导出成功')
} finally {
window.URL.revokeObjectURL(blobUrl)
}
}
const chartOptions = computed(() => {
2026-04-17 09:15:58 +08:00
return {
title: {
text: ''
},
grid: {
top: 30,
left: 48,
right: 22,
bottom: 52
},
tooltip: {
trigger: 'item',
formatter(params: any) {
const rawValue = Array.isArray(params.value) ? params.value : params.value?.value
if (!Array.isArray(rawValue)) {
2026-04-17 09:15:58 +08:00
return ''
}
const [duration, residualVoltage, statusText] = rawValue
2026-04-17 09:15:58 +08:00
return [
`类型: ${params.seriesName}`,
2026-04-17 09:15:58 +08:00
`持续时间: ${duration} s`,
`残余电压: ${residualVoltage} %`,
...(statusText ? [`状态: ${statusText}`] : [])
2026-04-17 09:15:58 +08:00
].join('<br/>')
}
},
legend: {
top: 0,
right: 0,
data: ['特性测试曲线']
},
xAxis: {
type: 'log',
2026-04-17 09:15:58 +08:00
name: '持续时间(s)',
nameLocation: 'middle',
nameGap: 34,
min: xAxisMin.value,
max: xAxisMax.value,
logBase: 10,
2026-04-17 09:15:58 +08:00
minorTick: {
show: true,
splitNumber: 10
},
minorSplitLine: {
show: false,
2026-04-17 09:15:58 +08:00
lineStyle: {
color: '#e8edf6'
}
},
splitLine: {
show: true,
2026-04-17 09:15:58 +08:00
lineStyle: {
color: '#cfd8e6'
}
},
axisLabel: {
formatter(value: number) {
return formatLogDurationLabel(value)
2026-04-17 09:15:58 +08:00
}
}
},
yAxis: {
type: 'value',
name: '暂降幅值',
min: 0,
max: 100,
interval: 10,
minorTick: {
show: true,
splitNumber: 2
},
minorSplitLine: {
show: true,
lineStyle: {
color: '#e8edf6'
}
},
splitLine: {
lineStyle: {
color: '#cfd8e6'
}
},
axisLabel: {
formatter(value: number) {
return `${value}%`
}
}
},
dataZoom: [],
series: [
{
name: '特性测试曲线',
type: 'line',
smooth: true,
showSymbol: true,
symbolSize: 7,
lineStyle: {
color: '#ff2a2a',
width: 3
},
itemStyle: {
color: '#ff2a2a'
},
data: solidCharacteristicCurveSeriesData.value
},
{
name: '特性曲线延伸',
type: 'line',
smooth: true,
showSymbol: true,
symbolSize: 7,
lineStyle: {
color: '#ff2a2a',
width: 3,
type: 'dashed'
},
itemStyle: {
color: '#ff2a2a'
},
data: dashedCharacteristicCurveSeriesData.value
2026-04-17 09:15:58 +08:00
},
{
name: '暂降点',
type: 'scatter',
symbolSize: 10,
data: chartPoints.value.map(item => ({
value: [item.duration, item.residualVoltage, getStatusText(item.status)],
itemStyle: {
color: STATUS_COLOR_MAP[item.status]
}
}))
}
],
options: {
animation: false
}
}
})
watch(
() => props.webMsgSend,
newValue => {
if (!newValue) {
2026-04-17 09:15:58 +08:00
return
}
const nextPoints = extractPoints(newValue)
if (!nextPoints.length) {
const nextCurvePoints = extractCharacteristicCurvePoints(newValue)
if (nextCurvePoints.length) {
characteristicCurveData.value = nextCurvePoints
}
2026-04-17 09:15:58 +08:00
return
}
const existingPointMap = new Map(
chartPoints.value.map(item => [`${item.duration}|${item.residualVoltage}`, item] as const)
)
2026-04-17 09:15:58 +08:00
nextPoints.forEach(item => {
const key = `${item.duration}|${item.residualVoltage}`
existingPointMap.set(key, item)
2026-04-17 09:15:58 +08:00
})
chartPoints.value = Array.from(existingPointMap.values())
2026-04-17 09:15:58 +08:00
const nextCurvePoints = extractCharacteristicCurvePoints(newValue)
if (nextCurvePoints.length) {
characteristicCurveData.value = nextCurvePoints
2026-04-17 09:15:58 +08:00
}
},
{ deep: true }
)
2026-04-17 09:15:58 +08:00
watch(
() => props.resultData,
newValue => {
if (!newValue) {
2026-04-17 09:15:58 +08:00
return
}
chartPoints.value = extractPoints(newValue)
const nextCurvePoints = extractCharacteristicCurvePoints(newValue)
if (nextCurvePoints.length) {
characteristicCurveData.value = nextCurvePoints
2026-04-17 09:15:58 +08:00
}
},
{ deep: true, immediate: true }
2026-04-17 09:15:58 +08:00
)
watch(
() => props.selectedMapping,
() => {
chartPoints.value = []
characteristicCurveData.value = []
}
2026-04-17 09:15:58 +08:00
)
</script>
<style scoped>
.dip-chart-card {
border: 1px solid var(--el-border-color-light);
}
:deep(.dip-chart-card .el-card__header) {
padding: 10px 14px;
}
:deep(.dip-chart-card .el-card__body) {
padding: 10px 14px 14px;
}
.card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.header-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
2026-04-17 09:15:58 +08:00
.card-header-main {
min-width: 0;
}
.card-title {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.card-subtitle {
margin-top: 4px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
.draw-curve-button {
margin-top: -2px;
flex-shrink: 0;
}
.chart-wrapper {
height: 400px;
}
</style>