yeg
2026-09-09 1b0179bba53bcc75e8ae358a4a58e8c8fb33c043
接入化成报表真实页面与充放电曲线

保留现有页面布局,支持多选化成测试、循环并集、逐电池报表字段修改与公式联动。接通真实保存、历史版本及打印,充放电分别按电池单线绘制并保留空档。
2个文件已添加
6个文件已修改
751 ■■■■■ 已修改文件
src/api/formationReports.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/formationReportCalculations.ts 58 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/formationReportFields.ts 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/formationSavedReportPrint.ts 113 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/formationStepChart.ts 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/FormationReport.vue 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/FormationReportForm.vue 429 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/formation-real-report.test.mjs 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/formationReports.ts
@@ -5,8 +5,8 @@
export interface FormationSelection { testId: number; testBatteryId: number }
export interface FormationCandidate { selection: FormationSelection; name: string; type: string; groupId: number; batteryGroup: string; batteryNo: string; consoleDetail?: TestConsoleDetail | null }
export interface FormationField { key: string; scope: 'detail' | 'cycle'; kind: 'battery' | 'metric' | 'manual'; displayName: string; unit: string; fieldDefinitionId?: number | null }
export interface FormationChargeStep { stepNo: number; name: string; stepType: string; endVoltage: number | null; direction?: 'CHARGE' | 'DISCHARGE' | 'UNKNOWN' }
export interface FormationField { key: string; scope: 'detail' | 'cycle'; kind: 'battery' | 'metric' | 'manual' | 'calculated'; displayName: string; unit: string; fieldDefinitionId?: number | null }
export interface FormationChargeStep { stepNo: number; name: string; stepType: string; endVoltage: number | null; capacity?: number | null; direction?: 'CHARGE' | 'DISCHARGE' | 'UNKNOWN' }
export interface FormationItem {
  source: FormationCandidate
  cycleNos: number[]
@@ -26,6 +26,7 @@
  description: string; settings: FormationSettings; versionNo: number; operatorName: string; versionCreatedAt: number; changeSummary: string
  units: Record<string, string>; displayUnits: Record<string, string>
  curveMode?: 'actual-steps'
  calculatedValues: Record<string, number | null>
}
export interface FormationTemplate { id: number; name: string; type: 'formation'; fields: FormationField[]; creator: string; updatedAt: number }
export interface CreateFormationReport { name: string; description: string; selections: FormationSelection[]; settings: FormationSettings }
src/utils/formationReportCalculations.ts
New file
@@ -0,0 +1,58 @@
import type { FormationField, FormationItem, FormationSettings } from '@/api/formationReports'
export interface FormationResult { value: number | null; reason: string }
export const formationInputKeys = ['Z', 'H', 'F', 'A', 'B']
export const formationCalculationKeys = ['FN', 'C', 'chargePerGram', 'chargePerGramTotal', 'capacity1v', 'capacity1vf', 'dischargeInflection', 'dischargeInflectionTotal']
export function formationCellKey(item: FormationItem, cycle: number, key: string) {
  return `${item.source.selection.testId}|${item.source.selection.testBatteryId}|${cycle}|${key}`
}
/** 报表人工值优先,即使是空字符串也不能回退电池组;基础字段始终按来源列隔离。 */
export function formationInputValue(item: FormationItem, field: FormationField, values: Record<string, string>, cycle = 0) {
  const key = formationCellKey(item, cycle, field.key)
  if (Object.hasOwn(values, key)) return values[key]!
  if (field.kind !== 'battery') return ''
  return item.businessFields.find(value => field.fieldDefinitionId != null
    ? value.fieldDefinitionId === field.fieldDefinitionId : value.fieldName === field.key)?.fieldValue ?? ''
}
const result = (value: number | null, reason = ''): FormationResult => value != null && !Number.isFinite(value)
  ? { value: null, reason: '计算结果超出有效数值范围' } : { value, reason }
function number(value: string, label: string) {
  return /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(value.trim()) ? result(Number(value)) : result(null, `${label}为空或不是数字`)
}
function multiply(...values: FormationResult[]): FormationResult {
  const missing = values.find(value => value.value == null)
  return missing ?? result(values.reduce((product, value) => product * value.value!, 1))
}
function divide(value: FormationResult, by: FormationResult, label: string): FormationResult {
  if (value.value == null) return value
  if (by.value == null) return by
  return by.value === 0 ? result(null, `${label}不能为0`) : result(value.value / by.value)
}
/** 页面预览与编辑按已确认公式联动,保存时由后端用相同快照重新计算并冻结。 */
export function formationCalculatedValue(item: FormationItem, cycle: number, key: string, settings: Pick<FormationSettings, 'fields' | 'values'>): FormationResult {
  const read = (name: string) => {
    const field = settings.fields.find(field => field.scope === 'detail' && field.key === name)
      ?? {key:name,scope:'detail',kind:'battery',displayName:name,unit:''} as FormationField
    return number(formationInputValue(item, field, settings.values), name)
  }
  const h = read('H'), z = read('Z'), a = read('A'), f = read('F'), b = read('B')
  const c = multiply(h, z, a, f, b)
  if (key === 'FN') return divide(divide(f, h, 'H'), z, 'Z')
  if (key === 'C') return c
  if (!item.cycleNos.includes(cycle)) return result(null, '无该循环')
  const manual = number(settings.values[formationCellKey(item, cycle, 'manual')] ?? '', '数字')
  if (manual.value != null && manual.value < 0) { manual.value = null; manual.reason = '数字不能为负数' }
  if (key === 'chargePerGram') return divide(divide(multiply(manual, c), h, 'H'), z, 'Z')
  if (key === 'chargePerGramTotal') return divide(multiply(manual, c), f, 'F')
  const last = [...(item.chargeSteps?.[cycle] ?? [])].filter(step => step.direction === 'DISCHARGE').sort((a,b)=>a.stepNo-b.stepNo).at(-1)
  const capacity = result(last?.capacity == null ? null : Number(last.capacity) * 1000, '最后一个放电工步容量缺失')
  if (key === 'capacity1v') return capacity
  if (key === 'capacity1vf') return divide(capacity, f, 'F')
  // 仅这两个指标是用户指定的演示占位,页面和保存版使用固定值。
  if (key === 'dischargeInflection') return result(3.934, '演示占位')
  if (key === 'dischargeInflectionTotal') return result(3.982, '演示占位')
  return result(null, '指标未定义')
}
export function formationNumberText(value: number | null | undefined) {
  return value == null || !Number.isFinite(Number(value)) ? '—' : String(Number(Number(value).toPrecision(12)))
}
src/utils/formationReportFields.ts
@@ -10,16 +10,12 @@
export const defaultFormationFields: FormationField[] = [
  ...['Z', 'H', 'F', 'A', 'B'].map(key => ({ key, scope: 'detail' as const, kind: 'battery' as const, displayName: key, unit: '' })),
  ...[['FN', 'F/N'], ['C', 'C']].map(([key, displayName]) => ({ key: key!, scope: 'detail' as const, kind: 'manual' as const, displayName: displayName!, unit: '' })),
  ...[['FN', 'F/N'], ['C', 'C']].map(([key, displayName]) => ({ key: key!, scope: 'detail' as const, kind: 'calculated' as const, displayName: displayName!, unit: '' })),
  ...[['method', '测试方法'], ['method2', '测试方法2'], ['manual', '数字']].map(([key, displayName]) => ({ key: key!, scope: 'cycle' as const, kind: 'manual' as const, displayName: displayName!, unit: '' })),
  ...[
    ['chargeCapacity', '充电容量', 'mAh'], ['dischargeCapacity', '放电容量', 'mAh'],
    ['chargeEnergy', '充电能量', 'mWh'], ['dischargeEnergy', '放电能量', 'mWh'],
    ['efficiency', '充放电效率', '%'], ['energyEfficiency', '能量效率', '%'],
  ].map(([key, displayName, unit]) => ({ key: key!, scope: 'cycle' as const, kind: 'metric' as const, displayName: displayName!, unit: unit! })),
  ...[
    ['method2', '测试方法2'], ['manual', '数字'], ['chargePerGram', '每克N充电量'], ['chargePerGramTotal', '每克F充电量'],
    ['capacity1v', '1.0V容量'], ['capacity1vf', '1.0VF克容量'], ['dischargeInflection', '拐点容量'], ['dischargeInflectionTotal', '拐点F克容量'],
  ].map(([key, displayName]) => ({ key: key!, scope: 'cycle' as const, kind: 'manual' as const, displayName: displayName!, unit: '' })),
    ['chargePerGram', '每克N充电量'], ['chargePerGramTotal', '每克F充电量'],
    ['capacity1v', '1.0V容量'], ['capacity1vf', '1.0VF完整容量'], ['dischargeInflection', '拐点容量'], ['dischargeInflectionTotal', '拐点完整容量'],
  ].map(([key, displayName]) => ({ key: key!, scope: 'cycle' as const, kind: 'calculated' as const, displayName: displayName!, unit: key === 'capacity1v' ? 'mAh' : '' })),
]
export function formationFieldLabel(field: FormationField) { return `${field.displayName}${field.unit ? ` (${field.unit})` : ''}` }
src/utils/formationSavedReportPrint.ts
@@ -2,12 +2,12 @@
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'
import { reportDetailColumn, reportDetailsHtml } from './reportTestDetails'
use([LineChart, GridComponent, LegendComponent, SVGRenderer])
function escapeHtml(value: unknown) {
@@ -15,77 +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) => {
    if (!cycle) return reportDetailsHtml(items.map(item => reportDetailColumn(item.source)), record.settings.sourceDetailOverrides)
    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 && record.curveMode !== 'actual-steps' ? [
        ['充电工步号', ...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))],
      ] : []
      if (cycle && record.curveMode === 'actual-steps') {
        const steps = group.map(item => formationChargeSteps(item, cycle, record.settings.manualChargeSteps))
        const count = Math.max(1, ...steps.map(values => values.length))
        for (let index = 0; index < count; index++) rows.push([`充电工步${index + 1} / 终止电压 (V)`, ...group.map((item, column) => value(item, () => {
          const step = steps[column]?.[index]
          if (!step) return index ? '—' : (item.chargeSteps?.[cycle]?.length ? '未识别充电工步' : '缺工步')
          return `工步${step.stepNo} · ${step.name || '未命名工步'} · ${step.endVoltage ?? '缺失'}${step.endVoltage == null ? '' : ' V'}`
        }))])
/** 打印只读指定版本的字段、人工输入及计算结果,绝不向当前来源补数。 */
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})`:'—'
        })])
      }
      for (const field of record.settings.fields.filter(field => field.scope === 'cycle')) {
        rows.push([formationFieldLabel(field), ...group.map(item => value(item, () =>
          record.settings.values[formationValueKey(item, cycle, field)] ?? formationSourceValue(item, cycle, field)))])
      }
      // 循环指标缺失继续使用缺失标记,人工详情由独立详情表保留空字符串。
      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(value => cell(value)).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 => {
    if (record.curveMode === 'actual-steps') {
      const groups = formationStepGroups.map(group => {
        const series = formationStepSeries(record.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)}${item.hidden ? '(已隐藏)' : ''}</span>`).join('')
        const available = series.some(item => item.records.length)
        const charts = available ? formationCurveMetrics.filter(metric => (record.settings.metrics ?? formationCurveMetrics.map(value => value.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, record.items, cycle, group.key, record.settings), tooltip: { show: false } }); svg = chart.renderToSVGString() }
          finally { chart.dispose() }
          return `<article class="chart-card"><h3>循环${cycle} · ${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>${available ? '当前未启用曲线指标' : '所选工步暂无已保存实时数据'}</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 class="cycle-section" data-cycle="${cycle}"><h2>循环${cycle}</h2>${tables(cycle)}${groups}</section>`
    }
    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>`
      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}.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(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)
}
src/utils/formationStepChart.ts
@@ -1,37 +1,51 @@
import type { FormationItem, FormationSettings } from '@/api/formationReports'
import type { PersistedTestRecord } from '@/api/tests'
import { formationSeriesKey, formationSeriesColor } from './formationCycleChart'
import { measurementChartOption, type ReportMetricKey } from './reportMeasurementChart'
export const formationStepGroups = [{ key: 'charge', title: '充电' }, { key: 'discharge', title: '放电' }] as const
export type FormationStepGroup = typeof formationStepGroups[number]['key']
export function formationCycleKey(item: FormationItem, cycle: number) { return `${formationSeriesKey(item)}|${cycle}` }
export function formationCycleKey(item: FormationItem, cycle: number) { return formationSeriesKey(item) + '|' + cycle }
/** 可靠充电工步全部展示;人工选择仅补充未知类型,始终与曲线选择分开。 */
export function formationChargeSteps(item: FormationItem, cycle: number, manual: Record<string, number[]> = {}) {
  return (item.chargeSteps?.[cycle] ?? []).filter(step => step.direction === 'CHARGE'
    || (step.direction === 'UNKNOWN' && (manual[formationCycleKey(item, cycle)] ?? []).includes(step.stepNo)))
/** 化成只使用可靠识别的实际工步,逻辑合并与人工范围均不参与。 */
export function formationChargeSteps(item: FormationItem, cycle: number, _manual?: Record<string, number[]>) {
  return (item.chargeSteps?.[cycle] ?? []).filter(step => step.direction === 'CHARGE').sort((a,b)=>a.stepNo-b.stepNo)
}
export function formationSelectedSteps(item: FormationItem, cycle: number, group: FormationStepGroup, settings: Pick<FormationSettings, 'curveStepSelections' | 'manualChargeSteps'>) {
  const explicit = settings.curveStepSelections?.[formationCycleKey(item, cycle)]?.[group]
  if (explicit) return explicit
  const reliable = (item.chargeSteps?.[cycle] ?? []).filter(step => step.direction === (group === 'charge' ? 'CHARGE' : 'DISCHARGE')).map(step => step.stepNo)
  return group === 'charge' && !reliable.length ? settings.manualChargeSteps?.[formationCycleKey(item, cycle)] ?? [] : reliable
export function formationSelectedSteps(item: FormationItem, cycle: number, group: FormationStepGroup, _settings?: Pick<FormationSettings, 'curveStepSelections' | 'manualChargeSteps'>) {
  return (item.chargeSteps?.[cycle] ?? []).filter(step => step.direction === (group === 'charge' ? 'CHARGE' : 'DISCHARGE')).map(step=>step.stepNo)
}
function emptyPoint(row: PersistedTestRecord): PersistedTestRecord {
  return {...row, voltage:null,current:null,temperature:null,positivePotential:null,negativePotential:null}
}
export function formationStepSeries(items: FormationItem[], cycle: number, group: FormationStepGroup, settings: FormationSettings) {
  return items.flatMap((item, index) => formationSelectedSteps(item, cycle, group, settings).map(stepNo => {
    const step = item.chargeSteps?.[cycle]?.find(step => step.stepNo === stepNo)
    const range = item.stepCurves?.[cycle]?.[stepNo]
    const key = `${formationCycleKey(item, cycle)}|${stepNo}`
    return { key, name: `${item.source.name} · ${item.source.batteryGroup} / ${item.source.batteryNo} · 循环${cycle} 工步${stepNo} ${step?.name ?? ''}`,
      zeroTime: range?.zeroTime ?? null, records: range?.records ?? [], hidden: settings.hiddenSeriesKeys?.includes(key) ?? false,
      color: settings.seriesColors?.[key] ?? formationSeriesColor(item, index, settings.seriesColors ?? {}) }
  }))
  return items.filter(item=>item.cycleNos.includes(cycle)).map(item => {
    const index = items.indexOf(item), key = formationSeriesKey(item)
    const selected = new Set(formationSelectedSteps(item,cycle,group))
    const allSteps = [...(item.chargeSteps?.[cycle] ?? [])].sort((a,b)=>a.stepNo-b.stepNo)
    const all = [...(item.curves?.[cycle]?.records ?? [])].sort((a,b)=>Number(a.deviceTime ?? a.collectorDeviceTime)-Number(b.deviceTime ?? b.collectorDeviceTime))
    const chosen = all.filter(row=>selected.has(row.stepNo))
    const times = chosen.flatMap(row=>[row.deviceTime,row.collectorDeviceTime]).filter((time): time is number=>time!=null).map(Number)
    const zeroTime = times.length ? Math.min(...times) : null
    const endTime = times.length ? Math.max(...times) : null
    const records: PersistedTestRecord[] = []
    let previousStep: number | undefined
    for (const row of all) {
      const times = [row.deviceTime,row.collectorDeviceTime].filter(time=>time!=null).map(Number)
      if (zeroTime==null || endTime==null || !times.some(time=>time>=zeroTime && time<=endTime)) continue
      if (selected.has(row.stepNo)) {
        // 即使静置没有实时记录,也用空值绘图分隔符断开两段;不修改冻结记录。
        if (previousStep!=null && row.stepNo!==previousStep && allSteps.some(step=>step.stepNo>previousStep! && step.stepNo<row.stepNo && !selected.has(step.stepNo))) records.push(emptyPoint(row))
        records.push(row); previousStep=row.stepNo
      } else records.push(emptyPoint(row))
    }
    return {key,name:item.source.name+'-'+item.source.batteryNo,zeroTime,records,hidden:settings.hiddenSeriesKeys?.includes(key)??false,color:formationSeriesColor(item,index,settings.seriesColors??{})}
  })
}
/** 每实际工步独立成线;共用已验证的时间、毫单位和双侧轴,隐藏不重算范围。 */
/** 一电池一方向一条曲线,以该方向最早有效设备时间归零,保留实际间隔。 */
export function formationStepChartOption(metric: { key: ReportMetricKey; unit: string }, items: FormationItem[], cycle: number, group: FormationStepGroup, settings: FormationSettings) {
  const series = formationStepSeries(items, cycle, group, settings)
  return measurementChartOption(metric, series, series.map(item => item.color))
  const series = formationStepSeries(items,cycle,group,settings)
  const option = measurementChartOption(metric,series,series.map(item=>item.color))
  // ECharts 按名称控制图例;内部使用来源键,显示文字仍保持简短,避免同名来源串联显隐。
  const labels = Object.fromEntries(series.map(item=>[item.key,item.name]))
  return {...option,tooltip:{trigger:'axis',renderMode:'richText',formatter:(entries:Array<{seriesName:string;value:[number,number|null]}>)=>entries.filter(entry=>entry.value[1]!=null).map(entry=>`${labels[entry.seriesName]??entry.seriesName}: ${entry.value[1]} ${metric.unit}`).join('\n')},legend:{...option.legend,formatter:(key:string)=>labels[key]??key,selected:Object.fromEntries(series.map(item=>[item.key,!item.hidden]))},series:option.series.map((line,index)=>({...line,name:series[index]!.key}))}
}
src/views/FormationReport.vue
@@ -1,13 +1,22 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import { useFormationReports, type FormationReportRecord } from '@/stores/formationReports'
import { createFormationReportPrintData, openFormationReportPrint } from '@/utils/formationReportPrint'
import { listFormationReports, deleteFormationReport, getFormationReport, type FormationSummary } from '@/api/formationReports'
import { getUserInfo } from '@/api/auth'
import { openFormationSavedReportPrint } from '@/utils/formationSavedReportPrint'
interface FilterState { reportName: string; creator: string; dateRange: [string, string] | null }
const router = useRouter()
const { records, removeReport } = useFormationReports()
type FormationReportRecord = Omit<FormationSummary,'createdAt'> & {createdAt:string}
const records=ref<FormationReportRecord[]>([]), loading=ref(false), error=ref(''), deletingId=ref<number|null>(null)
const permissions=ref<string[]>([])
const can=(action:string)=>permissions.value.some(value=>value==='*:*:*'||value===`system:formation-report:${action}`)
onMounted(async()=>{
  loading.value=true
  try{permissions.value=(await getUserInfo()).permissions??[];records.value=(await listFormationReports()).map(item=>({...item,createdAt:new Date(item.createdAt).toLocaleString('sv-SE')}))}
  catch(cause){error.value=cause instanceof Error?cause.message:'化成报表列表加载失败'}finally{loading.value=false}
})
const filters = reactive<FilterState>({ reportName: '', creator: '', dateRange: null })
const appliedFilters = ref<FilterState>({ reportName: '', creator: '', dateRange: null })
const currentPage = ref(1)
@@ -27,20 +36,23 @@
function openCreate() { router.push('/report-formation/new') }
function openEdit(item: FormationReportRecord) { router.push(`/report-formation/${item.id}/edit`) }
function openView(item: FormationReportRecord) { router.push({ path: `/report-formation/${item.id}/edit`, query: { mode: 'view' } }) }
function downloadPdf(item: FormationReportRecord) {
  const opened = openFormationReportPrint(createFormationReportPrintData(item))
  if (!opened) { ElMessage.warning('浏览器阻止了报表窗口,请允许弹出窗口后重试'); return }
  ElMessage.info('已打开打印版报表,请在打印窗口选择“另存为 PDF”')
async function downloadPdf(item: FormationReportRecord) {
  try{const record=await getFormationReport(item.id);await openFormationSavedReportPrint(item.id,record.versionNo)}
  catch(cause){ElMessage.error(cause instanceof Error?cause.message:'打印版加载失败')}
}
async function deleteReport(item: FormationReportRecord) {
  try { await ElMessageBox.confirm(`删除“${item.name}”后无法恢复,是否继续?`, '确认删除化成报表', { confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' }); removeReport(item.id); ElMessage.success('化成报表已删除') } catch { /* 用户取消 */ }
  if(deletingId.value!=null)return
  try{await ElMessageBox.confirm(`删除“${item.name}”及全部版本,不影响来源测试,删除后无法恢复。是否继续?`,'确认删除化成报表',{confirmButtonText:'删除',cancelButtonText:'取消',type:'warning'})}catch{return}
  deletingId.value=item.id
  try{await deleteFormationReport(item.id);records.value=records.value.filter(row=>row.id!==item.id);currentPage.value=Math.min(currentPage.value,Math.max(1,Math.ceil(filteredRecords.value.length/pageSize.value)));ElMessage.success('化成报表已删除')}
  catch(cause){ElMessage.error(cause instanceof Error?cause.message:'删除失败')}finally{deletingId.value=null}
}
</script>
<template>
  <div class="formation-page">
  <div class="formation-page" v-loading="loading"><el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
    <section class="filter-panel"><header class="section-heading"><div><i></i><strong>查询报表</strong><span>FORMATION REPORT QUERY</span></div><small>共 {{ records.length }} 份报表</small></header><div class="filter-grid"><label><span>报表名称</span><el-input v-model="filters.reportName" clearable placeholder="输入报表名称或ID" @keyup.enter="applySearch" /></label><label><span>创建人</span><el-select v-model="filters.creator" clearable placeholder="全部"><el-option label="全部" value="全部" /><el-option v-for="item in creatorOptions" :key="item" :label="item" :value="item" /></el-select></label><label><span>创建时间</span><el-date-picker v-model="filters.dateRange" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="结束日期" range-separator="至" unlink-panels /></label><div class="filter-actions"><button class="primary-button" type="button" @click="applySearch"><el-icon><Search /></el-icon>查询</button><button class="secondary-button" type="button" @click="resetSearch"><el-icon><RefreshLeft /></el-icon>重置</button></div></div></section>
    <section class="result-panel"><div class="result-toolbar"><div><i></i><strong>化成报表列表</strong><span>{{ filteredRecords.length }} RESULTS · 按创建时间倒序</span></div><div class="toolbar-actions"><button class="primary-button create-button" type="button" @click="openCreate"><el-icon><Plus /></el-icon>新建报表</button></div></div><div class="table-wrap"><table><thead><tr><th>报表ID</th><th>报表名称</th><th>测试名称</th><th>电池组</th><th>创建时间</th><th>创建人</th><th class="action-column">操作</th></tr></thead><tbody><tr v-for="item in pagedRecords" :key="item.id"><td class="id-cell">#{{ item.id }}</td><td><strong>{{ item.name }}</strong></td><td>{{ item.testName }}</td><td>{{ item.batteryGroup }}</td><td>{{ item.createdAt }}</td><td>{{ item.creator }}</td><td class="action-column"><div class="row-actions"><button type="button" @click="openView(item)">查看报表</button><button type="button" @click="openEdit(item)">编辑</button><button type="button" @click="downloadPdf(item)">下载PDF</button><button class="delete-action" type="button" @click="deleteReport(item)">删除</button></div></td></tr><tr v-if="!pagedRecords.length"><td colspan="7" class="empty-cell">暂无符合条件的化成报表</td></tr></tbody></table></div><footer class="pagination-bar"><span>显示 {{ pagedRecords.length }} 条,共 {{ filteredRecords.length }} 条</span><el-pagination v-model:current-page="currentPage" v-model:page-size="pageSize" :page-sizes="[10, 20, 50]" :total="filteredRecords.length" layout="sizes, prev, pager, next" background /></footer></section>
    <section class="result-panel"><div class="result-toolbar"><div><i></i><strong>化成报表列表</strong><span>{{ filteredRecords.length }} RESULTS · 按创建时间倒序</span></div><div class="toolbar-actions"><button v-if="can('add')" class="primary-button create-button" type="button" @click="openCreate"><el-icon><Plus /></el-icon>新建报表</button></div></div><div class="table-wrap"><table><thead><tr><th>报表ID</th><th>报表名称</th><th>测试名称</th><th>电池组</th><th>创建时间</th><th>创建人</th><th class="action-column">操作</th></tr></thead><tbody><tr v-for="item in pagedRecords" :key="item.id"><td class="id-cell">#{{ item.id }}</td><td><strong>{{ item.name }}</strong></td><td>{{ item.testName }}</td><td>{{ item.batteryGroup }}</td><td>{{ item.createdAt }}</td><td>{{ item.creator }}</td><td class="action-column"><div class="row-actions"><button v-if="can('query')" type="button" @click="openView(item)">查看报表</button><button v-if="can('edit')" type="button" @click="openEdit(item)">编辑</button><button v-if="can('export')" type="button" @click="downloadPdf(item)">下载PDF</button><button v-if="can('remove')" :disabled="deletingId!=null" class="delete-action" type="button" @click="deleteReport(item)">删除</button></div></td></tr><tr v-if="!pagedRecords.length"><td colspan="7" class="empty-cell">暂无符合条件的化成报表</td></tr></tbody></table></div><footer class="pagination-bar"><span>显示 {{ pagedRecords.length }} 条,共 {{ filteredRecords.length }} 条</span><el-pagination v-model:current-page="currentPage" v-model:page-size="pageSize" :page-sizes="[10, 20, 50]" :total="filteredRecords.length" layout="sizes, prev, pager, next" background /></footer></section>
  </div>
</template>
src/views/FormationReportForm.vue
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { use } from 'echarts/core'
@@ -7,263 +7,197 @@
import { CanvasRenderer } from 'echarts/renderers'
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
import VChart from 'vue-echarts'
import { getCurrentUser } from '@/utils/auth'
import { useFormationReports, type FormationReportCycle } from '@/stores/formationReports'
import { useBatteryGroupFields } from '@/stores/batteryGroupFields'
import { formationCycleFields as cycleFields, formationFixedReportFields as fixedReportFields, formationMetricDefinitions, openFormationReportPrint, type FormationMetricDefinition } from '@/utils/formationReportPrint'
import { getUserInfo } from '@/api/auth'
import { getFormationSources, previewFormationReport, getFormationReport, createFormationReport, editFormationReport, refreshFormationSource,
  listFormationVersions, listFormationTemplates, createFormationTemplate, updateFormationTemplate,
  type FormationCandidate, type FormationMatrix, type FormationSettings, type FormationItem, type FormationField, type FormationVersion, type FormationTemplate, type SavedFormationReport } from '@/api/formationReports'
import { defaultFormationFields, formationSourceValue } from '@/utils/formationReportFields'
import { formationCellKey, formationInputValue, formationCalculatedValue, formationNumberText } from '@/utils/formationReportCalculations'
import { formationCurveMetrics, formationSeriesKey, formationSeriesColor } from '@/utils/formationCycleChart'
import { formationChargeSteps, formationStepChartOption, formationStepGroups } from '@/utils/formationStepChart'
import { openFormationSavedReportPrint } from '@/utils/formationSavedReportPrint'
use([CanvasRenderer, LineChart, GridComponent, LegendComponent, TooltipComponent])
const route = useRoute(); const router = useRouter(); const currentUser = getCurrentUser(); const { records, createReport, updateReport } = useFormationReports()
const { fields: managedGroupFields } = useBatteryGroupFields()
const editingId = computed(() => { const value = Number(route.params.id); return Number.isFinite(value) && value > 0 ? value : null })
const editingRecord = computed(() => editingId.value ? records.value.find(item => item.id === editingId.value) || null : null)
const route = useRoute(), router = useRouter()
const editingId = computed(() => Number(route.params.id) || null)
const readonly = computed(() => route.query.mode === 'view')
const reportName = ref(''); const testName = ref('SP2电池化成测试'); const batteryGroup = ref('SP2样品组'); const saving = ref(false)
const cycles = ref<FormationReportCycle[]>([
  { cycle: 1, method: '恒流充电 / 恒流放电', chargeVoltage: '4.20', dischargeCapacity: '98.6%', retention: '100%', result: '合格' },
  { cycle: 2, method: '恒流充电 / 恒流放电', chargeVoltage: '4.20', dischargeCapacity: '98.1%', retention: '99.5%', result: '合格' },
  { cycle: 3, method: '恒流充电 / 恒流放电', chargeVoltage: '4.20', dischargeCapacity: '97.6%', retention: '99.0%', result: '合格' },
])
const description = ref('汇总化成阶段的充放电数据、容量保持率和温升情况。')
const seriesColors = ref(['#3978f6', '#7d42d8', '#f08a3e'])
const formationMetricVisible = reactive<Record<FormationMetricDefinition['key'], boolean>>({ voltage: true, current: true, positiveVoltage: true, negativeVoltage: true, temperature: true })
const visibleFormationMetrics = computed(() => formationMetricDefinitions.filter(metric => formationMetricVisible[metric.key]))
const activeCycle = ref(1)
const reportColumns = reactive([
  { id: 1, name: 'SP2电池化成测试', samples: ['样品1', '样品2', '样品3'] },
  { id: 2, name: '高温化成测试', samples: ['样品1', '样品2', '样品3'] },
])
const batteryFields = ref<string[]>([])
const specialFields = ref<string[]>([])
const reportFieldValues = reactive<Record<string, string>>({})
const templateDialogVisible = ref(false)
const templateMode = ref<'battery' | 'special'>('battery')
const selectedTemplateKey = ref('cycle')
const batteryTemplateStorageKey = 'battery_detection_group_field_templates_v1'
const presetBatteryTemplateLabels: Record<string, string[]> = {
  '标准容量样品': ['额定容量', '电流', '内阻'],
  '标准循环样品': ['额定容量', '循环温度', '内阻'],
  '低温性能样品': ['目标温度', '静置时间', '环境备注'],
}
const batteryTemplateSelections = reactive<Record<string, number[]>>((() => {
  try {
    const stored = localStorage.getItem(batteryTemplateStorageKey)
    return stored ? JSON.parse(stored) as Record<string, number[]> : {}
  } catch {
    localStorage.removeItem(batteryTemplateStorageKey)
    return {}
  }
})())
const selectedBatteryTemplateName = ref('标准容量样品')
const selectedBatteryTemplateFieldIds = ref<number[]>([])
const specialTemplateStorageKey = 'battery_detection_compare_special_templates_v2'
const specialTemplateSelections = reactive<Record<string, string[]>>((() => {
  try {
    const stored = localStorage.getItem(specialTemplateStorageKey)
    return stored ? JSON.parse(stored) as Record<string, string[]> : {}
  } catch {
    localStorage.removeItem(specialTemplateStorageKey)
    return {}
  }
})())
const fieldTemplates = computed(() => [
  { key: 'cycle', name: '循环寿命模板', fields: ['容量保持率', '能量效率', '循环次数', '内阻变化'] },
  { key: 'thermal', name: '热特性模板', fields: ['最高温度', '温差', '温升速率'] },
  ...Object.entries(specialTemplateSelections).map(([name, fields]) => ({ key: `saved:${name}`, name, fields })),
])
const reportSamples = computed(() => reportColumns.flatMap(column => column.samples))
const enabledGroupFields = computed(() => managedGroupFields.value.filter(field => field.status === '启用'))
const batteryTemplateFieldOptions = computed(() => [...managedGroupFields.value].sort((a, b) => b.createdAt.localeCompare(a.createdAt)))
const batteryTemplateNames = computed(() => [
  '自定义',
  '标准容量样品',
  '标准循环样品',
  '低温性能样品',
  ...Object.keys(batteryTemplateSelections).filter(name => !['自定义', '标准容量样品', '标准循环样品', '低温性能样品'].includes(name)),
])
const selectedTemplate = computed(() => fieldTemplates.value.find(item => item.key === selectedTemplateKey.value) || fieldTemplates.value[0])
const reportDetailFields = computed(() => [
  ...fixedReportFields,
  ...batteryFields.value.map(field => ({ key: `battery:${field}`, label: field, mode: 'input', unit: '' })),
  ...specialFields.value.map(field => ({ key: `special:${field}`, label: field, mode: 'input', unit: '' })),
])
watch(testName, value => { reportColumns[0].name = value })
const cycleFieldValues = reactive<Record<string, string>>({})
const reportName = ref(''), description = ref(''), error = ref('')
const loading = ref(false), saving = ref(false), previewLoading = ref(false)
const permissions = ref<string[]>([])
const can = (action: string) => permissions.value.some(value => value === '*:*:*' || value === `system:formation-report:${action}`)
const candidates = ref<FormationCandidate[]>([]), selectedTestIds = ref<number[]>([])
const matrix = ref<FormationMatrix>({ items: [], cycleNos: [] })
const saved = ref<SavedFormationReport | null>(null)
const makeSettings = (): FormationSettings => ({fields:defaultFormationFields.map(field=>({...field})),values:{},chargeStepSelections:{},metrics:formationCurveMetrics.map(metric=>metric.key),seriesColors:{},hiddenSeriesKeys:[],activeCycle:null})
const settings = ref<FormationSettings>(makeSettings())
const dirty = ref(false)
const historyVisible=ref(false),history=ref<FormationVersion[]>([])
const busy = computed(()=>loading.value || saving.value || previewLoading.value)
const testOptions = computed(()=>[...new Map((editingId.value ? matrix.value.items.map(item=>item.source) : candidates.value).map(source=>[source.selection.testId,{id:source.selection.testId,name:source.name}])).values()])
const reportColumns = computed(()=>[...new Set(matrix.value.items.map(item=>item.source.selection.testId))].map(id=>({id,name:matrix.value.items.find(item=>item.source.selection.testId===id)!.source.name,items:matrix.value.items.filter(item=>item.source.selection.testId===id)})))
const detailFields = computed(()=>settings.value.fields.filter(field=>field.scope==='detail'))
const methodFields = computed(()=>settings.value.fields.filter(field=>field.scope==='cycle' && ['method','method2'].includes(field.key)))
const cycleFields = computed(()=>settings.value.fields.filter(field=>field.scope==='cycle' && !['method','method2'].includes(field.key)))
const activeCycle = computed({get:()=>settings.value.activeCycle??matrix.value.cycleNos[0]??null,set:(value:number|null)=>{settings.value.activeCycle=value;if(!readonly.value)dirty.value=true}})
const visibleMetrics = computed(()=>formationCurveMetrics.filter(metric=>settings.value.metrics?.includes(metric.key)))
const unknownSteps = computed(()=>matrix.value.items.flatMap(item=>matrix.value.cycleNos.flatMap(cycle=>(item.chargeSteps?.[cycle]??[]).filter(step=>!step.direction || step.direction==='UNKNOWN').map(step=>`${item.source.name}-${item.source.batteryNo} 循环${cycle} 工步${step.stepNo}`))).join(';'))
let loadSequence = 0, previewSequence = 0
const message = (cause: unknown) => cause instanceof Error ? cause.message : '请求失败,请重试'
function cycleValueKey(cycle: number, field: string, sample: string) { return `${cycle}|${field}|${sample}` }
function rawValue(cycle: number, fieldIndex: number, sampleIndex: number) {
  return (3.76 + cycle * 0.03 + fieldIndex * 0.018 + sampleIndex * 0.012).toFixed(3)
function loadSaved(record: SavedFormationReport) {
  saved.value=record;reportName.value=record.name;description.value=record.description??''
  matrix.value={items:record.items,cycleNos:record.cycleNos}
  settings.value=structuredClone(record.settings)
  selectedTestIds.value=[...new Set(record.items.map(item=>item.source.selection.testId))]
  dirty.value=false;error.value=''
}
function loadEditingRecord() {
  const record = editingRecord.value
  if (!record) return
  reportName.value = record.name; testName.value = record.testName; batteryGroup.value = record.batteryGroup; cycles.value = record.cycles.map(item => ({ ...item })); description.value = record.description
  reportColumns[0].name = record.testName
  reportColumns[1].name = `${record.testName} · 对照`
  const restoredSamples = record.samples.slice(0, 3)
  if (restoredSamples.length) reportColumns.forEach(column => { column.samples = [...restoredSamples] })
  Object.assign(cycleFieldValues, record.cycleFieldValues || {})
  Object.assign(reportFieldValues, record.reportFieldValues || {})
  if (record.batteryFields) batteryFields.value = [...record.batteryFields]
  if (record.specialFields) specialFields.value = [...record.specialFields]
  if (record.metrics) formationMetricDefinitions.forEach(metric => { formationMetricVisible[metric.key] = record.metrics!.includes(metric.key) })
  if (record.seriesColors?.length) seriesColors.value = [...record.seriesColors]
}
loadEditingRecord()
function reportValueKey(field: string, sample: string) { return `${field}|${sample}` }
function rawReportValue(fieldIndex: number, sampleIndex: number) { return (1.02 + fieldIndex * 0.06 + sampleIndex * 0.03).toFixed(2) }
function addReportField() {
  ElMessageBox.prompt('请输入要添加的字段名称', '添加项', { confirmButtonText: '添加', cancelButtonText: '取消', inputPlaceholder: '例如:环境备注', inputValidator: value => value.trim() ? true : '请输入字段名称' }).then(({ value }) => {
    const name = value.trim()
    if (reportDetailFields.value.some(field => field.label === name)) { ElMessage.warning('该字段已经存在'); return }
    specialFields.value.push(name)
    ElMessage.success(`已添加字段“${name}”`)
  }).catch(() => { /* 用户取消 */ })
}
function persistBatteryTemplates() { localStorage.setItem(batteryTemplateStorageKey, JSON.stringify(batteryTemplateSelections)) }
function resolveBatteryTemplateFieldIds(name: string) {
  const savedIds = batteryTemplateSelections[name]
  if (savedIds) return savedIds.filter(id => enabledGroupFields.value.some(field => field.id === id))
  const labels = presetBatteryTemplateLabels[name] || []
  return enabledGroupFields.value.filter(field => labels.includes(field.name)).map(field => field.id)
}
function loadBatteryTemplate(name: string) { selectedBatteryTemplateFieldIds.value = resolveBatteryTemplateFieldIds(name) }
function applyBatteryTemplateFields() {
  const fixedLabels = fixedReportFields.map(field => field.label)
  batteryFields.value = selectedBatteryTemplateFieldIds.value
    .map(id => enabledGroupFields.value.find(field => field.id === id)?.name)
    .filter((name): name is string => typeof name === 'string')
    .filter(name => !fixedLabels.includes(name))
  specialFields.value = specialFields.value.filter(field => !batteryFields.value.includes(field) && !fixedLabels.includes(field))
}
function switchBatteryTemplate(name: string) { loadBatteryTemplate(name); applyBatteryTemplateFields() }
function saveBatteryTemplate() { applyBatteryTemplateFields(); batteryTemplateSelections[selectedBatteryTemplateName.value] = [...selectedBatteryTemplateFieldIds.value]; persistBatteryTemplates(); ElMessage.success(`模板“${selectedBatteryTemplateName.value}”已保存`) }
async function saveBatteryTemplateAs() {
watch(()=>route.fullPath,async()=>{
  const seq=++loadSequence; ++previewSequence
  loading.value=true;previewLoading.value=false;error.value='';saved.value=null;matrix.value={items:[],cycleNos:[]};settings.value=makeSettings();selectedTestIds.value=[]
  reportName.value='';description.value='';dirty.value=false;historyVisible.value=false
  try {
    const result = await ElMessageBox.prompt('请输入新模板名称,当前字段勾选会保存为新模板。', '另存电池组字段模板', { confirmButtonText: '保存', cancelButtonText: '取消', inputPlaceholder: '输入模板名称', inputValidator: value => value.trim() ? true : '请输入模板名称' })
    const name = result.value.trim()
    if (batteryTemplateNames.value.includes(name)) { ElMessage.warning('模板名称已存在,请使用其他名称'); return }
    selectedBatteryTemplateName.value = name
    applyBatteryTemplateFields()
    batteryTemplateSelections[name] = [...selectedBatteryTemplateFieldIds.value]
    persistBatteryTemplates()
    ElMessage.success(`已另存为“${name}”`)
  } catch { /* 用户取消 */ }
}
function openFieldTemplate(mode: 'battery' | 'special') {
  templateMode.value = mode
  if (mode === 'battery') {
    if (!batteryTemplateNames.value.includes(selectedBatteryTemplateName.value)) selectedBatteryTemplateName.value = '自定义'
    loadBatteryTemplate(selectedBatteryTemplateName.value)
  } else {
    selectedTemplateKey.value = 'cycle'
  }
  templateDialogVisible.value = true
}
function applyFieldTemplate() {
  const fixedLabels = fixedReportFields.map(field => field.label)
  if (templateMode.value === 'battery') {
    applyBatteryTemplateFields()
  } else {
    specialFields.value = selectedTemplate.value.fields.filter(field => !fixedLabels.includes(field) && !batteryFields.value.includes(field))
  }
  templateDialogVisible.value = false
  ElMessage.success(`${templateMode.value === 'battery' ? '电池组字段' : '特殊字段'}模板已应用`)
}
async function saveSpecialTemplate() {
  if (!specialFields.value.length) { ElMessage.warning('请先添加或选择特殊字段'); return }
  try {
    const result = await ElMessageBox.prompt('请输入特殊字段模板名称,当前特殊字段会保存为新模板。', '另存特殊字段模板', { confirmButtonText: '保存', cancelButtonText: '取消', inputPlaceholder: '输入模板名称', inputValidator: value => value.trim() ? true : '请输入模板名称' })
    const name = result.value.trim()
    if (fieldTemplates.value.some(item => item.name === name)) { ElMessage.warning('特殊字段模板名称已存在,请使用其他名称'); return }
    specialTemplateSelections[name] = [...specialFields.value]
    localStorage.setItem(specialTemplateStorageKey, JSON.stringify(specialTemplateSelections))
    selectedTemplateKey.value = `saved:${name}`
    ElMessage.success(`已另存为“${name}”`)
  } catch { /* 用户取消 */ }
}
function updateSeriesColor(index: number, color: string | null) { if (color) seriesColors.value[index] = color }
function chartOption(metric: FormationMetricDefinition) {
  const names = reportSamples.value.slice(0, 3)
  const cycleOffset = Math.max(0, activeCycle.value - 1) * 0.012
  const visibleSeries = metric.values.slice(0, Math.max(1, names.length)).map(series => series.map(value => Number((value + cycleOffset).toFixed(3))))
  const visibleValues = visibleSeries.flat()
  const dataMax = Math.max(0, ...visibleValues)
  const axisMax = metric.unit === '℃' ? Math.ceil(dataMax / 10) * 10 : Math.ceil(dataMax * 10) / 10
  const maxDataLength = Math.max(1, ...visibleSeries.map(series => series.length))
  const timeLabels = Array.from({ length: maxDataLength }, (_, index) => `${Math.floor(index / 2)}:${index % 2 ? '30' : '00'}`)
  const createYAxis = (position: 'left' | 'right') => ({ type: 'value', name: metric.unit, position, min: 0, max: axisMax, nameTextStyle: { color: '#8795a3', fontSize: 10 }, axisLine: { show: true, lineStyle: { color: '#cbd8e4' } }, axisTick: { show: true, lineStyle: { color: '#cbd8e4' } }, splitLine: { show: position === 'left', lineStyle: { color: '#e8eef4' } }, axisLabel: { color: '#8795a3', fontSize: 10 } })
  return { animation: false, color: seriesColors.value, grid: { top: 40, right: 46, bottom: 42, left: 46 }, tooltip: { trigger: 'axis' }, legend: { top: 4, right: 6, icon: 'roundRect', itemWidth: 14, itemHeight: 8, textStyle: { fontSize: 11, color: '#52606d' } }, xAxis: { type: 'category', boundaryGap: false, name: '时间/h:mm', nameLocation: 'middle', nameGap: 28, data: timeLabels, axisLine: { lineStyle: { color: '#cbd8e4' } }, axisLabel: { color: '#8795a3', fontSize: 10, interval: 'auto' }, nameTextStyle: { color: '#26333f', fontSize: 11, fontWeight: 600 } }, yAxis: [createYAxis('left'), createYAxis('right')], series: names.map((name, index) => ({ name, type: 'line', smooth: true, showSymbol: false, lineStyle: { width: 2 }, data: visibleSeries[index] || visibleSeries[0] })) }
}
function exportPdf() {
  if (!reportName.value.trim()) {
    ElMessage.warning('请先输入报表名称')
    return
  }
  const opened = openFormationReportPrint({
    name: reportName.value.trim(),
    reportId: editingRecord.value?.id,
    creator: editingRecord.value?.creator || currentUser?.displayName || 'Admin',
    createdAt: editingRecord.value?.createdAt,
    testName: testName.value,
    batteryGroup: batteryGroup.value,
    samples: [...new Set(reportSamples.value)],
    cycles: cycles.value.map(item => ({ ...item })),
    cycleFieldValues: { ...cycleFieldValues },
    reportFieldValues: { ...reportFieldValues },
    batteryFields: [...batteryFields.value],
    specialFields: [...specialFields.value],
    metrics: visibleFormationMetrics.value.map(item => item.key),
    seriesColors: [...seriesColors.value],
    description: description.value.trim(),
  })
  if (!opened) {
    ElMessage.warning('浏览器阻止了报表窗口,请允许弹出窗口后重试')
    return
  }
  ElMessage.info('已打开打印版报表,请在打印窗口选择“另存为 PDF”')
}
    const info=await getUserInfo();if(seq!==loadSequence)return
    permissions.value=info.permissions??[]
    if(!can(editingId.value ? readonly.value ? 'query':'edit':'add'))throw new Error('无当前化成报表操作权限')
    if(editingId.value){
      const record=await getFormationReport(editingId.value,readonly.value&&route.query.version?Number(route.query.version):undefined,!readonly.value)
      if(seq===loadSequence)loadSaved(record)
    }else{
      const sources=await getFormationSources();if(seq===loadSequence)candidates.value=sources
    }
  }catch(cause){if(seq===loadSequence)error.value=message(cause)}finally{if(seq===loadSequence)loading.value=false}
},{immediate:true})
async function saveReport() {
  if (!reportName.value.trim()) { ElMessage.warning('请输入报表名称'); return }
  if (!testName.value || !batteryGroup.value) { ElMessage.warning('请选择测试和电池组'); return }
  saving.value = true
  await new Promise(resolve => window.setTimeout(resolve, 220))
  const payload = {
    name: reportName.value.trim(),
    creator: currentUser?.displayName || 'Admin',
    testName: testName.value,
    batteryGroup: batteryGroup.value,
    samples: [...new Set(reportSamples.value)],
    cycles: cycles.value.map(item => ({ ...item })),
    cycleFieldValues: { ...cycleFieldValues },
    reportFieldValues: { ...reportFieldValues },
    batteryFields: [...batteryFields.value],
    specialFields: [...specialFields.value],
    metrics: visibleFormationMetrics.value.map(item => item.key),
    seriesColors: [...seriesColors.value],
    description: description.value.trim(),
  }
  editingId.value ? updateReport(editingId.value, payload) : createReport(payload)
  saving.value = false
  ElMessage.success(editingId.value ? '化成报表已更新' : '化成报表已保存')
  await router.push('/report-formation')
/** 多选异步返回只归属当前选择,旧请求不能把另一测试数据带入现有表格。 */
async function selectTests() {
  const seq=++previewSequence
  const selections=selectedTestIds.value.flatMap(id=>candidates.value.filter(source=>source.selection.testId===id).map(source=>source.selection))
  error.value='';previewLoading.value=true;dirty.value=true
  if(!selections.length){matrix.value={items:[],cycleNos:[]};settings.value=makeSettings();previewLoading.value=false;return}
  try{
    const next=await previewFormationReport(selections);if(seq!==previewSequence)return
    settings.value.values=Object.fromEntries(Object.entries(settings.value.values).filter(([key])=>{
      const parts=key.split('|'),item=next.items.find(item=>formationSeriesKey(item)===parts.slice(0,2).join('|'))
      return item&&(parts[2]==='0'||item.cycleNos.includes(Number(parts[2])))
    }))
    matrix.value=next
    const extras=next.items.flatMap(item=>item.businessFields).filter(field=>!settings.value.fields.some(existing=>existing.key===field.fieldName))
    for(const field of extras)if(!settings.value.fields.some(existing=>existing.key===field.fieldName))settings.value.fields.push({key:field.fieldName,scope:'detail',kind:'battery',displayName:field.fieldName,unit:'',fieldDefinitionId:field.fieldDefinitionId})
    const sourceKeys=new Set(next.items.map(formationSeriesKey))
    settings.value.seriesColors=Object.fromEntries(Object.entries(settings.value.seriesColors??{}).filter(([key])=>sourceKeys.has(key)))
    settings.value.hiddenSeriesKeys=(settings.value.hiddenSeriesKeys??[]).filter(key=>sourceKeys.has(key))
    if(!next.cycleNos.includes(activeCycle.value??-1))settings.value.activeCycle=next.cycleNos[0]??null
  }catch(cause){if(seq===previewSequence){matrix.value={items:[],cycleNos:[]};error.value=message(cause)}}finally{if(seq===previewSequence)previewLoading.value=false}
}
function updateValue(item:FormationItem,cycle:number,field:FormationField,value:string){settings.value.values[formationCellKey(item,cycle,field.key)]=value;dirty.value=true}
function inputValue(item:FormationItem,cycle:number,field:FormationField){return formationInputValue(item,field,settings.value.values,cycle)}
function calculation(item:FormationItem,cycle:number,field:FormationField){return formationCalculatedValue(item,cycle,field.key,settings.value)}
function displayValue(item:FormationItem,cycle:number,field:FormationField){
  if(field.kind!=='calculated')return formationSourceValue(item,cycle,field)||'—'
  const key=formationCellKey(item,cycle,field.key)
  return formationNumberText(readonly.value&&saved.value ? saved.value.calculatedValues?.[key] : calculation(item,cycle,field).value)
}
function chargeCount(cycle:number){return Math.max(0,...matrix.value.items.map(item=>formationChargeSteps(item,cycle).length))}
function voltageTitle(item:FormationItem,cycle:number,index:number){const step=formationChargeSteps(item,cycle)[index];return step?`工步${step.stepNo} ${step.name||''} · 终止电压 (V)`:'无对应充电工步'}
function setMetric(key:typeof formationCurveMetrics[number]['key'],show:boolean){settings.value.metrics=formationCurveMetrics.filter(metric=>metric.key===key?show:settings.value.metrics?.includes(metric.key)).map(metric=>metric.key);if(!readonly.value)dirty.value=true}
function setColor(item:FormationItem,color:string|null){if(color){settings.value.seriesColors={...settings.value.seriesColors,[formationSeriesKey(item)]:color};dirty.value=true}}
function toggleSeries(item:FormationItem){const key=formationSeriesKey(item),hidden=new Set(settings.value.hiddenSeriesKeys??[]);hidden.has(key)?hidden.delete(key):hidden.add(key);settings.value.hiddenSeriesKeys=[...hidden];if(!readonly.value)dirty.value=true}
function legendChanged(event:{selected:Record<string,boolean>}){settings.value.hiddenSeriesKeys=Object.entries(event.selected).filter(([,shown])=>!shown).map(([key])=>key);if(!readonly.value)dirty.value=true}
async function saveReport(){
  if(!reportName.value.trim()){ElMessage.warning('请输入报表名称');return}
  if(!matrix.value.items.length || error.value){ElMessage.warning('请先选择测试并完成数据加载');return}
  if(Object.entries(settings.value.values).some(([key,value])=>key.endsWith('|manual')&&value.trim()&&!/^(?:\d+(?:\.\d*)?|\.\d+)$/.test(value.trim()))){ElMessage.warning('数字仅允许非负整数或小数');return}
  let reason=''
  if(saved.value){try{reason=(await ElMessageBox.prompt('请输入本次修改说明','保存修改',{inputValidator:value=>Boolean(value?.trim())||'请输入修改说明'})).value.trim()}catch{return}}
  saving.value=true
  try{
    // Vue 的响应式代理先序列化成普通提交对象;只提交字段值,测量和公式由后端冻结。
    const payload={name:reportName.value.trim(),description:description.value,settings:JSON.parse(JSON.stringify(settings.value)) as FormationSettings}
    const record=saved.value?await editFormationReport(saved.value.id,{...payload,baseVersionNo:saved.value.versionNo,changeSummary:reason}):await createFormationReport({...payload,selections:matrix.value.items.map(item=>item.source.selection)})
    loadSaved(record);ElMessage.success('化成报表已保存')
    await router.replace({path:`/report-formation/${record.id}/edit`,query:{mode:'view'}})
  }catch(cause){ElMessage.error(message(cause))}finally{saving.value=false}
}
async function refreshSource(){
  if(!saved.value)return
  if(dirty.value){ElMessage.warning('请先保存当前修改,再更新来源');return}
  let reason='';try{reason=(await ElMessageBox.prompt('重新读取来源数据并生成新版本,保留报表人工字段值。请输入说明。','按最新来源生成',{inputValidator:value=>Boolean(value?.trim())||'请输入说明'})).value.trim()}catch{return}
  saving.value=true
  try{loadSaved(await refreshFormationSource(saved.value.id,saved.value.versionNo,reason));ElMessage.success('已生成最新来源版本')}catch(cause){ElMessage.error(message(cause))}finally{saving.value=false}
}
async function exportPdf(){
  if(!saved.value||dirty.value){ElMessage.warning('请先保存报表后再导出');return}
  try{await openFormationSavedReportPrint(saved.value.id,saved.value.versionNo)}catch(cause){ElMessage.error(message(cause))}
}
async function openHistory(){if(!saved.value)return;try{history.value=await listFormationVersions(saved.value.id);historyVisible.value=true}catch(cause){ElMessage.error(message(cause))}}
function viewVersion(version:number){historyVisible.value=false;router.push({path:`/report-formation/${editingId.value}/edit`,query:{mode:'view',version}})}
const templateDialogVisible=ref(false),templateMode=ref<'battery'|'special'>('battery'),templates=ref<FormationTemplate[]>([]),templateId=ref<number|null>(null),templateLoading=ref(false)
const templateFieldKeys=ref<string[]>([])
const availableFields=computed(()=>[...new Map([...settings.value.fields,...matrix.value.items.flatMap(item=>item.businessFields.map(field=>({key:field.fieldName,scope:'detail' as const,kind:'battery' as const,displayName:field.fieldName,unit:'',fieldDefinitionId:field.fieldDefinitionId})))].filter(field=>field.scope==='detail'&&field.kind=== (templateMode.value==='battery'?'battery':'manual')).map(field=>[field.key,field])).values()])
async function addReportField(){
  try{const name=(await ElMessageBox.prompt('请输入字段名称','添加项',{inputValidator:value=>Boolean(value?.trim())||'请输入字段名称'})).value.trim()
    if(name.includes('|')||name.length>100||settings.value.fields.some(field=>field.key===name)){ElMessage.warning('字段名称重复或无效');return}
    settings.value.fields.push({key:name,scope:'detail',kind:'manual',displayName:name,unit:''});dirty.value=true
  }catch{/* 用户取消 */}
}
async function openFieldTemplate(mode:'battery'|'special'){
  templateMode.value=mode;templateId.value=null;templateFieldKeys.value=settings.value.fields.filter(field=>field.scope==='detail'&&field.kind===(mode==='battery'?'battery':'manual')).map(field=>field.key)
  templateDialogVisible.value=true;templateLoading.value=true
  try{templates.value=await listFormationTemplates()}catch(cause){ElMessage.error(message(cause))}finally{templateLoading.value=false}
}
function selectTemplate(id:number|null){const template=templates.value.find(value=>value.id===id);if(template)templateFieldKeys.value=template.fields.filter(field=>field.scope==='detail'&&field.kind===(templateMode.value==='battery'?'battery':'manual')).map(field=>field.key)}
function applyFieldTemplate(){
  const kind=templateMode.value==='battery'?'battery':'manual',required=new Set(defaultFormationFields.filter(field=>field.scope==='detail').map(field=>field.key))
  const options=[...availableFields.value,...(templates.value.find(value=>value.id===templateId.value)?.fields??[])].filter(field=>field.scope==='detail'&&field.kind===kind)
  settings.value.fields=settings.value.fields.filter(field=>field.scope!=='detail'||field.kind!==kind||required.has(field.key))
  for(const key of templateFieldKeys.value){const field=options.find(field=>field.key===key);if(field&&!settings.value.fields.some(value=>value.key===key))settings.value.fields.push({...field})}
  const allowed=new Set(settings.value.fields.map(field=>field.key));settings.value.values=Object.fromEntries(Object.entries(settings.value.values).filter(([key])=>allowed.has(key.split('|').at(-1)!)))
  dirty.value=true;templateDialogVisible.value=false
}
async function saveFieldTemplate(asNew=false){
  try{const selected=templates.value.find(value=>value.id===templateId.value)
    const name=(await ElMessageBox.prompt('模板只保存字段选择,不包含测试和填写值。','保存化成字段模板',{inputValue:asNew?'':selected?.name??'',inputValidator:value=>Boolean(value?.trim())||'请输入名称'})).value.trim()
    applyFieldTemplate();const fields=settings.value.fields.filter(field=>field.scope==='detail'&&field.kind===(templateMode.value==='battery'?'battery':'manual'))
    if(!fields.length){ElMessage.warning('请先选择或添加字段');return}
    const existing=templates.value.find(value=>value.name===name)
    if(existing){if(!can('edit'))throw new Error('无编辑模板权限');await updateFormationTemplate(existing.id,name,fields)}else{if(!can('add'))throw new Error('无新增模板权限');await createFormationTemplate(name,fields)}
    ElMessage.success('模板已保存')
  }catch(cause){if(cause!=='cancel'&&cause!=='close')ElMessage.error(message(cause))}
}
</script>
<template>
  <div class="formation-form-page" :class="{ 'is-readonly': readonly }"><div class="page-tools"><button class="back-button" type="button" @click="router.push('/report-formation')"><el-icon><ArrowLeft /></el-icon>返回化成报表</button><span>{{ readonly ? 'VIEW FORMATION REPORT' : editingId ? 'EDIT FORMATION REPORT' : 'NEW FORMATION REPORT' }}</span></div>
    <section class="report-name-panel"><label><span>报表名称</span><el-input v-model="reportName" clearable placeholder="请输入报表名称" /></label></section>
    <section class="report-data-panel"><header class="panel-heading"><div><strong>报表数据</strong><span>测试详情字段</span></div></header><div class="report-data-table-wrap"><table class="report-data-table" :style="{ minWidth: `${Math.max(980, reportSamples.length * 150 + 160)}px` }"><thead><tr><th>电池组</th><th v-for="column in reportColumns" :key="column.id" :colspan="column.samples.length"><span class="group-index">{{ column.id }}</span>{{ column.name }}</th></tr><tr><th>电池号</th><template v-for="column in reportColumns" :key="`samples-${column.id}`"><th v-for="(sample, sampleIndex) in column.samples" :key="`${column.id}-${sampleIndex}`"><span class="fixed-sample">{{ sample }}</span></th></template></tr></thead><tbody><tr v-for="(field, fieldIndex) in reportDetailFields" :key="field.key"><td>{{ field.label }}</td><template v-for="(column, columnIndex) in reportColumns" :key="`fields-${column.id}`"><td v-for="(sample, sampleIndex) in column.samples" :key="`${field.key}-${column.id}-${sampleIndex}`"><el-input v-if="field.mode === 'input'" v-model="reportFieldValues[reportValueKey(field.key, `${column.id}-${sample}`)]" size="small" placeholder="请输入" /><span v-else-if="field.mode === 'raw'" class="raw-value">{{ rawReportValue(fieldIndex, sampleIndex + columnIndex) }}</span><span v-else class="auto-value">自动计算</span></td></template></tr></tbody></table></div><div v-if="!readonly" class="report-data-actions"><button class="primary-button" type="button" @click="addReportField"><el-icon><Plus /></el-icon>添加项</button><button class="secondary-button" type="button" @click="openFieldTemplate('battery')"><el-icon><DocumentCopy /></el-icon>电池组字段模板</button><button class="secondary-button" type="button" @click="openFieldTemplate('special')"><el-icon><DocumentCopy /></el-icon>选择特殊字段模板</button><button class="secondary-button" type="button" @click="saveSpecialTemplate"><el-icon><DocumentCopy /></el-icon>另存特殊字段模板</button></div></section>
    <section class="cycle-panel"><header class="panel-heading"><div><strong>化成循环明细</strong></div></header><div class="cycle-list"><article v-for="cycle in cycles" :key="cycle.cycle" class="cycle-block"><div class="cycle-block-heading"><strong>循环{{ cycle.cycle }}</strong></div><div class="cycle-table-wrap"><table class="cycle-matrix" :style="{ minWidth: `${Math.max(920, reportSamples.length * 170 + 190)}px` }"><thead><tr><th>指标字段</th><th v-for="(sample, sampleIndex) in reportSamples" :key="`${sample}-${sampleIndex}`">{{ sample }}</th></tr></thead><tbody><tr class="method-row"><td>测试方法</td><td v-for="(sample, sampleIndex) in reportSamples" :key="`${sample}-${sampleIndex}`"><el-input v-model="cycle.method" size="small" /></td></tr><tr v-for="(field, fieldIndex) in cycleFields" :key="field.key"><td>{{ field.label }}</td><td v-for="(sample, sampleIndex) in reportSamples" :key="`${field.key}-${sample}-${sampleIndex}`"><el-input v-if="field.mode === 'input'" v-model="cycleFieldValues[cycleValueKey(cycle.cycle, field.key, `${sample}-${sampleIndex}`)]" size="small" placeholder="请输入" /><span v-else-if="field.mode === 'raw'" class="raw-value">{{ rawValue(cycle.cycle, fieldIndex, sampleIndex) }}</span><span v-else class="auto-value">自动计算</span></td></tr></tbody></table></div></article></div></section>
    <section class="metrics-panel"><div v-if="!readonly" class="metric-switches"><label v-for="metric in formationMetricDefinitions" :key="metric.key"><span>{{ metric.title }}</span><el-switch v-model="formationMetricVisible[metric.key]" inline-prompt active-text="显" inactive-text="隐" /></label></div><div class="cycle-chart-tabs"><button v-for="cycle in cycles" :key="cycle.cycle" type="button" :class="{ active: activeCycle === cycle.cycle }" @click="activeCycle = cycle.cycle">循环{{ cycle.cycle }}</button></div><div class="chart-legend-bar"><strong>化成图例</strong><div v-for="(sample, index) in reportSamples.slice(0, 3)" :key="`${sample}-${index}`" class="chart-legend-item"><el-color-picker v-if="!readonly" class="legend-color-picker" :model-value="seriesColors[index]" :show-alpha="false" popper-class="legend-color-popper" :aria-label="`修改${sample}颜色`" @change="updateSeriesColor(index, $event)" /><span>{{ sample }}</span></div></div><div class="charts-grid"><article v-for="metric in visibleFormationMetrics" :key="metric.key" class="chart-card"><header><strong>{{ metric.title }}</strong><span>{{ metric.unit }}</span></header><VChart class="metric-chart" :option="chartOption(metric)" autoresize /></article></div></section>
    <section class="note-panel"><label><span>报表说明</span><el-input v-model="description" type="textarea" :rows="3" maxlength="300" show-word-limit /></label></section><footer class="form-actions"><button class="secondary-button" type="button" @click="exportPdf"><el-icon><Download /></el-icon>导出PDF</button><button class="secondary-button" type="button" @click="router.push('/report-formation')">{{ readonly ? '返回列表' : '取消' }}</button><button v-if="!readonly" class="primary-button" type="button" :disabled="saving" @click="saveReport"><el-icon><Check /></el-icon>{{ saving ? '保存中' : editingId ? '保存修改' : '保存报表' }}</button></footer>
    <el-dialog v-model="templateDialogVisible" class="formation-template-dialog" :title="templateMode === 'battery' ? '选择电池组字段模板' : '选择特殊字段模板'" :width="templateMode === 'battery' ? '720px' : '560px'" align-center>
      <div v-if="templateMode === 'battery'" class="battery-template-editor"><label class="battery-template-select"><span>选择模板</span><el-select v-model="selectedBatteryTemplateName" @change="switchBatteryTemplate"><el-option v-for="name in batteryTemplateNames" :key="name" :label="name" :value="name" /></el-select></label><el-checkbox-group v-if="batteryTemplateFieldOptions.length" v-model="selectedBatteryTemplateFieldIds" class="battery-template-fields" @change="applyBatteryTemplateFields"><el-checkbox v-for="field in batteryTemplateFieldOptions" :key="field.id" :value="field.id" :disabled="field.status === '禁用'" :title="`${field.name}(${field.type},${field.status})`">{{ field.name }}</el-checkbox></el-checkbox-group><div v-else class="template-empty">暂无字段,请先到电池组字段管理中添加字段</div></div>
      <el-radio-group v-else v-model="selectedTemplateKey" class="template-options"><el-radio v-for="item in fieldTemplates" :key="item.key" :value="item.key" border>{{ item.name }}({{ item.fields.join('、') }})</el-radio></el-radio-group>
      <template #footer><div v-if="templateMode === 'battery'" class="battery-template-actions"><button class="primary-button" type="button" @click="applyFieldTemplate"><el-icon><Check /></el-icon>确认</button><button class="primary-button" type="button" @click="saveBatteryTemplate"><el-icon><Check /></el-icon>保存</button><button class="secondary-button" type="button" @click="saveBatteryTemplateAs"><el-icon><DocumentCopy /></el-icon>另存模板</button></div><div v-else><button class="secondary-button" type="button" @click="templateDialogVisible = false">取消</button><button class="primary-button" type="button" @click="applyFieldTemplate">应用模板</button></div></template>
    </el-dialog>
  <div class="formation-form-page" :class="{ 'is-readonly': readonly }" v-loading="loading">
    <div class="page-tools"><button class="back-button" type="button" @click="router.push('/report-formation')"><el-icon><ArrowLeft /></el-icon>返回化成报表</button><span>{{ readonly ? 'VIEW FORMATION REPORT' : editingId ? 'EDIT FORMATION REPORT' : 'NEW FORMATION REPORT' }}</span></div>
    <el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
    <fieldset :disabled="busy || !!error" class="report-fieldset">
    <section class="report-name-panel"><label><span>报表名称</span><el-input v-model="reportName" :disabled="readonly" clearable maxlength="100" placeholder="请输入报表名称" @input="dirty=true" /></label><label><span>选择测试</span><el-select v-model="selectedTestIds" multiple filterable collapse-tags collapse-tags-tooltip :disabled="!!editingId || busy" placeholder="选择已完成的化成测试" @change="selectTests"><el-option v-for="test in testOptions" :key="test.id" :label="test.name" :value="test.id" /></el-select></label></section>
    <el-empty v-if="!matrix.items.length && !previewLoading" :description="editingId ? '该版本暂无来源数据' : '请选择化成测试'" />
    <div v-loading="previewLoading" v-if="matrix.items.length">
    <section class="report-data-panel"><header class="panel-heading"><div><strong>报表数据</strong><span>电池组字段</span></div></header>
      <div class="report-data-table-wrap"><table class="report-data-table" :style="{minWidth:`${Math.max(980,matrix.items.length*150+160)}px`}"><thead><tr><th>测试 / 电池组</th><th v-for="column in reportColumns" :key="column.id" :colspan="column.items.length">{{ column.name }} / {{ column.items[0]?.source.batteryGroup }}</th></tr><tr><th>电池号</th><th v-for="item in matrix.items" :key="formationSeriesKey(item)">{{ item.source.batteryNo }}</th></tr></thead><tbody>
        <tr v-for="field in detailFields" :key="field.key"><td>{{ field.displayName }}</td><td v-for="item in matrix.items" :key="formationSeriesKey(item)"><span v-if="field.kind==='calculated'" class="auto-value" :title="calculation(item,0,field).reason">{{ displayValue(item,0,field) }}</span><el-input v-else :model-value="inputValue(item,0,field)" :disabled="readonly" size="small" maxlength="500" placeholder="请输入" @update:model-value="updateValue(item,0,field,$event)" /></td></tr>
      </tbody></table></div>
      <div v-if="!readonly" class="report-data-actions"><button class="primary-button" type="button" @click="addReportField"><el-icon><Plus /></el-icon>添加项</button><button class="secondary-button" type="button" @click="openFieldTemplate('battery')">电池组字段模板</button><button class="secondary-button" type="button" @click="openFieldTemplate('special')">选择特殊字段模板</button><button class="secondary-button" type="button" @click="openFieldTemplate('special')">另存特殊字段模板</button></div>
    </section>
    <el-alert v-if="unknownSteps" :title="`以下工步方向未识别,未纳入充放电统计:${unknownSteps}`" type="warning" :closable="false" />
    <section class="cycle-panel"><header class="panel-heading"><div><strong>化成循环明细</strong><span>拐点两项暂为演示占位</span></div></header><el-empty v-if="!matrix.cycleNos.length" description="所选测试暂无已保存循环数据" /><div class="cycle-list"><article v-for="cycle in matrix.cycleNos" :key="cycle" class="cycle-block"><div class="cycle-block-heading"><strong>循环{{ cycle }}</strong></div><div class="cycle-table-wrap"><table class="cycle-matrix" :style="{minWidth:`${Math.max(920,matrix.items.length*170+190)}px`}"><thead><tr><th>指标字段</th><th v-for="item in matrix.items" :key="formationSeriesKey(item)">{{ item.source.name }}<br>{{ item.source.batteryNo }}</th></tr></thead><tbody>
      <tr v-for="field in methodFields" :key="field.key" class="method-row"><td>{{ field.displayName }}</td><td v-for="item in matrix.items" :key="formationSeriesKey(item)"><span v-if="!item.cycleNos.includes(cycle)">无该循环</span><el-input v-else :model-value="inputValue(item,cycle,field)" :disabled="readonly" size="small" maxlength="500" placeholder="请输入" @update:model-value="updateValue(item,cycle,field,$event)" /></td></tr>
      <tr v-for="index in chargeCount(cycle)" :key="`voltage-${index}`"><td>终止电压</td><td v-for="item in matrix.items" :key="formationSeriesKey(item)" :title="voltageTitle(item,cycle,index-1)"><span class="raw-value">{{ !item.cycleNos.includes(cycle)?'无该循环':formationNumberText(formationChargeSteps(item,cycle)[index-1]?.endVoltage) }}</span></td></tr>
      <tr v-for="field in cycleFields" :key="field.key"><td>{{ field.displayName }}{{ field.unit?` (${field.unit})`:'' }}</td><td v-for="item in matrix.items" :key="formationSeriesKey(item)"><span v-if="!item.cycleNos.includes(cycle)">无该循环</span><el-input v-else-if="field.kind==='manual'" :model-value="inputValue(item,cycle,field)" :type="field.key==='manual'?'number':'text'" :min="field.key==='manual'?0:undefined" step="any" :disabled="readonly" size="small" maxlength="500" placeholder="请输入" @update:model-value="updateValue(item,cycle,field,$event)" /><span v-else :class="field.kind==='calculated'?'auto-value':'raw-value'" :title="calculation(item,cycle,field).reason">{{ displayValue(item,cycle,field) }}</span></td></tr>
    </tbody></table></div></article></div></section>
    <section class="metrics-panel"><div class="metric-switches"><label v-for="metric in formationCurveMetrics" :key="metric.key"><span>{{ metric.title }}</span><el-switch :model-value="settings.metrics?.includes(metric.key)" inline-prompt active-text="显" inactive-text="隐" @update:model-value="setMetric(metric.key,!!$event)" /></label></div><div class="cycle-chart-tabs"><button v-for="cycle in matrix.cycleNos" :key="cycle" type="button" :class="{active:activeCycle===cycle}" @click="activeCycle=cycle">循环{{ cycle }}</button></div>
      <div class="chart-legend-bar"><strong>化成图例</strong><div v-for="(item,index) in matrix.items" :key="formationSeriesKey(item)" class="chart-legend-item"><el-color-picker v-if="!readonly" class="legend-color-picker" :model-value="formationSeriesColor(item,index,settings.seriesColors??{})" :show-alpha="false" :aria-label="`修改${item.source.name}-${item.source.batteryNo}颜色`" @change="setColor(item,$event)" /><button type="button" :class="{ 'hidden-legend':settings.hiddenSeriesKeys?.includes(formationSeriesKey(item)) }" @click="toggleSeries(item)">{{ item.source.name }}-{{ item.source.batteryNo }}</button></div></div>
      <template v-if="activeCycle!=null"><div v-for="group in formationStepGroups" :key="group.key" class="chart-zone"><h3>{{ group.title }}工步曲线</h3><div class="charts-grid"><article v-for="metric in visibleMetrics" :key="metric.key" class="chart-card"><header><strong>{{ metric.title }}</strong><span>{{ metric.unit }}</span></header><VChart class="metric-chart" :option="formationStepChartOption(metric,matrix.items,activeCycle,group.key,settings)" autoresize @legendselectchanged="legendChanged" /></article></div></div></template>
    </section>
    </div>
    <section class="note-panel"><label><span>报表说明</span><el-input v-model="description" :disabled="readonly" type="textarea" :rows="3" maxlength="2000" show-word-limit @input="dirty=true" /></label></section>
    </fieldset>
    <footer class="form-actions"><span v-if="saved">V{{ saved.versionNo }}</span><button v-if="saved&&can('query')" class="secondary-button" type="button" :disabled="busy" @click="openHistory">历史版本</button><button v-if="saved&&!readonly&&can('edit')" class="secondary-button" type="button" :disabled="busy||!!error" @click="refreshSource">按最新来源生成</button><button v-if="can('export')" class="secondary-button" type="button" :disabled="busy||!!error" @click="exportPdf"><el-icon><Download /></el-icon>导出PDF</button><button class="secondary-button" type="button" @click="router.push('/report-formation')">{{ readonly?'返回列表':'取消' }}</button><button v-if="!readonly&&can(editingId?'edit':'add')" class="primary-button" type="button" :disabled="busy||!!error||!matrix.items.length" @click="saveReport"><el-icon><Check /></el-icon>{{ saving?'保存中':editingId?'保存修改':'保存报表' }}</button></footer>
    <el-dialog v-model="templateDialogVisible" class="formation-template-dialog" :title="templateMode==='battery'?'选择电池组字段模板':'选择特殊字段模板'" width="720px" align-center><div v-loading="templateLoading" class="battery-template-editor"><label class="battery-template-select"><span>选择模板</span><el-select v-model="templateId" clearable placeholder="自定义" @change="selectTemplate"><el-option v-for="template in templates" :key="template.id" :label="template.name" :value="template.id" /></el-select></label><el-checkbox-group v-model="templateFieldKeys" class="battery-template-fields"><el-checkbox v-for="field in availableFields" :key="field.key" :value="field.key" :disabled="['Z','H','F','A','B'].includes(field.key)">{{ field.displayName }}</el-checkbox></el-checkbox-group></div><template #footer><div class="battery-template-actions"><button class="primary-button" type="button" :disabled="templateLoading" @click="applyFieldTemplate">确认</button><button class="primary-button" type="button" :disabled="templateLoading" @click="saveFieldTemplate()">保存</button><button class="secondary-button" type="button" :disabled="templateLoading" @click="saveFieldTemplate(true)">另存模板</button></div></template></el-dialog>
    <el-dialog v-model="historyVisible" title="历史版本" width="min(800px,calc(100vw - 28px))"><el-table :data="history"><el-table-column prop="versionNo" label="版本" width="80"/><el-table-column prop="operatorName" label="操作人"/><el-table-column label="时间"><template #default="{row}">{{ new Date(row.createdAt).toLocaleString() }}</template></el-table-column><el-table-column prop="changeSummary" label="修改说明"/><el-table-column label="操作" width="100"><template #default="{row}"><el-button link type="primary" @click="viewVersion(row.versionNo)">查看版本</el-button></template></el-table-column></el-table></el-dialog>
  </div>
</template>
@@ -280,4 +214,5 @@
@media(max-width:900px){.metrics-panel .charts-grid{grid-template-columns:minmax(0,1fr)}}@media(max-width:680px){.metric-switches{gap:14px;padding:0 12px}.metrics-panel>.chart-legend-bar{align-items:flex-start;flex-direction:column;gap:10px;padding:12px 12px}.cycle-chart-tabs{padding-left:12px}}
.formation-form-page.is-readonly :deep(.el-input), .formation-form-page.is-readonly :deep(.el-textarea) { pointer-events: none; }
.formation-form-page.is-readonly :deep(.el-input__wrapper), .formation-form-page.is-readonly :deep(.el-textarea__inner) { background: #f3f6f8; box-shadow: 0 0 0 1px #d8e1e9 inset; }
.report-fieldset{border:0;margin:0;padding:0;min-width:0}.report-name-panel{display:flex;flex-wrap:wrap;gap:24px}.report-name-panel label{flex:1;min-width:280px}.report-name-panel .el-select{width:100%}.chart-zone h3{padding:16px 24px 0;margin:0;color:#26333f;font-size:14px}.chart-legend-item button{border:0;background:transparent;color:#52606d;cursor:pointer}.hidden-legend{text-decoration:line-through;opacity:.5}.cycle-chart-tabs{flex-wrap:wrap;row-gap:8px}.form-actions{flex-wrap:wrap}.auto-value{font-variant-numeric:tabular-nums}@media(max-width:680px){.report-name-panel label{min-width:0;flex-basis:100%}.form-actions{position:static}.formation-form-page{padding-bottom:0}}
</style>
tests/formation-real-report.test.mjs
New file
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import load from './load-typescript.cjs'
const { formationCalculatedValue } = load(new URL('../src/utils/formationReportCalculations.ts', import.meta.url))
const { formationStepChartOption, formationStepSeries } = load(new URL('../src/utils/formationStepChart.ts', import.meta.url))
const source = { selection: { testId: 1, testBatteryId: 11 }, name: '化成甲', batteryNo: '电池1', batteryGroup: '组1' }
const item = { source, cycleNos: [1], metrics: {}, businessFields: Object.entries({H:'2',Z:'3',A:'4',F:'5',B:'6'}).map(([fieldName,fieldValue])=>({fieldName,fieldValue})),
  chargeSteps: {1:[{stepNo:1,direction:'CHARGE',endVoltage:4.1},{stepNo:2,direction:'UNKNOWN'}, {stepNo:3,direction:'CHARGE',endVoltage:4.2},{stepNo:4,direction:'DISCHARGE',capacity:0.25}]},
  curves: {1:{zeroTime:1000,records:[
    {sourceType:'SNAPSHOT',stepNo:1,deviceTime:1000,voltage:3,current:0.1,temperature:25},
    {sourceType:'SNAPSHOT',stepNo:1,deviceTime:2000,voltage:4,current:0.1,temperature:26},
    {sourceType:'SNAPSHOT',stepNo:2,deviceTime:3000,voltage:99,current:99,temperature:99},
    {sourceType:'SNAPSHOT',stepNo:3,deviceTime:5000,voltage:4.1,current:0,temperature:27},
    {sourceType:'SNAPSHOT',stepNo:4,deviceTime:6000,voltage:1,current:-0.1,temperature:28},
  ]}} }
const settings = {fields:[], values:{'1|11|1|manual':'2'},chargeStepSelections:{}}
test('报表字段修改驱动公式,空值不按零且末工步容量不回退',()=>{
  assert.equal(formationCalculatedValue(item,1,'chargePerGram',settings).value,240)
  assert.equal(formationCalculatedValue(item,1,'capacity1vf',settings).value,50)
  const changed = {...settings,values:{...settings.values,'1|11|0|F':'10'}}
  assert.equal(formationCalculatedValue(item,0,'C',changed).value,1440)
  assert.equal(formationCalculatedValue(item,1,'chargePerGram',changed).value,480)
  assert.equal(formationCalculatedValue(item,1,'capacity1vf',changed).value,25)
  assert.equal(formationCalculatedValue(item,1,'chargePerGram',{...settings,values:{}}).value,null)
  assert.equal(formationCalculatedValue(item,1,'capacity1vf',{...settings,values:{'1|11|0|F':'0'}}).value,null)
  const missing = {...item,chargeSteps:{1:[...item.chargeSteps[1],{stepNo:5,direction:'DISCHARGE',capacity:null}]}}
  assert.equal(formationCalculatedValue(missing,1,'capacity1v',settings).value,null)
})
test('同方向按电池单线保留真实间隔,静置断线且放电独立归零',()=>{
  assert.equal(formationStepSeries([item],1,'charge',settings).length,1)
  const charge = formationStepChartOption({key:'voltage',unit:'V'},[item],1,'charge',settings)
  assert.deepEqual(charge.series[0].data.filter(point=>point[1]!==null),[[0,3],[1,4],[4,4.1]])
  assert.ok(charge.series[0].data.some(point=>point[1]===null && point[0]>=1 && point[0]<=4))
  assert.deepEqual(formationStepChartOption({key:'current',unit:'mA'},[item],1,'discharge',settings).series[0].data,[[0,-100]])
  assert.deepEqual(formationStepChartOption({key:'temperature',unit:'℃'},[item],1,'charge',settings).series[0].data.filter(point=>point[1]!==null),[[0,25],[1,26],[4,27]])
})