yeg
2026-09-06 e6a282cb844d7f5f7ec6c0fb18b643fd6f3d557e
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
<script setup lang="ts">
import { toTestDisplayValue } from '@/utils/testDataUnits'
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { use } from 'echarts/core'
import { LineChart } from 'echarts/charts'
import { CanvasRenderer } from 'echarts/renderers'
import { GridComponent, LegendComponent, MarkLineComponent, TooltipComponent } from 'echarts/components'
import VChart from 'vue-echarts'
import { getCurrentUser } from '@/utils/auth'
import { useBatteryGroupFields } from '@/stores/batteryGroupFields'
import { useSpliceReports, type SpliceReportPayload, type SpliceTestSelection } from '@/stores/spliceReports'
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 { records, createReport, updateReport } = useSpliceReports()
const { fields: managedGroupFields } = useBatteryGroupFields()
const editingId = computed(() => {
  const value = Number(route.params.id)
  return Number.isFinite(value) && value > 0 ? value : null
})
const editingRecord = computed(() => editingId.value ? records.value.find(item => item.id === editingId.value) || null : null)
const readonly = computed(() => route.query.mode === 'view')
 
const reportName = ref('')
const 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: 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 },
]
 
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.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 loadEditingRecord() {
  const record = editingRecord.value
  if (!record) return
  reportName.value = record.name
  experimentName.value = record.experimentName
  batteryGroup.value = record.batteryGroup
  samples.value = [...record.samples]
  selectedTests.value = record.tests.map(item => ({ ...item }))
  batteryFields.value = [...record.batteryFields]
  specialFields.value = [...record.specialFields]
  Object.keys(detailFieldValues).forEach(field => delete detailFieldValues[field])
  Object.entries(record.detailFieldValues).forEach(([field, values]) => { detailFieldValues[field] = { ...values } })
  formationMetricDefinitions.forEach(metric => { metricVisible[metric.key] = record.metrics.includes(metric.key) })
  seriesColors.value = [...record.seriesColors]
  dynamicDetailFields.value.forEach(ensureDetailField)
}
loadEditingRecord()
 
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]
}
 
function openFieldTemplate(mode: 'battery' | 'special') {
  templateMode.value = mode
  if (mode === 'battery') {
    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
  await new Promise(resolve => window.setTimeout(resolve, 220))
  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: enabledMetrics.value.map(metric => metric.key),
    seriesColors: [...seriesColors.value],
  }
  editingId.value ? updateReport(editingId.value, payload) : createReport(payload)
  saving.value = false
  ElMessage.success(editingId.value ? '拼接报表已更新' : '拼接报表已保存')
  await router.push('/report-splice')
}
</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>
 
    <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 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 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>
 
    <footer class="form-actions"><button class="secondary-button" type="button" @click="router.push('/report-splice')">{{ readonly ? '返回列表' : '取消' }}</button><button v-if="!readonly" class="primary-button" type="button" :disabled="saving" @click="saveReport"><el-icon><Check /></el-icon>{{ saving ? '保存中' : editingId ? '保存修改' : '保存报表' }}</button></footer>
 
    <el-dialog v-model="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; }
</style>