yeg
2026-08-26 9f48020dfd3e29d1c0049a3da9df3203c40bc264
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { use } from 'echarts/core'
import { LineChart } from 'echarts/charts'
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'
 
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 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>>({})
 
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 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() {
  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”')
}
 
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')
}
</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>
</template>
 
<style scoped>
.formation-form-page{width:100%;padding-bottom:78px}.page-tools{height:34px;display:flex;align-items:center;justify-content:space-between}.page-tools>span{color:#7f8d99;font-size:10px;font-weight:600}.back-button{display:inline-flex;align-items:center;gap:6px;border:0;color:#52606d;background:transparent;font-size:13px;cursor:pointer}.primary-button,.secondary-button{height:34px;display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:0 13px;border:1px solid transparent;border-radius:3px;font-size:13px;cursor:pointer;white-space:nowrap}.primary-button{color:#fff;background:var(--blue);border-color:var(--blue)}.secondary-button{color:#52606d;background:#fff;border-color:var(--line-strong)}.report-name-panel,.setup-panel,.cycle-panel,.metrics-panel,.note-panel{background:#fff;border:1px solid #d8e1e9;margin-bottom:16px}.report-name-panel{padding:18px 22px}.report-name-panel label,.note-panel label{display:grid;grid-template-columns:72px minmax(260px,520px);align-items:center;gap:12px}.report-name-panel label>span,.note-panel label>span,.setup-grid label>span{color:#52606d;font-size:12px;font-weight:500}.report-name-panel :deep(.el-input__wrapper),.setup-grid :deep(.el-input__wrapper),.setup-grid :deep(.el-select__wrapper),.note-panel :deep(.el-textarea__inner),.cycle-table :deep(.el-input__wrapper){border-radius:3px;box-shadow:0 0 0 1px #d8e1e9 inset;background:#f8fafc}.panel-heading{min-height:48px;display:flex;align-items:center;justify-content:space-between;padding:0 24px;border-bottom:1px solid var(--line)}.panel-heading>div{display:flex;align-items:center;gap:9px}.panel-heading>strong,.panel-heading>div>strong{color:#26333f;font-size:13px}.panel-heading span{color:#8795a3;font-size:11px;margin-left:10px}.setup-grid{display:grid;grid-template-columns:repeat(2,minmax(240px,1fr));gap:18px;padding:20px 24px 12px}.setup-grid label{display:grid;grid-template-columns:72px minmax(0,1fr);align-items:center;gap:10px}.sample-toolbar{display:flex;align-items:center;justify-content:space-between;padding:4px 24px 10px;color:#52606d;font-size:12px}.sample-list{display:flex;flex-wrap:wrap;gap:10px;padding:0 24px 20px}.sample-chip{display:flex;align-items:center;gap:6px}.sample-chip :deep(.el-select){width:150px}.delete-link{border:0;color:#c45454;background:transparent;font-size:12px;cursor:pointer}.cycle-tabs{display:flex;gap:0;padding:16px 24px 0}.cycle-tabs button{min-width:84px;height:30px;color:#1677dd;background:#fff;border:1px solid #78b2f9;font-size:12px;cursor:pointer}.cycle-tabs button:first-child{border-radius:3px 0 0 3px}.cycle-tabs button:last-child{border-radius:0 3px 3px 0}.cycle-tabs button+button{border-left:0}.cycle-tabs button.active{color:#fff;background:#2188f5}.cycle-table-wrap{overflow-x:auto;padding:14px 24px 22px}.cycle-table{width:100%;min-width:900px;border-collapse:collapse;color:#52606d;font-size:12px}.cycle-table th,.cycle-table td{height:42px;padding:0 10px;border:1px solid #cbd8e4;text-align:center;white-space:nowrap}.cycle-table th{color:#fff;background:#3c6497;font-weight:600}.cycle-table th:nth-child(1){width:90px}.cycle-table th:nth-child(2){width:240px}.cycle-table th:nth-child(3){width:150px}.cycle-table th:nth-child(4),.cycle-table th:nth-child(5){width:130px}.charts-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;padding:18px 24px 24px}.chart-legend-bar{display:flex;align-items:center;gap:20px;padding:12px 24px;border-bottom:1px solid var(--line);color:#52606d;font-size:12px}.legend-item{display:inline-flex;align-items:center;gap:7px}.legend-color{width:14px;height:14px;border-radius:2px}.chart-card{border:1px solid #d8e1e9;background:#fff}.chart-card header{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;color:#26333f;font-size:12px}.chart-card header span{color:#8795a3;font-size:10px}.metric-chart{width:100%;height:230px}.note-panel{padding:18px 24px}.note-panel :deep(.el-textarea){min-width:260px}.form-actions{position:sticky;bottom:0;z-index:4;display:flex;justify-content:flex-end;gap:10px;padding:14px 0;background:rgba(244,248,251,.96);border-top:1px solid #d8e1e9}@media(max-width:900px){.setup-grid,.charts-grid{grid-template-columns:1fr}.report-name-panel label,.note-panel label{grid-template-columns:1fr}.report-name-panel label>span,.note-panel label>span{margin-bottom:6px}.form-actions{position:static}}
.cycle-matrix{width:100%;border-collapse:collapse;color:#52606d;font-size:12px}.cycle-matrix th,.cycle-matrix td{height:40px;padding:0 10px;border:1px solid #cbd8e4;text-align:center;white-space:nowrap}.cycle-matrix th{color:#fff;background:#3c6497;font-weight:600}.cycle-matrix th:first-child{width:190px}.cycle-matrix th:last-child{width:90px}.cycle-matrix td:first-child{color:#31404d;background:#f2f7fb;font-weight:500;text-align:left}.cycle-matrix td:last-child{color:#8795a3;background:#fafcfe}.cycle-matrix :deep(.el-input__wrapper){border-radius:3px;box-shadow:0 0 0 1px #d8e1e9 inset;background:#f8fafc}.method-row td:first-child{border-left:3px solid #2188f5}.raw-value{color:#405261;font-family:'Cascadia Mono',Consolas,monospace}.auto-value{color:#2188f5;font-weight:500}.cycle-summary{display:flex;align-items:center;gap:28px;margin:0 24px 20px;padding:12px 14px;border:1px solid #d8e1e9;background:#f7fafc}.cycle-summary>div{display:flex;flex-direction:column;gap:3px}.cycle-summary span{color:#8795a3;font-size:10px}.cycle-summary strong{color:#33414e;font-size:12px}.cycle-summary>button{margin-left:auto}@media(max-width:900px){.cycle-summary{align-items:flex-start;flex-wrap:wrap}.cycle-summary>button{margin-left:0}}
.report-data-panel{background:#fff;border:1px solid #d8e1e9;margin-bottom:16px}.report-data-meta{display:flex;align-items:center;gap:12px}.report-data-meta label{display:flex;align-items:center;gap:6px;color:#52606d;font-size:11px;font-weight:500}.report-data-meta :deep(.el-select){width:190px}.report-data-meta :deep(.el-input){width:150px}.report-data-meta :deep(.el-input__wrapper),.report-data-meta :deep(.el-select__wrapper){min-height:30px;border-radius:3px;box-shadow:0 0 0 1px #d8e1e9 inset;background:#f8fafc}.report-data-table-wrap{overflow-x:auto;padding:18px 24px 0}.report-data-table{width:100%;border-collapse:collapse;color:#52606d;font-size:12px}.report-data-table th,.report-data-table td{height:38px;padding:0 10px;border:1px solid #cbd8e4;text-align:center;white-space:nowrap}.report-data-table th{color:#fff;background:#3c6497;font-weight:600}.report-data-table th:first-child,.report-data-table td:first-child{width:150px}.report-data-table td:first-child{color:#31404d;background:#f7fafc;font-weight:500}.report-data-table td:last-child,.report-data-table th:last-child{width:90px;color:#8795a3;background:#fafcfe}.report-data-table :deep(.el-select){width:100px}.report-data-table :deep(.el-select__wrapper),.report-data-table :deep(.el-input__wrapper){min-height:28px;border-radius:3px;box-shadow:0 0 0 1px #d8e1e9 inset;background:#fff}.group-index{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;margin-right:8px;border:1px solid #cbd8e4;border-radius:50%;color:#647380;background:#fff;font-size:10px}.report-data-actions{display:flex;align-items:center;gap:10px;padding:18px 24px 20px}.report-data-actions .primary-button{height:30px;padding:0 12px;font-size:12px}@media(max-width:900px){.report-data-meta{align-items:flex-start;flex-direction:column;gap:6px}.report-data-actions{flex-wrap:wrap}}
.fixed-meta-value,.fixed-sample{color:#405261;font-weight:500}.fixed-meta-value{min-width:110px;padding:6px 10px;border:1px solid #d8e1e9;background:#f8fafc}.fixed-sample{display:inline-block;min-width:72px;padding:5px 8px;border:1px solid #d8e1e9;border-radius:3px;background:#f8fafc}
.formation-form-page :deep(.formation-template-dialog){max-width:calc(100vw - 28px);border-radius:4px}.formation-form-page :deep(.formation-template-dialog .el-dialog__header){margin:0;padding:17px 20px;border-bottom:1px solid var(--line)}.formation-form-page :deep(.formation-template-dialog .el-dialog__body){padding:20px}.formation-form-page :deep(.formation-template-dialog .el-dialog__footer){padding:14px 20px;border-top:1px solid var(--line)}.battery-template-select{display:flex;align-items:center;gap:12px;margin-bottom:18px;color:#52606d;font-size:12px}.battery-template-select :deep(.el-select){width:260px}.battery-template-fields{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px 18px;max-height:260px;overflow:auto;padding:14px;border:1px solid #d8e1e9;background:#f8fafc}.template-options{display:flex;flex-direction:column;gap:10px}.template-options :deep(.el-radio){height:auto;min-height:42px;margin-right:0;white-space:normal}.battery-template-actions{display:flex;justify-content:flex-end;gap:8px}.template-empty{padding:30px 0;color:#8795a3;text-align:center}@media(max-width:680px){.battery-template-fields{grid-template-columns:repeat(2,minmax(0,1fr))}.battery-template-actions{flex-wrap:wrap}}
.report-data-table th:last-child{width:auto;color:#fff;background:#3c6497}.report-data-table td:last-child{width:auto;color:#52606d;background:#fff}
.cycle-list{display:flex;flex-direction:column}.cycle-block{padding-top:18px;border-bottom:8px solid #eef3f7}.cycle-block:last-child{border-bottom:0}.cycle-block-heading{display:flex;align-items:center;gap:12px;margin:0 24px;padding:9px 12px;border-left:3px solid #2188f5;background:#f2f7fb}.cycle-block-heading strong{color:#26333f;font-size:13px}.cycle-block-heading span{color:#8795a3;font-size:11px}.readonly-cycle-value{color:#405261;font-size:12px}.cycle-block .cycle-table-wrap{padding-top:12px}.cycle-block .cycle-summary{margin-bottom:18px}
.cycle-matrix th:last-child{width:auto;color:#fff;background:#3c6497}.cycle-matrix td:last-child{width:auto;color:#52606d;background:#fff}
.metric-switches{display:flex;flex-wrap:wrap;align-items:center;gap:20px 28px;min-height:58px;padding:0 24px;border-bottom:1px solid var(--line)}.metric-switches label{display:inline-flex;align-items:center;gap:7px;color:#52606d;font-size:12px}.metric-switches :deep(.el-switch){--el-switch-on-color:#3978f6}.cycle-chart-tabs{display:flex;gap:0;padding:16px 24px 0}.cycle-chart-tabs button{min-width:84px;height:30px;color:#1677dd;background:#fff;border:1px solid #78b2f9;font-size:12px;cursor:pointer}.cycle-chart-tabs button:first-child{border-radius:3px 0 0 3px}.cycle-chart-tabs button:last-child{border-radius:0 3px 3px 0}.cycle-chart-tabs button+button{border-left:0}.cycle-chart-tabs button.active{color:#fff;background:#2188f5}.metrics-panel>.chart-legend-bar{min-height:52px;display:flex;flex-wrap:wrap;align-items:center;gap:12px 24px;padding:0 24px;border-bottom:1px solid var(--line)}.metrics-panel>.chart-legend-bar>strong{color:#26333f;font-size:12px;font-weight:600}.chart-legend-item{display:inline-flex;align-items:center;gap:7px;min-width:0;color:#52606d;font-size:12px}.chart-legend-item span{max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.legend-color-picker{width:18px;height:18px;display:inline-flex;align-items:center;justify-content:center}.legend-color-picker :deep(.el-color-picker__trigger){width:14px!important;height:14px!important;padding:0!important;border:0!important;border-radius:2px;background:transparent!important;box-shadow:none!important;overflow:hidden}.legend-color-picker :deep(.el-color-picker__color){width:14px;height:14px;border:0;border-radius:2px;box-shadow:0 0 0 1px rgba(36,54,71,.18)}.legend-color-picker :deep(.el-color-picker__color-inner){border-radius:2px}.legend-color-picker :deep(.el-color-picker__icon),.legend-color-picker :deep(.el-color-picker__empty),.legend-color-picker :deep(.el-color-picker__trigger svg),.legend-color-picker :deep(.el-color-picker__trigger .el-icon){display:none!important}.legend-color-picker :deep(.el-color-picker__trigger:hover){box-shadow:0 0 0 2px rgba(43,120,163,.24)!important}:global(.legend-color-popper.el-popper){padding:0!important;border:1px solid #b9c9d8!important;border-radius:4px!important;box-shadow:0 10px 24px rgba(25,48,69,.18)!important;overflow:hidden}:global(.legend-color-popper .el-color-picker-panel){border:0!important;box-shadow:none!important}.metrics-panel .charts-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;padding:18px 24px 24px}.metrics-panel .chart-card{min-width:0;border:1px solid #edf1f5;background:#fff}.metrics-panel .chart-card header{height:38px;display:flex;align-items:center;gap:18px;padding:0 14px;color:#52606d;font-size:11px}.metrics-panel .chart-card header strong{margin-right:auto;color:#26333f;font-size:13px}.metrics-panel .metric-chart{height:210px}
@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; }
</style>