接入化成报表真实页面与充放电曲线
保留现有页面布局,支持多选化成测试、循环并集、逐电池报表字段修改与公式联动。接通真实保存、历史版本及打印,充放电分别按电池单线绘制并保留空档。
| | |
| | | |
| | | 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[] |
| | |
| | | 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 } |
| New file |
| | |
| | | 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))) |
| | | } |
| | |
| | | |
| | | 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})` : ''}` } |
| | |
| | | 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) { |
| | |
| | | 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) |
| | | } |
| | |
| | | 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}))} |
| | | } |
| | |
| | | <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) |
| | |
| | | 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> |
| | | |
| | |
| | | <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' |
| | |
| | | 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> |
| | | |
| | |
| | | @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> |
| New file |
| | |
| | | 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]]) |
| | | }) |