yeg
2026-09-08 cfd4511378aefa04d4e06058cee628b6edd8e9c1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
import { init, use } from 'echarts/core'
import { LineChart } from 'echarts/charts'
import { GridComponent, LegendComponent } from 'echarts/components'
import { SVGRenderer } from 'echarts/renderers'
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'
import { reportDetailColumn, reportDetailsHtml } from './reportTestDetails'
 
use([LineChart, GridComponent, LegendComponent, SVGRenderer])
function escapeHtml(value: unknown) {
  const entities: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
  return String(value ?? '').replace(/[&<>"']/g, character => entities[character]!)
}
 
function printHtml(record: SavedFormationReport) {
  const colors = record.settings.seriesColors ?? {}
  const items = [...new Set(record.items.map(item => item.source.selection.testId))].flatMap(id => record.items.filter(item => item.source.selection.testId === id))
  const cell = (value: unknown) => `<td>${escapeHtml(value == null || value === '' ? '缺失' : value)}</td>`
  const tables = (cycle: number) => {
    if (!cycle) return reportDetailsHtml(items.map(item => reportDetailColumn(item.source)), record.settings.sourceDetailOverrides)
    const result: string[] = []
    // 每组最多四列,按原测试分组顺序打印全部矩阵;长表重复列头并允许跨页。
    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 && 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')) {
        rows.push([formationFieldLabel(field), ...group.map(item => value(item, () =>
          record.settings.values[formationValueKey(item, cycle, field)] ?? formationSourceValue(item, cycle, field)))])
      }
      // 循环指标缺失继续使用缺失标记,人工详情由独立详情表保留空字符串。
      result.push(`<table class="matrix-table"><thead><tr><th>${cycle ? `循环${cycle} / 测试` : '测试'}</th>${group.map(item => `<th>${escapeHtml(item.source.name)}</th>`).join('')}</tr><tr><th>电池组 / 电池号</th>${group.map(item => `<th>${escapeHtml(item.source.batteryGroup)} / ${escapeHtml(item.source.batteryNo)}</th>`).join('')}</tr></thead><tbody>${rows.map(row => `<tr>${row.map(value => cell(value)).join('')}</tr>`).join('')}</tbody></table>`)
    }
    return result.join('')
  }
  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 })
      let svg: string
      try { chart.setOption({ ...formationChartOption(metric, record.items, cycle, colors), tooltip: { show: false } }); svg = chart.renderToSVGString() }
      finally { chart.dispose() }
      return `<article class="chart-card"><h3>循环${cycle} · ${escapeHtml(metric.title)} (${escapeHtml(metric.unit)})</h3>${svg}<div class="legend">${legend}</div></article>`
    }).join('') : ''
    return `<section class="cycle-section" data-cycle="${cycle}"><h2>循环${cycle}</h2>${tables(cycle)}<div class="charts">${charts || `<p>${available ? '当前未启用曲线指标' : record.items.some(item => item.curves) ? '该循环暂无已保存实时数据' : '该版本未保存曲线数据'}</p>`}</div></section>`
  }).join('')
  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}.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>`
}
 
/** 指定当前或历史保存版本打印,不读取页面草稿和来源测试。 */
export function openFormationSavedReportPrint(id: number, versionNo: number) {
  return openSavedReportPrint(() => getFormationPrintVersion(id, versionNo), printHtml)
}