yeg
2026-09-09 1b0179bba53bcc75e8ae358a4a58e8c8fb33c043
src/utils/formationSavedReportPrint.ts
@@ -2,9 +2,11 @@
import { LineChart } from 'echarts/charts'
import { GridComponent, LegendComponent } from 'echarts/components'
import { SVGRenderer } from 'echarts/renderers'
import { formationValueKey, getFormationPrintVersion, type FormationItem, type SavedFormationReport } from '@/api/formationReports'
import { formationChargeChoice, formationFieldLabel, formationSourceValue } from './formationReportFields'
import { formationChartOption, formationCurveMetrics, formationSeriesColor, formationSeriesName } from './formationCycleChart'
import { getFormationPrintVersion, type FormationItem, type FormationField, type SavedFormationReport } from '@/api/formationReports'
import { formationFieldLabel, formationSourceValue } from './formationReportFields'
import { formationCellKey, formationInputValue, formationNumberText } from './formationReportCalculations'
import { formationCurveMetrics } from './formationCycleChart'
import { formationChargeSteps, formationStepGroups, formationStepSeries, formationStepChartOption } from './formationStepChart'
import { openSavedReportPrint } from './savedReportPrint'
use([LineChart, GridComponent, LegendComponent, SVGRenderer])
@@ -13,50 +15,58 @@
  return String(value ?? '').replace(/[&<>"']/g, character => entities[character]!)
}
function printHtml(record: SavedFormationReport) {
  const colors = record.settings.seriesColors ?? {}
  const items = [...new Set(record.items.map(item => item.source.selection.testId))].flatMap(id => record.items.filter(item => item.source.selection.testId === id))
  const cell = (value: unknown) => `<td>${escapeHtml(value == null || value === '' ? '缺失' : value)}</td>`
  const tables = (cycle: number) => {
    const result: string[] = []
    // 每组最多四列,按原测试分组顺序打印全部矩阵;长表重复列头并允许跨页。
    for (let start = 0; start < items.length; start += 4) {
      const group = items.slice(start, start + 4)
      const value = (item: FormationItem, read: () => unknown) => cycle && !item.cycleNos.includes(cycle) ? '无该循环' : read()
      const rows: unknown[][] = cycle ? [
        ['充电工步号', ...group.map(item => value(item, () => formationChargeChoice(item, cycle, record.settings.chargeStepSelections ?? {})?.stepNo ?? '未识别'))],
        ['充电工步名称', ...group.map(item => value(item, () => formationChargeChoice(item, cycle, record.settings.chargeStepSelections ?? {})?.name || '未选择'))],
        ['终止电压 (V)', ...group.map(item => value(item, () => formationChargeChoice(item, cycle, record.settings.chargeStepSelections ?? {})?.endVoltage))],
      ] : []
      for (const field of record.settings.fields.filter(field => field.scope === (cycle ? 'cycle' : 'detail'))) {
        rows.push([formationFieldLabel(field), ...group.map(item => value(item, () =>
          record.settings.values[formationValueKey(item, cycle, field)] ?? formationSourceValue(item, cycle, field)))])
/** 打印只读指定版本的字段、人工输入及计算结果,绝不向当前来源补数。 */
export function formationSavedReportHtml(record: SavedFormationReport) {
  const items = record.items
  const value = (item:FormationItem,cycle:number,field:FormationField) => {
    if(cycle && !item.cycleNos.includes(cycle))return '无该循环'
    if(field.kind==='calculated')return formationNumberText(record.calculatedValues?.[formationCellKey(item,cycle,field.key)])
    return field.kind==='metric'?formationSourceValue(item,cycle,field):formationInputValue(item,field,record.settings.values,cycle)
  }
  const tables = (cycle:number) => {
    const result:string[]=[]
    // 每组最多四个来源列,完整输出所有电池;动态终止电压行保持全表相同序号。
    const stepCount=cycle?Math.max(0,...items.map(item=>formationChargeSteps(item,cycle).length)):0
    for(let start=0;start<items.length;start+=4){
      const group=items.slice(start,start+4),rows:unknown[][]=[]
      const fields=record.settings.fields.filter(field=>field.scope===(cycle?'cycle':'detail'))
      const append=(field:FormationField)=>rows.push([formationFieldLabel(field),...group.map(item=>value(item,cycle,field))])
      if(cycle){
        fields.filter(field=>['method','method2'].includes(field.key)).forEach(append)
        for(let index=0;index<stepCount;index++)rows.push(['终止电压',...group.map(item=>{
          if(!item.cycleNos.includes(cycle))return '无该循环'
          const step=formationChargeSteps(item,cycle)[index]
          return step?`${formationNumberText(step.endVoltage)}${step.endVoltage==null?'':' V'}(工步${step.stepNo})`:'—'
        })])
      }
      result.push(`<table class="matrix-table"><thead><tr><th>${cycle ? `循环${cycle} / 测试` : '测试'}</th>${group.map(item => `<th>${escapeHtml(item.source.name)}</th>`).join('')}</tr><tr><th>电池组 / 电池号</th>${group.map(item => `<th>${escapeHtml(item.source.batteryGroup)} / ${escapeHtml(item.source.batteryNo)}</th>`).join('')}</tr></thead><tbody>${rows.map(row => `<tr>${row.map(cell).join('')}</tr>`).join('')}</tbody></table>`)
      fields.filter(field=>!cycle||!['method','method2'].includes(field.key)).forEach(append)
      result.push(`<table><thead><tr><th>${cycle?`循环${cycle}`:'报表数据'}</th>${group.map(item=>`<th>${escapeHtml(item.source.name)}<br>${escapeHtml(item.source.batteryGroup)} / ${escapeHtml(item.source.batteryNo)}</th>`).join('')}</tr></thead><tbody>${rows.map(row=>`<tr>${row.map(cell=>`<td>${escapeHtml(cell)}</td>`).join('')}</tr>`).join('')}</tbody></table>`)
    }
    return result.join('')
  }
  const legend = items.map(item => `<span><i style="background:${formationSeriesColor(item, record.items.indexOf(item), colors)}"></i>${escapeHtml(formationSeriesName(item))}</span>`).join('')
  const cycles = record.cycleNos.map(cycle => {
    const available = record.items.some(item => item.curves?.[cycle]?.records.length)
    const charts = available ? formationCurveMetrics.filter(metric => (record.settings.metrics ?? formationCurveMetrics.map(item => item.key)).includes(metric.key)).map(metric => {
      const chart = init(null, undefined, { renderer: 'svg', ssr: true, width: 900, height: 300 })
      let svg: string
      try { chart.setOption({ ...formationChartOption(metric, record.items, cycle, colors), tooltip: { show: false } }); svg = chart.renderToSVGString() }
      finally { chart.dispose() }
      return `<article class="chart-card"><h3>循环${cycle} · ${escapeHtml(metric.title)} (${escapeHtml(metric.unit)})</h3>${svg}<div class="legend">${legend}</div></article>`
    }).join('') : ''
    return `<section class="cycle-section" data-cycle="${cycle}"><h2>循环${cycle}</h2>${tables(cycle)}<div class="charts">${charts || `<p>${available ? '当前未启用曲线指标' : record.items.some(item => item.curves) ? '该循环暂无已保存实时数据' : '该版本未保存曲线数据'}</p>`}</div></section>`
  const cycles=record.cycleNos.map(cycle=>{
    const groups=formationStepGroups.map(group=>{
      const series=formationStepSeries(items,cycle,group.key,record.settings)
      const legend=series.map(item=>`<span${item.hidden?' class="hidden-series"':''}><i style="background:${escapeHtml(item.color)}"></i>${escapeHtml(item.name)}</span>`).join('')
      const charts=formationCurveMetrics.filter(metric=>(record.settings.metrics??formationCurveMetrics.map(item=>item.key)).includes(metric.key)).map(metric=>{
        const chart=init(null,undefined,{renderer:'svg',ssr:true,width:900,height:300})
        let svg:string
        try{chart.setOption({...formationStepChartOption(metric,items,cycle,group.key,record.settings),tooltip:{show:false}});svg=chart.renderToSVGString()}finally{chart.dispose()}
        return `<article class="chart-card"><h3>${group.title} · ${escapeHtml(metric.title)} (${escapeHtml(metric.unit)})</h3>${svg}<div class="legend">${legend}</div></article>`
      }).join('')
      return `<section data-group="${group.key}"><h3>${group.title}工步曲线</h3><div class="charts">${charts||'<p>当前未启用曲线指标</p>'}</div></section>`
    }).join('')
    const unknown=items.flatMap(item=>(item.chargeSteps?.[cycle]??[]).filter(step=>!step.direction||step.direction==='UNKNOWN').map(step=>`${item.source.name}-${item.source.batteryNo} 工步${step.stepNo}`))
    return `<section data-cycle="${cycle}"><h2>循环${cycle}</h2>${tables(cycle)}${unknown.length?`<p>方向未识别,未纳入统计:${escapeHtml(unknown.join(';'))}</p>`:''}${groups}</section>`
  }).join('')
  const date = (time: number) => escapeHtml(new Date(time).toLocaleString())
  const date=(time:number)=>escapeHtml(new Date(time).toLocaleString())
  return `<!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}*{box-sizing:border-box}body{margin:0;padding:16px;font:11px "Microsoft YaHei",sans-serif;color:#26333f;background:#fff;letter-spacing:0;print-color-adjust:exact;-webkit-print-color-adjust:exact}
    .report-header{display:flex;flex-wrap:wrap;gap:16px;justify-content:space-between;border-bottom:2px solid #2b78a3;padding-bottom:12px;margin-bottom:16px}.report-header>div{min-width:0;max-width:100%}h1{font-size:22px;margin:0;overflow-wrap:anywhere}.saved-meta{display:flex;flex-wrap:wrap;gap:8px 18px;max-width:50%;overflow-wrap:anywhere}h2{font-size:14px;background:#f4f8fb;padding:10px;border:1px solid #d8e1e9;break-after:avoid}h3{margin:0;padding:8px 12px;font-size:13px}table{width:100%;table-layout:fixed;border-collapse:collapse;margin:0 0 16px}thead{display:table-header-group}tr{break-inside:avoid}td,th{border:1px solid #cbd8e4;padding:7px;white-space:pre-wrap;overflow-wrap:anywhere;text-align:center}th{background:#3c6497;color:#fff}td:first-child,th:first-child{width:150px;text-align:left}td:first-child{background:#f4f8fb}.charts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.chart-card{min-width:0;border:1px solid #d8e1e9;break-inside:avoid}.chart-card svg{display:block;width:100%;height:auto}.legend{display:flex;flex-wrap:wrap;gap:8px 16px;padding:10px}.legend span{display:flex;align-items:flex-start;gap:6px;overflow-wrap:anywhere;max-width:100%;break-inside:avoid}.legend i{flex:0 0 12px;height:12px;margin-top:2px}.note{white-space:pre-wrap;overflow-wrap:anywhere}
    @media(max-width:680px){.charts{grid-template-columns:1fr}.saved-meta{max-width:100%}}@media print{body{padding:0}.charts{display:block}.chart-card{margin-bottom:12px}.report-header{break-inside:avoid}}
  </style></head><body><header class="report-header"><div><h1>${escapeHtml(record.name)}</h1><p>检测室数据可视化系统 · 化成报表</p></div><div class="saved-meta"><span>报表ID #${record.id}</span><span>版本 V${record.versionNo}</span><span>创建人 ${escapeHtml(record.creator)}</span><span>创建时间 ${date(record.createdAt)}</span><span>版本操作人 ${escapeHtml(record.operatorName)}</span><span>版本时间 ${date(record.versionCreatedAt)}</span><span>修改说明 ${escapeHtml(record.changeSummary)}</span></div></header><section><h2>报表数据</h2>${tables(0)}</section>${cycles}<section><h2>报表说明</h2><div class="note">${escapeHtml(record.description || '-')}</div></section></body></html>`
    @page{size:A4 landscape;margin:10mm}*{box-sizing:border-box}body{margin:0;padding:16px;font:11px "Microsoft YaHei",sans-serif;color:#26333f;background:#fff;print-color-adjust:exact;-webkit-print-color-adjust:exact}
    .report-header{display:flex;flex-wrap:wrap;gap:16px;justify-content:space-between;border-bottom:2px solid #2b78a3;padding-bottom:12px;margin-bottom:16px}.report-header>div{min-width:0;max-width:100%}h1{font-size:22px;margin:0;overflow-wrap:anywhere}.saved-meta{display:flex;flex-wrap:wrap;gap:8px 18px;max-width:50%;overflow-wrap:anywhere}h2{font-size:14px;background:#f4f8fb;padding:10px;border:1px solid #d8e1e9;break-after:avoid}h3{margin:0;padding:8px 12px;font-size:13px}table{width:100%;table-layout:fixed;border-collapse:collapse;margin:0 0 16px}thead{display:table-header-group}tr{break-inside:avoid}td,th{border:1px solid #cbd8e4;padding:7px;white-space:pre-wrap;overflow-wrap:anywhere;text-align:center}th{background:#3c6497;color:#fff}td:first-child,th:first-child{width:150px;text-align:left}td:first-child{background:#f4f8fb}.charts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.chart-card{min-width:0;border:1px solid #d8e1e9;break-inside:avoid}.chart-card svg{display:block;width:100%;height:auto}.legend{display:flex;flex-wrap:wrap;gap:8px 16px;padding:10px}.legend span{display:flex;gap:6px;overflow-wrap:anywhere;max-width:100%}.hidden-series{text-decoration:line-through;opacity:.55}.legend i{flex:0 0 12px;height:12px;margin-top:2px}.note{white-space:pre-wrap;overflow-wrap:anywhere}@media print{body{padding:0}.charts{display:block}.chart-card{margin-bottom:12px}.report-header{break-inside:avoid}}
  </style></head><body><header class="report-header"><div><h1>${escapeHtml(record.name)}</h1><p>检测室数据可视化系统 · 化成报表</p></div><div class="saved-meta"><span>报表ID #${record.id} · V${record.versionNo}</span><span>创建人 ${escapeHtml(record.creator)}</span><span>创建时间 ${date(record.createdAt)}</span><span>版本操作人 ${escapeHtml(record.operatorName)}</span><span>版本时间 ${date(record.versionCreatedAt)}</span><span>修改说明 ${escapeHtml(record.changeSummary)}</span></div></header><h2>报表数据</h2>${tables(0)}<p>拐点容量、拐点完整容量为演示占位。</p>${cycles}<section><h2>报表说明</h2><div class="note">${escapeHtml(record.description)}</div></section></body></html>`
}
/** 指定当前或历史保存版本打印,不读取页面草稿和来源测试。 */
export function openFormationSavedReportPrint(id: number, versionNo: number) {
  return openSavedReportPrint(() => getFormationPrintVersion(id, versionNo), printHtml)
  return openSavedReportPrint(() => getFormationPrintVersion(id, versionNo), formationSavedReportHtml)
}