import type { CompareMetricKey, CompareReportRecord, CompareTestSelection } from '@/stores/compareReports'
|
|
export interface CompareMetricDefinition {
|
key: CompareMetricKey
|
title: string
|
unit: string
|
values: number[][]
|
}
|
|
export interface CompareReportPrintData {
|
name: string
|
reportId?: number
|
creator?: string
|
createdAt?: string
|
experimentName: string
|
batteryGroup: string
|
tests: CompareTestSelection[]
|
detailTests: CompareTestSelection[]
|
fields: string[]
|
detailFieldValues: Record<string, Record<string, string>>
|
metrics: CompareMetricKey[]
|
seriesColors: string[]
|
}
|
|
export const compareMetricDefinitions: CompareMetricDefinition[] = [
|
{ key: 'voltage', title: '电压', unit: 'V', values: [[1.25, 1.34, 1.46, 1.42, 1.48, 1.55, 1.58], [1.12, 1.20, 1.31, 1.28, 1.36, 1.31, 1.28], [1.38, 1.43, 1.49, 1.47, 1.51, 1.56, 1.62]] },
|
{ key: 'current', title: '电流', unit: 'A', values: [[1.10, 1.15, 1.23, 1.20, 1.26, 1.30, 1.38], [0.94, 1.02, 1.11, 1.06, 1.10, 1.08, 1.04], [1.18, 1.24, 1.29, 1.27, 1.34, 1.39, 1.48]] },
|
{ key: 'positiveVoltage', title: '正电位', unit: 'V', values: [[1.30, 1.37, 1.48, 1.45, 1.52, 1.57, 1.61], [1.16, 1.25, 1.34, 1.30, 1.39, 1.35, 1.30], [1.40, 1.45, 1.50, 1.48, 1.54, 1.59, 1.65]] },
|
{ key: 'negativeVoltage', title: '负电位', unit: 'V', values: [[0.82, 0.90, 0.98, 0.95, 1.02, 1.08, 1.11], [0.66, 0.74, 0.82, 0.78, 0.86, 0.83, 0.80], [0.90, 0.96, 1.04, 1.01, 1.08, 1.13, 1.18]] },
|
{ key: 'temperature', title: '温度', unit: '℃', values: [[25, 27, 30, 29, 32, 35, 38], [25, 26, 29, 31, 34, 37, 41], [25, 28, 31, 30, 33, 36, 39]] },
|
]
|
|
const defaultBatteryFields = ['额定容量', '电流', '内阻']
|
const defaultSpecialFields = ['温升']
|
const defaultSeriesColors = ['#3978f6', '#7d42d8', '#f08a3e']
|
|
export function compareDetailKey(item: CompareTestSelection, batteryGroup: string) {
|
return `${item.id}|${item.batteryGroup || batteryGroup}|${item.batteryNo}`
|
}
|
|
export function uniqueCompareDetailTests(tests: CompareTestSelection[], batteryGroup: string) {
|
const seen = new Set<string>()
|
return tests.filter(item => {
|
const key = compareDetailKey(item, batteryGroup)
|
if (seen.has(key)) return false
|
seen.add(key)
|
return true
|
})
|
}
|
|
export function createCompareReportPrintData(record: CompareReportRecord): CompareReportPrintData {
|
const batteryGroup = record.batteryGroup || '电池组1'
|
const fallbackTests: CompareTestSelection[] = [
|
{ id: 100000 + record.id * 2, name: record.baselineTest, type: '化成', batteryGroup, batteryNo: record.batterySamples?.[0] || 'SP26-3', process: '恒流充电', cycle: 3 },
|
{ id: 100001 + record.id * 2, name: record.comparisonTest, type: '化成', batteryGroup, batteryNo: record.batterySamples?.[1] || 'SP26-4', process: '恒流放电', cycle: 3 },
|
]
|
const tests = (record.compareTests?.length ? record.compareTests : fallbackTests).map(item => ({ ...item }))
|
const fields = [
|
...(record.batteryTemplateFields ? record.batteryTemplateFields : defaultBatteryFields),
|
...(record.specialFields ? record.specialFields : defaultSpecialFields),
|
]
|
|
return {
|
name: record.name,
|
reportId: record.id,
|
creator: record.creator,
|
createdAt: record.createdAt,
|
experimentName: record.description.split(' · ')[0] || 'XXX实验',
|
batteryGroup,
|
tests,
|
detailTests: uniqueCompareDetailTests(tests, batteryGroup),
|
fields,
|
detailFieldValues: record.detailFieldValues || {},
|
metrics: record.metrics ? [...record.metrics] : compareMetricDefinitions.map(metric => metric.key),
|
seriesColors: record.seriesColors?.length ? [...record.seriesColors] : [...defaultSeriesColors],
|
}
|
}
|
|
function escapeHtml(value: unknown) {
|
const entities: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
return String(value ?? '').replace(/[&<>"']/g, character => entities[character])
|
}
|
|
function safeColor(value: string | undefined, index: number) {
|
return value && /^#[0-9a-f]{6}$/i.test(value) ? value : defaultSeriesColors[index % defaultSeriesColors.length]
|
}
|
|
function timeLabel(index: number) {
|
const hours = Math.floor(index / 60)
|
const minutes = String(index % 60).padStart(2, '0')
|
return `${hours}:${minutes}`
|
}
|
|
function createChartSvg(metric: CompareMetricDefinition, data: CompareReportPrintData) {
|
const chartTests = data.detailTests.slice(0, 3)
|
const series = chartTests.map((item, index) => ({
|
name: `${item.name} · ${item.batteryNo}`,
|
color: safeColor(data.seriesColors[index], index),
|
values: metric.values[index] || metric.values[0],
|
}))
|
const values = series.flatMap(item => item.values)
|
const dataMax = Math.max(0, ...values)
|
const axisMax = Math.max(metric.unit === '℃' ? Math.ceil(dataMax / 10) * 10 : Math.ceil(dataMax * 10) / 10, 1)
|
const width = 680
|
const height = 260
|
const left = 54
|
const right = 626
|
const top = 32
|
const bottom = 214
|
const plotWidth = right - left
|
const plotHeight = bottom - top
|
const pointCount = Math.max(1, ...series.map(item => item.values.length))
|
const yTicks = Array.from({ length: 6 }, (_, index) => axisMax * index / 5)
|
const xTicks = Array.from({ length: pointCount }, (_, index) => index)
|
const yCoordinate = (value: number) => bottom - (value / axisMax) * plotHeight
|
const xCoordinate = (index: number) => pointCount === 1 ? left : left + index * plotWidth / (pointCount - 1)
|
|
const gridLines = yTicks.map(value => {
|
const y = yCoordinate(value)
|
const label = metric.unit === '℃' ? Math.round(value) : Number(value.toFixed(2))
|
return `<line x1="${left}" y1="${y}" x2="${right}" y2="${y}" stroke="#e8eef4"/><text x="${left - 8}" y="${y + 4}" text-anchor="end">${label}</text><text x="${right + 8}" y="${y + 4}" text-anchor="start">${label}</text>`
|
}).join('')
|
const xLabels = xTicks.map(index => `<text x="${xCoordinate(index)}" y="${bottom + 20}" text-anchor="middle">${timeLabel(index)}</text>`).join('')
|
const lines = series.map(item => {
|
const points = item.values.map((value, index) => `${xCoordinate(index)},${yCoordinate(value)}`).join(' ')
|
return `<polyline points="${points}" fill="none" stroke="${item.color}" stroke-width="2.2" stroke-linejoin="round" stroke-linecap="round"/>`
|
}).join('')
|
|
return `<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="${escapeHtml(metric.title)}曲线">
|
<g fill="#7b8794" font-family="Microsoft YaHei, sans-serif" font-size="10">
|
${gridLines}
|
${xLabels}
|
<text x="${left}" y="18">${escapeHtml(metric.unit)}</text>
|
<text x="${right}" y="18" text-anchor="end">${escapeHtml(metric.unit)}</text>
|
<text x="${(left + right) / 2}" y="252" text-anchor="middle" fill="#405261">时间/h:mm</text>
|
</g>
|
<line x1="${left}" y1="${top}" x2="${left}" y2="${bottom}" stroke="#cbd8e4"/>
|
<line x1="${right}" y1="${top}" x2="${right}" y2="${bottom}" stroke="#cbd8e4"/>
|
<line x1="${left}" y1="${bottom}" x2="${right}" y2="${bottom}" stroke="#cbd8e4"/>
|
${lines}
|
</svg>`
|
}
|
|
function createPrintHtml(data: CompareReportPrintData) {
|
const enabledMetrics = compareMetricDefinitions.filter(metric => data.metrics.includes(metric.key))
|
const chartTests = data.detailTests.slice(0, 3)
|
const compareRows = data.tests.length
|
? data.tests.map(item => `<tr><td>${escapeHtml(item.name)}</td><td>${escapeHtml(item.type)}</td><td>${escapeHtml(item.batteryNo)}</td><td>${escapeHtml(item.process)}</td><td>${escapeHtml(item.cycle)}</td></tr>`).join('')
|
: '<tr><td colspan="5" class="empty">暂无对比测试</td></tr>'
|
const detailHeader = data.detailTests.map((_, index) => `<th>对比项 ${index + 1}</th>`).join('')
|
const detailCell = (item: CompareTestSelection, value: unknown) => `<td data-key="${escapeHtml(compareDetailKey(item, data.batteryGroup))}">${escapeHtml(value || '-')}</td>`
|
const fixedDetailRows = [
|
`<tr><td>测试</td>${data.detailTests.map(item => detailCell(item, item.name)).join('')}</tr>`,
|
`<tr><td>电池组</td>${data.detailTests.map(item => detailCell(item, item.batteryGroup || data.batteryGroup)).join('')}</tr>`,
|
`<tr><td>电池号</td>${data.detailTests.map(item => detailCell(item, item.batteryNo)).join('')}</tr>`,
|
].join('')
|
const fieldRows = data.fields.map(field => `<tr><td>${escapeHtml(field)}</td>${data.detailTests.map(item => detailCell(item, data.detailFieldValues[field]?.[compareDetailKey(item, data.batteryGroup)] || '-')).join('')}</tr>`).join('')
|
const detailRows = data.detailTests.length ? `${fixedDetailRows}${fieldRows}` : '<tr><td colspan="2" class="empty">暂无测试详情</td></tr>'
|
const legend = chartTests.map((item, index) => `<span><i style="background:${safeColor(data.seriesColors[index], index)}"></i>${escapeHtml(item.name)} · ${escapeHtml(item.batteryNo)}</span>`).join('')
|
const charts = enabledMetrics.map(metric => `<article class="chart-card"><header><strong>${escapeHtml(metric.title)}</strong></header>${createChartSvg(metric, data)}<div class="chart-inner-legend">${legend}</div></article>`).join('')
|
const savedMeta = data.reportId ? `<span>报告ID <strong>#${data.reportId}</strong></span><span>创建人 <strong>${escapeHtml(data.creator || '-')}</strong></span><span>创建时间 <strong>${escapeHtml(data.createdAt || '-')}</strong></span>` : ''
|
|
return `<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><title>${escapeHtml(data.name)}</title><style>
|
@page{size:A4 landscape;margin:10mm}*{box-sizing:border-box}body{margin:0;color:#26333f;background:#fff;font-family:"Microsoft YaHei",sans-serif;font-size:11px;-webkit-print-color-adjust:exact;print-color-adjust:exact}.report-header{display:flex;align-items:flex-end;justify-content:space-between;gap:20px;margin-bottom:16px;padding-bottom:12px;border-bottom:2px solid #2b78a3}.report-header h1{margin:0;font-size:22px}.report-header p{margin:5px 0 0;color:#71808d}.saved-meta{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px 18px;color:#7b8794}.saved-meta strong{margin-left:5px;color:#405261}.section{margin:0 0 16px;break-inside:auto}.section-title{display:flex;align-items:center;gap:8px;height:34px;margin:0;padding:0 10px;border:1px solid #d8e1e9;border-bottom:0;background:#f4f8fb;font-size:13px}.section-title:before{width:3px;height:15px;background:#2b78a3;content:""}.experiment{padding:10px 12px;border:1px solid #d8e1e9;border-bottom:0;color:#52606d}.experiment strong{margin-left:12px;color:#26333f}table{width:100%;border-collapse:collapse;table-layout:fixed}th,td{height:32px;padding:6px 9px;border:1px solid #cbd8e4;text-align:center;overflow-wrap:anywhere}th{color:#fff;background:#3c6497;font-weight:600}.detail-table th:first-child,.detail-table td:first-child{width:130px;text-align:left}.detail-table td:first-child{background:#f7fafc;font-weight:600}.empty{height:64px;color:#8795a3}.curve-header{display:flex;align-items:center;gap:18px;min-height:38px;padding:8px 10px;border:1px solid #d8e1e9;border-bottom:0}.curve-header strong{font-size:13px}.legend{display:flex;flex-wrap:wrap;gap:8px 18px}.legend span,.chart-inner-legend span{display:inline-flex;align-items:center;gap:6px;color:#52606d}.legend i,.chart-inner-legend i{width:12px;height:12px;border:1px solid rgba(38,51,63,.18);display:inline-block}.charts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;padding:10px;border:1px solid #d8e1e9}.chart-card{position:relative;min-width:0;border:1px solid #d8e1e9;break-inside:avoid}.chart-card header{height:32px;display:flex;align-items:center;padding:0 10px;border-bottom:1px solid #edf1f5}.chart-card svg{display:block;width:100%;height:auto}.chart-inner-legend{display:flex;flex-wrap:wrap;gap:5px 12px;padding:0 10px 9px;font-size:9px}.no-chart{grid-column:1/-1;padding:30px;text-align:center;color:#8795a3}@media print{.section-title,.curve-header,th,.detail-table td:first-child{print-color-adjust:exact;-webkit-print-color-adjust:exact}}
|
</style></head><body>
|
<header class="report-header"><div><h1>${escapeHtml(data.name)}</h1><p>检测室数据可视化系统 · 对比报表</p></div><div class="saved-meta">${savedMeta}</div></header>
|
<section class="section"><h2 class="section-title">对比信息</h2><table><thead><tr><th>测试名称</th><th>测试类型</th><th>电池号</th><th>工步</th><th>循环</th></tr></thead><tbody>${compareRows}</tbody></table></section>
|
<section class="section"><h2 class="section-title">测试详情</h2><div class="experiment">实验<strong>${escapeHtml(data.experimentName || '-')}</strong></div><table class="detail-table"><thead><tr><th>字段</th>${detailHeader}</tr></thead><tbody>${detailRows}</tbody></table></section>
|
<section class="section"><div class="curve-header"><strong>对比曲线</strong><div class="legend">${legend}</div></div><div class="charts">${charts || '<div class="no-chart">当前未启用曲线指标</div>'}</div></section>
|
</body></html>`
|
}
|
|
export function openCompareReportPrint(data: CompareReportPrintData) {
|
const popup = window.open('', '_blank', 'width=1200,height=820')
|
if (!popup) return false
|
popup.document.open()
|
popup.document.write(createPrintHtml(data))
|
popup.document.close()
|
window.setTimeout(() => {
|
popup.focus()
|
popup.print()
|
}, 250)
|
return true
|
}
|