| | |
| | | } from '@/utils/compareReportPrint' |
| | | import type { CompareMetricKey } from '@/stores/compareReports' |
| | | import { useBatteryGroupFields } from '@/stores/batteryGroupFields' |
| | | import type { PersistedTestRecord, TestLogicStepMapping } from '@/api/tests' |
| | | |
| | | interface ConsoleCell { |
| | | no: string |
| | |
| | | durationMinutes?: number |
| | | testName?: string |
| | | batteryGroup?: string |
| | | records: PersistedTestRecord[] |
| | | logicStepMappings?: TestLogicStepMapping[] |
| | | }>(), { |
| | | durationMinutes: 5, |
| | | testName: '', |
| | | batteryGroup: '', |
| | | records: () => [], |
| | | logicStepMappings: () => [], |
| | | }) |
| | | |
| | | use([CanvasRenderer, LineChart, GridComponent, LegendComponent, TooltipComponent]) |
| | | |
| | | const palette = ['#3978f6', '#7d42d8', '#f08a3e', '#23a983', '#e15268', '#1b9ac8', '#b5892f', '#6957c8', '#4f8d44', '#d264a3', '#46748e', '#a95d37'] |
| | | const seriesColors = ref<string[]>(props.cells.map((_, index) => palette[index % palette.length])) |
| | | const selectedStep = ref('5-21 恒流充电') |
| | | const selectedStep = ref('') |
| | | const selectedCycle = ref('') |
| | | const selectedCellNos = ref<string[]>(props.cells.map(cell => cell.no)) |
| | | const experimentName = ref(props.testName || 'XXX实验') |
| | | const batteryFields = ref(['额定容量', '电流', '内阻']) |
| | |
| | | 循环寿命模板: ['容量保持率', '能量效率', '循环次数', '内阻变化'], |
| | | 热特性模板: ['最高温度', '温差', '温升速率'], |
| | | } |
| | | const stepOptions = ['5-21 恒流充电', '5-22 搁置', '5-23 恒流放电', '5-24 搁置'] |
| | | const pointCount = 31 |
| | | const chartUpdateOptions = { notMerge: true, lazyUpdate: false } |
| | | |
| | | interface StepOption { |
| | | value: string |
| | | label: string |
| | | cycleNo: number |
| | | stepNo?: number |
| | | logical?: TestLogicStepMapping |
| | | } |
| | | |
| | | const cycleOptions = computed(() => [...new Set(props.records |
| | | .map(record => Number(record.cycleNo)) |
| | | .filter(value => Number.isFinite(value) && value > 0))].sort((a, b) => a - b)) |
| | | |
| | | const stepOptions = computed<StepOption[]>(() => { |
| | | const actual = new Map<string, StepOption>() |
| | | for (const record of props.records) { |
| | | const cycleNo = Number(record.cycleNo) |
| | | const stepNo = Number(record.stepNo) |
| | | if (!Number.isFinite(cycleNo) || !Number.isFinite(stepNo) || cycleNo <= 0 || stepNo <= 0) continue |
| | | const value = `actual:${cycleNo}:${stepNo}` |
| | | if (!actual.has(value)) actual.set(value, { value, label: `${cycleNo}-${stepNo} ${record.stepName || '-'}`, cycleNo, stepNo }) |
| | | } |
| | | const logical: StepOption[] = [] |
| | | for (const cycleNo of cycleOptions.value) { |
| | | for (const mapping of props.logicStepMappings || []) { |
| | | logical.push({ value: `logical:${cycleNo}:${mapping.logicalNo}`, label: `${cycleNo}-逻辑${mapping.logicalNo} ${mapping.logicalName}`, cycleNo, logical: mapping }) |
| | | } |
| | | } |
| | | return [...actual.values(), ...logical] |
| | | }) |
| | | |
| | | function ensureDetailFieldValues() { |
| | | detailFields.value.forEach(field => { |
| | |
| | | }, { deep: true }) |
| | | |
| | | watch(detailFields, ensureDetailFieldValues) |
| | | |
| | | watch(stepOptions, options => { |
| | | if (!options.length) { |
| | | selectedStep.value = '' |
| | | return |
| | | } |
| | | if (!options.some(option => option.value === selectedStep.value)) selectedStep.value = options[0].value |
| | | }, { immediate: true }) |
| | | |
| | | watch(cycleOptions, options => { |
| | | const current = Number(selectedCycle.value) |
| | | if (selectedCycle.value && !options.includes(current)) selectedCycle.value = '' |
| | | }) |
| | | |
| | | async function addCustomField() { |
| | | try { |
| | |
| | | if (color) seriesColors.value[index] = color |
| | | } |
| | | |
| | | function elapsedLabel(index: number) { |
| | | const totalSeconds = Math.round(props.durationMinutes * 60 * index / (pointCount - 1)) |
| | | const hours = Math.floor(totalSeconds / 3600) |
| | | const minutes = Math.floor((totalSeconds % 3600) / 60) |
| | | return `${hours}:${String(minutes).padStart(2, '0')}` |
| | | function metricMatches(metric: CompareMetricKey, record: PersistedTestRecord) { |
| | | if (metric === 'voltage') return record.sourceType !== 'COLLECTOR' && record.voltage != null |
| | | if (metric === 'current') return record.sourceType !== 'COLLECTOR' && record.current != null |
| | | if (record.sourceType !== 'COLLECTOR') return false |
| | | const code = String(record.metricCode || '').toUpperCase() |
| | | if (metric === 'temperature') return code === 'TEMPERATURE' || code.includes('TEMPERATURE') |
| | | if (metric === 'positiveVoltage') return code === 'POSITIVE_POTENTIAL' || code.includes('POSITIVE_POTENTIAL') |
| | | if (metric === 'negativeVoltage') return code === 'NEGATIVE_POTENTIAL' || code.includes('NEGATIVE_POTENTIAL') |
| | | return false |
| | | } |
| | | |
| | | function baseValue(cell: ConsoleCell, metric: CompareMetricDefinition) { |
| | | if (metric.key === 'voltage') return Number(cell.voltage) |
| | | if (metric.key === 'current') return Number(cell.current) / 1000 |
| | | if (metric.key === 'positiveVoltage') return Number(cell.positive) |
| | | if (metric.key === 'negativeVoltage') return Number(cell.negative) |
| | | return Number(cell.temperature) |
| | | function metricValue(metric: CompareMetricKey, record: PersistedTestRecord) { |
| | | if (metric === 'voltage') return Number(record.voltage) |
| | | if (metric === 'current') return Number(record.current) |
| | | return Number(record.metricValue) |
| | | } |
| | | |
| | | function seriesValues(cell: ConsoleCell, metric: CompareMetricDefinition, cellIndex: number) { |
| | | const base = baseValue(cell, metric) |
| | | const isRest = selectedStep.value.includes('搁置') |
| | | const isDischarge = selectedStep.value.includes('放电') |
| | | return Array.from({ length: pointCount }, (_, pointIndex) => { |
| | | const progress = pointIndex / (pointCount - 1) |
| | | const wave = Math.sin(progress * Math.PI * 3 + cellIndex * 0.45) |
| | | const ripple = Math.cos(progress * Math.PI * 7 + cellIndex * 0.18) |
| | | const amplitude = metric.key === 'temperature' ? 1.8 : metric.key === 'current' ? 0.55 : 0.12 |
| | | if (isRest) { |
| | | const restBase = metric.key === 'current' ? 0 : base |
| | | const restAmplitude = metric.key === 'temperature' ? 0.35 : metric.key === 'current' ? 0.04 : 0.025 |
| | | return Number((restBase + wave * restAmplitude + ripple * restAmplitude * 0.18).toFixed(3)) |
| | | } |
| | | const trend = metric.key === 'temperature' |
| | | ? progress * (isDischarge ? 1.2 : 2.6) |
| | | : metric.key === 'current' |
| | | ? progress * (isDischarge ? -0.42 : 0.3) |
| | | : progress * (isDischarge ? -0.3 : 0.22) |
| | | return Number((base + wave * amplitude + ripple * amplitude * 0.22 + trend).toFixed(3)) |
| | | function selectedRecords() { |
| | | const option = stepOptions.value.find(item => item.value === selectedStep.value) |
| | | if (!option) return [] |
| | | const cycle = selectedCycle.value ? Number(selectedCycle.value) : option.cycleNo |
| | | return props.records.filter(record => { |
| | | if (Number(record.cycleNo) !== cycle) return false |
| | | const stepNo = Number(record.stepNo) |
| | | if (option.logical) return stepNo >= option.logical.startStepNo && stepNo <= option.logical.endStepNo |
| | | return stepNo === option.stepNo |
| | | }) |
| | | } |
| | | |
| | | const hasSelectedRecords = computed(() => selectedRecords().length > 0) |
| | | |
| | | function recordTime(record: PersistedTestRecord) { |
| | | const value = record.deviceTime || record.recordedAt |
| | | const parsed = Date.parse(value || '') |
| | | return Number.isFinite(parsed) ? parsed : null |
| | | } |
| | | |
| | | function chartOption(metric: CompareMetricDefinition) { |
| | | const source = selectedRecords() |
| | | const cellIndexes = selectedCells.value.map(cell => props.cells.findIndex(item => item.no === cell.no)) |
| | | const seriesData = selectedCells.value.map((cell, index) => seriesValues(cell, metric, Math.max(0, cellIndexes[index]))) |
| | | const values = seriesData.flat() |
| | | const seriesData = selectedCells.value.map(cell => { |
| | | return source.filter(record => record.batteryNo === cell.no && metricMatches(metric.key, record)) |
| | | .map(record => ({ time: recordTime(record), value: metricValue(metric.key, record) })) |
| | | .filter(point => point.time !== null && Number.isFinite(point.value)) |
| | | .sort((a, b) => (a.time as number) - (b.time as number)) |
| | | }) |
| | | const timestamps = [...new Set(seriesData.flat().map(point => point.time as number))].sort((a, b) => a - b) |
| | | const start = timestamps[0] |
| | | const values = seriesData.flat().map(point => point.value) |
| | | const dataMin = values.length ? Math.min(...values) : 0 |
| | | const dataMax = values.length ? Math.max(...values) : 1 |
| | | const margin = Math.max((dataMax - dataMin) * 0.16, metric.key === 'temperature' ? 1 : 0.08) |
| | |
| | | xAxis: { |
| | | type: 'category', |
| | | boundaryGap: false, |
| | | name: '时间/h:mm', |
| | | name: '设备时间', |
| | | nameLocation: 'middle', |
| | | nameGap: 29, |
| | | data: Array.from({ length: pointCount }, (_, index) => elapsedLabel(index)), |
| | | data: timestamps.map(value => start == null ? new Date(value).toISOString() : `${((value - start) / 3600000).toFixed(3)} h`), |
| | | axisLine: { lineStyle: { color: '#cbd8e4' } }, |
| | | axisLabel: { color: '#8795a3', fontSize: 10, interval: 4 }, |
| | | nameTextStyle: { color: '#52606d', fontSize: 10 }, |
| | |
| | | smooth: true, |
| | | showSymbol: false, |
| | | lineStyle: { width: 2 }, |
| | | data: seriesData[index], |
| | | data: timestamps.map(timestamp => { |
| | | const point = seriesData[index].find(item => item.time === timestamp) |
| | | return point ? point.value : null |
| | | }), |
| | | })), |
| | | } |
| | | } |
| | |
| | | <section class="data-select-panel"> |
| | | <header class="panel-heading"><div><strong>数据选择</strong><el-icon><ArrowUp /></el-icon></div></header> |
| | | <div class="data-select-content"> |
| | | <label class="step-select"><span>循环工步</span><el-select v-model="selectedStep"><el-option v-for="item in stepOptions" :key="item" :label="item" :value="item" /></el-select></label> |
| | | <label class="step-select"><span>循环</span><el-select v-model="selectedCycle" clearable placeholder="跟随工步"><el-option v-for="item in cycleOptions" :key="item" :label="`循环 ${item}`" :value="String(item)" /></el-select></label> |
| | | <label class="step-select"><span>循环工步</span><el-select v-model="selectedStep" :disabled="!stepOptions.length" placeholder="暂无实际工步"><el-option v-for="item in stepOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></label> |
| | | <div class="battery-select"><span>电池号</span><el-checkbox-group v-model="selectedCellNos"><el-checkbox v-for="cell in cells" :key="cell.no" :value="cell.no">{{ cell.no }}</el-checkbox></el-checkbox-group></div> |
| | | </div> |
| | | </section> |
| | |
| | | </div> |
| | | </div> |
| | | |
| | | <div v-if="selectedCells.length && visibleMetrics.length" class="charts-grid"> |
| | | <div v-if="selectedCells.length && visibleMetrics.length && hasSelectedRecords" class="charts-grid"> |
| | | <article v-for="metric in visibleMetrics" :key="metric.key" class="chart-card"> |
| | | <header><strong>{{ metric.title }}</strong></header> |
| | | <VChart :key="`${metric.key}-${selectedStep}-${selectedCellNos.join('|')}`" class="metric-chart" :option="chartOption(metric)" :update-options="chartUpdateOptions" autoresize /> |
| | | </article> |
| | | </div> |
| | | <div v-else class="chart-empty">暂无可展示数据</div> |
| | | <div v-else class="chart-empty">暂无当前测试实时数据</div> |
| | | </section> |
| | | |
| | | <el-dialog v-model="templateDialogVisible" :title="templateMode === 'battery' ? '选择电池组字段模板' : '选择特殊字段模板'" width="620px" align-center> |