yeg
2026-09-08 70d0c660afa9cb10c52056cee7c98ab94d48e70f
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
<script setup lang="ts">
import { computed, reactive, ref, watch, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { getUserInfo } from '@/api/auth'
import { getSpliceSources, previewSpliceReport, createSpliceReport, editSpliceReport, refreshSpliceSource,
  getSpliceReport, getSpliceVersion, listSpliceVersions, spliceFields, spliceFieldValue, spliceStatisticKey, spliceSourceKey, spliceSourceValue, spliceExperiment, spliceSourceFields, spliceDetailValue,
  type SpliceCandidate, type SpliceMatrix, type SpliceField, type SpliceSettings, type SpliceSegment, type SavedSpliceReport } from '@/api/spliceReports'
import { openSpliceSavedReportPrint } from '@/utils/spliceSavedReport'
import SpliceSavedReport from '@/components/SpliceSavedReport.vue'
import SpliceFieldControls from '@/components/SpliceFieldControls.vue'
 
const route = useRoute()
const router = useRouter()
const editingId = computed(() => Number(route.params.id) > 0 ? Number(route.params.id) : 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 saving = ref(false)
const printing = ref(false)
const canExport = ref(false)
const canCreate = ref(false)
const canEdit = ref(false)
const canQuery = ref(false)
const history = ref<Awaited<ReturnType<typeof listSpliceVersions>>>([])
const historyVisible = ref(false)
const reportName = ref('')
const experimentName = ref('')
const sources = ref<SpliceCandidate[]>([])
const selectedTests = ref<SpliceCandidate[]>([])
const samples = ref<string[]>([])
const fields = ref<SpliceField[]>([])
const detailFieldValues = ref<Record<string, Record<string, string>>>({})
const statisticValues = ref<Record<string, string>>({})
const sourceExperimentNames = ref<Record<string,string>>({})
const sourceDetailOverrides = ref<SpliceSettings['sourceDetailOverrides']>({})
const hiddenSeries = ref(false)
const statisticMetrics = [{ key: 'finalVoltage1', title: '最终电压1', unit: 'V' }, { key: 'energyEfficiency', title: '能量效率', unit: '%' }] as const
const statisticVisible = reactive({ finalVoltage1: true, energyEfficiency: true })
const seriesColors = ref(['#3978f6', '#109854', '#d25b45', '#7754a8'])
const draftMatrix = ref<SpliceMatrix | null>(null)
const previewing = ref(false)
const previewError = ref('')
let previewRequest = 0
let reordered = false
const addDialogVisible = ref(false)
const selectedCandidateId = ref<string | null>(null)
const candidateGroupId = ref<number | null>(null)
const candidateQuery = ref('')
const batteryGroup = computed(() => [...new Set(selectedTests.value.map(item => item.group))].join('、'))
const candidateTests = computed(() => sources.value.filter(source =>
  (!candidateGroupId.value || source.groupId === candidateGroupId.value) &&
  (source.name.includes(candidateQuery.value) || String(source.selection.testId).includes(candidateQuery.value) || source.batteryNo.includes(candidateQuery.value))))
const candidateGroups = computed(() => [...new Map(sources.value.map(source => [source.groupId, source.group])).entries()])
const selections = computed(() => selectedTests.value.map(item => item.selection))
const matrix = computed(() => savedReport.value || draftMatrix.value)
const segmentMode = computed(() => !savedReport.value || savedReport.value.spliceMode === 'battery-segments')
const detailColumns = computed(() => matrix.value?.segments || [])
const sourceFields = computed(() => spliceSourceFields(detailColumns.value, sourceDetailOverrides.value))
const independentDetails = computed(() => segmentMode.value && sourceDetailOverrides.value !== undefined)
const statisticSettings = computed<SpliceSettings>(() => ({
  ...(sourceDetailOverrides.value === undefined ? {} : { sourceDetailOverrides: sourceDetailOverrides.value }),
  sourceExperimentNames: sourceExperimentNames.value,
  hiddenSeries: hiddenSeries.value,
  fieldStructure: fields.value,
  batteryFields: fields.value.filter(field => field.category === 'battery').map(field => field.name),
  specialFields: fields.value.filter(field => field.category === 'special').map(field => field.name),
  detailFieldValues: Object.fromEntries(fields.value.map(field => [field.name, Object.fromEntries((segmentMode.value ? selectedTests.value.map(spliceSourceKey) : samples.value)
    .filter(battery => detailFieldValues.value[field.name]?.[battery] !== undefined)
    .map(battery => [battery, detailFieldValues.value[field.name]![battery]!]))])),
  statisticValues: statisticValues.value,
  metrics: statisticMetrics.filter(metric => statisticVisible[metric.key]).map(metric => metric.key), seriesColors: seriesColors.value,
}))
const statisticPreview = computed<SavedSpliceReport | null>(() => matrix.value ? {
  id: 0, createdAt: 0, creator: '', versionNo: 0, operatorName: '', versionCreatedAt: 0, changeSummary: '',
  spliceMode: 'battery-segments',
  selections: selections.value, ...savedReport.value, ...matrix.value,
  name: reportName.value, description: experimentName.value, settings: statisticSettings.value,
} : null)
 
function formContent() { return JSON.stringify([reportName.value, experimentName.value, statisticSettings.value]) }
function applySaved(saved: SavedSpliceReport) {
  savedReport.value = saved
  reportName.value = saved.name; experimentName.value = saved.description || ''
  fields.value = spliceFields(saved.settings).map(field => ({ ...field }))
  detailFieldValues.value = structuredClone(saved.settings.detailFieldValues || {})
  statisticValues.value = { ...saved.settings.statisticValues }
  sourceExperimentNames.value = { ...saved.settings.sourceExperimentNames }
  sourceDetailOverrides.value = saved.settings.sourceDetailOverrides === undefined ? undefined : structuredClone(saved.settings.sourceDetailOverrides)
  hiddenSeries.value = saved.settings.hiddenSeries === true
  seriesColors.value = [...(saved.settings.seriesColors || ['#3978f6', '#109854', '#d25b45', '#7754a8'])]
  selectedTests.value = saved.segments.map(segment => segment.source)
  samples.value = [...new Set(saved.segments.map(segment => segment.source.batteryNo))]
  const keys = saved.settings.metrics
  statisticMetrics.forEach(metric => { statisticVisible[metric.key] = !keys || (keys.length > 0 && !keys.some(key => statisticMetrics.some(item => item.key === key))) || keys.includes(metric.key) })
  savedContent.value = formContent()
}
async function loadPage() {
  loading.value = true; loadError.value = ''; savedReport.value = null; draftMatrix.value = null
  previewRequest++
  try {
    const permissions = (await getUserInfo()).permissions || []
    const has = (action: string) => permissions.includes('*:*:*') || permissions.includes(`system:splice-report:${action}`)
    canExport.value = has('export'); canCreate.value = has('add'); canEdit.value = has('edit'); canQuery.value = has('query')
    if (editingId.value) {
      if (!(readonly.value ? canQuery.value : canEdit.value)) throw new Error(readonly.value ? '无查看拼接报表权限' : '无编辑拼接报表权限')
      const version = route.query.version === undefined ? undefined : Number(route.query.version)
      if (version !== undefined && (!Number.isInteger(version) || version < 1)) throw new Error('报表版本号无效')
      applySaved(version === undefined ? await getSpliceReport(editingId.value, !readonly.value) : await getSpliceVersion(editingId.value, version))
    } else {
      if (!canCreate.value) throw new Error('无新增拼接报表权限')
      selectedTests.value = []; samples.value = []; reportName.value = ''; experimentName.value = ''
      fields.value = []; detailFieldValues.value = {}; statisticValues.value = {}; reordered = false
      sourceExperimentNames.value = {}; hiddenSeries.value = false
      sourceDetailOverrides.value = {}
      sources.value = await getSpliceSources()
      savedContent.value = formContent()
    }
  } catch (error) { loadError.value = error instanceof Error ? error.message : '拼接报表加载失败' }
  finally { loading.value = false }
}
onMounted(loadPage)
watch(() => route.fullPath, loadPage)
watch(selections, async selected => {
  if (editingId.value) return
  const request = ++previewRequest
  draftMatrix.value = null; previewError.value = ''
  // 来源移除后同步移除其人工值,仍在范围内的修改继续保留。
  statisticValues.value = Object.fromEntries(Object.entries(statisticValues.value).filter(([key]) =>
    selected.some(selection => key.startsWith(`${selection.testId}|${selection.testBatteryId}|`))))
  sourceDetailOverrides.value = Object.fromEntries(Object.entries(sourceDetailOverrides.value || {}).filter(([key]) =>
    selected.some(selection => key === `${selection.testId}|${selection.testBatteryId}`)))
  if (selected.length < 2) { previewing.value = false; return }
  previewing.value = true
  try {
    const result = await previewSpliceReport(selected)
    if (request === previewRequest) {
      const allowed = new Set(result.segments.flatMap(segment => segment.originalCycleNos.flatMap(cycle =>
        statisticMetrics.map(metric => spliceStatisticKey(segment, cycle, metric.key)))))
      statisticValues.value = Object.fromEntries(Object.entries(statisticValues.value).filter(([key]) => allowed.has(key)))
      draftMatrix.value = result
    }
  } catch (error) {
    if (request === previewRequest) previewError.value = error instanceof Error ? error.message : '统计预览失败'
  } finally { if (request === previewRequest) previewing.value = false }
}, { deep: true })
 
function openAddTest() { selectedCandidateId.value = null; candidateQuery.value = ''; addDialogVisible.value = true }
function addSpliceTest() {
  const candidate = candidateTests.value.find(source => `${source.selection.testId}:${source.selection.testBatteryId}` === selectedCandidateId.value)
  if (!candidate) { ElMessage.warning('请选择需要拼接的测试'); return }
  if (selectedTests.value.some(item => item.selection.testId === candidate.selection.testId && item.selection.testBatteryId === candidate.selection.testBatteryId)) { ElMessage.warning('该测试电池已添加'); return }
  selectedTests.value.push(candidate)
  if (!reordered) selectedTests.value.sort((a, b) => a.startedAt - b.startedAt || a.selection.testId - b.selection.testId)
  samples.value = [...new Set(selectedTests.value.map(item => item.batteryNo))]
  addDialogVisible.value = false
}
function removeSpliceTest(index: number) {
  selectedTests.value.splice(index, 1)
  samples.value = [...new Set(selectedTests.value.map(item => item.batteryNo))]
}
function moveTest(index: number, direction: number) {
  const item = selectedTests.value[index]
  if (!item) return
  selectedTests.value.splice(index, 1); selectedTests.value.splice(index + direction, 0, item); reordered = true
}
function fieldValue(field: SpliceField, battery: string) { return spliceFieldValue(matrix.value?.segments || [], statisticSettings.value, field, battery) }
function setFieldValue(field: SpliceField, battery: string, value: string) {
  detailFieldValues.value[field.name] ||= {}
  detailFieldValues.value[field.name][battery] = value
}
function setDetailValue(segment: SpliceSegment, value: string, field?: typeof sourceFields.value[number]) {
  sourceDetailOverrides.value ||= {}
  const key = spliceSourceKey(segment.source)
  const current = sourceDetailOverrides.value[key] || {}
  sourceDetailOverrides.value[key] = field ? { ...current, fieldValues: { ...current.fieldValues, [field.key]: value } } : { ...current, experimentName: value }
}
function applyFields(selected: SpliceField[]) {
  // 模板不携值;同名但身份或单位不同的字段不能继承原模板人工值。来源详情覆盖完全独立。
  for (const field of selected) {
    const previous = fields.value.find(item => item.name === field.name)
    if (!previous || (previous.fieldDefinitionId ?? null) !== (field.fieldDefinitionId ?? null) || previous.category !== field.category || previous.unit !== field.unit) delete detailFieldValues.value[field.name]
  }
  fields.value = selected
}
async function addSpecialField() {
  try {
    const { value } = await ElMessageBox.prompt('请输入字段名称', '添加项', { confirmButtonText: '添加', cancelButtonText: '取消', inputValidator: value => Boolean(value?.trim()) || '请输入字段名称' })
    const name = value.trim()
    if (fields.value.some(field => field.name === name)) { ElMessage.warning('该字段已经存在'); return }
    fields.value.push({ name, displayName: name, category: 'special', unit: '' })
  } catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error('添加字段失败') }
}
function updateSeriesColor(index: number, color: string | null) { if (color) seriesColors.value[index] = color }
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 } }) }
async function refreshSource() {
  if (!savedReport.value) return
  if (formContent() !== savedContent.value) { ElMessage.warning('有未保存修改,请先保存新版本'); return }
  try {
    const { value } = await ElMessageBox.prompt('请输入修改说明', '按最新来源生成新版本', { confirmButtonText: '生成新版本', cancelButtonText: '取消', inputValidator: value => Boolean(value?.trim()) || '请输入修改说明' })
    saving.value = true
    applySaved(await refreshSpliceSource(savedReport.value.id, savedReport.value.versionNo, value.trim()))
    ElMessage.success('已按最新来源生成新版本')
  } catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '更新来源失败') }
  finally { saving.value = false }
}
async function saveReport() {
  if (!reportName.value.trim()) { ElMessage.warning('请输入拼接报表名称'); return }
  if (!editingId.value && (!draftMatrix.value || previewing.value)) { ElMessage.warning('请选择至少两个测试电池段,并等待统计预览'); return }
  saving.value = true
  try {
    const payload = { name: reportName.value.trim(), description: experimentName.value, settings: statisticSettings.value }
    if (editingId.value && savedReport.value) await editSpliceReport(editingId.value, { ...payload, baseVersionNo: savedReport.value.versionNo, changeSummary: '更新拼接报表设置' })
    else await createSpliceReport({ ...payload, selections: selections.value })
    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></div><small>{{ selectedTests.length }} 个来源段</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="!editingId">操作</th></tr></thead><tbody>
        <tr v-for="(item, index) in selectedTests" :key="spliceSourceKey(item)"><td><span class="order-index">{{ index + 1 }}</span></td><td>{{ item.name }}</td><td>{{ item.type }}</td><td>{{ item.group }}</td><td>{{ item.batteryNo }}</td>
          <td v-if="!editingId"><el-tooltip content="上移"><el-button circle aria-label="上移测试" :disabled="index === 0" @click="moveTest(index, -1)"><el-icon><ArrowUp /></el-icon></el-button></el-tooltip><el-tooltip content="下移"><el-button circle aria-label="下移测试" :disabled="index === selectedTests.length - 1" @click="moveTest(index, 1)"><el-icon><ArrowDown /></el-icon></el-button></el-tooltip><button class="delete-link" @click="removeSpliceTest(index)">删除</button></td></tr>
        <tr v-if="!selectedTests.length"><td colspan="6">暂无已选测试</td></tr>
      </tbody></table></div>
      <div v-if="!editingId" 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></div></header>
      <div class="detail-meta"><label><span>报表说明</span><el-input v-model="experimentName" /></label><div><span>电池组</span><strong>{{ batteryGroup }}</strong></div></div>
      <div v-if="independentDetails" class="detail-table-wrap"><table class="detail-table source-detail-table" :style="{ minWidth: `${Math.max(760, detailColumns.length * 190 + 170)}px` }"><thead><tr><th>来源详情字段</th><th v-for="segment in detailColumns" :key="spliceSourceKey(segment.source)">{{ segment.source.name }}<br>{{ segment.source.group }} · {{ segment.source.batteryNo }}</th></tr></thead><tbody>
        <tr><td>实验</td><td v-for="segment in detailColumns" :key="spliceSourceKey(segment.source)"><el-input :model-value="spliceDetailValue(segment, sourceDetailOverrides)" :aria-label="`${segment.source.name} ${segment.source.batteryNo} 实验名称`" placeholder="缺失" @update:model-value="setDetailValue(segment, $event)" /></td></tr>
        <tr v-for="field in sourceFields" :key="field.key"><td>{{ field.name }}({{ field.category === 'battery' ? '电池字段' : '特殊字段' }})</td><td v-for="segment in detailColumns" :key="spliceSourceKey(segment.source)"><el-input :model-value="spliceDetailValue(segment, sourceDetailOverrides, field)" :aria-label="`${segment.source.name} ${segment.source.batteryNo} ${field.category === 'battery' ? '电池字段' : '特殊字段'} ${field.name}`" size="small" placeholder="缺失" @update:model-value="setDetailValue(segment, $event, field)" /></td></tr>
      </tbody></table></div>
      <div v-if="segmentMode" class="detail-table-wrap"><table class="detail-table report-field-table" :style="{ minWidth: `${Math.max(760, detailColumns.length * 190 + 170)}px` }"><thead><tr><th>{{ independentDetails ? '报表字段' : '字段' }}</th><th v-for="segment in detailColumns" :key="spliceSourceKey(segment.source)">{{ segment.source.name }}<br>{{ segment.source.group }} · {{ segment.source.batteryNo }}</th></tr></thead><tbody>
        <tr v-if="!independentDetails"><td>实验</td><td v-for="segment in detailColumns" :key="spliceSourceKey(segment.source)"><el-input :model-value="spliceExperiment(segment, statisticSettings)" placeholder="缺失" @update:model-value="sourceExperimentNames[spliceSourceKey(segment.source)] = $event" /></td></tr>
        <tr v-for="field in fields" :key="field.name"><td>{{ field.displayName }}{{ field.unit ? ` (${field.unit})` : '' }}</td><td v-for="segment in detailColumns" :key="spliceSourceKey(segment.source)"><el-input :model-value="spliceSourceValue(segment, statisticSettings, field)" :aria-label="`${segment.source.name} ${segment.source.batteryNo} 报表字段 ${field.displayName}`" size="small" placeholder="缺失" @update:model-value="setFieldValue(field, spliceSourceKey(segment.source), $event)" /></td></tr>
      </tbody></table></div>
      <div v-else class="detail-table-wrap"><table class="detail-table" :style="{ minWidth: `${Math.max(760, samples.length * 190 + 170)}px` }"><thead><tr><th>字段</th><th v-for="sample in samples" :key="sample">{{ sample }}</th></tr></thead><tbody>
        <tr v-for="field in fields" :key="field.name"><td>{{ field.displayName }}{{ field.unit ? ` (${field.unit})` : '' }}</td><td v-for="sample in samples" :key="sample"><el-input :model-value="fieldValue(field, sample)" size="small" placeholder="缺失" @update:model-value="setFieldValue(field, sample, $event)" /></td></tr>
      </tbody></table></div>
      <div class="detail-actions"><button class="primary-button" type="button" @click="addSpecialField"><el-icon><Plus /></el-icon>添加项</button></div>
      <SpliceFieldControls :fields="fields" :segments="matrix?.segments || []" :can-create="canCreate" :can-edit="canEdit" @apply="applyFields" />
    </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 v-if="segmentMode" class="chart-legend-bar"><div class="chart-legend-item"><el-color-picker :model-value="seriesColors[0]" aria-label="修改接续曲线颜色" @change="updateSeriesColor(0, $event)" /><button type="button" :aria-pressed="!hiddenSeries" @click="hiddenSeries = !hiddenSeries">接续曲线{{ hiddenSeries ? '(已隐藏)' : '' }}</button></div></div>
      <div v-else 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" :charts-only="segmentMode" />
    </section>
    <el-alert v-if="previewError" :title="previewError" type="error" :closable="false" />
    <p v-if="previewing">正在加载循环统计</p>
    <el-empty v-else-if="!matrix && !previewError" description="暂无循环统计预览" />
    <section v-if="matrix" class="detail-panel">
      <header class="panel-heading"><strong>循环统计表</strong></header>
      <div class="test-table-wrap"><table class="statistics-editor"><thead><tr><th>来源测试</th><th>电池号</th><th>原循环号</th><th>拼接循环号</th><th v-for="metric in statisticMetrics" :key="metric.key">{{ metric.title }} ({{ metric.unit }})</th></tr></thead><tbody>
        <template v-for="segment in matrix.segments" :key="segment.source.selection.testBatteryId"><tr v-for="cycle in segment.originalCycleNos" :key="cycle"><td>{{ segment.source.name }}</td><td>{{ segment.source.batteryNo }}</td><td>{{ cycle }}</td><td>{{ cycle + segment.offset }}</td>
          <td v-for="metric in statisticMetrics" :key="metric.key"><el-input :aria-label="`${segment.source.name} ${segment.source.batteryNo} 循环${cycle} ${metric.title}`" :model-value="statisticValues[spliceStatisticKey(segment, cycle, metric.key)] ?? String(segment.statistics[String(cycle)]?.[metric.key] ?? '')" placeholder="缺失" @update:model-value="statisticValues[spliceStatisticKey(segment, cycle, metric.key)] = $event" /><small>原值:{{ segment.statistics[String(cycle)]?.[metric.key] ?? '缺失' }}</small><el-button v-if="spliceStatisticKey(segment, cycle, metric.key) in statisticValues" link @click="delete statisticValues[spliceStatisticKey(segment, cycle, metric.key)]">还原</el-button></td>
        </tr></template>
      </tbody></table></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="canQuery && 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="canEdit && savedReport && !readonly" class="secondary-button" :disabled="saving || loading" @click="refreshSource">按最新来源生成新版本</button>
      <button v-if="!readonly && (editingId ? canEdit : canCreate)" 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="min(560px, 94vw)" align-center destroy-on-close>
      <div class="dialog-form">
        <label><span>电池组</span><el-select v-model="candidateGroupId" clearable placeholder="全部"><el-option v-for="[id, name] in candidateGroups" :key="id" :label="name" :value="id" /></el-select></label>
        <label><span>测试名称</span><el-input v-model="candidateQuery" placeholder="输入测试名称或ID搜索" /></label>
        <label><span>选择测试电池</span><el-select v-model="selectedCandidateId" filterable clearable placeholder="选择检测完成测试电池"><el-option v-for="item in candidateTests" :key="`${item.selection.testId}:${item.selection.testBatteryId}`" :label="`${item.name}(${item.group},${item.batteryNo})`" :value="`${item.selection.testId}:${item.selection.testBatteryId}`" :disabled="selectedTests.some(test => test.selection.testId === item.selection.testId && test.selection.testBatteryId === item.selection.testBatteryId)" /></el-select></label>
      </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>
  </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}
.statistics-editor{min-width:1000px;table-layout:fixed}.statistics-editor th:nth-last-child(-n+2){width:250px}.statistics-editor td{padding:8px;white-space:normal;overflow-wrap:anywhere;vertical-align:top}.statistics-editor small{display:block;margin-top:5px;color:#647380;overflow-wrap:anywhere}.test-table th:last-child{width:150px}.panel-actions{flex-wrap:wrap}.test-table .el-button{width:26px;height:26px;padding:0;margin-right:4px;margin-left:0}
</style>