项目图表读取当前有效数据
使用工步、循环及电池组当前 F 生成项目图表趋势,移除演示曲线。
| | |
| | | import { CanvasRenderer } from 'echarts/renderers' |
| | | import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components' |
| | | import VChart from 'vue-echarts' |
| | | import type { PersistedTestCycle, PersistedTestStep } from '@/api/tests' |
| | | |
| | | interface ConsoleCell { |
| | | no: string |
| | | voltage: string |
| | | temperature: string |
| | | positive: string |
| | | negative: string |
| | | capacity: string |
| | | current: string |
| | | } |
| | | |
| | | interface ProjectMetric { |
| | | key: 'finalVoltage1' | 'finalVoltage2' | 'dischargeCapacityF' |
| | | title: string |
| | | unit: string |
| | | } |
| | | |
| | | const props = defineProps<{ |
| | | cells: ConsoleCell[] |
| | | }>() |
| | | |
| | | interface ConsoleCell { no: string } |
| | | type MetricKey = 'finalVoltage1' | 'finalVoltage2' | 'dischargeCapacityF' |
| | | interface ProjectMetric { key: MetricKey; title: string; unit: string } |
| | | const props = defineProps<{ cells: ConsoleCell[]; steps: PersistedTestStep[]; cycles: PersistedTestCycle[]; capacityFactor: string }>() |
| | | use([CanvasRenderer, LineChart, GridComponent, LegendComponent, TooltipComponent]) |
| | | |
| | | const metrics: ProjectMetric[] = [ |
| | | { key: 'finalVoltage1', title: '最终电压1', unit: 'V' }, |
| | | { key: 'finalVoltage2', title: '最终电压2', unit: 'V' }, |
| | | { key: 'dischargeCapacityF', title: '每循环放电末容量 / F', unit: 'F' }, |
| | | { key: 'dischargeCapacityF', title: '每循环放电末容量 / F', unit: '' }, |
| | | ] |
| | | 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 cycleLabels = Array.from({ length: 61 }, (_, index) => String(index + 1)) |
| | | const chartUpdateOptions = { notMerge: true, lazyUpdate: false } |
| | | |
| | | watch(() => props.cells, cells => { |
| | | seriesColors.value = cells.map((_, index) => seriesColors.value[index] || palette[index % palette.length]) |
| | | }, { deep: true }) |
| | | |
| | | const chartStateKey = computed(() => props.cells.map(cell => cell.no).join('|')) |
| | | |
| | | function metricBase(cell: ConsoleCell, metric: ProjectMetric) { |
| | | if (metric.key === 'finalVoltage1') return Number(cell.voltage) |
| | | if (metric.key === 'finalVoltage2') return Number(cell.positive) |
| | | return Number(cell.capacity) / 1000 |
| | | watch(() => props.cells, cells => { seriesColors.value = cells.map((_, index) => seriesColors.value[index] || palette[index % palette.length]) }, { deep: true }) |
| | | const cycleLabels = computed(() => [...new Set([...props.steps, ...props.cycles].map(row => Number(row.cycleNo)).filter(value => Number.isFinite(value) && value > 0))].sort((a, b) => a - b).map(String)) |
| | | const capacityFactor = computed(() => { const value = Number(props.capacityFactor); return Number.isFinite(value) && value !== 0 ? value : null }) |
| | | const capacityFactorUnavailable = computed(() => capacityFactor.value === null) |
| | | const chartStateKey = computed(() => `${props.cells.map(cell => cell.no).join('|')}:${cycleLabels.value.join('|')}:${props.steps.length}:${props.cycles.length}:${props.capacityFactor}`) |
| | | function numericValue(value: unknown): number | null { |
| | | if (value === null || value === undefined || (typeof value === 'string' && !value.trim())) return null |
| | | const number = Number(value) |
| | | return Number.isFinite(number) ? number : null |
| | | } |
| | | |
| | | function seriesValues(cell: ConsoleCell, metric: ProjectMetric, cellIndex: number) { |
| | | const base = metricBase(cell, metric) |
| | | return cycleLabels.map((_, pointIndex) => { |
| | | const progress = pointIndex / (cycleLabels.length - 1) |
| | | const cycleWave = Math.sin(progress * Math.PI * 4 + cellIndex * 0.38) |
| | | const localWave = Math.cos(progress * Math.PI * 9 + cellIndex * 0.2) |
| | | if (metric.key === 'dischargeCapacityF') { |
| | | return Number((base * (1 - progress * 0.08) + cycleWave * 0.018 + localWave * 0.006).toFixed(4)) |
| | | // 只读取当前有效工步;缺少该循环或最后工步终止电压时保留空点,不补值。 |
| | | function lastStepVoltage(batteryNo: string, cycleNo: number): number | null { |
| | | const rows = props.steps.filter(row => String(row.batteryNo) === batteryNo && Number(row.cycleNo) === cycleNo).sort((a, b) => Number(a.stepNo) - Number(b.stepNo)) |
| | | return rows.length ? numericValue(rows[rows.length - 1].endVoltage) : null |
| | | } |
| | | const direction = metric.key === 'finalVoltage1' ? 1 : -1 |
| | | return Number((base + cycleWave * 0.075 + localWave * 0.018 + direction * progress * 0.04).toFixed(4)) |
| | | }) |
| | | // F 是当前电池组的非零数值除数,缺失循环或无效 F 均不生成曲线点。 |
| | | function dischargeCapacityPerFactor(batteryNo: string, cycleNo: number): number | null { |
| | | if (capacityFactor.value === null) return null |
| | | const row = props.cycles.find(item => String(item.batteryNo) === batteryNo && Number(item.cycleNo) === cycleNo) |
| | | const capacity = row ? numericValue(row.dischargeCapacity) : null |
| | | return capacity === null ? null : capacity / capacityFactor.value |
| | | } |
| | | |
| | | function seriesValues(cell: ConsoleCell, metric: ProjectMetric): Array<number | null> { |
| | | return cycleLabels.value.map(label => metric.key === 'finalVoltage1' ? lastStepVoltage(cell.no, Number(label)) : dischargeCapacityPerFactor(cell.no, Number(label))) |
| | | } |
| | | function chartOption(metric: ProjectMetric) { |
| | | const seriesData = props.cells.map((cell, index) => seriesValues(cell, metric, index)) |
| | | const values = seriesData.flat() |
| | | const dataMin = values.length ? Math.min(...values) : 0 |
| | | const dataMax = values.length ? Math.max(...values) : 1 |
| | | const margin = Math.max((dataMax - dataMin) * 0.15, metric.key === 'dischargeCapacityF' ? 0.03 : 0.08) |
| | | const createYAxis = (position: 'left' | 'right') => ({ |
| | | type: 'value', |
| | | name: metric.unit, |
| | | position, |
| | | nameLocation: 'middle', |
| | | nameGap: 42, |
| | | nameRotate: position === 'left' ? 90 : -90, |
| | | min: Number((dataMin - margin).toFixed(3)), |
| | | max: Number((dataMax + margin).toFixed(3)), |
| | | nameTextStyle: { color: '#8795a3', fontSize: 10 }, |
| | | axisLine: { show: true, lineStyle: { color: '#cbd8e4' } }, |
| | | axisTick: { show: true, lineStyle: { color: '#cbd8e4' } }, |
| | | axisLabel: { color: '#8795a3', fontSize: 10, margin: 8 }, |
| | | splitLine: { show: position === 'left', lineStyle: { color: '#e8eef4' } }, |
| | | }) |
| | | |
| | | return { |
| | | animation: false, |
| | | color: seriesColors.value, |
| | | grid: { top: 62, right: 62, bottom: 48, left: 62 }, |
| | | tooltip: { trigger: 'axis' }, |
| | | legend: { |
| | | top: 8, |
| | | left: 12, |
| | | right: 12, |
| | | type: 'scroll', |
| | | icon: 'roundRect', |
| | | itemWidth: 14, |
| | | itemHeight: 8, |
| | | itemGap: 16, |
| | | textStyle: { fontSize: 10, color: '#52606d' }, |
| | | }, |
| | | xAxis: { |
| | | type: 'category', |
| | | boundaryGap: false, |
| | | name: '循环次数', |
| | | nameLocation: 'middle', |
| | | nameGap: 30, |
| | | data: cycleLabels, |
| | | axisLine: { lineStyle: { color: '#cbd8e4' } }, |
| | | axisLabel: { color: '#8795a3', fontSize: 10, interval: 9 }, |
| | | nameTextStyle: { color: '#52606d', fontSize: 10 }, |
| | | }, |
| | | yAxis: [createYAxis('left'), createYAxis('right')], |
| | | series: props.cells.map((cell, index) => ({ name: cell.no, type: 'line', smooth: true, showSymbol: false, lineStyle: { width: 2 }, data: seriesData[index] })), |
| | | } |
| | | const hasDataSeries = metric.key !== 'finalVoltage2' |
| | | const seriesData = hasDataSeries ? props.cells.map(cell => seriesValues(cell, metric)) : [] |
| | | const values = seriesData.flat().filter((value): value is number => value !== null) |
| | | const dataMin = values.length ? Math.min(...values) : undefined |
| | | const dataMax = values.length ? Math.max(...values) : undefined |
| | | const margin = dataMin === undefined || dataMax === undefined ? undefined : Math.max((dataMax - dataMin) * 0.15, 0.01) |
| | | return { animation: false, color: seriesColors.value, grid: { top: 62, right: 34, bottom: 48, left: 62 }, tooltip: { trigger: 'axis' }, legend: { top: 8, left: 12, right: 12, type: 'scroll', icon: 'roundRect', itemWidth: 14, itemHeight: 8, itemGap: 16, textStyle: { fontSize: 10, color: '#52606d' } }, xAxis: { type: 'category', boundaryGap: false, name: '循环次数', nameLocation: 'middle', nameGap: 30, data: cycleLabels.value, axisLine: { lineStyle: { color: '#cbd8e4' } }, axisLabel: { color: '#8795a3', fontSize: 10 }, nameTextStyle: { color: '#52606d', fontSize: 10 } }, yAxis: { type: 'value', name: metric.unit, nameLocation: 'middle', nameGap: metric.unit ? 42 : 0, min: margin === undefined ? undefined : dataMin! - margin, max: margin === undefined ? undefined : dataMax! + margin, axisLabel: { color: '#8795a3', fontSize: 10 }, splitLine: { lineStyle: { color: '#e8eef4' } } }, series: hasDataSeries ? props.cells.map((cell, index) => ({ name: cell.no, type: 'line', showSymbol: false, connectNulls: false, lineStyle: { width: 2 }, data: seriesData[index] })) : [] } |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <section class="project-chart-panel"> |
| | | <article v-for="metric in metrics" :key="metric.key" class="project-chart-card"> |
| | | <header><strong>{{ metric.title }}</strong></header> |
| | | <VChart :key="`${metric.key}-${chartStateKey}`" class="project-chart" :option="chartOption(metric)" :update-options="chartUpdateOptions" autoresize /> |
| | | </article> |
| | | <p v-if="capacityFactorUnavailable" class="factor-warning" role="status">电池组 F 不可用,未计算每循环放电末容量 / F。</p> |
| | | <article v-for="metric in metrics" :key="metric.key" class="project-chart-card"><header><strong>{{ metric.title }}</strong></header><VChart :key="`${metric.key}-${chartStateKey}`" class="project-chart" :option="chartOption(metric)" :update-options="chartUpdateOptions" autoresize /></article> |
| | | </section> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .project-chart-panel{display:grid;gap:16px;min-width:0}.project-chart-card{min-width:0;border:1px solid #d8e1e9;background:#fff}.project-chart-card>header{height:44px;display:flex;align-items:center;padding:0 18px;border-bottom:1px solid #edf1f5}.project-chart-card>header strong{color:#26333f;font-size:13px}.project-chart{width:100%;height:270px}@media(max-width:680px){.project-chart{height:250px}.project-chart-card>header{padding:0 12px}} |
| | | .project-chart-panel{display:grid;gap:16px;min-width:0}.project-chart-card{min-width:0;border:1px solid #d8e1e9;background:#fff}.project-chart-card>header{height:44px;display:flex;align-items:center;padding:0 18px;border-bottom:1px solid #edf1f5}.project-chart-card>header strong{color:#26333f;font-size:13px}.project-chart{width:100%;height:270px}.factor-warning{margin:0;padding:10px 14px;border:1px solid #efd59e;color:#8b5a14;background:#fff8e9;font-size:12px}@media(max-width:680px){.project-chart{height:250px}.project-chart-card>header{padding:0 12px}} |
| | | </style> |
| | |
| | | import ConsoleChartPanel from '@/components/ConsoleChartPanel.vue' |
| | | import ConsoleProjectChartPanel from '@/components/ConsoleProjectChartPanel.vue' |
| | | import ConsoleDataListPanel from '@/components/ConsoleDataListPanel.vue' |
| | | import { endTestReception, getPersistedTestRecords, getTestDetail, getTestLiveState, openTestLiveStream, pauseTestReception, resumeTestReception, saveLogicStepMappings, startTestReception, type PersistedTestRecord, type TestConfigurationDetail, type TestLiveState, type TestLogicStepMapping } from '@/api/tests' |
| | | import { endTestReception, getPersistedTestCycles, getPersistedTestRecords, getPersistedTestSteps, getTestDetail, getTestLiveState, openTestLiveStream, pauseTestReception, resumeTestReception, saveLogicStepMappings, startTestReception, type PersistedTestCycle, type PersistedTestRecord, type PersistedTestStep, type TestConfigurationDetail, type TestLiveState, type TestLogicStepMapping } from '@/api/tests' |
| | | import { getBatteryGroupDetail } from '@/api/batteryGroups' |
| | | |
| | | type ConsoleState = '待测试' | '检测中' | '暂停' | '检测完成' |
| | | type ReceptionState = '未开始接收' | '接收中' | '暂停接收' | '已结束接收' |
| | |
| | | const runtimeAttach = ref(false) |
| | | const receptionState = ref<ReceptionState>('未开始接收') |
| | | const realtimeRecords = ref<PersistedTestRecord[]>([]) |
| | | const projectSteps = ref<PersistedTestStep[]>([]) |
| | | const projectCycles = ref<PersistedTestCycle[]>([]) |
| | | const projectCapacityFactor = ref('') |
| | | function formatDateTime(value?: string | null) { |
| | | return value ? value.replace('T', ' ').replace(/Z$/, '').replace(/\.\d{3,6}$/, '') : '-' |
| | | } |
| | |
| | | liveError.value = error instanceof Error ? error.message : '实时状态加载失败' |
| | | } |
| | | connectLiveStream() |
| | | if (activeTab.value === 'project-chart') await loadProjectChartData() |
| | | } catch (error) { |
| | | ElMessage.error(error instanceof Error ? error.message : '测试状态加载失败') |
| | | } |
| | |
| | | |
| | | watch(activeTab, tab => { |
| | | if (tab === 'step-chart') void loadRealtimeRecords() |
| | | if (tab === 'project-chart') void loadProjectChartData() |
| | | }) |
| | | |
| | | async function loadProjectChartData() { |
| | | try { |
| | | const detail = backendDetail.value || await getTestDetail(testId.value) |
| | | const groupId = detail.batteries?.[0]?.groupId |
| | | const [steps, cycles, group] = await Promise.all([ |
| | | getPersistedTestSteps(testId.value), |
| | | getPersistedTestCycles(testId.value), |
| | | groupId ? getBatteryGroupDetail(groupId) : Promise.resolve(null), |
| | | ]) |
| | | projectSteps.value = steps |
| | | projectCycles.value = cycles |
| | | projectCapacityFactor.value = group?.specs.f || '' |
| | | } catch (error) { |
| | | ElMessage.error(error instanceof Error ? error.message : '项目图表数据加载失败') |
| | | projectSteps.value = [] |
| | | projectCycles.value = [] |
| | | } |
| | | } |
| | | |
| | | const elapsedText = computed(() => { |
| | | const hours = Math.floor(elapsedSeconds.value / 3600) |
| | |
| | | </div> |
| | | |
| | | <ConsoleChartPanel v-if="activeTab === 'step-chart'" :test-id="testId" :cells="cells" :records="realtimeRecords" :step-plan-rows="detectorPlanRows" :logic-step-mappings="stepSettings" :console-detail="backendDetail?.consoleDetail" :test-name="profile.name" :battery-group="profile.batteryGroup" /> |
| | | <ConsoleProjectChartPanel v-if="activeTab === 'project-chart'" :cells="cells" /> |
| | | <ConsoleProjectChartPanel v-if="activeTab === 'project-chart'" :cells="cells" :steps="projectSteps" :cycles="projectCycles" :capacity-factor="projectCapacityFactor" /> |
| | | |
| | | <ConsoleDataListPanel v-if="activeTab === 'data-list' && backendDetail" :test-id="testId" :test-name="profile.name" :cells="cells" :can-import="state === '检测完成'" :reception-status="backendDetail.receptionStatus" :bindings="backendDetail.batteries.map(item => ({ batteryNo: item.batteryNo, bindingId: item.binding?.bindingId, channelId: item.binding?.channelId, devid: item.binding?.devid, chlid: item.binding?.chlid }))" /> |
| | | |