Files
pqs-9100_client/frontend/src/views/machine/freqConverter/components/freqConverterDipChart.vue
caozehui 2ba5ebaddb Merge remote-tracking branch 'origin/hainan' into hainan
# Conflicts:
#	frontend/src/views/machine/freqConverter/components/freqConverterDipChart.vue
2026-05-08 11:36:53 +08:00

821 lines
20 KiB
Vue

<template>
<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
v-if="!props.autoDrawCurve"
type="primary"
plain
class="draw-curve-button"
@click="drawCharacteristicCurve"
>
绘制特性曲线
</el-button>
</div>
</div>
</template>
<div class="chart-wrapper">
<MyEchart ref="chartRef" :options="chartOptions"/>
</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'
import MyEchart from '@/components/echarts/line/index.vue'
type ChartPointStatus = 'pass' | 'fail'
interface ChartPoint {
duration: number
residualVoltage: number
status: ChartPointStatus
}
interface CharacteristicCurvePoint {
duration: number
residualVoltage: number
time: string | null
timeMs: number | null
}
interface NormalizedTolerantPoint {
duration: number
residualVoltage: number
tolerant: number | null
status: ChartPointStatus
time: string | null
timeMs: number | null
}
const props = defineProps<{
selectedMapping?: Record<string, any> | null
webMsgSend?: any
resultData?: any
autoDrawCurve?: boolean
}>()
const STATUS_COLOR_MAP: Record<ChartPointStatus, string> = {
pass: '#4e73df',
fail: '#4b4b4b'
}
const CHARACTERISTIC_POINT_COLOR = '#ff4d4f'
const chartPoints = ref<ChartPoint[]>([])
const characteristicCurveData = ref<CharacteristicCurvePoint[]>([])
const characteristicCurveVisible = ref(false)
const chartRef = ref<any>(null)
const selectedMappingText = computed(() => {
if (!props.selectedMapping) {
return '未选择变频器'
}
return `变频器:${props.selectedMapping.freqConverterName || '-'}`
})
const xAxisMin = computed(() => {
return 0.001
// 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(() => {
return 1000
// 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) => {
// 保留1位小数
let aResidualVoltage = Math.floor(a.residualVoltage * 10) / 10
let bResidualVoltage = Math.floor(b.residualVoltage * 10) / 10
if (aResidualVoltage != bResidualVoltage) {
return a.residualVoltage - b.residualVoltage;
} else {
let aDuration = a.duration * 1000 - a.duration * 1000 % 10
let bDuration = b.duration * 1000 - b.duration * 1000 % 10
if (aDuration != bDuration) {
return a.duration - b.duration
} else if (a.timeMs !== null && b.timeMs !== null && a.timeMs !== b.timeMs) {
return a.timeMs - b.timeMs
} else {
return 0
}
}
// if (a.timeMs !== null && b.timeMs !== null && a.timeMs !== b.timeMs) {
// return a.timeMs - b.timeMs
// } else {
// return 0
// }
//
// return a.residualVoltage - b.residualVoltage
})
})
const solidCharacteristicCurveSeriesData = computed(() => {
if (!characteristicCurveVisible.value) {
return [] as Array<[number, number, string]>
}
return sortedCharacteristicCurveData.value.map(item => [item.duration, item.residualVoltage, '特性曲线'])
})
const characteristicCurvePointSeriesData = computed(() => {
return sortedCharacteristicCurveData.value.map(item => ({
value: [item.duration, item.residualVoltage, '特性点']
}))
})
const passPointSeriesData = computed(() => {
return sortedChartPoints.value
.filter(item => item.status === 'pass')
.map(item => ({
value: [item.duration, item.residualVoltage, getStatusText(item.status)]
}))
})
const failPointSeriesData = computed(() => {
return sortedChartPoints.value
.filter(item => item.status === 'fail')
.map(item => ({
value: [item.duration, item.residualVoltage, getStatusText(item.status)]
}))
})
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
}
const parsePointTime = (value: unknown) => {
if (typeof value !== 'string') {
return {
time: null,
timeMs: null
}
}
const normalizedValue = value.trim()
const match = normalizedValue.match(
/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})\.(\d{3})$/
)
if (!match) {
return {
time: normalizedValue || null,
timeMs: null
}
}
const [, year, month, day, hour, minute, second, millisecond] = match
const parsedDate = new Date(
Number(year),
Number(month) - 1,
Number(day),
Number(hour),
Number(minute),
Number(second),
Number(millisecond)
)
if (
parsedDate.getFullYear() !== Number(year) ||
parsedDate.getMonth() !== Number(month) - 1 ||
parsedDate.getDate() !== Number(day) ||
parsedDate.getHours() !== Number(hour) ||
parsedDate.getMinutes() !== Number(minute) ||
parsedDate.getSeconds() !== Number(second) ||
parsedDate.getMilliseconds() !== Number(millisecond)
) {
return {
time: normalizedValue,
timeMs: null
}
}
return {
time: normalizedValue,
timeMs: parsedDate.getTime()
}
}
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
)
}
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)
const {time, timeMs} = parsePointTime(source.time)
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
)
return {
duration,
residualVoltage,
tolerant,
time,
timeMs,
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
}
}
const extractCharacteristicCurvePoints = (payload: any) => {
const result: CharacteristicCurvePoint[] = []
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.time ? `${point.time}|${point.duration}|${point.residualVoltage}` : `${point.duration}|${point.residualVoltage}`
if (!seen.has(key)) {
seen.add(key)
result.push({
duration: point.duration,
residualVoltage: point.residualVoltage,
time: point.time,
timeMs: point.timeMs
})
}
}
Object.values(node).forEach(item => {
if (item && typeof item === 'object') {
walk(item)
}
})
}
walk(rootPayload)
return result
}
const mergeCharacteristicCurvePoints = (points: CharacteristicCurvePoint[]) => {
if (!points.length) {
return
}
const existingPointMap = new Map(
characteristicCurveData.value.map(item => [
item.time ? `${item.time}|${item.duration}|${item.residualVoltage}` : `${item.duration}|${item.residualVoltage}`,
item
] as const)
)
points.forEach(item => {
const key = item.time ? `${item.time}|${item.duration}|${item.residualVoltage}` : `${item.duration}|${item.residualVoltage}`
existingPointMap.set(key, item)
})
characteristicCurveData.value = Array.from(existingPointMap.values())
}
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
}
const updateCharacteristicCurveVisibility = () => {
if (props.autoDrawCurve) {
characteristicCurveVisible.value = characteristicCurveData.value.length > 0
}
}
const drawCharacteristicCurve = () => {
if (!sortedCharacteristicCurveData.value.length) {
characteristicCurveVisible.value = false
ElMessage.warning('暂无特性曲线点')
return
}
characteristicCurveVisible.value = true
}
const downloadChartImage = async () => {
if (!hasChartData.value) {
ElMessage.warning('暂无可下载的图表数据')
return
}
await nextTick()
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,
残余电压_pct: 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.duration,
残余电压_pct: item.residualVoltage,
时间: item.time ?? ''
}))
)
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(() => {
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)) {
return ''
}
const [duration, residualVoltage, statusText] = rawValue
return [
`类型: ${params.seriesName}`,
`持续时间: ${duration} s`,
`残余电压: ${residualVoltage} %`,
...(statusText ? [`状态: ${statusText}`] : [])
].join('<br/>')
}
},
legend: {
top: 0,
right: 0,
data: ['特性曲线', '特性点', '耐受点', '不耐受点']
},
xAxis: {
type: 'log',
name: '持续时间(s)',
nameLocation: 'middle',
nameGap: 34,
min: xAxisMin.value,
max: xAxisMax.value,
logBase: 10,
minorTick: {
show: true,
splitNumber: 10
},
minorSplitLine: {
show: false,
lineStyle: {
color: '#e8edf6'
}
},
splitLine: {
show: true,
lineStyle: {
color: '#cfd8e6'
}
},
axisLabel: {
formatter(value: number) {
return formatLogDurationLabel(value)
}
}
},
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: false,
lineStyle: {
color: CHARACTERISTIC_POINT_COLOR,
width: 3
},
itemStyle: {
color: CHARACTERISTIC_POINT_COLOR
},
data: solidCharacteristicCurveSeriesData.value
},
{
name: '特性点',
type: 'scatter',
symbolSize: 10,
itemStyle: {
color: CHARACTERISTIC_POINT_COLOR
},
data: characteristicCurvePointSeriesData.value
},
{
name: '耐受点',
type: 'scatter',
symbolSize: 10,
itemStyle: {
color: STATUS_COLOR_MAP.pass
},
data: passPointSeriesData.value
},
{
name: '不耐受点',
type: 'scatter',
symbolSize: 10,
itemStyle: {
color: STATUS_COLOR_MAP.fail
},
data: failPointSeriesData.value
}
],
options: {
animation: false
}
}
})
watch(
() => props.autoDrawCurve,
newValue => {
characteristicCurveVisible.value = newValue ? characteristicCurveData.value.length > 0 : false
},
{ immediate: true }
)
watch(
() => props.webMsgSend,
newValue => {
if (!newValue) {
return
}
const nextPoints = extractPoints(newValue)
if (nextPoints.length) {
const existingPointMap = new Map(
chartPoints.value.map(item => [`${item.duration}|${item.residualVoltage}`, item] as const)
)
nextPoints.forEach(item => {
const key = `${item.duration}|${item.residualVoltage}`
existingPointMap.set(key, item)
})
chartPoints.value = Array.from(existingPointMap.values())
}
mergeCharacteristicCurvePoints(extractCharacteristicCurvePoints(newValue))
updateCharacteristicCurveVisibility()
},
{deep: true}
)
watch(
() => props.resultData,
newValue => {
if (!newValue) {
return
}
chartPoints.value = extractPoints(newValue)
characteristicCurveData.value = extractCharacteristicCurvePoints(newValue)
updateCharacteristicCurveVisibility()
},
{deep: true, immediate: true}
)
watch(
() => props.selectedMapping,
() => {
chartPoints.value = []
characteristicCurveData.value = []
characteristicCurveVisible.value = false
}
)
</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;
}
.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);
}
.chart-wrapper {
height: 400px;
}
</style>