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
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
import { reportDetailsHtml } from './reportTestDetails'
import { init, use } from 'echarts/core'
import { LineChart } from 'echarts/charts'
import { GridComponent, MarkLineComponent } from 'echarts/components'
import { SVGRenderer } from 'echarts/renderers'
import { getSplicePrintVersion, spliceFields, spliceFieldValue, spliceStatisticKey, spliceReportDetailColumn, 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, chartsOnly = false) {
  if (record.spliceMode === 'battery-segments') return segmentReportBody(record, chartsOnly)
  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 = spliceFields(record.settings)
  // 早期版本的五类实时显隐键不适用于循环统计,不能据此补造测量曲线。
  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.displayName)}${field.unit ? ` (${escapeHtml(field.unit)})` : ''}</td><td>${cell(spliceFieldValue(record.segments, record.settings, 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 => {
        const override = record.settings.statisticValues?.[spliceStatisticKey(segment, no, metric.key)]
        return `<td>${override === undefined ? display(stat[metric.key]) : `${cell(override)}<br><small>计算原值:${display(stat[metric.key])}</small>`}</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 = sources.flatMap(testId => {
        // 各电池可能缺少段首循环,来源线取本图参与电池的最早循环,跨图时限定在图内。
        const starts = segments.filter(segment => segment.source.selection.testId === testId)
          .map(segment => segment.offset + segment.originalCycleNos[0]!)
        return starts.length ? [{ name: label(testId), xAxis: Math.max(range[0]!, Math.min(...starts)) }] : []
      })
      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><div class="splice-chart-plot">${svg}</div><div class="splice-legend">${legend}</div><div class="splice-legend">${sourceLegend}</div></article>`)
      }
    }
  }
  const date = (value: unknown) => escapeHtml(new Date(Number(value)).toLocaleString())
  const heading = record.id ? `<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>` : '<p>未保存预览</p>'
  return `<div class="splice-snapshot"><header><h1>${cell(record.name)}</h1>${heading}</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>`
}
 
/** 新版每指标一条完整接续线;旧版仍走原快照呈现,不静默重算历史偏移。 */
function segmentReportBody(record: SavedSpliceReport, chartsOnly: boolean) {
  const segments = record.segments
  const cycles = record.cycleNos
  const savedColor = record.settings.seriesColors?.[0]
  const color = savedColor && /^#[\da-f]{6}$/i.test(savedColor) ? savedColor : '#3978f6'
  const sourceName = (index: number) => `来源${index + 1} · ${segments[index]!.source.name} · ${segments[index]!.source.group} · ${segments[index]!.source.batteryNo}`
  const charts = metrics.filter(metric => !record.settings.metrics || record.settings.metrics.includes(metric.key)).map(metric => {
    if (!cycles.length) return `<article class="splice-chart"><h3>${metric.title}</h3><p>无已保存循环统计</p></article>`
    const chart = init(null, undefined, { renderer: 'svg', ssr: true, width: 900, height: 320 })
    let svg: string
    try {
      chart.setOption({ animation: false, grid: { top: 42, right: 35, bottom: 48, left: 70 },
        xAxis: { type: 'value', min: cycles[0]! - 0.5, max: cycles.at(-1)! + 0.5, minInterval: 1, name: '拼接循环号', nameLocation: 'middle', nameGap: 30, axisLabel: { hideOverlap: true, formatter: (value: number) => Number.isInteger(value) ? String(value) : '' } },
        yAxis: { type: 'value', scale: true, name: metric.unit },
        series: [{ name: '接续曲线', type: 'line', connectNulls: false, showSymbol: true, symbolSize: 5, itemStyle: { color },
          // 人工表格值不进入计算原值曲线;缺值保留断点,全部指标共用循环偏移。
          data: record.settings.hiddenSeries ? [] : segments.flatMap((segment, index) => segment.originalCycleNos.map(no => ({ name: `${sourceName(index)} · 原循环${no} · 拼接循环${no + segment.offset}`, value: [no + segment.offset, numeric(segment.statistics[String(no)]?.[metric.key])] }))),
          markLine: { silent: true, symbol: 'none', lineStyle: { color: '#82909b', type: 'dashed' }, label: { show: false }, data: segments.flatMap((segment, index) => segment.originalCycleNos.length ? [{ name: `来源${index + 1}`, xAxis: segment.originalCycleNos[0]! + segment.offset }] : []) },
        }],
      })
      svg = chart.renderToSVGString()
    } finally { chart.dispose() }
    return `<article class="splice-chart" data-series-count="1"><h3>${metric.title} (${metric.unit})</h3><div class="splice-chart-plot">${svg}</div><div class="splice-legend"><span><i style="background:${color}"></i>接续曲线${record.settings.hiddenSeries ? '(已隐藏)' : ''}</span></div><div class="splice-legend">${segments.map((segment, index) => `<span>${cell(sourceName(index))} · ${segment.originalCycleNos.length ? `循环${segment.originalCycleNos[0]! + segment.offset}~${segment.originalCycleNos.at(-1)! + segment.offset}` : '无已保存循环'}</span>`).join('')}</div></article>`
  }).join('')
  if (chartsOnly) return `<div class="splice-snapshot">${charts}</div>`
  const details = `<section class="source-detail-section"><h2>测试详情</h2>${reportDetailsHtml(segments.map(spliceReportDetailColumn), record.settings.sourceDetailOverrides)}</section>`
  const sources = `<section><h2>来源顺序与循环对应</h2><table><thead><tr><th>来源</th><th>原循环号 → 拼接循环号</th></tr></thead><tbody>${segments.map((segment, index) => `<tr><td>${cell(sourceName(index))}</td><td>${segment.originalCycleNos.map(no => `${no} → ${no + segment.offset}`).join('、') || '无已保存循环'}</td></tr>`).join('')}</tbody></table></section>`
  const statistics = segments.map((segment, index) => `<section class="splice-statistics"><h2>${cell(sourceName(index))}</h2><table><thead><tr><th>原循环号</th><th>拼接循环号</th>${metrics.map(metric => `<th>${metric.title} (${metric.unit})</th>`).join('')}</tr></thead><tbody>${segment.originalCycleNos.map(no => `<tr><td>${no}</td><td>${no + segment.offset}</td>${metrics.map(metric => {
    const original = segment.statistics[String(no)]?.[metric.key]
    const override = record.settings.statisticValues?.[spliceStatisticKey(segment, no, metric.key)]
    return `<td>${override === undefined ? display(original) : `${escapeHtml(override)}<br><small>计算原值:${display(original)}</small>`}</td>`
  }).join('')}</tr>`).join('') || '<tr><td colspan="4">无已保存循环</td></tr>'}</tbody></table></section>`).join('')
  return `<div class="splice-snapshot"><header><h1>${cell(record.name)}</h1><p>拼接报表 #${record.id} · 版本 V${record.versionNo}</p><p>创建人 ${cell(record.creator)} · 版本操作人 ${cell(record.operatorName)}</p><p>修改说明 ${cell(record.changeSummary)}</p></header>${sources}${details}<section><h2>统计图表</h2>${charts || '<p>当前未启用统计图指标</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>`)
}