yeg
2026-09-09 e8b60709ea9cf9804a4a9af568f8d5a20f2c4ec9
修复对比辅助曲线缺失并按来源拆分拼接曲线

导入快照有辅助测量但缺少独立采集器时间,原绘图和空提示将其全部过滤;现统一回退到已有记录绝对时间。拼接原按同组同电池跨测试合线,现按测试和测试电池独立分段横向接续,页面与打印共用。补充曲线点、来源分段和打印实线回归。
5个文件已修改
87 ■■■■■ 已修改文件
src/utils/reportMeasurementChart.ts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/spliceProjectChart.ts 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ReportCompareForm.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/refinement.ui.test.mjs 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/report-chart-behavior.test.mjs 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/reportMeasurementChart.ts
@@ -5,12 +5,17 @@
export type ReportMetricKey = 'voltage' | 'current' | 'positiveVoltage' | 'negativeVoltage' | 'temperature'
export interface MeasurementSeries { name: string; zeroTime: number | null; records: PersistedTestRecord[]; hidden?: boolean }
/** 曲线与空状态使用同一时间口径;导入快照的已有辅助测量可沿用记录绝对时间。 */
export function measurementRecordTime(metric: ReportMetricKey, row: PersistedTestRecord) {
  if (metric === 'voltage' || metric === 'current') return row.deviceTime
  return row.collectorDeviceTime ?? (row.sourceType === 'SNAPSHOT' ? row.deviceTime : null)
}
/** 页面和打印共用测量、时间及单位口径;不连接缺失值,也不限制曲线条数。 */
export function measurementChartOption(metric: { key: ReportMetricKey; unit: string }, items: MeasurementSeries[], colors: string[]) {
  const field: Record<ReportMetricKey, keyof PersistedTestRecord> = { voltage: 'voltage', current: 'current', positiveVoltage: 'positivePotential', negativeVoltage: 'negativePotential', temperature: 'temperature' }
  const series = items.map(item => item.records.flatMap(row => {
    // 柜与采集器分别使用自己的设备时间,共享已冻结的所选范围零点。
    const time = metric.key === 'voltage' || metric.key === 'current' ? row.deviceTime : row.collectorDeviceTime
    const time = measurementRecordTime(metric.key, row)
    if (time == null || item.zeroTime == null) return []
    const value = row[field[metric.key]]
    return [[(Number(time) - item.zeroTime) / 1000, value == null ? null : toTestDisplayValue(metric.key, Number(value))] as [number, number | null]]
src/utils/spliceProjectChart.ts
@@ -1,19 +1,20 @@
import type { SpliceMatrix } from '@/api/spliceReports'
import type { ProjectSeries } from './projectCycleChart'
/** 同组同电池号跨测试接成一条线;跨组同名电池不能混成同一业务身份。 */
/** 每个测试电池独立成段;即使电池相同也不跨测试合线,累计位置由快照提供。 */
export function spliceProjectSeries(matrix: SpliceMatrix): ProjectSeries[] {
  const result = new Map<string, ProjectSeries>()
  for (const segment of matrix.segments) {
  return matrix.segments.map(segment => {
    const source = segment.source
    const key = JSON.stringify([source.groupId, source.batteryNo])
    let series = result.get(key)
    if (!series) { series = { key, name: `${source.group} (#${source.groupId}) · ${source.batteryNo}`, points: [] }; result.set(key, series) }
    const series: ProjectSeries = {
      key: `${source.selection.testId}|${source.selection.testBatteryId}`,
      name: `${source.name} (#${source.selection.testId}) · ${source.group} · ${source.batteryNo}`,
      points: [],
    }
    for (const no of segment.originalCycleNos) {
      const position = segment.cyclePositions?.[String(no)]
      if (position === undefined) continue
      series.points.push({ position, source: `${source.name} (#${source.selection.testId}) · ${source.batteryNo} · 原循环${no}`, values: segment.statistics[String(no)] || {} })
    }
  }
  return [...result.values()]
    return series
  })
}
src/views/ReportCompareForm.vue
@@ -14,6 +14,7 @@
  type CompareCandidate, type CompareItem, type SavedCompareReport } from '@/api/compareReports'
import { compareMetricDefinitions as metricDefinitions, openCompareReportPrint } from '@/utils/compareReportPrint'
import { compareChartOption, compareSeriesName } from '@/utils/compareReportChart'
import { measurementRecordTime } from '@/utils/reportMeasurementChart'
import { compareDetailSources } from '@/utils/compareSourceDetails'
import ReportTestDetail from '@/components/ReportTestDetail.vue'
import { reportDetailColumn, snapshotReportDetails } from '@/utils/reportTestDetails'
@@ -352,7 +353,7 @@
      <div v-if="chartSeries.length" class="chart-legend-bar"><strong>对比图例</strong><div v-for="(item, index) in chartSeries" :key="compareSelectionKey(item.selection)" class="chart-legend-item"><el-color-picker v-if="!readonly" class="legend-color-picker" :model-value="seriesColors[index]" :show-alpha="false" popper-class="legend-color-popper" :aria-label="`修改${item.name}颜色`" @change="updateSeriesColor(index, $event)" /><i v-else class="series-swatch" :style="{ background: seriesColors[index] }" /><button type="button" class="legend-name" :class="{ 'is-hidden': hiddenSeriesKeys.includes(compareSelectionKey(item.selection)) }" :aria-pressed="!hiddenSeriesKeys.includes(compareSelectionKey(item.selection))" :disabled="readonly" @click="toggleSeries(item)">{{ item.name }} (#{{ item.id }}) · {{ item.batteryGroup }} · {{ item.batteryNo }} · 循环{{ item.cycle }} · {{ item.process }}</button></div></div>
      <!-- 报表切换和比对项移除时完整替换配置,避免图表库将颜色数组缩短误判为组件合并。 -->
      <div v-if="!visibleMetrics.length" class="chart-empty">当前未启用曲线指标</div>
      <div class="charts-grid"><article v-for="metric in visibleMetrics" :key="metric.key" class="chart-card"><header><strong>{{ metric.title }}</strong></header><VChart class="metric-chart" :option="compareChartOption(metric, sourceItems, seriesColors, hiddenSeriesKeys)" :update-options="{ notMerge: true }" autoresize @legendselectchanged="!readonly && (hiddenSeriesKeys = sourceItems.filter(item => !$event.selected[compareSeriesName(item)]).map(item => compareSelectionKey(item.source.selection)))" /><div v-if="!sourceItems.some(item => !hiddenSeriesKeys.includes(compareSelectionKey(item.source.selection)) && item.records.some(row => (metric.key === 'voltage' || metric.key === 'current' ? row.deviceTime : row.collectorDeviceTime) != null && (metric.key === 'positiveVoltage' ? row.positivePotential : metric.key === 'negativeVoltage' ? row.negativePotential : row[metric.key]) != null))" class="chart-empty">{{ sourceItems.length && hiddenSeriesKeys.length === sourceItems.length ? '当前全部曲线已隐藏' : '所选范围暂无该指标数据' }}</div></article></div>
      <div class="charts-grid"><article v-for="metric in visibleMetrics" :key="metric.key" class="chart-card"><header><strong>{{ metric.title }}</strong></header><VChart class="metric-chart" :option="compareChartOption(metric, sourceItems, seriesColors, hiddenSeriesKeys)" :update-options="{ notMerge: true }" autoresize @legendselectchanged="!readonly && (hiddenSeriesKeys = sourceItems.filter(item => !$event.selected[compareSeriesName(item)]).map(item => compareSelectionKey(item.source.selection)))" /><div v-if="!sourceItems.some(item => !hiddenSeriesKeys.includes(compareSelectionKey(item.source.selection)) && item.records.some(row => measurementRecordTime(metric.key, row) != null && (metric.key === 'positiveVoltage' ? row.positivePotential : metric.key === 'negativeVoltage' ? row.negativePotential : row[metric.key]) != null))" class="chart-empty">{{ sourceItems.length && hiddenSeriesKeys.length === sourceItems.length ? '当前全部曲线已隐藏' : '所选范围暂无该指标数据' }}</div></article></div>
    </section>
    <footer class="form-actions">
tests/refinement.ui.test.mjs
@@ -22,7 +22,7 @@
const source = (id, groupId=9) => ({ selection:{testId:id,testBatteryId:id,cycleNo:1,logicalNo:1},name:`测试${id}`,type:'循环',groupId,group:`组${groupId}`,batteryGroup:`组${groupId}`,batteryNo:'1号',process:'合并工步',startedAt:1,consoleDetail:{batteryFields:[],specialFields:[],fieldValues:{}} })
const segments = [source(1),source(2),source(3,10)].map((source,index) => ({source,originalCycleNos:index===0?[1,3]:index===1?[10,11]:[1],cyclePositions:index===0?{'1':1,'3':2}:index===1?{'10':3,'11':4}:{'1':5},capacityFactor:2,offset:0,statistics:Object.fromEntries((index===0?[1,3]:index===1?[10,11]:[1]).map(no=>[no,{finalVoltage1:4-no/100,dischargeCapacityF:2}])),consoleDetail:{batteryFields:[],specialFields:[],fieldValues:{}} }))
const splice = {id:7,name:'循环拼接验收',description:'按实际循环接续',versionNo:1,creator:'负责人',operatorName:'负责人',createdAt:now,versionCreatedAt:now,changeSummary:'新建',spliceMode:'project-cycles',segments,cycleNos:[1,2,3,4,5],selections:segments.map(item=>item.source.selection),settings:{metrics:['finalVoltage1','finalVoltage2','dischargeCapacityF'],seriesColors:['#3978f6','#7d42d8'],sourceDetailOverrides:{}}}
const compare = {id:7,name:'工步对比验收',description:'跨测试叠加',versionNo:1,creator:'负责人',operatorName:'负责人',createdAt:now,versionCreatedAt:now,changeSummary:'新建',items:[source(1),source(2)].map(source=>({source,zeroTime:1000,records:[{deviceTime:1000,collectorDeviceTime:1000,voltage:3,current:0.1,temperature:25},{deviceTime:61000,collectorDeviceTime:61000,voltage:4,current:0.2,temperature:26}]})),settings:{metrics:['voltage','current'],seriesColors:['#3978f6','#7d42d8'],sourceDetailOverrides:{}}}
const compare = {id:7,name:'工步对比验收',description:'跨测试叠加',versionNo:1,creator:'负责人',operatorName:'负责人',createdAt:now,versionCreatedAt:now,changeSummary:'新建',displayUnits:{},items:[source(1),source(2)].map(source=>({source,zeroTime:1000,records:[{sourceType:'SNAPSHOT',deviceTime:1000,voltage:3,current:0.1,temperature:25,positivePotential:1.5,negativePotential:-0.1},{sourceType:'SNAPSHOT',deviceTime:61000,voltage:4,current:0.2,temperature:26,positivePotential:1.6,negativePotential:-0.2}]})),settings:{metrics:['voltage','current','positiveVoltage','negativeVoltage','temperature'],seriesColors:['#3978f6','#7d42d8'],sourceDetailOverrides:{}}}
let dashboardFails = false, todoFails = false
const requests = [], errors = []
context.on('page', page => page.on('pageerror', error => errors.push(error.message)))
@@ -96,18 +96,31 @@
  await expect(popup.locator('body')).toContainText('2:原循环3')
  await expect(popup.locator('body')).toContainText('3:原循环10')
  await expect(popup.locator('svg')).toHaveCount(3)
  // 同组同名电池跨测试仍有各自图例,打印不能合并来源段。
  await expect(popup.locator('svg').first()).toContainText('测试1 (#1) · 组9 · 1号')
  await expect(popup.locator('svg').first()).toContainText('测试2 (#2) · 组9 · 1号')
  await popup.close()
  await page.setViewportSize({width:390,height:844})
  await expect.poll(() => page.locator('.app-main').evaluate(element => element.getBoundingClientRect().left)).toBe(0)
  await page.screenshot({path:resolve(artifacts,'splice-mobile.png'),fullPage:true,animations:'disabled'})
  await page.setViewportSize({width:1440,height:1000})
  await page.goto('http://refinement.test/report-compare/7/edit?mode=edit')
  await expect(page.locator('.chart-card')).toHaveCount(2)
  await expect(page.locator('.chart-card')).toHaveCount(5)
  await expect(page.locator('.chart-empty')).toHaveCount(0)
  await expect(page.locator('.report-test-detail')).not.toContainText('循环')
  await expect(page.locator('.report-test-detail')).not.toContainText('工步')
  await expect(page.locator('.chart-legend-bar')).toContainText('合并工步')
  await expect(page.getByText('数据选择',{exact:true})).toHaveCount(0)
  await page.screenshot({path:resolve(artifacts,'compare-desktop.png'),fullPage:true})
  const comparePopupPromise=page.waitForEvent('popup')
  await page.getByRole('button',{name:'打印 / 另存 PDF'}).click()
  const comparePopup=await comparePopupPromise
  await expect(comparePopup.locator('.chart-card svg')).toHaveCount(5)
  for (const chart of await comparePopup.locator('.chart-card svg').all()) {
    await expect(chart.locator('path[fill="none"][stroke="#3978f6"][stroke-width="2"]')).toHaveCount(1)
    await expect(chart.locator('path[fill="none"][stroke="#7d42d8"][stroke-width="2"]')).toHaveCount(1)
  }
  await comparePopup.close()
  await page.goto('http://refinement.test/operation-log')
  await expect(page.locator('.operation-log-page tbody')).toContainText('测试详情:修改(成功)')
  const formationStart=requests.length
tests/report-chart-behavior.test.mjs
@@ -5,18 +5,47 @@
const { spliceProjectSeries } = load(new URL('../src/utils/spliceProjectChart.ts', import.meta.url))
const { measurementChartOption } = load(new URL('../src/utils/reportMeasurementChart.ts', import.meta.url))
test('缺循环连续接续,同组同电池合线,跨组同名电池独立且提示原循环', () => {
test('导入快照只有记录时间时,已有正负电位和温度仍绘成曲线', () => {
  // 当前报表的导入记录有三个辅助测量值,但没有独立采集器时间。
  const items = [{ name: '导入电池', zeroTime: 1000, records: [
    { sourceType: 'SNAPSHOT', deviceTime: 1000, temperature: 25.5, positivePotential: 1.505, negativePotential: -0.105 },
    { sourceType: 'SNAPSHOT', deviceTime: 61000, temperature: 26, positivePotential: 1.6, negativePotential: -0.1 },
  ] }]
  for (const [key, values] of [['temperature', [25.5,26]], ['positiveVoltage', [1.505,1.6]], ['negativeVoltage', [-0.105,-0.1]]]) {
    const option = measurementChartOption({key, unit:key === 'temperature' ? '℃' : 'V'}, items, ['#123456'])
    assert.deepEqual(option.series[0].data, [[0,values[0]],[60,values[1]]], key)
  }
})
test('辅助测量优先使用独立采集器时间,不为缺少时间或数值的记录补点', () => {
  const records = [
    { sourceType:'SNAPSHOT', deviceTime:1000, collectorDeviceTime:9000, temperature:0 },
    { sourceType:'SNAPSHOT', deviceTime:61000, temperature:null },
    { sourceType:'SNAPSHOT', temperature:26 },
    { sourceType:'COLLECTOR', deviceTime:121000, temperature:27 },
  ]
  const option = measurementChartOption({key:'temperature',unit:'℃'}, [{name:'甲',zeroTime:1000,records}], [])
  assert.deepEqual(option.series[0].data, [[8,0],[60,null]])
})
test('每个测试电池独立横向接续,同组同电池跨测试不合线且提示原循环', () => {
  const segment = (testId, groupId, originalCycleNos, cyclePositions) => ({ source: { name: `测试${testId}`, group: `组${groupId}`, groupId, batteryNo: '1号', selection: { testId, testBatteryId: testId } }, originalCycleNos, cyclePositions, statistics: Object.fromEntries(originalCycleNos.map(no => [no, { finalVoltage1: no === 3 ? null : 4, dischargeCapacityF: 2 }])) })
  const matrix = { cycleNos: [1, 2, 3, 4, 5], segments: [segment(1, 9, [1,3], {1:1,3:2}), segment(2, 9, [10,11], {10:3,11:4}), segment(3, 10, [1], {1:5})] }
  const before = structuredClone(matrix)
  const series = spliceProjectSeries(matrix)
  assert.equal(series.length, 2)
  assert.deepEqual(series[0].points.map(point => point.position), [1,2,3,4])
  assert.equal(series.length, 3)
  assert.equal(new Set(series.map(item => item.key)).size, 3)
  assert.deepEqual(series[0].points.map(point => point.position), [1,2])
  assert.equal(series[0].points[1].source, '测试1 (#1) · 1号 · 原循环3')
  assert.equal(series[0].points[2].source, '测试2 (#2) · 1号 · 原循环10')
  assert.deepEqual(series[1].points.map(point => point.position), [5])
  assert.equal(series[1].points[0].source, '测试2 (#2) · 1号 · 原循环10')
  assert.deepEqual(series[1].points.map(point => point.position), [3,4])
  assert.deepEqual(series[2].points.map(point => point.position), [5])
  assert.match(series[0].name, /测试1/)
  assert.match(series[1].name, /测试2/)
  const option = projectCycleChartOption(projectMetrics[0], series, matrix.cycleNos, ['#123456', '#abcdef'], [series[1].key], true)
  assert.deepEqual(option.series[0].data.map(point => point.value), [[1,4],[2,null],[3,4],[4,4]])
  assert.deepEqual(option.series[0].data.map(point => point.value), [[1,4],[2,null]])
  assert.deepEqual(option.series[1].data.map(point => point.value), [[3,4],[4,4]])
  assert.equal(option.series[2].showSymbol, true)
  assert.equal(option.legend.selected[series[1].name], false)
  assert.equal(option.series[0].connectNulls, false)
  assert.equal(option.xAxis.name, '累计位置')