yeg
2026-09-07 fca70eaab1dcd4b13f31562406d0d6f9312922f1
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
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
<script setup lang="ts">
import { toTestDisplayValue } from '@/utils/testDataUnits'
import { computed, reactive, ref, watch, onMounted } 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, MarkLineComponent, TooltipComponent } from 'echarts/components'
import VChart from 'vue-echarts'
import { getCurrentUser } from '@/utils/auth'
import { getBatteryGroupFields, type BatteryGroupField } from '@/api/batteryGroupFields'
import { useSpliceReports, type SpliceReportPayload, type SpliceTestSelection } from '@/stores/spliceReports'
import { getSpliceSources, getSpliceReport, getSpliceVersion, listSpliceVersions, type SavedSpliceReport } from '@/api/spliceReports'
import { getUserInfo } from '@/api/auth'
import { openSpliceSavedReportPrint } from '@/utils/spliceSavedReport'
import SpliceSavedReport from '@/components/SpliceSavedReport.vue'
import { formationFixedReportFields, formationMetricDefinitions, type FormationMetricDefinition, type FormationMetricKey } from '@/utils/formationReportPrint'
 
use([CanvasRenderer, LineChart, GridComponent, LegendComponent, MarkLineComponent, TooltipComponent])
 
const route = useRoute()
const router = useRouter()
const currentUser = getCurrentUser()
const { createReport, updateReport } = useSpliceReports()
const managedGroupFields = ref<BatteryGroupField[]>([])
const editingId = computed(() => {
  const value = Number(route.params.id)
  return Number.isFinite(value) && value > 0 ? value : null
})
const readonly = computed(() => route.query.mode === 'view' || Boolean(route.query.version))
const savedReport = ref<SavedSpliceReport | null>(null)
const savedContent = ref('')
const loading = ref(false)
const loadError = ref('')
const printing = ref(false)
const canExport = ref(false)
const history = ref<Awaited<ReturnType<typeof listSpliceVersions>>>([])
const historyVisible = ref(false)
const statisticMetrics = [{ key: 'finalVoltage1', title: '最终电压1' }, { key: 'energyEfficiency', title: '能量效率' }] as const
const statisticVisible = reactive({ finalVoltage1: true, energyEfficiency: true })
const statisticSettings = computed(() => ({
  ...savedReport.value?.settings,
  batteryFields: batteryFields.value, specialFields: specialFields.value, detailFieldValues,
  metrics: statisticMetrics.filter(metric => statisticVisible[metric.key]).map(metric => metric.key),
  seriesColors: seriesColors.value,
}))
const statisticPreview = computed(() => savedReport.value ? { ...savedReport.value, name: reportName.value, description: experimentName.value, settings: statisticSettings.value } : null)
 
function formContent() {
  return JSON.stringify([reportName.value, experimentName.value, selectedTests.value, batteryFields.value, specialFields.value, detailFieldValues, metricVisible, statisticVisible, seriesColors.value])
}
async function printReport() {
  if (!savedReport.value) { ElMessage.warning('请先保存报表后再打印'); return }
  if (formContent() !== savedContent.value) { ElMessage.warning('有未保存修改,请先保存新版本后再打印'); return }
  printing.value = true
  try { await openSpliceSavedReportPrint(savedReport.value.id, savedReport.value.versionNo) }
  catch (error) { ElMessage.error(error instanceof Error ? error.message : '打印失败,请重试') }
  finally { printing.value = false }
}
async function openHistory() {
  if (!editingId.value) return
  if (formContent() !== savedContent.value) { ElMessage.warning('有未保存修改,请先保存新版本后再查看历史'); return }
  try { history.value = await listSpliceVersions(editingId.value); historyVisible.value = true }
  catch (error) { ElMessage.error(error instanceof Error ? error.message : '历史版本加载失败') }
}
async function openVersion(version: number) {
  historyVisible.value = false
  await router.push({ path: route.path, query: { mode: 'view', version } })
}
 
const reportName = ref('')
const experimentName = ref('SP2连续工况实验')
const batteryGroup = ref('SP2样品组')
const samples = ref(['SP26-1', 'SP26-2', 'SP26-3'])
const saving = ref(false)
const selectedTests = ref<SpliceTestSelection[]>([
  { id: 101, name: 'SP2电池化成测试', type: '化成', durationMinutes: 90 },
])
const candidateTests = ref<SpliceTestSelection[]>([
  { id: 102, name: '化成后静置测试', type: '静置', durationMinutes: 60 },
  { id: 103, name: '首次容量测试', type: '容量', durationMinutes: 75 },
  { id: 104, name: '常温倍率放电测试', type: '倍率', durationMinutes: 80 },
  { id: 105, name: '恢复容量测试', type: '容量', durationMinutes: 70 },
  { id: 106, name: '循环后静置测试', type: '静置', durationMinutes: 45 },
])
async function loadPage() {
  loading.value = true
  loadError.value = ''
  savedReport.value = null
  try {
  const info = await getUserInfo()
  canExport.value = Boolean(info.permissions?.some(permission => permission === '*:*:*' || permission === 'system:splice-report:export'))
  if (editingId.value) {
    const version = route.query.version === undefined ? undefined : Number(route.query.version)
    if (version !== undefined && (!Number.isInteger(version) || version < 1)) throw new Error('报表版本号无效')
    const saved = version === undefined ? await getSpliceReport(editingId.value, !readonly.value) : await getSpliceVersion(editingId.value, version)
    savedReport.value = saved
    reportName.value = saved.name
    experimentName.value = saved.description || ''
    batteryGroup.value = saved.segments[0]?.source.group || ''
    samples.value = [...new Set(saved.segments.map(segment => segment.source.batteryNo))]
    const settings = saved.settings || {}
    statisticMetrics.forEach(metric => {
      statisticVisible[metric.key] = settings.metrics?.length === 0 || settings.metrics?.some(key => statisticMetrics.some(item => item.key === key))
        ? settings.metrics.includes(metric.key) : true
    })
    selectedTests.value = saved.selections.map(selection => {
      const source = saved.segments.find(item => item.source.selection.testBatteryId === selection.testBatteryId)?.source
      return { id: selection.testId, testBatteryId: selection.testBatteryId, name: source?.name || `测试${selection.testId}`, type: source?.type || '未知', durationMinutes: 0 }
    })
    const savedSettings = settings as Record<string, unknown>
    batteryFields.value = Array.isArray(savedSettings.batteryFields) ? [...savedSettings.batteryFields as string[]] : batteryFields.value
    specialFields.value = Array.isArray(savedSettings.specialFields) ? [...savedSettings.specialFields as string[]] : specialFields.value
    Object.keys(detailFieldValues).forEach(field => delete detailFieldValues[field])
    if (savedSettings.detailFieldValues && typeof savedSettings.detailFieldValues === 'object') Object.entries(savedSettings.detailFieldValues as Record<string, Record<string, string>>).forEach(([field, values]) => { detailFieldValues[field] = { ...values } })
    formationMetricDefinitions.forEach(metric => { metricVisible[metric.key] = Array.isArray(savedSettings.metrics) ? (savedSettings.metrics as string[]).includes(metric.key) : true })
    if (Array.isArray(savedSettings.seriesColors)) seriesColors.value = [...savedSettings.seriesColors as string[]]
    dynamicDetailFields.value.forEach(ensureDetailField)
  } else {
    const sources = await getSpliceSources()
    candidateTests.value = sources.map(source => ({ id: source.selection.testId, testBatteryId: source.selection.testBatteryId, name: source.name, type: source.type, durationMinutes: 0 }))
    selectedTests.value = candidateTests.value.length ? [candidateTests.value[0]!] : []
  }
  savedContent.value = formContent()
  } catch (error) { loadError.value = error instanceof Error ? error.message : '拼接报表加载失败' }
  finally { loading.value = false }
}
onMounted(loadPage)
watch(() => route.fullPath, loadPage)
 
const batteryFields = ref(['额定容量', '内阻'])
const specialFields = ref(['环境温度'])
const detailFieldValues = reactive<Record<string, Record<string, string>>>({})
const metricVisible = reactive<Record<FormationMetricKey, boolean>>({ voltage: true, current: true, positiveVoltage: true, negativeVoltage: true, temperature: true })
const seriesColors = ref(['#3978f6', '#7d42d8', '#f08a3e'])
const addDialogVisible = ref(false)
const selectedCandidateId = ref<number | null>(null)
const templateDialogVisible = ref(false)
const templateMode = ref<'battery' | 'special'>('battery')
const selectedBatteryFieldIds = ref<number[]>([])
const selectedSpecialTemplateKey = ref('cycle')
const specialTemplateStorageKey = 'battery_detection_splice_special_templates_v1'
const savedSpecialTemplates = 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 enabledMetrics = computed(() => formationMetricDefinitions.filter(metric => metricVisible[metric.key]))
const dynamicDetailFields = computed(() => [...batteryFields.value, ...specialFields.value])
const selectedCandidate = computed(() => candidateTests.value.find(item => item.id === selectedCandidateId.value) || null)
const batteryFieldOptions = computed(() => [...managedGroupFields.value].sort((a, b) => b.createdAt.localeCompare(a.createdAt)))
const specialTemplates = computed(() => [
  { key: 'cycle', name: '循环寿命模板', fields: ['容量保持率', '能量效率', '循环次数', '内阻变化'] },
  { key: 'thermal', name: '热特性模板', fields: ['最高温度', '温差', '温升速率'] },
  ...Object.entries(savedSpecialTemplates).map(([name, fields]) => ({ key: `saved:${name}`, name, fields })),
])
const selectedSpecialTemplate = computed(() => specialTemplates.value.find(item => item.key === selectedSpecialTemplateKey.value) ?? specialTemplates.value[0]!)
const totalDuration = computed(() => selectedTests.value.reduce((total, item) => total + item.durationMinutes, 0))
const timeline = computed(() => {
  const labels: string[] = []
  const segments: Array<{ id: number; name: string; start: number; end: number; duration: number }> = []
  let elapsed = 0
  selectedTests.value.forEach((test, testIndex) => {
    const pointCount = 7
    for (let pointIndex = 0; pointIndex < pointCount; pointIndex += 1) {
      if (testIndex > 0 && pointIndex === 0) continue
      const localMinutes = Math.round(test.durationMinutes * pointIndex / (pointCount - 1))
      labels.push(formatMinutes(elapsed + localMinutes))
    }
    segments.push({ id: test.id, name: test.name, start: elapsed, end: elapsed + test.durationMinutes, duration: test.durationMinutes })
    elapsed += test.durationMinutes
  })
  return { labels, segments }
})
 
function formatMinutes(totalMinutes: number) {
  return `${Math.floor(totalMinutes / 60)}:${String(totalMinutes % 60).padStart(2, '0')}`
}
 
function ensureDetailField(field: string) {
  if (!detailFieldValues[field]) detailFieldValues[field] = {}
  samples.value.forEach(sample => {
    if (detailFieldValues[field][sample] === undefined) detailFieldValues[field][sample] = ''
  })
}
 
dynamicDetailFields.value.forEach(ensureDetailField)
watch(dynamicDetailFields, fields => fields.forEach(ensureDetailField))
 
function openAddTest() {
  selectedCandidateId.value = null
  addDialogVisible.value = true
}
 
function addSpliceTest() {
  const candidate = selectedCandidate.value
  if (!candidate) { ElMessage.warning('请选择需要拼接的测试'); return }
  if (selectedTests.value.some(item => item.id === candidate.id)) { ElMessage.warning('该测试已经加入拼接'); return }
  selectedTests.value.push({ ...candidate })
  addDialogVisible.value = false
  ElMessage.success('已添加拼接测试,测试详情保持不变')
}
 
function removeSpliceTest(index: number) {
  if (index === 0) { ElMessage.warning('基础测试决定测试详情,不能删除'); return }
  selectedTests.value.splice(index, 1)
}
 
function rawDetailValue(fieldIndex: number, sampleIndex: number) {
  return (1.02 + fieldIndex * 0.06 + sampleIndex * 0.03).toFixed(2)
}
 
function addSpecialField() {
  ElMessageBox.prompt('请输入要添加的字段名称', '添加项', {
    confirmButtonText: '添加', cancelButtonText: '取消', inputPlaceholder: '例如:环境湿度',
    inputValidator: value => value.trim() ? true : '请输入字段名称',
  }).then(({ value }) => {
    const name = value.trim()
    if (dynamicDetailFields.value.includes(name) || formationFixedReportFields.some(field => field.label === name)) { ElMessage.warning('该字段已经存在'); return }
    specialFields.value.push(name)
    ensureDetailField(name)
    ElMessage.success(`已添加字段“${name}”`)
  }).catch(() => { /* 用户取消 */ })
}
 
function removeSpecialField(field: string) {
  specialFields.value = specialFields.value.filter(item => item !== field)
  delete detailFieldValues[field]
}
 
async function openFieldTemplate(mode: 'battery' | 'special') {
  templateMode.value = mode
  if (mode === 'battery') {
    try { managedGroupFields.value = await getBatteryGroupFields() }
    catch (error) { ElMessage.error(error instanceof Error ? error.message : '字段加载失败'); return }
    selectedBatteryFieldIds.value = batteryFieldOptions.value.filter(field => batteryFields.value.includes(field.name)).map(field => field.id)
  } else {
    selectedSpecialTemplateKey.value = 'cycle'
  }
  templateDialogVisible.value = true
}
 
function applyFieldTemplate() {
  if (templateMode.value === 'battery') {
    batteryFields.value = selectedBatteryFieldIds.value
      .map(id => batteryFieldOptions.value.find(field => field.id === id)?.name)
      .filter((name): name is string => Boolean(name))
    specialFields.value = specialFields.value.filter(field => !batteryFields.value.includes(field))
  } else {
    specialFields.value = selectedSpecialTemplate.value.fields.filter(field => !batteryFields.value.includes(field))
  }
  dynamicDetailFields.value.forEach(ensureDetailField)
  templateDialogVisible.value = false
  ElMessage.success('字段模板已应用')
}
 
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 (specialTemplates.value.some(item => item.name === name)) { ElMessage.warning('模板名称已存在'); return }
    savedSpecialTemplates[name] = [...specialFields.value]
    localStorage.setItem(specialTemplateStorageKey, JSON.stringify(savedSpecialTemplates))
    ElMessage.success(`已另存为“${name}”`)
  } catch { /* 用户取消 */ }
}
 
function updateSeriesColor(index: number, color: string | null) {
  if (color) seriesColors.value[index] = color
}
 
function chartOption(metric: FormationMetricDefinition) {
  const names = samples.value.slice(0, 3)
  const values = names.map((_, sampleIndex) => {
    const data: number[] = []
    selectedTests.value.forEach((_, testIndex) => {
      const source = metric.values[sampleIndex] || metric.values[0]
      source.forEach((value, pointIndex) => {
        if (testIndex > 0 && pointIndex === 0) return
        const segmentOffset = metric.unit === '℃' ? testIndex * 1.2 : testIndex * 0.035
        data.push(toTestDisplayValue(metric.key, Number((value + segmentOffset).toFixed(3))))
      })
    })
    return data
  })
  const visibleValues = values.flat()
  const dataMax = Math.max(0, ...visibleValues)
  const axisMax = Math.max(metric.unit === '℃' ? Math.ceil(dataMax / 10) * 10 : Math.ceil(dataMax * 10) / 10, 1)
  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 },
  })
  const boundaries = timeline.value.segments.slice(0, -1).map(segment => ({ name: segment.name, xAxis: formatMinutes(segment.end) }))
  return {
    animation: false,
    color: seriesColors.value,
    grid: { top: 48, right: 52, bottom: 46, left: 52 },
    tooltip: { trigger: 'axis' },
    legend: { top: 6, right: 8, icon: 'roundRect', itemWidth: 14, itemHeight: 8, textStyle: { fontSize: 11, color: '#52606d' } },
    xAxis: { type: 'category', boundaryGap: false, name: '累计时间/h:mm', nameLocation: 'middle', nameGap: 30, data: timeline.value.labels, axisLine: { lineStyle: { color: '#cbd8e4' } }, axisLabel: { color: '#8795a3', fontSize: 10, interval: 'auto' } },
    yAxis: [createYAxis('left'), createYAxis('right')],
    series: names.map((name, index) => ({
      name, type: 'line', smooth: true, showSymbol: false, lineStyle: { width: 2 }, data: values[index],
      markLine: index === 0 ? { silent: true, symbol: ['none', 'none'], lineStyle: { color: '#9aabbb', type: 'dashed', width: 1 }, label: { show: true, color: '#52606d', fontSize: 10, formatter: '{b}', position: 'insideEndTop' }, data: boundaries } : undefined,
    })),
  }
}
 
async function saveReport() {
  if (!reportName.value.trim()) { ElMessage.warning('请输入拼接报表名称'); return }
  if (selectedTests.value.length < 2) { ElMessage.warning('请至少添加一个拼接测试'); return }
  saving.value = true
  try {
  const payload: SpliceReportPayload = {
    name: reportName.value.trim(),
    creator: currentUser?.displayName || 'Admin',
    experimentName: experimentName.value.trim(),
    batteryGroup: batteryGroup.value,
    samples: [...samples.value],
    tests: selectedTests.value.map(item => ({ ...item })),
    batteryFields: [...batteryFields.value],
    specialFields: [...specialFields.value],
    detailFieldValues: Object.fromEntries(Object.entries(detailFieldValues).map(([field, values]) => [field, { ...values }])),
    metrics: savedReport.value ? statisticSettings.value.metrics : enabledMetrics.value.map(metric => metric.key),
    seriesColors: [...seriesColors.value],
  }
  if (editingId.value) {
    if (!savedReport.value) throw new Error('请重新加载报表后编辑')
    await updateReport(editingId.value, savedReport.value.versionNo, payload)
  } else await createReport(payload)
  ElMessage.success(editingId.value ? '拼接报表已更新' : '拼接报表已保存')
  await router.push('/report-splice')
  } catch (error) { ElMessage.error(error instanceof Error ? error.message : '保存失败,请重试') }
  finally { saving.value = false }
}
</script>
 
<template>
  <div class="splice-form-page" :class="{ 'is-readonly': readonly }">
    <div class="page-tools"><button class="back-button" type="button" @click="router.push('/report-splice')"><el-icon><ArrowLeft /></el-icon>返回拼接报表</button><span>{{ readonly ? 'VIEW SPLICE REPORT' : editingId ? 'EDIT SPLICE REPORT' : 'NEW SPLICE REPORT' }}</span></div>
 
    <el-alert v-if="loadError" :title="loadError" type="error" :closable="false" />
    <p v-if="loading">正在加载拼接报表</p>
    <SpliceSavedReport v-if="readonly && savedReport && !loading && !loadError" :report="savedReport" />
    <template v-if="!readonly && !loading && !loadError">
 
    <section class="report-name-panel"><label><span>拼接报表名称</span><el-input v-model="reportName" clearable placeholder="请输入拼接报表名称" /></label></section>
 
    <section class="test-panel">
      <header class="panel-heading"><div><strong>拼接测试</strong><span>按照列表顺序横向拼接曲线</span></div><small>累计 {{ formatMinutes(totalDuration) }}</small></header>
      <div class="test-table-wrap"><table class="test-table"><thead><tr><th>顺序</th><th>测试名称</th><th>测试类型</th><th>固定电池号</th><th>拼接时长</th><th v-if="!readonly">操作</th></tr></thead><tbody><tr v-for="(item, index) in selectedTests" :key="item.id"><td><span class="order-index">{{ index + 1 }}</span></td><td>{{ item.name }}</td><td>{{ item.type }}</td><td>{{ samples.join('、') }}</td><td>{{ formatMinutes(item.durationMinutes) }}</td><td v-if="!readonly"><span v-if="index === 0" class="base-lock">基础测试</span><button v-else class="delete-link" type="button" @click="removeSpliceTest(index)">删除</button></td></tr></tbody></table></div>
      <div v-if="!readonly" class="panel-actions"><button class="primary-button compact-button" type="button" @click="openAddTest"><el-icon><Plus /></el-icon>添加拼接</button></div>
    </section>
 
    <section class="detail-panel">
      <header class="panel-heading"><div><strong>测试详情</strong><span>固定于基础测试,添加拼接不会改变</span></div></header>
      <div class="detail-meta"><label><span>实验</span><el-input v-model="experimentName" /></label><div><span>基础测试</span><strong>{{ selectedTests[0]?.name }}</strong></div></div>
      <div class="detail-table-wrap"><table class="detail-table" :style="{ minWidth: `${Math.max(760, samples.length * 190 + 170)}px` }"><thead><tr><th>电池组</th><th :colspan="samples.length">{{ batteryGroup }}</th></tr><tr><th>电池号</th><th v-for="sample in samples" :key="sample">{{ sample }}</th></tr></thead><tbody><tr v-for="(field, fieldIndex) in (savedReport ? [] : formationFixedReportFields)" :key="field.key"><td>{{ field.label }}</td><td v-for="(sample, sampleIndex) in samples" :key="sample"><span v-if="field.mode === 'raw'" class="raw-value">{{ rawDetailValue(fieldIndex, sampleIndex) }}</span><span v-else class="auto-value">自动计算</span></td></tr><tr v-for="field in dynamicDetailFields" :key="field"><td>{{ field }}</td><td v-for="sample in samples" :key="sample"><el-input v-model="detailFieldValues[field][sample]" size="small" placeholder="请输入" /></td></tr></tbody></table></div>
      <div v-if="!readonly" class="detail-actions"><button class="primary-button" type="button" @click="addSpecialField"><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="saveSpecialTemplate">另存特殊字段模板</button><div v-if="specialFields.length" class="special-field-list"><span v-for="field in specialFields" :key="field">{{ field }}<button type="button" :aria-label="`删除${field}`" @click="removeSpecialField(field)">×</button></span></div></div>
    </section>
 
    <section v-if="statisticPreview" class="chart-panel">
      <div class="metric-switches"><label v-for="metric in statisticMetrics" :key="metric.key"><span>{{ metric.title }}</span><el-switch v-model="statisticVisible[metric.key]" /></label></div>
      <div class="chart-legend-bar"><div v-for="(sample, index) in samples" :key="sample" class="chart-legend-item"><el-color-picker :model-value="seriesColors[index]" :aria-label="`修改${sample}颜色`" @change="updateSeriesColor(index, $event)" /><span>{{ sample }}</span></div></div>
      <SpliceSavedReport :report="statisticPreview" />
    </section>
    <section v-else class="chart-panel">
      <header class="panel-heading"><div><strong>拼接曲线</strong><span>不同测试沿累计时间轴横向连接</span></div></header>
      <div v-if="!readonly" class="metric-switches"><label v-for="metric in formationMetricDefinitions" :key="metric.key"><span>{{ metric.title }}</span><el-switch v-model="metricVisible[metric.key]" inline-prompt active-text="显" inactive-text="隐" /></label></div>
      <div class="segment-ruler" aria-label="拼接测试时间段"><div v-for="(segment, index) in timeline.segments" :key="segment.id" class="segment" :class="`segment-${index % 4}`" :style="{ flexBasis: `${segment.duration / Math.max(totalDuration, 1) * 100}%` }"><strong>{{ index + 1 }}. {{ segment.name }}</strong><span>{{ formatMinutes(segment.start) }} - {{ formatMinutes(segment.end) }}</span></div></div>
      <div class="chart-legend-bar"><strong>样品图例</strong><div v-for="(sample, index) in samples.slice(0, 3)" :key="sample" 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-list"><article v-for="metric in enabledMetrics" :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>
 
    </template>
    <footer class="form-actions">
      <button v-if="canExport" class="secondary-button" type="button" :disabled="printing || saving || loading || !!loadError" @click="printReport"><el-icon><Printer /></el-icon>{{ printing ? '正在打开打印' : '打印 / 另存 PDF' }}</button>
      <button v-if="savedReport && !loading && !loadError" class="secondary-button" type="button" @click="openHistory">历史版本</button>
      <button class="secondary-button" type="button" @click="router.push('/report-splice')">{{ readonly ? '返回列表' : '取消' }}</button>
      <button v-if="!readonly" class="primary-button" type="button" :disabled="saving || loading || !!loadError" @click="saveReport"><el-icon><Check /></el-icon>{{ saving ? '保存中' : editingId ? '保存修改' : '保存报表' }}</button>
    </footer>
    <el-dialog v-model="historyVisible" title="历史版本" width="min(720px, 94vw)">
      <el-table :data="history"><el-table-column prop="versionNo" label="版本" width="70" /><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="80"><template #default="{ row }"><el-button link type="primary" @click="openVersion(row.versionNo)">查看</el-button></template></el-table-column></el-table>
    </el-dialog>
 
    <el-dialog v-model="addDialogVisible" class="add-dialog" title="添加拼接" width="560px" align-center destroy-on-close>
      <div class="dialog-form"><label><span>选择测试</span><el-select v-model="selectedCandidateId" filterable clearable placeholder="输入测试名称或ID搜索"><el-option v-for="item in candidateTests" :key="item.id" :label="`${item.name}(${item.type},${formatMinutes(item.durationMinutes)})`" :value="item.id" :disabled="selectedTests.some(test => test.id === item.id)" /></el-select></label><label><span>电池组</span><div class="fixed-dialog-value">{{ batteryGroup }}</div></label><label><span>电池号</span><div class="fixed-dialog-value">{{ samples.join('、') }}</div></label><small>电池组和电池号由基础测试固定,添加拼接不会修改测试详情。</small></div>
      <template #footer><button class="secondary-button" type="button" @click="addDialogVisible = false">取消</button><button class="primary-button" type="button" @click="addSpliceTest"><el-icon><Plus /></el-icon>添加拼接</button></template>
    </el-dialog>
 
    <el-dialog v-model="templateDialogVisible" class="template-dialog" :title="templateMode === 'battery' ? '电池组字段模板' : '选择特殊字段模板'" :width="templateMode === 'battery' ? '720px' : '600px'" align-center>
      <el-checkbox-group v-if="templateMode === 'battery'" v-model="selectedBatteryFieldIds" class="battery-field-grid"><el-checkbox v-for="field in batteryFieldOptions" :key="field.id" :value="field.id" :disabled="field.status === '禁用'">{{ field.name }}({{ field.type }})</el-checkbox></el-checkbox-group>
      <el-radio-group v-else v-model="selectedSpecialTemplateKey" class="template-options"><el-radio v-for="item in specialTemplates" :key="item.key" :value="item.key" border>{{ item.name }}({{ item.fields.join('、') }})</el-radio></el-radio-group>
      <template #footer><button class="secondary-button" type="button" @click="templateDialogVisible = false">取消</button><button class="primary-button" type="button" @click="applyFieldTemplate"><el-icon><Check /></el-icon>应用模板</button></template>
    </el-dialog>
  </div>
</template>
 
<style scoped>
.splice-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}.back-button:hover{color:var(--blue)}.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)}.primary-button:disabled{opacity:.55;cursor:wait}.report-name-panel,.test-panel,.detail-panel,.chart-panel{margin-bottom:16px;border:1px solid #d8e1e9;background:#fff}.report-name-panel{padding:18px 22px}.report-name-panel label{display:grid;grid-template-columns:100px minmax(280px,560px);align-items:center;gap:12px}.report-name-panel label>span,.detail-meta label>span{color:#52606d;font-size:12px}.report-name-panel :deep(.el-input__wrapper),.detail-meta :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);background:#fbfdff}.panel-heading>div{display:flex;align-items:center;gap:9px}.panel-heading strong{color:#26333f;font-size:13px}.panel-heading span,.panel-heading small{color:#8795a3;font-size:11px}.test-table-wrap,.detail-table-wrap{overflow-x:auto;padding:18px 24px 0}table{width:100%;border-collapse:collapse;color:#52606d;font-size:12px}th,td{height:40px;padding:0 10px;border:1px solid #cbd8e4;text-align:center;white-space:nowrap}th{color:#fff;background:#3c6497;font-weight:600}.test-table{min-width:900px}.test-table th:nth-child(1){width:70px}.test-table th:nth-child(3){width:110px}.test-table th:nth-child(4){width:260px}.test-table th:nth-child(5){width:110px}.test-table th:last-child{width:100px}.order-index{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border:1px solid #aac0d4;color:#405261;background:#f7fafc}.base-lock{color:#647380}.delete-link{border:0;color:#b84e4e;background:transparent;font-size:12px;cursor:pointer}.panel-actions,.detail-actions{display:flex;align-items:center;gap:10px;padding:16px 24px 20px}.compact-button{height:30px;font-size:12px}.detail-meta{display:grid;grid-template-columns:minmax(280px,520px) minmax(260px,1fr);gap:20px;padding:18px 24px 0}.detail-meta label,.detail-meta>div{display:grid;grid-template-columns:72px minmax(0,1fr);align-items:center;gap:10px}.detail-meta>div{min-height:34px;color:#52606d;font-size:12px}.detail-meta>div strong{color:#26333f}.detail-table th:first-child,.detail-table td:first-child{width:150px;text-align:left}.detail-table td:first-child{color:#31404d;background:#f2f7fb;font-weight:600}.detail-table :deep(.el-input__wrapper){min-height:28px;border-radius:3px;box-shadow:0 0 0 1px #d8e1e9 inset;background:#fff}.raw-value{color:#405261;font-family:'Cascadia Mono',Consolas,monospace}.auto-value{color:#2188f5;font-weight:500}.special-field-list{display:flex;flex-wrap:wrap;gap:6px;margin-left:auto}.special-field-list>span{display:inline-flex;align-items:center;gap:5px;padding:4px 7px;border:1px solid #d8e1e9;color:#52606d;background:#f8fafc;font-size:11px}.special-field-list button{border:0;color:#ad4a4a;background:transparent;cursor:pointer}.metric-switches{display:flex;flex-wrap:wrap;align-items:center;gap:20px 28px;min-height:56px;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}.segment-ruler{display:flex;min-width:0;margin:16px 24px 0;border:1px solid #cbd8e4;background:#f8fafc}.segment{min-width:120px;display:flex;justify-content:center;flex-direction:column;gap:3px;height:52px;padding:0 12px;border-right:1px solid #cbd8e4;overflow:hidden}.segment:last-child{border-right:0}.segment strong,.segment span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.segment strong{color:#31404d;font-size:11px}.segment span{color:#71808d;font-size:10px}.segment-0{border-top:3px solid #3978f6}.segment-1{border-top:3px solid #6aa07a}.segment-2{border-top:3px solid #d58b3d}.segment-3{border-top:3px solid #8b69c7}.chart-legend-bar{min-height:50px;display:flex;flex-wrap:wrap;align-items:center;gap:12px 24px;margin-top:14px;padding:0 24px;border-top:1px solid var(--line);border-bottom:1px solid var(--line);color:#52606d;font-size:12px}.chart-legend-bar>strong{color:#26333f}.chart-legend-item{display:inline-flex;align-items:center;gap:7px}.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__icon),.legend-color-picker :deep(.el-color-picker__empty),.legend-color-picker :deep(.el-color-picker__trigger svg){display:none!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}.charts-list{display:grid;grid-template-columns:minmax(0,1fr);gap:14px;padding:18px 24px 24px}.chart-card{min-width:0;border:1px solid #d8e1e9;background:#fff}.chart-card header{height:38px;display:flex;align-items:center;justify-content:space-between;padding:0 14px;border-bottom:1px solid #edf1f5}.chart-card header strong{color:#26333f;font-size:13px}.chart-card header span{color:#8795a3;font-size:10px}.metric-chart{width:100%;height:250px}.form-actions{position:sticky;bottom:0;z-index:4;display:flex;justify-content:flex-end;gap:10px;padding:14px 0;border-top:1px solid #d8e1e9;background:rgba(244,248,251,.96)}.dialog-form{display:grid;gap:16px}.dialog-form label{display:grid;grid-template-columns:78px minmax(0,1fr);align-items:center;gap:12px;color:#52606d;font-size:12px}.dialog-form :deep(.el-select){width:100%}.fixed-dialog-value{min-height:34px;display:flex;align-items:center;padding:0 11px;border:1px solid #d8e1e9;background:#f8fafc;color:#405261}.dialog-form small{margin-left:90px;color:#8795a3;font-size:11px}.battery-field-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px 18px;max-height:300px;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}@media(max-width:900px){.detail-meta{grid-template-columns:1fr}.segment-ruler{overflow-x:auto}.segment{flex:0 0 180px!important}}@media(max-width:680px){.report-name-panel label{grid-template-columns:1fr}.panel-heading{align-items:flex-start;flex-direction:column;justify-content:center;gap:4px}.detail-actions{align-items:stretch;flex-direction:column}.special-field-list{margin-left:0}.metric-switches{gap:14px;padding:12px}.chart-legend-bar{align-items:flex-start;flex-direction:column;gap:9px;padding:12px}.charts-list{padding:12px}.battery-field-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
.splice-form-page.is-readonly :deep(.el-input), .splice-form-page.is-readonly :deep(.el-textarea) { pointer-events: none; }
.splice-form-page.is-readonly :deep(.el-input__wrapper), .splice-form-page.is-readonly :deep(.el-textarea__inner) { background: #f3f6f8; box-shadow: 0 0 0 1px #d8e1e9 inset; }
.form-actions{flex-wrap:wrap}.splice-form-page{min-width:0}
</style>