yeg
2026-09-08 f1b5b2be3ece578d9279089e4824154a60006b3b
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
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
<script setup lang="ts">
import { computed, 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 { getUserInfo } from '@/api/auth'
import { getFormationSources, previewFormationReport, formationValueKey, type FormationCandidate, type FormationItem, type FormationField } from '@/api/formationReports'
import { defaultFormationFields, formationFieldLabel, formationSourceValue, formationChargeChoice } from '@/utils/formationReportFields'
import { formationChartOption, formationCurveMetrics, formationSeriesKey, formationSeriesName, formationSeriesColor } from '@/utils/formationCycleChart'
import type { ReportMetricKey } from '@/utils/reportMeasurementChart'
import { openFormationSavedReportPrint } from '@/utils/formationSavedReportPrint'
import FormationFieldControls from '@/components/FormationFieldControls.vue'
import FormationStepCurves from '@/components/FormationStepCurves.vue'
import { formationChargeSteps, formationCycleKey } from '@/utils/formationStepChart'
import type { FormationSettings, FormationStepSelection } from '@/api/formationReports'
import { createFormationReport, getFormationReport, editFormationReport, refreshFormationSource, listFormationVersions,
  type SavedFormationReport, type FormationVersion } from '@/api/formationReports'
 
use([CanvasRenderer, LineChart, GridComponent, LegendComponent, TooltipComponent])
const route = useRoute()
const router = useRouter()
const reportName = ref('')
const description = ref('')
const loadError = ref('')
const previewError = ref('')
const loading = ref(false)
const previewLoading = ref(false)
const canCreate = ref(false)
const canEdit = ref(false)
const canQuery = ref(false)
const canExport = ref(false)
const printing = ref(false)
const editingId = computed(() => Number(route.params.id) || null)
const readonly = computed(() => Boolean(editingId.value) && (route.query.mode !== 'edit' || !canEdit.value || Boolean(route.query.version)))
const saved = ref<SavedFormationReport | null>(null)
const savedContent = ref('')
const saving = ref(false)
const changeSummary = ref('')
const historyVisible = ref(false)
const history = ref<FormationVersion[]>([])
const historyLoading = ref(false)
const historyError = ref('')
const candidates = ref<FormationCandidate[]>([])
const selectedKeys = ref<string[]>([])
const items = ref<FormationItem[]>([])
const cycleNos = ref<number[]>([])
const fields = ref<FormationField[]>(structuredClone(defaultFormationFields))
const values = ref<Record<string, string>>({})
const chargeStepSelections = ref<Record<string, number>>({})
const metrics = ref<ReportMetricKey[]>(formationCurveMetrics.map(metric => metric.key))
const seriesColors = ref<Record<string, string>>({})
const activeCycle = ref<number | null>(null)
const manualChargeSteps = ref<Record<string, number[]>>({})
const curveStepSelections = ref<Record<string, FormationStepSelection>>({})
const hiddenSeriesKeys = ref<string[]>([])
const actualSteps = computed(() => !saved.value || saved.value.curveMode === 'actual-steps')
const stepSettings = computed<FormationSettings>(() => ({ fields: fields.value, values: values.value, chargeStepSelections: chargeStepSelections.value, metrics: metrics.value, seriesColors: seriesColors.value, manualChargeSteps: manualChargeSteps.value, curveStepSelections: curveStepSelections.value, hiddenSeriesKeys: hiddenSeriesKeys.value, activeCycle: activeCycle.value }))
function changeStepSettings(settings: Partial<FormationSettings>) {
  if (settings.curveStepSelections) curveStepSelections.value = settings.curveStepSelections
  if (settings.hiddenSeriesKeys) hiddenSeriesKeys.value = settings.hiddenSeriesKeys
  if (settings.seriesColors) seriesColors.value = settings.seriesColors
}
function unknownSteps(item: FormationItem, cycle: number) { return (item.chargeSteps?.[cycle] ?? []).filter(step => step.direction === 'UNKNOWN') }
function terminationRows(cycle: number) { return Math.max(1, ...items.value.map(item => formationChargeSteps(item, cycle, manualChargeSteps.value).length)) }
function termination(item: FormationItem, cycle: number, index: number) {
  if (!item.cycleNos.includes(cycle)) return '无该循环'
  const step = formationChargeSteps(item, cycle, manualChargeSteps.value)[index]
  if (!step) return index ? '—' : (item.chargeSteps?.[cycle]?.length ? '未识别充电工步' : '缺工步')
  return `工步${step.stepNo} · ${step.name || '未命名工步'} · ${step.endVoltage ?? '缺失'}${step.endVoltage == null ? '' : ' V'}`
}
const visibleMetrics = computed(() => formationCurveMetrics.filter(metric => metrics.value.includes(metric.key)))
const hasCurves = computed(() => items.value.some(item => item.curves?.[activeCycle.value ?? -1]?.records.length))
const detailFields = computed(() => fields.value.filter(field => field.scope === 'detail'))
const cycleFields = computed(() => fields.value.filter(field => field.scope === 'cycle'))
const reportColumns = computed(() => [...new Set(items.value.map(item => item.source.selection.testId))].map(id => ({
  id, name: items.value.find(item => item.source.selection.testId === id)!.source.name,
  items: items.value.filter(item => item.source.selection.testId === id),
})))
const reportItems = computed(() => reportColumns.value.flatMap(column => column.items))
const tableStyle = computed(() => ({ minWidth: `${Math.max(720, reportItems.value.length * 160 + 180)}px` }))
function selectionKey(candidate: FormationCandidate) { return `${candidate.selection.testId}|${candidate.selection.testBatteryId}` }
function inputValue(item: FormationItem, cycle: number, field: FormationField) {
  return values.value[formationValueKey(item, cycle, field)] ?? formationSourceValue(item, cycle, field)
}
function setValue(item: FormationItem, cycle: number, field: FormationField, value: string) {
  values.value[formationValueKey(item, cycle, field)] = value
}
function chargeChoices(item: FormationItem, cycle: number) { return item.chargeSteps?.[String(cycle)] || [] }
function chargeChoice(item: FormationItem, cycle: number) {
  return formationChargeChoice(item, cycle, chargeStepSelections.value)
}
function chargeKey(item: FormationItem, cycle: number) { return `${item.source.selection.testId}|${item.source.selection.testBatteryId}|${cycle}` }
function updateSeriesColor(item: FormationItem, color: string | null) { if (color) seriesColors.value[formationSeriesKey(item)] = color }
function formContent() { return JSON.stringify({ name: reportName.value, description: description.value, settings: actualSteps.value ? stepSettings.value : { fields: fields.value, values: values.value, chargeStepSelections: chargeStepSelections.value, metrics: metrics.value, seriesColors: seriesColors.value } }) }
function restore(record: SavedFormationReport) {
  saved.value = record; reportName.value = record.name; description.value = record.description || ''
  items.value = record.items; cycleNos.value = record.cycleNos
  fields.value = record.settings.fields; values.value = record.settings.values; chargeStepSelections.value = record.settings.chargeStepSelections || {}
  metrics.value = record.settings.metrics ?? formationCurveMetrics.map(metric => metric.key)
  seriesColors.value = record.settings.seriesColors ?? {}
  manualChargeSteps.value = record.settings.manualChargeSteps ?? {}; curveStepSelections.value = record.settings.curveStepSelections ?? {}
  hiddenSeriesKeys.value = record.settings.hiddenSeriesKeys ?? []; activeCycle.value = record.settings.activeCycle ?? record.cycleNos[0] ?? null
  changeSummary.value = ''; savedContent.value = formContent()
}
function pruneValues() {
  const allowed = new Set(items.value.flatMap(item => fields.value.flatMap(field =>
    (field.scope === 'detail' ? [0] : item.cycleNos).map(cycle => formationValueKey(item, cycle, field)))))
  values.value = Object.fromEntries(Object.entries(values.value).filter(([key]) => allowed.has(key)))
  const sourceKeys = new Set(items.value.flatMap(item => [formationSeriesKey(item), ...Object.entries(item.stepCurves ?? {}).flatMap(([cycle, steps]) => Object.keys(steps).map(step => `${formationSeriesKey(item)}|${cycle}|${step}`))]))
  hiddenSeriesKeys.value = hiddenSeriesKeys.value.filter(key => sourceKeys.has(key))
  const cycleKeys = new Set(items.value.flatMap(item => item.cycleNos.map(cycle => formationCycleKey(item, cycle))))
  manualChargeSteps.value = Object.fromEntries(Object.entries(manualChargeSteps.value).filter(([key]) => cycleKeys.has(key)))
  curveStepSelections.value = Object.fromEntries(Object.entries(curveStepSelections.value).filter(([key]) => cycleKeys.has(key)))
  seriesColors.value = Object.fromEntries(Object.entries(seriesColors.value).filter(([key]) => sourceKeys.has(key)))
}
function applyFields(next: FormationField[]) {
  // 模板仅改变草稿结构;同名换共享身份时不能继承原字段的人工调整。
  const unchanged = new Set(next.filter(field => fields.value.some(previous => previous.key === field.key && previous.scope === field.scope
    && previous.kind === field.kind && (previous.fieldDefinitionId ?? null) === (field.fieldDefinitionId ?? null))).map(field => `${field.scope}|${field.key}`))
  values.value = Object.fromEntries(Object.entries(values.value).filter(([key]) => {
    const parts = key.split('|')
    return unchanged.has(`${parts[2] === '0' ? 'detail' : 'cycle'}|${parts[3]}`)
  }))
  fields.value = next
  pruneValues()
}
async function saveReport() {
  if (!reportName.value.trim()) { ElMessage.warning('请输入报表名称'); return }
  if (!items.value.length || previewLoading.value || previewError.value) { ElMessage.warning('请先选择有效化成来源'); return }
  if (editingId.value && !changeSummary.value.trim()) { ElMessage.warning('请输入修改说明'); return }
  saving.value = true
  try {
    pruneValues()
    const content = { name: reportName.value.trim(), description: description.value, settings: actualSteps.value ? stepSettings.value : { fields: fields.value, values: values.value, chargeStepSelections: chargeStepSelections.value, metrics: metrics.value, seriesColors: seriesColors.value } }
    const result = editingId.value && saved.value
      ? await editFormationReport(editingId.value, { ...content, baseVersionNo: saved.value.versionNo, changeSummary: changeSummary.value.trim() })
      : await createFormationReport({ ...content, selections: items.value.map(item => item.source.selection) })
    restore(result)
    ElMessage.success('化成报表已保存')
    if (canQuery.value) await router.replace({ path: `/report-formation/${result.id}/edit`, query: { mode: 'view' } })
    else if (canEdit.value) await router.replace({ path: `/report-formation/${result.id}/edit`, query: { mode: 'edit' } })
    else { selectedKeys.value = []; values.value = {}; reportName.value = ''; description.value = ''; saved.value = null }
  } catch (error) { ElMessage.error(error instanceof Error ? error.message : '化成报表保存失败') }
  finally { saving.value = false }
}
async function refreshSource() {
  if (!saved.value || !editingId.value) return
  if (formContent() !== savedContent.value) { ElMessage.warning('请先保存当前修改'); return }
  try {
    const { value } = await ElMessageBox.prompt('按最新来源生成新版本,保留仍属于当前循环的人工调整;已消失循环的人工值仅保留在历史版。', '按最新来源生成新版本', {
      inputPlaceholder: '请输入修改说明', inputValidator: value => Boolean(value?.trim()) || '请输入修改说明', confirmButtonText: '生成', cancelButtonText: '取消',
    })
    saving.value = true
    const result = await refreshFormationSource(editingId.value, saved.value.versionNo, value.trim())
    restore(result); ElMessage.success('已按最新来源生成新版本')
  } catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '更新来源失败') }
  finally { saving.value = false }
}
async function openHistory() {
  if (!editingId.value) return
  historyVisible.value = true; historyLoading.value = true; historyError.value = ''
  try { history.value = await listFormationVersions(editingId.value) }
  catch (error) { historyError.value = error instanceof Error ? error.message : '历史版本加载失败' }
  finally { historyLoading.value = false }
}
async function openVersion(versionNo: number) {
  historyVisible.value = false
  await router.push({ path: `/report-formation/${editingId.value}/edit`, query: { mode: 'view', version: versionNo } })
}
async function printReport(versionNo = saved.value?.versionNo) {
  if (printing.value || saving.value || loading.value || loadError.value || !canExport.value) return
  if (!saved.value || versionNo === undefined) { ElMessage.warning('请先保存报表后再打印'); return }
  if (formContent() !== savedContent.value || changeSummary.value.trim()) { ElMessage.warning('有未保存修改,请先保存新版本后再打印'); return }
  printing.value = true
  try { await openFormationSavedReportPrint(saved.value.id, versionNo) }
  catch (error) { ElMessage.error(error instanceof Error ? error.message : '打印失败,请重试') }
  finally { printing.value = false }
}
// 快速切换来源时只接受最后一次预览,避免旧响应把矩阵归入错误的选择。
let previewSequence = 0
async function preview() {
  const sequence = ++previewSequence
  previewError.value = ''
  if (!selectedKeys.value.length) { items.value = []; cycleNos.value = []; previewLoading.value = false; return }
  previewLoading.value = true
  try {
    const selections = candidates.value.filter(candidate => selectedKeys.value.includes(selectionKey(candidate))).map(candidate => candidate.selection)
    const result = await previewFormationReport(selections)
    if (sequence !== previewSequence) return
    items.value = result.items
    cycleNos.value = result.cycleNos
    fields.value = fields.value.map(field => field.kind === 'battery' && field.fieldDefinitionId == null
      ? { ...field, fieldDefinitionId: items.value.flatMap(item => item.businessFields).find(value => value.fieldName === field.key)?.fieldDefinitionId ?? null } : field)
    pruneValues()
  } catch (error) {
    if (sequence !== previewSequence) return
    items.value = []; cycleNos.value = []
    previewError.value = error instanceof Error ? error.message : '化成数据加载失败'
  } finally { if (sequence === previewSequence) previewLoading.value = false }
}
async function load() {
  loading.value = true; loadError.value = ''
  previewSequence++; previewLoading.value = false; previewError.value = ''
  saved.value = null; items.value = []; cycleNos.value = []; values.value = {}; chargeStepSelections.value = {}; selectedKeys.value = []
  try {
    const permissions = (await getUserInfo()).permissions || []
    const has = (action: string) => permissions.includes('*:*:*') || permissions.includes(`system:formation-report:${action}`)
    canCreate.value = has('add'); canEdit.value = has('edit'); canQuery.value = has('query'); canExport.value = has('export')
    if (!editingId.value) {
      if (!canCreate.value) throw new Error('无新增化成报表权限')
      reportName.value = ''; description.value = ''; fields.value = structuredClone(defaultFormationFields); chargeStepSelections.value = {}
      metrics.value = formationCurveMetrics.map(metric => metric.key); seriesColors.value = {}
      manualChargeSteps.value = {}; curveStepSelections.value = {}; hiddenSeriesKeys.value = []; activeCycle.value = null
      candidates.value = await getFormationSources()
    } else {
      const editing = route.query.mode === 'edit' && !route.query.version
      if (editing ? !canEdit.value : !canQuery.value) throw new Error(editing ? '无编辑化成报表权限' : '无查看化成报表权限')
      restore(await getFormationReport(editingId.value, route.query.version ? Number(route.query.version) : undefined, editing))
    }
  } catch (error) { loadError.value = error instanceof Error ? error.message : '化成报表加载失败'; ElMessage.error(loadError.value) }
  finally { loading.value = false }
}
watch(selectedKeys, () => { if (!editingId.value) void preview() })
watch(cycleNos, cycles => { if (activeCycle.value === null || !cycles.includes(activeCycle.value)) activeCycle.value = cycles[0] ?? null })
watch(() => route.fullPath, () => { void load() }, { immediate: true })
</script>
 
<template>
  <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 ? '查看化成报表' : editingId ? '编辑化成报表' : '新建化成报表' }}</span></div>
    <el-alert v-if="loadError" :title="loadError" type="error" :closable="false" />
    <template v-else>
      <section class="report-name-panel"><label><span>报表名称</span><el-input v-model="reportName" :disabled="readonly" maxlength="100" clearable placeholder="请输入报表名称" /></label></section>
      <div v-if="saved" class="version-bar"><span>版本 {{ saved.versionNo }} · {{ saved.operatorName }} · {{ new Date(saved.versionCreatedAt).toLocaleString() }}</span><span>{{ saved.changeSummary }}</span><button v-if="canQuery" class="secondary-button" @click="openHistory">历史版本</button><button v-if="readonly && canEdit" class="secondary-button" @click="router.push({ path: `/report-formation/${editingId}/edit`, query: { mode: 'edit' } })">编辑</button><button v-if="!readonly" class="secondary-button" :disabled="saving" @click="refreshSource">按最新来源生成新版本</button></div>
      <section v-if="!editingId" class="source-panel">
        <label><span>化成测试 / 电池号</span><el-select v-model="selectedKeys" multiple filterable collapse-tags collapse-tags-tooltip placeholder="选择化成测试及电池号" :disabled="loading" aria-label="化成来源">
          <el-option v-for="candidate in candidates" :key="selectionKey(candidate)" :value="selectionKey(candidate)" :label="`${candidate.name} / ${candidate.batteryGroup} / ${candidate.batteryNo}`" />
        </el-select></label>
        <el-empty v-if="!loading && !candidates.length" description="暂无检测完成的化成测试" :image-size="60" />
      </section>
      <el-alert v-if="previewError" :title="previewError" type="error" :closable="false" />
      <div v-loading="previewLoading">
        <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="tableStyle">
            <thead><tr><th>测试</th><th v-for="column in reportColumns" :key="column.id" :colspan="column.items.length">{{ column.name }}</th></tr>
              <tr><th>电池组 / 电池号</th><th v-for="item in reportItems" :key="selectionKey(item.source)">{{ item.source.batteryGroup }} / {{ item.source.batteryNo }}</th></tr></thead>
            <tbody><tr v-for="field in detailFields" :key="field.key"><td>{{ formationFieldLabel(field) }}</td><td v-for="item in reportItems" :key="selectionKey(item.source)">
              <el-input :model-value="inputValue(item, 0, field)" @update:model-value="setValue(item, 0, field, $event)" :disabled="readonly" :aria-label="`${item.source.name} ${item.source.batteryNo} ${field.displayName}`" :placeholder="field.kind === 'manual' ? '人工填写' : '指标缺失'" size="small" />
            </td></tr></tbody>
          </table></div>
          <el-empty v-if="!reportItems.length && !previewLoading" description="暂无所选测试数据" :image-size="60" />
          <FormationFieldControls v-if="!readonly" :fields="fields" :items="items" :can-create="canCreate" :can-edit="canEdit" @apply="applyFields" />
        </section>
        <section class="cycle-panel"><div class="cycle-list">
          <article v-for="cycle in cycleNos" :key="cycle" class="cycle-card" :data-cycle="cycle">
            <h3>循环{{ cycle }}</h3>
            <div class="cycle-table-wrap"><table class="cycle-table cycle-data-table" :style="tableStyle">
              <thead><tr><th>测试</th><th v-for="column in reportColumns" :key="column.id" :colspan="column.items.length">{{ column.name }}</th></tr>
                <tr><th>指标</th><th v-for="item in reportItems" :key="selectionKey(item.source)">{{ item.source.batteryGroup }} / {{ item.source.batteryNo }}</th></tr></thead>
              <tbody><template v-if="!actualSteps"><tr><td>充电工步号</td><td v-for="item in reportItems" :key="selectionKey(item.source)"><template v-if="item.cycleNos.includes(cycle)"><el-select v-if="chargeChoices(item, cycle).length && !chargeChoice(item, cycle)" :model-value="chargeStepSelections[chargeKey(item, cycle)]" :disabled="readonly" placeholder="选择工步" size="small" @update:model-value="chargeStepSelections[chargeKey(item, cycle)] = $event"><el-option v-for="step in chargeChoices(item, cycle)" :key="step.stepNo" :label="`${step.stepNo} ${step.name || ''}`" :value="step.stepNo" /></el-select><span v-else>{{ chargeChoice(item, cycle)?.stepNo ?? '未识别' }}</span></template><span v-else class="missing-cycle">无该循环</span></td></tr>
                <tr><td>充电工步名称</td><td v-for="item in reportItems" :key="selectionKey(item.source)"><span v-if="!item.cycleNos.includes(cycle)" class="missing-cycle">无该循环</span><span v-else>{{ chargeChoice(item, cycle)?.name || '未选择' }}</span></td></tr>
                <tr><td>终止电压 (V)</td><td v-for="item in reportItems" :key="selectionKey(item.source)"><span v-if="!item.cycleNos.includes(cycle)" class="missing-cycle">无该循环</span><span v-else>{{ chargeChoice(item, cycle)?.endVoltage ?? '缺失' }}</span></td></tr>
                </template><template v-else>
                  <tr v-for="index in terminationRows(cycle)" :key="`termination-${index}`" class="charge-termination-row"><td>充电工步{{ index }} / 终止电压 (V)</td><td v-for="item in reportItems" :key="selectionKey(item.source)">{{ termination(item, cycle, index - 1) }}</td></tr>
                  <tr v-if="!readonly && reportItems.some(item => unknownSteps(item, cycle).length)"><td>人工指定未知充电工步</td><td v-for="item in reportItems" :key="selectionKey(item.source)"><el-select v-if="unknownSteps(item, cycle).length" v-model="manualChargeSteps[formationCycleKey(item, cycle)]" multiple :aria-label="`未知充电工步 ${item.source.name} ${item.source.batteryNo} 循环${cycle}`" placeholder="可选择多个实际工步"><el-option v-for="step in unknownSteps(item, cycle)" :key="step.stepNo" :value="step.stepNo" :label="`${step.stepNo} ${step.name || '未命名工步'}`" /></el-select><span v-else>—</span></td></tr>
                </template>
                <tr v-for="field in cycleFields" :key="field.key"><td>{{ formationFieldLabel(field) }}</td><td v-for="item in reportItems" :key="selectionKey(item.source)">
                <span v-if="!item.cycleNos.includes(cycle)" class="missing-cycle">无该循环</span>
                <el-input v-else :model-value="inputValue(item, cycle, field)" @update:model-value="setValue(item, cycle, field, $event)" :disabled="readonly" :aria-label="`${item.source.name} ${item.source.batteryNo} 循环${cycle} ${field.displayName}`" :placeholder="field.kind === 'manual' ? '人工填写' : '指标缺失'" size="small" />
              </td></tr></tbody>
            </table></div>
          </article>
          <el-empty v-if="reportItems.length && !cycleNos.length && !previewLoading" description="所选电池暂无循环记录" :image-size="60" />
        </div></section>
        <section class="metrics-panel">
          <header class="panel-heading"><div><strong>循环曲线</strong></div><label><el-select v-model="activeCycle" class="curve-cycle-select" aria-label="曲线循环" :disabled="!cycleNos.length"><el-option v-for="cycle in cycleNos" :key="cycle" :label="`循环${cycle}`" :value="cycle" /></el-select></label></header>
          <el-checkbox-group v-if="!readonly" v-model="metrics" class="metric-switches"><el-checkbox v-for="metric in formationCurveMetrics" :key="metric.key" :value="metric.key">{{ metric.title }}</el-checkbox></el-checkbox-group>
          <FormationStepCurves v-if="actualSteps" :items="items" :cycle="activeCycle" :settings="stepSettings" :readonly="readonly" @change="changeStepSettings" />
          <template v-else><div class="chart-legend-bar"><div v-for="(item, index) in items" :key="formationSeriesKey(item)" class="chart-legend-item">
            <el-color-picker v-if="!readonly" :model-value="formationSeriesColor(item, index, seriesColors)" :aria-label="`${formationSeriesName(item)}颜色`" @change="updateSeriesColor(item, $event)" />
            <i v-else class="series-swatch" :style="{ background: formationSeriesColor(item, index, seriesColors) }" /><span>{{ formationSeriesName(item) }}</span>
          </div></div>
          <div v-if="hasCurves && activeCycle !== null" class="charts-grid"><article v-for="metric in visibleMetrics" :key="metric.key" class="chart-card"><header><strong>{{ metric.title }} ({{ metric.unit }})</strong></header><VChart class="metric-chart" :option="formationChartOption(metric, items, activeCycle, seriesColors)" :update-options="{ notMerge: true }" autoresize /></article></div>
          <el-empty v-if="!hasCurves" :description="saved && !items.some(item => item.curves) ? '该版本未保存曲线数据' : '该循环暂无已保存实时数据'" :image-size="60" />
          <el-empty v-else-if="!visibleMetrics.length" description="当前未启用曲线指标" :image-size="60" /></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 /></label></section>
      <section v-if="editingId && !readonly" class="note-panel"><label><span>修改说明</span><el-input v-model="changeSummary" maxlength="500" placeholder="请输入修改说明" /></label></section>
      <footer class="form-actions"><button v-if="canExport" class="secondary-button" type="button" :disabled="printing || saving || loading || previewLoading" @click="printReport()"><el-icon><Printer /></el-icon>{{ printing ? '正在打开打印' : '打印 / 另存 PDF' }}</button><button class="secondary-button" type="button" @click="router.push('/report-formation')">返回列表</button><button v-if="!readonly && (editingId ? canEdit : canCreate)" class="primary-button" type="button" :disabled="saving || loading || previewLoading || !items.length" @click="saveReport"><el-icon><Check /></el-icon>{{ saving ? '保存中' : editingId ? '保存新版本' : '保存报表' }}</button></footer>
    </template>
    <el-dialog v-model="historyVisible" title="化成报表历史版本" width="min(760px, calc(100vw - 28px))"><el-alert v-if="historyError" :title="historyError" type="error" :closable="false" /><el-table v-loading="historyLoading" :data="history"><el-table-column prop="versionNo" label="版本" width="70" /><el-table-column prop="operatorName" label="操作人" width="110" /><el-table-column label="时间" width="180"><template #default="{ row }">{{ new Date(row.createdAt).toLocaleString() }}</template></el-table-column><el-table-column prop="changeSummary" label="修改说明" /><el-table-column label="操作" width="70"><template #default="{ row }"><el-button link type="primary" @click="openVersion(row.versionNo)">查看</el-button></template></el-table-column></el-table></el-dialog>
  </div>
</template>
 
<style scoped>
.charge-termination-row td{white-space:normal!important;padding:8px 10px!important}.metrics-panel .panel-heading{flex-wrap:wrap}
.curve-cycle-select{width:130px;flex:0 0 130px;margin:8px 0}.metrics-panel .chart-legend-item span{max-width:100%;overflow:visible;white-space:normal;overflow-wrap:anywhere}.series-swatch{flex:0 0 14px;height:14px}.metric-switches .el-checkbox{margin-right:0}.metrics-panel .chart-legend-item{max-width:100%;align-items:flex-start}.metrics-panel .chart-legend-item .el-color-picker{flex-shrink:0}.metrics-panel .metric-chart{height:260px}.metrics-panel .chart-legend-bar{padding-top:12px;padding-bottom:12px}
.version-bar{display:flex;align-items:center;flex-wrap:wrap;gap:10px;padding:8px 0 16px;color:#52606d;font-size:12px}.version-bar span{overflow-wrap:anywhere}
.source-panel{padding:16px;background:#fff;border:1px solid #d8e1e9;margin-bottom:16px}.source-panel label{display:flex;align-items:center;gap:16px}.source-panel .el-select{flex:1;min-width:0}.formation-form-page{min-width:0}.missing-cycle{color:#7b8794}.cycle-card h3{margin:0;padding:12px 16px;font-size:14px}.cycle-data-table,.report-data-table{table-layout:fixed}.cycle-data-table th,.report-data-table th{white-space:normal;overflow-wrap:anywhere}.cycle-data-table th:first-child,.report-data-table th:first-child{width:180px}.cycle-data-table td,.report-data-table td{overflow-wrap:anywhere}.formation-form-page :deep(.el-alert){margin-bottom:12px}@media(max-width:680px){.source-panel label{align-items:stretch;flex-direction:column;gap:8px}}
.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; }
.note-panel :deep(.el-textarea){min-width:0}.report-data-table th,.cycle-data-table th{white-space:normal;overflow-wrap:anywhere}.report-data-table td:last-child,.report-data-table th:last-child{width:auto}.report-data-table th:last-child{color:#fff;background:#3c6497}.cycle-data-table th:nth-child(n){width:auto}.cycle-data-table th:first-child{width:180px}.cycle-data-table td:first-child{white-space:normal;width:180px}.report-data-actions,.form-actions{flex-wrap:wrap}.report-data-table-wrap,.cycle-table-wrap{max-width:100%;box-sizing:border-box}
</style>