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, spliceSourceValue, spliceExperiment, 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> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
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 fields = spliceFields(record.settings)
|
const headers = segments.map((_, index) => `<th>${cell(sourceName(index))}</th>`).join('')
|
const details = `<section><h2>测试详情</h2><table><thead><tr><th>字段</th>${headers}</tr></thead><tbody><tr><td>实验</td>${segments.map(segment => `<td>${escapeHtml(spliceExperiment(segment, record.settings))}</td>`).join('')}</tr>${fields.map(field => `<tr><td>${cell(field.displayName)}${field.unit ? ` (${cell(field.unit)})` : ''}</td>${segments.map(segment => `<td>${escapeHtml(spliceSourceValue(segment, record.settings, field))}</td>`).join('')}</tr>`).join('')}</tbody></table></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>`)
|
}
|