yeg
2026-09-07 fca70eaab1dcd4b13f31562406d0d6f9312922f1
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
import { init, use } from 'echarts/core'
import { LineChart } from 'echarts/charts'
import { GridComponent, MarkLineComponent } from 'echarts/components'
import { SVGRenderer } from 'echarts/renderers'
import { getSplicePrintVersion, type SavedSpliceReport } from '@/api/spliceReports'
import { openSavedReportPrint } from './savedReportPrint'
import css from './spliceSavedReport.css?inline'
 
use([LineChart, GridComponent, MarkLineComponent, SVGRenderer])
const metrics = [
  { key: 'finalVoltage1', title: '最终电压1', unit: 'V' },
  { key: 'energyEfficiency', title: '能量效率', unit: '%' },
]
function escapeHtml(value: unknown) {
  const entities: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
  return String(value ?? '').replace(/[&<>"']/g, character => entities[character]!)
}
const cell = (value: unknown) => escapeHtml(value == null || value === '' ? '缺失' : value)
const numeric = (value: unknown) => typeof value === 'number' && Number.isFinite(value) ? value : null
const display = (value: unknown) => { const number = numeric(value); return number === null ? '缺失' : String(Number(number.toFixed(4))) }
 
/** 重开与打印共用冻结统计及人工字段,绝不重算或读取来源测试。 */
export function spliceSavedReportBody(record: SavedSpliceReport) {
  const sources = [...new Set(record.segments.map(segment => segment.source.selection.testId))]
  const batteries = [...new Set(record.segments.map(segment => segment.source.batteryNo))]
  const color = (battery: string) => {
    const index = batteries.indexOf(battery)
    const saved = record.settings.seriesColors?.[index]
    return saved && /^#[\da-f]{6}$/i.test(saved) ? saved : ['#3978f6', '#109854', '#d25b45', '#7754a8'][index % 4]!
  }
  const label = (testId: number) => `来源${sources.indexOf(testId) + 1}`
  const fields = [...(record.settings.batteryFields ?? []), ...(record.settings.specialFields ?? [])]
  // 早期版本的五类实时显隐键不适用于循环统计,不能据此补造测量曲线。
  const savedMetrics = record.settings.metrics
  const visibleMetrics = savedMetrics?.some(key => metrics.some(metric => metric.key === key)) || savedMetrics?.length === 0
    ? metrics.filter(metric => savedMetrics!.includes(metric.key)) : metrics
  const sourceRows = record.segments.map(segment => `<tr><td>${label(segment.source.selection.testId)}</td><td>${cell(segment.source.name)}</td><td>${cell(segment.source.group)}</td><td>${cell(segment.source.batteryNo)}</td><td>${segment.originalCycleNos.map(no => `${no} → ${segment.offset + no}`).join('、') || '无已保存循环'}</td></tr>`).join('')
  const fieldRows = batteries.flatMap(battery => fields.map(field => `<tr><td>${cell(battery)}</td><td>${cell(field)}</td><td>${cell(record.settings.detailFieldValues?.[field]?.[battery])}</td></tr>`)).join('')
  const statistics = sources.map(testId => {
    const segments = record.segments.filter(segment => segment.source.selection.testId === testId)
    const rows = segments.flatMap(segment => segment.originalCycleNos.map(no => {
      const stat = segment.statistics[String(no)] ?? {}
      return `<tr><td>${cell(segment.source.batteryNo)}</td><td>${no}</td><td>${segment.offset + no}</td>${metrics.map(metric => `<td>${display(stat[metric.key])}</td>`).join('')}</tr>`
    })).join('')
    const name = `${label(testId)} · ${segments[0]!.source.name}`
    return `<section class="splice-statistics"><h2>${cell(name)}</h2><table><thead><tr><th colspan="5">${cell(name)}</th></tr><tr><th>电池号</th><th>原循环号</th><th>拼接循环号</th><th>最终电压1 (V)</th><th>能量效率 (%)</th></tr></thead><tbody>${rows || '<tr><td colspan="5">无已保存循环</td></tr>'}</tbody></table></section>`
  }).join('')
  const cycles = [...new Set(record.segments.flatMap(segment => segment.originalCycleNos.map(no => segment.offset + no)))].sort((a, b) => a - b)
  const charts: string[] = []
  // 长分段按实际统计点分图,多电池按四条分组;不补缺失循环,所有分组均保留来源标识。
  for (let start = 0; start < cycles.length; start += 40) {
    const range = cycles.slice(start, start + 40)
    for (let batteryStart = 0; batteryStart < batteries.length; batteryStart += 4) {
      const group = batteries.slice(batteryStart, batteryStart + 4)
      const segments = record.segments.filter(segment => group.includes(segment.source.batteryNo) && segment.originalCycleNos.some(no => range.includes(segment.offset + no)))
      const boundaries = [...new Map(segments.map(segment => [segment.source.selection.testId, { name: label(segment.source.selection.testId), xAxis: Math.max(range[0]!, segment.offset + segment.originalCycleNos[0]!) }])).values()]
      const legend = group.map(battery => `<span><i style="background:${color(battery)}"></i>${cell(battery)}</span>`).join('')
      const sourceLegend = [...new Set(segments.map(segment => `${label(segment.source.selection.testId)} · ${segment.source.name}`))].map(name => `<span>${cell(name)}</span>`).join('')
      for (const metric of visibleMetrics) {
        const chart = init(null, undefined, { renderer: 'svg', ssr: true, width: 900, height: 300 })
        let svg: string
        try {
          chart.setOption({ animation: false, grid: { top: 42, right: 28, bottom: 45, left: 72 },
            xAxis: { type: 'value', min: range[0]! - 0.5, max: range.at(-1)! + 0.5, minInterval: 1, name: '拼接循环号', nameLocation: 'middle', nameGap: 28, axisLabel: { hideOverlap: true, formatter: (value: number) => Number.isInteger(value) ? String(value) : '' } },
            yAxis: { type: 'value', scale: true, name: metric.unit },
            series: group.map((battery, index) => ({ name: battery, type: 'line', connectNulls: false, showSymbol: true, symbolSize: 5, itemStyle: { color: color(battery) },
              data: segments.filter(segment => segment.source.batteryNo === battery).flatMap(segment => segment.originalCycleNos.filter(no => range.includes(segment.offset + no)).map(no => [segment.offset + no, numeric(segment.statistics[String(no)]?.[metric.key])])),
              markLine: index === 0 ? { silent: true, symbol: 'none', lineStyle: { color: '#82909b', type: 'dashed' }, label: { formatter: '{b}', position: 'end' }, data: boundaries } : undefined,
            })),
          })
          svg = chart.renderToSVGString()
        } finally { chart.dispose() }
        charts.push(`<article class="splice-chart"><h3>${metric.title} (${metric.unit}) · 拼接循环 ${range[0]}–${range.at(-1)}</h3>${svg}<div class="splice-legend">${legend}</div><div class="splice-legend">${sourceLegend}</div></article>`)
      }
    }
  }
  const date = (value: unknown) => escapeHtml(new Date(Number(value)).toLocaleString())
  return `<div class="splice-snapshot"><header><h1>${cell(record.name)}</h1><p>拼接报表 #${record.id} · 版本 V${record.versionNo}</p><p>创建人 ${cell(record.creator)} · 创建时间 ${date(record.createdAt)}</p><p>版本操作人 ${cell(record.operatorName)} · 版本时间 ${date(record.versionCreatedAt)}</p><p>修改说明 ${cell(record.changeSummary)}</p></header><section><h2>来源测试顺序与循环对应</h2><table><thead><tr><th>顺序</th><th>来源测试</th><th>电池组</th><th>参与电池号</th><th>原循环号 → 拼接循环号</th></tr></thead><tbody>${sourceRows}</tbody></table></section><section><h2>报表字段</h2>${fieldRows ? `<table><thead><tr><th>电池号</th><th>字段</th><th>保存值</th></tr></thead><tbody>${fieldRows}</tbody></table>` : '<p>无已保存字段</p>'}</section><section><h2>统计图表</h2>${charts.join('') || `<p>${cycles.length ? '当前未启用统计图指标' : '无已保存循环统计'}</p>`}</section>${statistics}<section><h2>报表说明</h2><div class="splice-note">${cell(record.description)}</div></section></div>`
}
 
/** 先打开窗口,再使用独立导出权限读取指定版本;页面草稿不进入打印内容。 */
export function openSpliceSavedReportPrint(id: number, versionNo: number) {
  return openSavedReportPrint(() => getSplicePrintVersion(id, versionNo), record => `<!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}body{margin:0;padding:16px;background:white}@media print{body{padding:0}}${css}</style></head><body>${spliceSavedReportBody(record)}</body></html>`)
}