yeg
2026-09-08 af9b86826fd84553f659df71e1de67d8df35ff91
补齐化成多工步打印及独立验收场景
1个文件已添加
2个文件已修改
261 ■■■■■ 已修改文件
src/utils/formationSavedReportPrint.ts 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/FormationReportForm.vue 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/e2e/formation-step-curves.spec.ts 216 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/formationSavedReportPrint.ts
@@ -5,6 +5,7 @@
import { formationValueKey, getFormationPrintVersion, type FormationItem, type SavedFormationReport } from '@/api/formationReports'
import { formationChargeChoice, formationFieldLabel, formationSourceValue } from './formationReportFields'
import { formationChartOption, formationCurveMetrics, formationSeriesColor, formationSeriesName } from './formationCycleChart'
import { formationChargeSteps, formationStepGroups, formationStepSeries, formationStepChartOption } from './formationStepChart'
import { openSavedReportPrint } from './savedReportPrint'
use([LineChart, GridComponent, LegendComponent, SVGRenderer])
@@ -23,11 +24,20 @@
    for (let start = 0; start < items.length; start += 4) {
      const group = items.slice(start, start + 4)
      const value = (item: FormationItem, read: () => unknown) => cycle && !item.cycleNos.includes(cycle) ? '无该循环' : read()
      const rows: unknown[][] = cycle ? [
      const rows: unknown[][] = cycle && record.curveMode !== 'actual-steps' ? [
        ['充电工步号', ...group.map(item => value(item, () => formationChargeChoice(item, cycle, record.settings.chargeStepSelections ?? {})?.stepNo ?? '未识别'))],
        ['充电工步名称', ...group.map(item => value(item, () => formationChargeChoice(item, cycle, record.settings.chargeStepSelections ?? {})?.name || '未选择'))],
        ['终止电压 (V)', ...group.map(item => value(item, () => formationChargeChoice(item, cycle, record.settings.chargeStepSelections ?? {})?.endVoltage))],
      ] : []
      if (cycle && record.curveMode === 'actual-steps') {
        const steps = group.map(item => formationChargeSteps(item, cycle, record.settings.manualChargeSteps))
        const count = Math.max(1, ...steps.map(values => values.length))
        for (let index = 0; index < count; index++) rows.push([`充电工步${index + 1} / 终止电压 (V)`, ...group.map((item, column) => value(item, () => {
          const step = steps[column]?.[index]
          if (!step) return index ? '—' : (item.chargeSteps?.[cycle]?.length ? '未识别充电工步' : '缺工步')
          return `工步${step.stepNo} · ${step.name || '未命名工步'} · ${step.endVoltage ?? '缺失'}${step.endVoltage == null ? '' : ' V'}`
        }))])
      }
      for (const field of record.settings.fields.filter(field => field.scope === (cycle ? 'cycle' : 'detail'))) {
        rows.push([formationFieldLabel(field), ...group.map(item => value(item, () =>
          record.settings.values[formationValueKey(item, cycle, field)] ?? formationSourceValue(item, cycle, field)))])
@@ -38,6 +48,22 @@
  }
  const legend = items.map(item => `<span><i style="background:${formationSeriesColor(item, record.items.indexOf(item), colors)}"></i>${escapeHtml(formationSeriesName(item))}</span>`).join('')
  const cycles = record.cycleNos.map(cycle => {
    if (record.curveMode === 'actual-steps') {
      const groups = formationStepGroups.map(group => {
        const series = formationStepSeries(record.items, cycle, group.key, record.settings)
        const legend = series.map(item => `<span${item.hidden ? ' class="hidden-series"' : ''}><i style="background:${escapeHtml(item.color)}"></i>${escapeHtml(item.name)}${item.hidden ? '(已隐藏)' : ''}</span>`).join('')
        const available = series.some(item => item.records.length)
        const charts = available ? formationCurveMetrics.filter(metric => (record.settings.metrics ?? formationCurveMetrics.map(value => value.key)).includes(metric.key)).map(metric => {
          const chart = init(null, undefined, { renderer: 'svg', ssr: true, width: 900, height: 300 })
          let svg: string
          try { chart.setOption({ ...formationStepChartOption(metric, record.items, cycle, group.key, record.settings), tooltip: { show: false } }); svg = chart.renderToSVGString() }
          finally { chart.dispose() }
          return `<article class="chart-card"><h3>循环${cycle} · ${group.title}工步 · ${escapeHtml(metric.title)} (${escapeHtml(metric.unit)})</h3>${svg}<div class="legend">${legend}</div></article>`
        }).join('') : ''
        return `<section data-group="${group.key}"><h3>${group.title}工步曲线</h3><div class="charts">${charts || `<p>${available ? '当前未启用曲线指标' : '所选工步暂无已保存实时数据'}</p>`}</div></section>`
      }).join('')
      return `<section class="cycle-section" data-cycle="${cycle}"><h2>循环${cycle}</h2>${tables(cycle)}${groups}</section>`
    }
    const available = record.items.some(item => item.curves?.[cycle]?.records.length)
    const charts = available ? formationCurveMetrics.filter(metric => (record.settings.metrics ?? formationCurveMetrics.map(item => item.key)).includes(metric.key)).map(metric => {
      const chart = init(null, undefined, { renderer: 'svg', ssr: true, width: 900, height: 300 })
@@ -51,7 +77,7 @@
  const date = (time: number) => escapeHtml(new Date(time).toLocaleString())
  return `<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><title>${escapeHtml(record.name)} - V${record.versionNo}</title><style>
    @page{size:A4 landscape;margin:10mm}*{box-sizing:border-box}body{margin:0;padding:16px;font:11px "Microsoft YaHei",sans-serif;color:#26333f;background:#fff;letter-spacing:0;print-color-adjust:exact;-webkit-print-color-adjust:exact}
    .report-header{display:flex;flex-wrap:wrap;gap:16px;justify-content:space-between;border-bottom:2px solid #2b78a3;padding-bottom:12px;margin-bottom:16px}.report-header>div{min-width:0;max-width:100%}h1{font-size:22px;margin:0;overflow-wrap:anywhere}.saved-meta{display:flex;flex-wrap:wrap;gap:8px 18px;max-width:50%;overflow-wrap:anywhere}h2{font-size:14px;background:#f4f8fb;padding:10px;border:1px solid #d8e1e9;break-after:avoid}h3{margin:0;padding:8px 12px;font-size:13px}table{width:100%;table-layout:fixed;border-collapse:collapse;margin:0 0 16px}thead{display:table-header-group}tr{break-inside:avoid}td,th{border:1px solid #cbd8e4;padding:7px;white-space:pre-wrap;overflow-wrap:anywhere;text-align:center}th{background:#3c6497;color:#fff}td:first-child,th:first-child{width:150px;text-align:left}td:first-child{background:#f4f8fb}.charts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.chart-card{min-width:0;border:1px solid #d8e1e9;break-inside:avoid}.chart-card svg{display:block;width:100%;height:auto}.legend{display:flex;flex-wrap:wrap;gap:8px 16px;padding:10px}.legend span{display:flex;align-items:flex-start;gap:6px;overflow-wrap:anywhere;max-width:100%;break-inside:avoid}.legend i{flex:0 0 12px;height:12px;margin-top:2px}.note{white-space:pre-wrap;overflow-wrap:anywhere}
    .report-header{display:flex;flex-wrap:wrap;gap:16px;justify-content:space-between;border-bottom:2px solid #2b78a3;padding-bottom:12px;margin-bottom:16px}.report-header>div{min-width:0;max-width:100%}h1{font-size:22px;margin:0;overflow-wrap:anywhere}.saved-meta{display:flex;flex-wrap:wrap;gap:8px 18px;max-width:50%;overflow-wrap:anywhere}h2{font-size:14px;background:#f4f8fb;padding:10px;border:1px solid #d8e1e9;break-after:avoid}h3{margin:0;padding:8px 12px;font-size:13px}table{width:100%;table-layout:fixed;border-collapse:collapse;margin:0 0 16px}thead{display:table-header-group}tr{break-inside:avoid}td,th{border:1px solid #cbd8e4;padding:7px;white-space:pre-wrap;overflow-wrap:anywhere;text-align:center}th{background:#3c6497;color:#fff}td:first-child,th:first-child{width:150px;text-align:left}td:first-child{background:#f4f8fb}.charts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.chart-card{min-width:0;border:1px solid #d8e1e9;break-inside:avoid}.chart-card svg{display:block;width:100%;height:auto}.legend{display:flex;flex-wrap:wrap;gap:8px 16px;padding:10px}.legend span{display:flex;align-items:flex-start;gap:6px;overflow-wrap:anywhere;max-width:100%;break-inside:avoid}.hidden-series{text-decoration:line-through;opacity:.55}.legend i{flex:0 0 12px;height:12px;margin-top:2px}.note{white-space:pre-wrap;overflow-wrap:anywhere}
    @media(max-width:680px){.charts{grid-template-columns:1fr}.saved-meta{max-width:100%}}@media print{body{padding:0}.charts{display:block}.chart-card{margin-bottom:12px}.report-header{break-inside:avoid}}
  </style></head><body><header class="report-header"><div><h1>${escapeHtml(record.name)}</h1><p>检测室数据可视化系统 · 化成报表</p></div><div class="saved-meta"><span>报表ID #${record.id}</span><span>版本 V${record.versionNo}</span><span>创建人 ${escapeHtml(record.creator)}</span><span>创建时间 ${date(record.createdAt)}</span><span>版本操作人 ${escapeHtml(record.operatorName)}</span><span>版本时间 ${date(record.versionCreatedAt)}</span><span>修改说明 ${escapeHtml(record.changeSummary)}</span></div></header><section><h2>报表数据</h2>${tables(0)}</section>${cycles}<section><h2>报表说明</h2><div class="note">${escapeHtml(record.description || '-')}</div></section></body></html>`
}
src/views/FormationReportForm.vue
@@ -174,7 +174,7 @@
async function printReport(versionNo = saved.value?.versionNo) {
  if (printing.value || saving.value || loading.value || loadError.value || !canExport.value) return
  if (!saved.value || versionNo === undefined) { ElMessage.warning('请先保存报表后再打印'); return }
  if (formContent() !== savedContent.value || changeSummary.value.trim()) { ElMessage.warning('有未保存修改,请先保存新版本后再打印'); return }
  if (!readonly.value && (formContent() !== savedContent.value || changeSummary.value.trim())) { ElMessage.warning('有未保存修改,请先保存新版本后再打印'); return }
  printing.value = true
  try { await openFormationSavedReportPrint(saved.value.id, versionNo) }
  catch (error) { ElMessage.error(error instanceof Error ? error.message : '打印失败,请重试') }
@@ -193,19 +193,6 @@
    if (sequence !== previewSequence) return
    items.value = result.items
    cycleNos.value = result.cycleNos
    // 可靠识别的充放电工步默认全部纳入曲线;未知类型留给操作员人工指定。
    const nextSelections: Record<string, FormationStepSelection> = { ...curveStepSelections.value }
    for (const item of items.value) for (const cycle of item.cycleNos) {
      const key = formationCycleKey(item, cycle)
      const choices = item.chargeSteps?.[String(cycle)] ?? []
      const charge = choices.filter(step => step.direction === 'CHARGE').map(step => step.stepNo)
      const discharge = choices.filter(step => step.direction === 'DISCHARGE').map(step => step.stepNo)
      const existing = nextSelections[key]
      nextSelections[key] = existing
        ? { charge: existing.charge ?? charge, discharge: existing.discharge ?? discharge }
        : { charge, discharge }
    }
    curveStepSelections.value = nextSelections
    fields.value = fields.value.map(field => field.kind === 'battery' && field.fieldDefinitionId == null
      ? { ...field, fieldDefinitionId: items.value.flatMap(item => item.businessFields).find(value => value.fieldName === field.key)?.fieldDefinitionId ?? null } : field)
    pruneValues()
tests/e2e/formation-step-curves.spec.ts
New file
@@ -0,0 +1,216 @@
import { expect, test, type Page } from '@playwright/test'
import { Ticket04Stack } from './support/ticket04-stack'
const stack = new Ticket04Stack()
test.use({ actionTimeout: 20_000 })
test.beforeAll(async () => { await stack.start(); seed() })
test.afterAll(async () => stack.stop())
const batteries = [8601, 8602, 8603, 8604, 8605]
const testId = (battery: number) => battery === 8602 ? 8601 : battery
const selections = batteries.map(id => ({ testId: testId(id), testBatteryId: id }))
const key = (id: number, cycle: number) => `${testId(id)}|${id}|${cycle}`
const fields = [{ key: 'note', scope: 'detail', kind: 'manual', displayName: '人工说明', unit: '' }]
async function login(page: Page, username = 'admin') {
  await page.goto('/login')
  await page.locator('input[autocomplete="username"]').fill(username)
  await page.locator('input[autocomplete="current-password"]').fill(username === 'admin' ? 'admin123' : 'Report123!')
  await page.getByRole('button', { name: '登录', exact: true }).click()
  await expect(page).toHaveURL(/\/dashboard$/)
}
async function api(page: Page, path: string, method = 'GET', data?: unknown, code = 200) {
  const token = await page.evaluate(() => localStorage.getItem('battery_detection_auth_token'))
  const body = await (await page.request.fetch(`${stack.backendUrl}/system/${path}`, { method, data, headers: { Authorization: `Bearer ${token}` } })).json()
  expect(body.code, `${path}: ${body.msg}`).toBe(code)
  return body.data ?? body
}
function seed() {
  stack.executeIsolatedSql(`
    INSERT INTO t_battery_group(group_id,group_name,model) VALUES(8601,'工步甲组','型号'),(8603,'工步乙组','型号');
    INSERT INTO t_test_type(test_type_id,test_type_name) VALUES(8601,'化成'),(8609,'其他类型');
    INSERT INTO t_test(test_id,test_name,creator_id,principal_id,test_type_id,test_purpose,test_status,reception_status)
      VALUES ${[8601, 8603, 8604, 8605].map(id => `(${id},'工步测试${id}',1,1,8601,'隔离验收','COMPLETED','ENDED')`).join(',')},(8608,'未完成化成',1,1,8601,'隔离验收','WAITING','NOT_STARTED'),(8609,'其他类型测试',1,1,8609,'隔离验收','COMPLETED','ENDED');
    INSERT INTO t_test_battery(test_battery_id,test_id,group_id,battery_no,collector_id)
      VALUES ${[...batteries, 8608, 8609].map(id => `(${id},${testId(id)},${id <= 8602 ? 8601 : 8603},'${id === 8602 ? '2号' : '同名电池'}',${id})`).join(',')};
    INSERT INTO t_test_realtime_record(test_id,test_battery_id,binding_id,adapter_code,source_identity,source_record_id,cabinet_devtype,cabinet_devid,cabinet_subdevid,cabinet_chlid,cabinet_sequence_id,cabinet_device_time_utc,collector_device_time_utc,server_received_at_utc,cycle_no,step_no,step_name,step_type,voltage_v,current_a,temperature,positive_potential,negative_potential,content_sha256)
      VALUES ${batteries.flatMap(id => [1, 3].flatMap(cycle => [1, 2, 3].flatMap(step => [0, 37, 145].map((seconds, point) => {
        const seq = cycle * 1000 + step * 10 + point
        const time = (delay: number) => new Date(Date.UTC(2026, 8, cycle, step, 0, seconds + delay, 123)).toISOString().replace('T', ' ').replace('Z', '')
        const type = step === 3 ? 'dc' : id === 8605 ? (step === 1 ? 'mystery' : 'pulse') : step === 1 ? 'cc' : 'cccv'
        return `(${testId(id)},${id},${id},'formation-step','formation:${id}','${seq}',27,1,1,1,${seq},'${time(10)}','${time(0)}','${time(11)}',${cycle},${step},'实际工步${step}','${type}',${step + point / 10},${point === 1 ? 'NULL' : point === 0 ? '0' : '-0.2'},${point === 1 ? 'NULL' : '0'},0.1,-0.2,UNHEX(SHA2('${id}:${seq}',256)))`
      })))).join(',')};
    INSERT INTO t_test_step_record(test_id,test_battery_id,binding_id,adapter_code,source_identity,source_record_id,dedupe_key,cycle_no,step_no,step_name,step_type,device_time_utc,server_received_at_utc,adapter_timezone_id,device_time_raw,voltage_v,content_sha256)
      VALUES ${batteries.flatMap(id => [1, 3].flatMap(cycle => [1, 2, 3].map(step => {
        const type = step === 3 ? 'dc' : id === 8605 ? (step === 1 ? 'mystery' : 'pulse') : step === 1 ? 'cc' : 'cccv'
        return `(${testId(id)},${id},${id},'formation-step','formation:${id}','${cycle}:${step}','${cycle}:${step}',${cycle},${step},'实际工步${step}','${type}','2026-09-01 00:00:00','2026-09-01 00:00:01','Asia/Hong_Kong','2026-09-01 08:00:00',${cycle === 3 && step === 2 ? 'NULL' : step === 1 ? '4.111' : step === 2 ? '4.222' : '2.555'},UNHEX(SHA2('${id}:${cycle}:${step}',256)))`
      }))).join(',')};
  `)
}
test('化成实际充放电工步完整冻结、未知多选、图例设置、旧版及多页打印独立', async ({ page }) => {
  await login(page)
  const errors: string[] = []
  page.on('pageerror', error => errors.push(error.message))
  const sources = await api(page, 'formation-report/sources')
  expect(sources.some((item: any) => [8608, 8609].includes(item.selection.testId))).toBe(false)
  for (const selection of [{ testId: 8601, testBatteryId: 8603 }, { testId: 8608, testBatteryId: 8608 }, { testId: 8609, testBatteryId: 8609 }]) await api(page, 'formation-report/preview', 'POST', [selection], 500)
  const matrix = await api(page, 'formation-report/preview', 'POST', selections)
  expect(matrix.cycleNos).toEqual([1, 3])
  expect(matrix.items).toHaveLength(5)
  expect(matrix.items[0].chargeSteps['1'].map((step: any) => step.direction)).toEqual(['CHARGE', 'CHARGE', 'DISCHARGE'])
  for (const item of matrix.items) for (const cycle of [1, 3]) for (const step of [1, 2, 3]) {
    const range = item.stepCurves[cycle][step]
    expect(range.records).toHaveLength(3)
    expect(range.zeroTime).toBe(Date.UTC(2026, 8, cycle, step, 0, 0, 123))
    expect(range.records.every((record: any) => record.stepNo === step && record.cycleNo === cycle)).toBe(true)
  }
  const base = { name: '归属校验', description: '', selections, settings: { fields, values: {}, chargeStepSelections: {}, manualChargeSteps: { [key(8605, 1)]: [1, 2] } } }
  for (const settings of [
    { ...base.settings, manualChargeSteps: { [key(8605, 1)]: [1, 99] } },
    { ...base.settings, manualChargeSteps: { [key(8601, 1)]: [1] } },
    { ...base.settings, curveStepSelections: { [key(8601, 1)]: { charge: [3], discharge: [] } } },
    { ...base.settings, curveStepSelections: { '999|999|1': { charge: [1], discharge: [] } } },
  ]) await api(page, 'formation-report', 'POST', { ...base, settings }, 500)
  await page.goto('/report-formation/new')
  await page.getByRole('button', { name: '打印 / 另存 PDF', exact: true }).click()
  await expect(page.getByText('请先保存报表后再打印', { exact: true })).toBeVisible()
  await page.locator('.source-panel .el-select').click()
  for (const id of batteries) await page.getByRole('option', { name: `工步测试${testId(id)} / ${id <= 8602 ? '工步甲组' : '工步乙组'} / ${id === 8602 ? '2号' : '同名电池'}`, exact: true }).click()
  await page.getByRole('combobox', { name: '化成来源', exact: true }).press('Escape')
  await expect(page.locator('[data-group="charge"] .chart-card')).toHaveCount(5)
  await expect(page.locator('[data-group="discharge"] .chart-card')).toHaveCount(5)
  await expect(page.locator('.cycle-card[data-cycle="1"] .charge-termination-row')).toHaveCount(2)
  await expect(page.locator('.cycle-card[data-cycle="1"]')).toContainText('4.111 V')
  await expect(page.locator('.cycle-card[data-cycle="1"]')).toContainText('4.222 V')
  await expect(page.locator('.cycle-card[data-cycle="3"] .charge-termination-row').nth(1)).toContainText('缺失')
  const unknown = page.getByRole('combobox', { name: '未知充电工步 工步测试8605 同名电池 循环1', exact: true })
  await unknown.click()
  for (const step of [1, 2]) await page.getByRole('option', { name: `${step} 实际工步${step}`, exact: true }).click()
  await unknown.press('Escape')
  await expect(page.locator('.cycle-card[data-cycle="1"] .charge-termination-row').nth(1).locator('td').last()).toContainText('4.222 V')
  const chargeChoice = page.getByRole('combobox', { name: '充电工步 工步测试8601 同名电池', exact: true })
  await chargeChoice.click()
  await page.getByRole('option', { name: '1 实际工步1', exact: true }).click()
  await chargeChoice.press('Escape')
  await expect(page.locator('.cycle-card[data-cycle="1"]')).toContainText('4.111 V')
  const legend = page.locator('[data-group="charge"] .chart-legend-item').filter({ hasText: '工步测试8601 · 工步甲组 / 同名电池 · 循环1 工步2' })
  await legend.getByRole('button').click()
  await expect(legend.getByRole('button')).toHaveAttribute('aria-pressed', 'false')
  await legend.locator('.el-color-picker').click()
  const panel = page.locator('.el-color-picker__panel:visible')
  await panel.locator('input').fill('#123456')
  await panel.getByRole('button', { name: /确定|确认|OK/ }).click()
  await expect(legend.getByRole('button')).toHaveAttribute('aria-pressed', 'false')
  await page.getByPlaceholder('请输入报表名称').fill('化成工步独立验收')
  await page.getByRole('button', { name: '保存报表', exact: true }).click()
  await expect(page).toHaveURL(/mode=view/)
  const id = Number(page.url().match(/formation\/(\d+)/)![1])
  const v1 = await api(page, `formation-report/${id}`)
  expect(v1.curveMode).toBe('actual-steps')
  expect(v1.settings.manualChargeSteps[key(8605, 1)]).toEqual([1, 2])
  expect(v1.settings.curveStepSelections[key(8601, 1)].charge).toEqual([2])
  expect(v1.settings.hiddenSeriesKeys).toEqual([`${key(8601, 1)}|2`])
  expect(v1.settings.seriesColors[`${key(8601, 1)}|2`]).toBe('#123456')
  expect(v1.units.current).toBe('A'); expect(v1.displayUnits.current).toBe('mA')
  const options = await page.evaluate(async (record) => {
    const path = '/src/utils/formationStepChart.ts'
    const { formationStepChartOption } = await import(path)
    return ['charge', 'discharge'].map(group => ['current', 'positiveVoltage'].map(metric => formationStepChartOption({ key: metric, unit: metric === 'current' ? 'mA' : 'V' }, record.items, 1, group, record.settings)))
  }, v1)
  for (const group of options) {
    expect(group[0].series[0].data).toEqual([[10, 0], [47, null], [155, -200]])
    expect(group[1].series[0].data).toEqual([[0, 0.1], [37, 0.1], [145, 0.1]])
    expect(group[0].yAxis[0].min).toBe(group[0].yAxis[1].min)
    expect(group[0].yAxis[0].max).toBe(group[0].yAxis[1].max)
  }
  expect(options[0][0].series.some((series: any) => series.name.includes('循环1 工步3'))).toBe(false)
  expect(options[1][0].series.every((series: any) => series.name.includes('循环1 工步3'))).toBe(true)
  for (const width of [1440, 390]) {
    await page.setViewportSize({ width, height: 900 })
    await page.locator('.metrics-panel').scrollIntoViewIfNeeded()
    await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
    expect(await page.locator('.metric-chart canvas').first().evaluate((canvas: HTMLCanvasElement) => {
      const bytes = canvas.getContext('2d')!.getImageData(0, 0, canvas.width, canvas.height).data
      let colored = 0
      for (let i = 0; i < bytes.length; i += 4) if (bytes[i + 3]! > 0 && Math.max(bytes[i]!, bytes[i + 1]!, bytes[i + 2]!) - Math.min(bytes[i]!, bytes[i + 1]!, bytes[i + 2]!) > 50) colored++
      return colored
    })).toBeGreaterThan(100)
    await page.screenshot({ path: `tests/e2e/.artifacts/formation-steps-${width}.png` })
  }
  await page.setViewportSize({ width: 1440, height: 1000 })
  await page.getByRole('button', { name: '编辑', exact: true }).click()
  await page.locator('.curve-cycle-select').click()
  await page.getByRole('option', { name: '循环3', exact: true }).click()
  await page.locator('.metric-switches .el-checkbox').filter({ hasText: '温度' }).click()
  await page.getByRole('button', { name: '打印 / 另存 PDF', exact: true }).click()
  await expect(page.getByText('有未保存修改,请先保存新版本后再打印', { exact: true })).toBeVisible()
  await page.getByPlaceholder('请输入修改说明').fill('保存循环和显示设置')
  await page.getByRole('button', { name: '保存新版本', exact: true }).click()
  await expect(page).toHaveURL(/mode=view/)
  await page.reload()
  await expect(page.locator('.curve-cycle-select')).toContainText('循环3')
  await expect(page.locator('.chart-card')).toHaveCount(8)
  const v2 = await api(page, `formation-report/${id}`)
  expect(v2.items).toEqual(v1.items)
  expect(v2.settings.activeCycle).toBe(3)
  await api(page, `formation-report/${id}/versions`, 'POST', { name: '陈旧', description: '', baseVersionNo: 1, changeSummary: '陈旧拒绝', settings: v1.settings }, 500)
  stack.executeIsolatedSql('UPDATE t_test_step_record SET voltage_v=9 WHERE test_id=8601; UPDATE t_test_realtime_record SET current_a=99 WHERE test_id=8601')
  const v3 = await api(page, `formation-report/${id}/refresh-source`, 'POST', { baseVersionNo: 2, changeSummary: '主动重读' })
  expect(v3.items[0].stepCurves['1']['1'].records.every((row: any) => row.current === 99)).toBe(true)
  expect(v3.items[0].chargeSteps['1'][0].endVoltage).toBe(9)
  expect((await api(page, `formation-report/${id}/versions/1`)).items).toEqual(v1.items)
  for (const sourceId of [8601, 8603, 8604, 8605]) await api(page, `test/${sourceId}`, 'DELETE')
  await api(page, `formation-report/${id}/refresh-source`, 'POST', { baseVersionNo: 3, changeSummary: '已删源拒绝' }, 500)
  const edited = await api(page, `formation-report/${id}/versions`, 'POST', { name: '删源后普通编辑', description: '', baseVersionNo: 3, changeSummary: '快照编辑', settings: v3.settings })
  expect(edited.items).toEqual(v3.items)
  await page.context().addInitScript(() => { window.print = () => { document.body.dataset.printRequested = 'true' } })
  for (const version of [1, 2]) {
    await page.goto(`/report-formation/${id}/edit?mode=view&version=${version}`)
    // 查看时切换循环不是可编辑草稿,不应阻止指定保存版打印。
    await page.locator('.curve-cycle-select').click()
    await page.getByRole('option', { name: '循环1', exact: true }).click()
    const popupPromise = page.waitForEvent('popup')
    await page.getByRole('button', { name: '打印 / 另存 PDF', exact: true }).click()
    const popup = await popupPromise
    await expect(popup.locator('body')).toHaveAttribute('data-print-requested', 'true')
    await expect(popup.locator('.chart-card')).toHaveCount(version === 1 ? 20 : 16)
    await expect(popup.locator('[data-cycle="1"] .matrix-table').first()).toContainText('4.111 V')
    await expect(popup.locator('[data-cycle="1"] .matrix-table').first()).toContainText('4.222 V')
    await expect(popup.locator('[data-cycle="1"] .matrix-table').last()).toContainText('4.222 V')
    await expect(popup.locator('[data-cycle="3"] .matrix-table').first()).toContainText('缺失')
    await expect(popup.locator('.hidden-series').first()).toContainText('循环1 工步2')
    await popup.pdf({ path: `tests/e2e/.artifacts/formation-steps-v${version}.pdf`, preferCSSPageSize: true, printBackground: true })
    await popup.screenshot({ path: `tests/e2e/.artifacts/formation-steps-print-v${version}.png`, fullPage: true })
    await popup.close()
  }
  // 隔离库模拟真实旧版整循环快照;读取、编辑和打印均不得静默补入新工步范围。
  stack.executeIsolatedSql(`UPDATE t_formation_report_version SET snapshot_json=JSON_REMOVE(snapshot_json,'$.curveMode',${batteries.map((_, index) => `'$.items[${index}].stepCurves'`).join(',')},'$.settings.manualChargeSteps','$.settings.curveStepSelections','$.settings.hiddenSeriesKeys','$.settings.activeCycle') WHERE report_id=${id} AND version_no=1`)
  const legacy = await api(page, `formation-report/${id}/versions/1`)
  await page.goto(`/report-formation/${id}/edit?mode=view&version=1`)
  await expect(page.locator('.step-curve-group')).toHaveCount(0)
  await expect(page.locator('.chart-card')).toHaveCount(5)
  const legacyPopupPromise = page.waitForEvent('popup')
  await page.getByRole('button', { name: '打印 / 另存 PDF', exact: true }).click()
  const legacyPopup = await legacyPopupPromise
  await expect(legacyPopup.locator('.chart-card')).toHaveCount(10)
  await expect(legacyPopup.locator('[data-group]')).toHaveCount(0)
  await legacyPopup.close()
  expect((await api(page, `formation-report/${id}/versions/1/print`)).items).toEqual(legacy.items)
  const template = await api(page, 'formation-report/templates', 'POST', { name: '工步字段结构模板', fields })
  const templates = await api(page, 'formation-report/templates')
  expect(templates.find((item: any) => item.id === template).fields).toEqual(fields.map(field => ({ ...field, fieldDefinitionId: null })))
  const modules = await api(page, 'menu/permissionList')
  const permissions = modules.flatMap((module: any) => module.pages).find((item: any) => item.pageName === '化成报表').permissions
  const menuIds = permissions.filter((item: any) => item.menuName === '查看').map((item: any) => item.menuId)
  await api(page, 'role', 'POST', { roleName: 'step_reader', roleKey: 'common', roleSort: 1, status: '0', menuIds })
  const roleList = await api(page, 'role/list?roleName=step_reader')
  await api(page, 'user', 'POST', { userName: 'step_reader', nickName: '工步只读', password: 'Report123!', status: '0', roleIds: [roleList.rows[0].roleId] })
  await page.evaluate(() => { localStorage.removeItem('battery_detection_auth_token'); localStorage.removeItem('battery_detection_auth_user') })
  await login(page, 'step_reader')
  await api(page, `formation-report/${id}/versions/1/print`, 'GET', undefined, 403)
  await api(page, 'formation-report', 'POST', base, 403)
  await api(page, `formation-report/${id}`, 'DELETE', undefined, 403)
  await api(page, `formation-report/${id}/versions`, 'POST', { name: '拒绝', description: '', baseVersionNo: 4, changeSummary: '拒绝', settings: v3.settings }, 403)
  expect(errors).toEqual([])
})