yeg
2026-09-02 7be6615a8906f0db4b4fefa1995dcaa6e3c6cdcb
让工步图表读取实时有效数据

工步图表按循环和工步筛选后端实时记录,展示温度、正电位和负电位等采集器测量。
1个文件已添加
3个文件已修改
179 ■■■■■ 已修改文件
package.json 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/ConsoleChartPanel.vue 142 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/TestConsole.vue 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/test-console-step-chart.contract.test.mjs 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
package.json
@@ -14,7 +14,8 @@
    "test:e2e:ticket08": "playwright test tests/e2e/running-start-and-reception-boundaries.spec.ts",
    "test:e2e:ticket10": "playwright test tests/e2e/pause-resume-reception.spec.ts",
    "test:e2e:ticket20": "playwright test tests/e2e/remove-drifted-result-query.spec.ts",
    "test:contract:ticket09": "node tests/test-console-live.contract.test.mjs"
    "test:contract:ticket09": "node tests/test-console-live.contract.test.mjs",
    "test:contract:ticket02": "node tests/test-console-step-chart.contract.test.mjs"
  },
  "dependencies": {
    "@element-plus/icons-vue": "^2.3.2",
src/components/ConsoleChartPanel.vue
@@ -12,6 +12,7 @@
} 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
@@ -28,17 +29,22 @@
  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(['额定容量', '电流', '内阻'])
@@ -69,9 +75,37 @@
  循环寿命模板: ['容量保持率', '能量效率', '循环次数', '内阻变化'],
  热特性模板: ['最高温度', '温差', '温升速率'],
}
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 => {
@@ -97,6 +131,19 @@
}, { 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 {
@@ -163,48 +210,55 @@
  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)
@@ -243,10 +297,10 @@
    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 },
@@ -258,7 +312,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
      }),
    })),
  }
}
@@ -287,7 +344,8 @@
    <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>
@@ -305,13 +363,13 @@
        </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>
src/views/TestConsole.vue
@@ -1,11 +1,11 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import ConsoleChartPanel from '@/components/ConsoleChartPanel.vue'
import ConsoleProjectChartPanel from '@/components/ConsoleProjectChartPanel.vue'
import ConsoleDataListPanel from '@/components/ConsoleDataListPanel.vue'
import { endTestReception, getTestDetail, getTestLiveState, openTestLiveStream, pauseTestReception, resumeTestReception, saveLogicStepMappings, startTestReception, type TestConfigurationDetail, type TestLiveState, type TestLogicStepMapping } from '@/api/tests'
import { endTestReception, getPersistedTestRecords, getTestDetail, getTestLiveState, openTestLiveStream, pauseTestReception, resumeTestReception, saveLogicStepMappings, startTestReception, type PersistedTestRecord, type TestConfigurationDetail, type TestLiveState, type TestLogicStepMapping } from '@/api/tests'
type ConsoleState = '待测试' | '检测中' | '暂停' | '检测完成'
type ReceptionState = '未开始接收' | '接收中' | '暂停接收' | '已结束接收'
@@ -23,6 +23,7 @@
let reconnectTimer: number | null = null
const runtimeAttach = ref(false)
const receptionState = ref<ReceptionState>('未开始接收')
const realtimeRecords = ref<PersistedTestRecord[]>([])
function formatDateTime(value?: string | null) {
  return value ? value.replace('T', ' ').replace(/Z$/, '').replace(/\.\d{3,6}$/, '') : '-'
}
@@ -136,6 +137,15 @@
  }
})
onBeforeUnmount(() => { closeLiveStream?.(); if (reconnectTimer !== null) window.clearTimeout(reconnectTimer) })
async function loadRealtimeRecords() {
  try { realtimeRecords.value = await getPersistedTestRecords(testId.value) }
  catch (error) { ElMessage.error(error instanceof Error ? error.message : '实时数据加载失败') }
}
watch(activeTab, tab => {
  if (tab === 'step-chart') void loadRealtimeRecords()
})
const elapsedText = computed(() => {
  const hours = Math.floor(elapsedSeconds.value / 3600)
@@ -394,7 +404,7 @@
    </div>
    </div>
    <ConsoleChartPanel v-if="activeTab === 'step-chart'" :cells="cells" :test-name="profile.name" :battery-group="profile.batteryGroup" :duration-minutes="5" />
    <ConsoleChartPanel v-if="activeTab === 'step-chart'" :cells="cells" :records="realtimeRecords" :logic-step-mappings="stepSettings" :test-name="profile.name" :battery-group="profile.batteryGroup" />
    <ConsoleProjectChartPanel v-if="activeTab === 'project-chart'" :cells="cells" />
<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 }))" />
tests/test-console-step-chart.contract.test.mjs
New file
@@ -0,0 +1,18 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
const chart = await readFile(new URL('../src/components/ConsoleChartPanel.vue', import.meta.url), 'utf8')
const consoleView = await readFile(new URL('../src/views/TestConsole.vue', import.meta.url), 'utf8')
assert.match(chart, /PersistedTestRecord/)
assert.match(chart, /metricCode/)
assert.match(chart, /POSITIVE_POTENTIAL/)
assert.match(chart, /NEGATIVE_POTENTIAL/)
assert.match(chart, /selectedRecords\(\)/)
assert.match(chart, /暂无当前测试实时数据/)
assert.doesNotMatch(chart, /const stepOptions = \['5-21 恒流充电'/)
assert.doesNotMatch(chart, /function seriesValues\(/)
assert.match(consoleView, /getPersistedTestRecords/)
assert.match(consoleView, /:records="realtimeRecords"/)
console.log('Ticket02/03 工步图表实时数据契约通过')