| | |
| | | <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> |