yeg
4 天以前 f4d7007b89cf565746af4623623a294b82666ff1
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
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
import type { FormationReportCycle, FormationReportMetricKey, FormationReportRecord } from '@/stores/formationReports'
 
export type FormationMetricKey = FormationReportMetricKey
 
export interface FormationMetricDefinition {
  key: FormationMetricKey
  title: string
  unit: string
  values: number[][]
}
 
export interface FormationCycleFieldDefinition {
  key: string
  label: string
  mode: 'input' | 'raw' | 'auto'
}
 
export interface FormationReportPrintData {
  name: string
  reportId?: number
  creator?: string
  createdAt?: string
  testName: string
  batteryGroup: string
  samples: string[]
  cycles: FormationReportCycle[]
  cycleFieldValues: Record<string, string>
  reportFieldValues: Record<string, string>
  batteryFields: string[]
  specialFields: string[]
  metrics: FormationMetricKey[]
  seriesColors: string[]
  description: string
}
 
export const formationMetricDefinitions: FormationMetricDefinition[] = [
  { key: 'voltage', title: '电压', unit: 'V', values: [[3.18, 3.35, 3.62, 3.86, 4.02, 4.18, 4.2], [3.2, 3.37, 3.64, 3.88, 4.04, 4.2, 4.22], [3.16, 3.33, 3.6, 3.84, 4.0, 4.16, 4.18]] },
  { key: 'current', title: '电流', unit: 'A', values: [[1.2, 1.18, 1.14, 1.08, 0.92, 0.6, 0.2], [1.24, 1.21, 1.17, 1.11, 0.95, 0.63, 0.23], [1.18, 1.16, 1.12, 1.06, 0.9, 0.58, 0.18]] },
  { key: 'positiveVoltage', title: '正电位', unit: 'V', values: [[1.82, 1.94, 2.18, 2.42, 2.66, 2.88, 3.02], [1.84, 1.96, 2.2, 2.44, 2.68, 2.9, 3.04], [1.8, 1.92, 2.16, 2.4, 2.64, 2.86, 3.0]] },
  { key: 'negativeVoltage', title: '负电位', unit: 'V', values: [[1.36, 1.41, 1.44, 1.46, 1.48, 1.5, 1.52], [1.38, 1.43, 1.46, 1.48, 1.5, 1.52, 1.54], [1.34, 1.39, 1.42, 1.44, 1.46, 1.48, 1.5]] },
  { key: 'temperature', title: '温度', unit: '℃', values: [[25, 26, 27, 29, 31, 33, 34], [25, 26.5, 27.5, 29.5, 31.5, 33.5, 34.5], [24.5, 25.5, 26.5, 28.5, 30.5, 32.5, 33.5]] },
]
 
export const formationFixedReportFields = [
  { key: 'Z', label: 'Z', mode: 'raw' as const },
  { key: 'H', label: 'H', mode: 'raw' as const },
  { key: 'F', label: 'F', mode: 'raw' as const },
  { key: 'A', label: 'A', mode: 'raw' as const },
  { key: 'B', label: 'B', mode: 'raw' as const },
  { key: 'FN', label: 'F/N', mode: 'auto' as const },
  { key: 'C', label: 'C', mode: 'auto' as const },
]
 
export const formationCycleFields: FormationCycleFieldDefinition[] = [
  { key: 'method2', label: '测试方法2', mode: 'input' },
  { key: 'charge005', label: '0.05C充电终止电压', mode: 'raw' },
  { key: 'charge01', label: '0.1C充电终止电压', mode: 'raw' },
  { key: 'manual', label: '数字', mode: 'input' },
  { key: 'chargeEnergy', label: '每克瓦充电量', mode: 'auto' },
  { key: 'chargeEnergyTotal', label: '每克瓦充电量', mode: 'auto' },
  { key: 'capacity1v', label: '1.0V容量', mode: 'raw' },
  { key: 'capacity1vf', label: '1.0VF完整容量', mode: 'auto' },
  { key: 'discharge', label: '拐点容量', mode: 'raw' },
  { key: 'dischargeTotal', label: '拐点完整容量', mode: 'auto' },
]
 
const defaultSeriesColors = ['#3978f6', '#7d42d8', '#f08a3e']
 
export function createFormationReportPrintData(record: FormationReportRecord): FormationReportPrintData {
  return {
    name: record.name,
    reportId: record.id,
    creator: record.creator,
    createdAt: record.createdAt,
    testName: record.testName,
    batteryGroup: record.batteryGroup,
    samples: [...record.samples],
    cycles: record.cycles.map(cycle => ({ ...cycle })),
    cycleFieldValues: { ...(record.cycleFieldValues || {}) },
    reportFieldValues: { ...(record.reportFieldValues || {}) },
    batteryFields: [...(record.batteryFields || [])],
    specialFields: [...(record.specialFields || [])],
    metrics: record.metrics ? [...record.metrics] : formationMetricDefinitions.map(metric => metric.key),
    seriesColors: record.seriesColors?.length ? [...record.seriesColors] : [...defaultSeriesColors],
    description: record.description,
  }
}
 
function escapeHtml(value: unknown) {
  const entities: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
  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) {
  return `${Math.floor(index / 2)}:${index % 2 ? '30' : '00'}`
}
 
function rawReportValue(fieldIndex: number, sampleIndex: number) {
  return (1.02 + fieldIndex * 0.06 + sampleIndex * 0.03).toFixed(2)
}
 
function rawCycleValue(cycle: number, fieldIndex: number, sampleIndex: number) {
  return (3.76 + cycle * 0.03 + fieldIndex * 0.018 + sampleIndex * 0.012).toFixed(3)
}
 
function createChartSvg(metric: FormationMetricDefinition, cycle: FormationReportCycle, data: FormationReportPrintData) {
  const samples = (data.samples.length ? data.samples : ['样品1', '样品2', '样品3']).slice(0, 3)
  const cycleOffset = Math.max(0, cycle.cycle - 1) * 0.012
  const series = samples.map((sample, index) => ({
    name: sample,
    color: safeColor(data.seriesColors[index], index),
    values: (metric.values[index] || metric.values[0]).map(value => Number((value + cycleOffset).toFixed(3))),
  }))
  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 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 = Array.from({ length: pointCount }, (_, 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: FormationReportPrintData) {
  const samples = data.samples.length ? data.samples : ['样品1', '样品2', '样品3']
  const columns = [
    { id: 1, name: data.testName, samples },
    { id: 2, name: `${data.testName} · 对照`, samples: [...samples] },
  ]
  const reportSamples = columns.flatMap(column => column.samples)
  const detailFields = [...formationFixedReportFields, ...data.batteryFields.map(field => ({ key: `battery:${field}`, label: field, mode: 'input' as const })), ...data.specialFields.map(field => ({ key: `special:${field}`, label: field, mode: 'input' as const }))]
  const reportHead = `<tr><th>电池组</th>${columns.map(column => `<th colspan="${column.samples.length}"><span class="group-index">${column.id}</span>${escapeHtml(column.name)}</th>`).join('')}</tr><tr><th>电池号</th>${reportSamples.map(sample => `<th>${escapeHtml(sample)}</th>`).join('')}</tr>`
  const reportRows = detailFields.map((field, fieldIndex) => {
    const cells = columns.flatMap((column, columnIndex) => column.samples.map((sample, sampleIndex) => {
      if (field.mode === 'raw') return `<td>${rawReportValue(fieldIndex, sampleIndex + columnIndex)}</td>`
      if (field.mode === 'auto') return '<td class="auto-value">自动计算</td>'
      const key = `${field.key}|${column.id}-${sample}`
      return `<td>${escapeHtml(data.reportFieldValues[key] || '-')}</td>`
    }))
    return `<tr><td>${escapeHtml(field.label)}</td>${cells.join('')}</tr>`
  }).join('')
  const samplesHeader = reportSamples.map(sample => `<th>${escapeHtml(sample)}</th>`).join('')
  const cycleSections = data.cycles.map(cycle => {
    const cycleRows = [
      `<tr><td>测试方法</td>${reportSamples.map(() => `<td>${escapeHtml(cycle.method || '-')}</td>`).join('')}</tr>`,
      ...formationCycleFields.map((field, fieldIndex) => `<tr><td>${escapeHtml(field.label)}</td>${reportSamples.map((sample, sampleIndex) => {
        if (field.mode === 'raw') return `<td>${rawCycleValue(cycle.cycle, fieldIndex, sampleIndex)}</td>`
        if (field.mode === 'auto') return '<td class="auto-value">自动计算</td>'
        const key = `${cycle.cycle}|${field.key}|${sample}-${sampleIndex}`
        return `<td>${escapeHtml(data.cycleFieldValues[key] || '-')}</td>`
      }).join('')}</tr>`),
    ].join('')
    const enabledMetrics = formationMetricDefinitions.filter(metric => data.metrics.includes(metric.key))
    const legend = samples.slice(0, 3).map((sample, index) => `<span><i style="background:${safeColor(data.seriesColors[index], index)}"></i>${escapeHtml(sample)}</span>`).join('')
    const charts = enabledMetrics.map(metric => `<article class="chart-card"><header><strong>${escapeHtml(metric.title)}</strong><span>${escapeHtml(metric.unit)}</span></header>${createChartSvg(metric, cycle, data)}<div class="chart-inner-legend">${legend}</div></article>`).join('')
    return `<section class="cycle-section"><h3>循环${cycle.cycle}</h3><table class="cycle-table"><thead><tr><th>指标字段</th>${samplesHeader}</tr></thead><tbody>${cycleRows}</tbody></table><div class="curve-header"><strong>循环${cycle.cycle}曲线</strong><div class="legend">${legend}</div></div><div class="charts">${charts || '<div class="no-chart">当前未启用曲线指标</div>'}</div></section>`
  }).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-title,.cycle-section h3{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,.cycle-section h3:before{width:3px;height:15px;background:#2b78a3;content:""}.section{margin:0 0 16px}.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:31px;padding:5px 8px;border:1px solid #cbd8e4;text-align:center;overflow-wrap:anywhere}th{color:#fff;background:#3c6497;font-weight:600}.report-table th:first-child,.report-table td:first-child,.cycle-table th:first-child,.cycle-table td:first-child{width:150px;text-align:left}.report-table td:first-child,.cycle-table td:first-child{color:#31404d;background:#f2f7fb;font-weight:600}.group-index{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;margin-right:7px;border:1px solid #cbd8e4;border-radius:50%;color:#647380;background:#fff;font-size:9px}.auto-value{color:#2188f5;font-weight:500}.cycle-section{margin:0 0 18px;break-inside:auto}.cycle-section h3{border-bottom:1px solid #d8e1e9}.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:30px;display:flex;align-items:center;justify-content:space-between;padding:0 10px;border-bottom:1px solid #edf1f5}.chart-card header span{color:#8795a3;font-size:10px}.chart-card svg{display:block;width:100%;height:auto}.chart-inner-legend{display:flex;flex-wrap:wrap;gap:5px 12px;padding:0 10px 8px;font-size:9px}.no-chart{grid-column:1/-1;padding:30px;text-align:center;color:#8795a3}.note{padding:10px 12px;border:1px solid #d8e1e9;background:#f7fafc;white-space:pre-wrap}@media print{.section-title,.cycle-section h3,th,.report-table td:first-child,.cycle-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><div class="experiment">测试<strong>${escapeHtml(data.testName)}</strong><span class="experiment-gap">电池组<strong>${escapeHtml(data.batteryGroup)}</strong></span></div><table class="report-table"><thead>${reportHead}</thead><tbody>${reportRows || '<tr><td colspan="2" class="empty">暂无报表字段</td></tr>'}</tbody></table></section>${cycleSections || '<section class="section"><div class="note">暂无化成循环数据</div></section>'}<section class="section"><h2 class="section-title">报表说明</h2><div class="note">${escapeHtml(data.description || '暂无说明')}</div></section></body></html>`
}
 
export function openFormationReportPrint(data: FormationReportPrintData) {
  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
}