yeg
2026-09-09 528d52f2c7c6f911c54cae6e3f25500eab4384fe
src/views/ReportCompareForm.vue
@@ -12,11 +12,11 @@
import type { CompareMetricKey } from '@/stores/compareReports'
import { compareSelectionKey, createCompareReport, editCompareReport, getCompareReport, getCompareSources, previewCompareReport, refreshCompareSource,
  type CompareCandidate, type CompareItem, type SavedCompareReport } from '@/api/compareReports'
import { listCompareFieldTemplates, createCompareFieldTemplate, updateCompareFieldTemplate, type CompareField, type CompareFieldTemplate } from '@/api/compareReports'
import { compareFields, compareFieldLabel, compareBusinessFieldValue } from '@/utils/compareReportFields'
import { compareMetricDefinitions as metricDefinitions, openCompareReportPrint } from '@/utils/compareReportPrint'
import { compareChartOption } from '@/utils/compareReportChart'
import { compareSourceKey, compareDetailSources, compareSourceFields, compareSourceValue, compareHasRangeDetailValues } from '@/utils/compareSourceDetails'
import { compareChartOption, compareSeriesName } from '@/utils/compareReportChart'
import { compareDetailSources } from '@/utils/compareSourceDetails'
import ReportTestDetail from '@/components/ReportTestDetail.vue'
import { reportDetailColumn, snapshotReportDetails } from '@/utils/reportTestDetails'
import type { CompareSettings } from '@/api/compareReports'
use([CanvasRenderer, LineChart, GridComponent, LegendComponent, TooltipComponent])
@@ -39,13 +39,12 @@
const changeSummary = ref('')
const loadError = ref('')
const previewLoading = ref(false)
let previewRequest = 0
const previewError = ref('')
const reportLoading = ref(false)
const saving = ref(false)
const reportName = ref('')
const experimentName = ref('')
const sourceDetailOverrides = ref<CompareSettings['sourceDetailOverrides']>({})
const rangeDetailValues = ref(false)
const description = ref('')
const batteryGroup = ref('')
type SelectedTest = CompareCandidate & { id: number; cycle: number }
@@ -56,27 +55,10 @@
const candidatesLoading = ref(false)
const candidatesError = ref('')
const testDialogVisible = ref(false)
const templateDialogVisible = ref(false)
const selectedTemplateKey = ref('')
const selectedCandidateId = ref<number | null>(null)
const selectedBatteryNo = ref('')
const selectedCycle = ref<number | null>(null)
const selectedProcess = ref('')
const templateMode = ref<'battery' | 'special'>('battery')
const batteryFields = ref<string[]>([])
const specialFields = ref<string[]>([])
const detailFieldValues = reactive<Record<string, Record<string, string>>>({})
const selectedBatteryTemplateName = ref('自定义')
const selectedBatteryTemplateFieldIds = ref<number[]>([])
const persistedTemplates = ref<CompareFieldTemplate[]>([])
const templateLoading = ref(false)
const templateSaving = ref(false)
const templateError = ref('')
const fieldStructure = ref<CompareField[]>([])
const fieldSettingsVisible = ref(false)
const fieldSettingsDraft = ref<CompareField[]>([])
const fieldTemplates = computed(() => persistedTemplates.value.map(item => ({ key: `remote:${item.id}`, name: item.name, fields: item.fields.map(compareFieldLabel) })))
const metricVisible = reactive<Record<CompareMetricKey, boolean>>({
  voltage: true,
@@ -89,20 +71,9 @@
const hiddenSeriesKeys = ref<string[]>([])
const visibleMetrics = computed(() => metricDefinitions.filter(metric => metricVisible[metric.key]))
const selectedTemplate = computed(() => persistedTemplates.value.find(item => `remote:${item.id}` === selectedTemplateKey.value))
const batteryTemplateNames = computed(() => ['自定义', ...persistedTemplates.value.map(item => item.name)])
const batteryTemplateFieldOptions = computed(() => [...new Map([
  ...sourceItems.value.flatMap(item => item.businessFields).map(field => [field.fieldDefinitionId, { id: field.fieldDefinitionId, name: field.fieldName, status: '启用' }] as const),
  ...fieldStructure.value.filter(field => field.category === 'battery' && field.fieldDefinitionId != null)
    .map(field => [field.fieldDefinitionId!, { id: field.fieldDefinitionId!, name: field.name, status: '启用' }] as const),
]).values()])
const enabledBatteryTemplateFields = computed(() => batteryTemplateFieldOptions.value.filter(field => field.status === '启用'))
const detailStructure = computed(() => compareFields({ batteryTemplateFields: batteryFields.value, specialFields: specialFields.value, fieldStructure: fieldStructure.value }))
const detailFields = computed(() => detailStructure.value.map(field => field.name))
const selectedCandidate = computed(() => candidateTests.value.find(item => item.id === selectedCandidateId.value) || null)
const sourceTests = computed(() => compareDetailSources(sourceItems.value).map(toSelectedTest))
const detailTests = computed(() => rangeDetailValues.value ? selectedTests.value : sourceTests.value)
const sourceFields = computed(() => compareSourceFields(sourceTests.value, sourceDetailOverrides.value))
const reportDetailColumns = computed(() => sourceTests.value.map(reportDetailColumn))
const chartSeries = computed(() => selectedTests.value)
const testCandidates = computed(() => candidates.value.filter(item => item.selection.testId === selectedCandidateId.value))
const batteryChoices = computed(() => [...new Set(testCandidates.value.map(item => item.batteryNo))])
@@ -125,65 +96,18 @@
  }
}
async function loadFieldTemplates() {
  templateLoading.value = true
  templateError.value = ''
  try { persistedTemplates.value = await listCompareFieldTemplates() }
  catch (error) { persistedTemplates.value = []; templateError.value = error instanceof Error ? error.message : '字段模板加载失败' }
  finally { templateLoading.value = false }
}
function detailKey(item: SelectedTest) {
  return rangeDetailValues.value ? compareSelectionKey(item.selection) : compareSourceKey(item)
}
function updateSourceDetail(item: SelectedTest, value: string, field?: string) {
  sourceDetailOverrides.value ||= {}
  const manual = sourceDetailOverrides.value[compareSourceKey(item)] ||= {}
  if (field) (manual.fieldValues ||= {})[field] = value
  else manual.experimentName = value
}
function ensureDetailFieldValues(field: string) {
  if (!detailFieldValues[field]) detailFieldValues[field] = {}
  detailTests.value.forEach(item => {
    const key = detailKey(item)
    // 未填写才读取当前预览或保存快照的业务值,显式清空不被后续预览覆盖。
    if (detailFieldValues[field][key] === undefined) {
      const source = sourceItems.value.find(value => compareSelectionKey(value.source.selection) === key)
      const sourceDetail = source?.source.consoleDetail
      const sourceValue = sourceDetail?.fieldValues?.[field]?.[item.batteryNo]
      detailFieldValues[field][key] = sourceValue !== undefined
        ? sourceValue
        : compareBusinessFieldValue(fieldDescriptor(field), source)
    }
  })
}
function loadEditingReport() {
  const record = editingRecord.value
  if (!record) return
  reportName.value = record.name
  experimentName.value = record.experimentName || ''
  sourceDetailOverrides.value = record.settings.sourceDetailOverrides === undefined ? undefined : JSON.parse(JSON.stringify(record.settings.sourceDetailOverrides))
  rangeDetailValues.value = compareHasRangeDetailValues(record.settings)
  description.value = record.description || ''
  sourceItems.value = record.items
  selectedTests.value = record.items.map(item => toSelectedTest(item.source))
  metricDefinitions.forEach(metric => { metricVisible[metric.key] = record.settings.metrics.includes(metric.key) })
  seriesColors.value = [...record.settings.seriesColors]
  hiddenSeriesKeys.value = [...(record.settings.hiddenSeriesKeys || [])]
  batteryFields.value = [...record.settings.batteryTemplateFields]
  specialFields.value = [...record.settings.specialFields]
  fieldStructure.value = (record.settings.fieldStructure || []).map(field => ({ ...field }))
  Object.keys(detailFieldValues).forEach(field => delete detailFieldValues[field])
  Object.entries(record.settings.detailFieldValues).forEach(([field, values]) => {
    detailFieldValues[field] = { ...values }
  })
  selectedBatteryTemplateName.value = '自定义'
  syncBatterySelection()
  detailFields.value.forEach(ensureDetailFieldValues)
  savedFormContent.value = JSON.stringify(formContent())
}
@@ -193,12 +117,12 @@
function toggleSeries(item: SelectedTest) {
  if (readonly.value) return
  const key = detailKey(item)
  // 详情按来源合列,曲线显隐仍按完整循环和工步范围独立保存。
  const key = compareSelectionKey(item.selection)
  hiddenSeriesKeys.value = hiddenSeriesKeys.value.includes(key)
    ? hiddenSeriesKeys.value.filter(value => value !== key) : [...hiddenSeriesKeys.value, key]
}
detailFields.value.forEach(ensureDetailFieldValues)
// 搜索跳转和浏览器后退会复用详情组件,旧请求的结果不能覆盖新报表。
watch(() => [editingId.value, route.query.mode, route.query.version] as const, async ([id, mode, version], _previous, onCleanup) => {
  let cancelled = false
@@ -208,23 +132,11 @@
  sourceItems.value = []
  reportName.value = ''
  description.value = ''
  experimentName.value = ''
  sourceDetailOverrides.value = {}
  rangeDetailValues.value = false
  batteryFields.value = []
  specialFields.value = []
  fieldStructure.value = []
  templateDialogVisible.value = false
  fieldSettingsVisible.value = false
  persistedTemplates.value = []
  selectedTemplateKey.value = ''
  selectedBatteryTemplateName.value = '自定义'
  seriesColors.value = ['#3978f6', '#7d42d8', '#f08a3e']
  hiddenSeriesKeys.value = []
  metricDefinitions.forEach(metric => { metricVisible[metric.key] = true })
  Object.keys(detailFieldValues).forEach(field => delete detailFieldValues[field])
  loadError.value = ''
  detailFields.value.forEach(ensureDetailFieldValues)
  previewError.value = ''
  canCreate.value = false
  canEdit.value = false
@@ -259,93 +171,21 @@
}, { immediate: true })
async function refreshPreview() {
  const current = ++previewRequest
  const selections = selectedTests.value.map(item => item.selection)
  previewLoading.value = true
  previewError.value = ''
  sourceItems.value = []
  try {
    const items = await previewCompareReport(selections)
    if (editingId.value) return
    if (editingId.value || current !== previewRequest) return
    sourceItems.value = items
    detailFields.value.forEach(ensureDetailFieldValues)
  } catch (error) { previewError.value = error instanceof Error ? error.message : '曲线预览失败' }
  finally { previewLoading.value = false }
}
async function saveCurrentFieldTemplate() {
  if (readonly.value || templateLoading.value || templateSaving.value || (!canCreate.value && !canEdit.value)) return
  let result: { value: string }
  try {
    result = await ElMessageBox.prompt('请输入模板名称', '保存对比字段模板', { inputValue: selectedTemplate.value?.name || '', inputValidator: value => value?.trim() && value.trim().length <= 100 ? true : '请输入不超过100字的模板名称', confirmButtonText: '保存', cancelButtonText: '取消' })
  } catch { return }
  const name = result.value.trim()
  const fields = detailFields.value.map(fieldDescriptor)
  const existing = persistedTemplates.value.find(item => item.name === name)
  if (existing && !canEdit.value) { ElMessage.warning('无编辑对比报表权限,不能覆盖模板'); return }
  if (!existing && !canCreate.value) { ElMessage.warning('无新增对比报表权限'); return }
  if (existing) {
    try { await ElMessageBox.confirm(`更新模板“${name}”的字段结构?`, '更新字段模板', { confirmButtonText: '更新', cancelButtonText: '取消' }) }
    catch { return }
  }
  templateSaving.value = true
  try {
    const id = existing ? existing.id : await createCompareFieldTemplate(name, fields)
    if (existing) await updateCompareFieldTemplate(existing.id, name, fields)
    await loadFieldTemplates()
    selectedTemplateKey.value = `remote:${id}`
    ElMessage.success('字段模板已保存')
  }
  catch (error) { ElMessage.error(error instanceof Error ? error.message : '模板保存失败') }
  finally { templateSaving.value = false }
}
function fieldDescriptor(name: string): CompareField {
  const field = detailStructure.value.find(item => item.name === name)!
  const definition = batteryTemplateFieldOptions.value.find(item => item.name === name)
  return { ...field, fieldDefinitionId: field.fieldDefinitionId ?? (field.category === 'battery' ? definition?.id : null) }
}
function syncBatterySelection() {
  selectedBatteryTemplateFieldIds.value = batteryFields.value.flatMap(name => {
    const id = fieldDescriptor(name).fieldDefinitionId
    return id == null ? [] : [id]
  })
}
function replaceFieldStructure(fields: CompareField[]) {
  const previous = detailFields.value.map(fieldDescriptor)
  // 同名不代表同一共享字段;身份改变必须清除旧值,不能把旧自动值或人工值归入新身份。
  for (const name of Object.keys(detailFieldValues)) {
    const before = previous.find(field => field.name === name)
    const after = fields.find(field => field.name === name)
    if (!before || !after || before.category !== after.category || (before.fieldDefinitionId ?? null) !== (after.fieldDefinitionId ?? null)) delete detailFieldValues[name]
  }
  batteryFields.value = fields.filter(field => field.category === 'battery').map(field => field.name)
  specialFields.value = fields.filter(field => field.category === 'special').map(field => field.name)
  fieldStructure.value = fields.map(field => ({ ...field }))
  syncBatterySelection()
  detailFields.value.forEach(ensureDetailFieldValues)
}
function openFieldSettings() {
  fieldSettingsDraft.value = detailFields.value.map(fieldDescriptor)
  fieldSettingsVisible.value = true
}
function moveField(index: number, offset: number) {
  const target = index + offset
  if (target < 0 || target >= fieldSettingsDraft.value.length) return
  const [field] = fieldSettingsDraft.value.splice(index, 1)
  if (field) fieldSettingsDraft.value.splice(target, 0, field)
}
function applyFieldSettings() {
  if (fieldSettingsDraft.value.some(field => !field.displayName?.trim())) { ElMessage.warning('展示名称不能为空'); return }
  fieldStructure.value = fieldSettingsDraft.value.map(field => ({ ...field, displayName: field.displayName!.trim(), unit: field.unit.trim() }))
  fieldSettingsVisible.value = false
  } catch (error) { if (current === previewRequest) previewError.value = error instanceof Error ? error.message : '曲线预览失败' }
  finally { if (current === previewRequest) previewLoading.value = false }
}
function openAddTest() {
  void loadCandidates()
  selectedCandidateId.value = null
  selectedBatteryNo.value = ''
  selectedCycle.value = null
@@ -388,109 +228,19 @@
    return
  }
  const index = selectedTests.value.indexOf(itemToRemove)
  hiddenSeriesKeys.value = hiddenSeriesKeys.value.filter(key => key !== detailKey(itemToRemove))
  hiddenSeriesKeys.value = hiddenSeriesKeys.value.filter(key => key !== compareSelectionKey(itemToRemove.selection))
  if (index >= 0) { selectedTests.value.splice(index, 1); seriesColors.value.splice(index, 1) }
  await refreshPreview()
}
function addCustomField() {
  ElMessageBox.prompt('请输入要添加的字段名称', '添加项', {
    confirmButtonText: '添加',
    cancelButtonText: '取消',
    inputPlaceholder: '例如:库仑效率',
    inputValidator: value => value.trim() ? true : '请输入字段名称',
  }).then(({ value }) => {
    const name = value.trim()
    if (detailFields.value.includes(name)) {
      ElMessage.warning('该字段已经存在')
      return
    }
    specialFields.value.push(name)
    ensureDetailFieldValues(name)
    ElMessage.success(`已添加字段“${name}”`)
  }).catch(() => {
    // 用户取消添加。
  })
}
function removeCustomField(field: string) {
  if (batteryFields.value.includes(field)) {
    ElMessage.warning('电池组模板字段请在电池组字段模板中调整')
    return
  }
  specialFields.value = specialFields.value.filter(item => item !== field)
  delete detailFieldValues[field]
}
function resolveBatteryTemplateFieldIds(name: string) {
  const template = persistedTemplates.value.find(item => item.name === name)
  return (template?.fields || []).filter(field => field.category === 'battery').flatMap(field => {
    const option = enabledBatteryTemplateFields.value.find(item => field.fieldDefinitionId != null ? item.id === field.fieldDefinitionId : item.name === field.name)
    return option ? [option.id] : []
  })
}
function loadBatteryTemplate(name: string) {
  selectedBatteryTemplateFieldIds.value = resolveBatteryTemplateFieldIds(name)
}
function switchBatteryTemplate(name: string) {
  const template = persistedTemplates.value.find(item => item.name === name)
  if (template) {
    const battery = template.fields.filter(field => field.category === 'battery')
    replaceFieldStructure([...battery, ...detailFields.value.map(fieldDescriptor).filter(field => field.category === 'special' && !battery.some(item => item.name === field.name))])
    return
  }
  loadBatteryTemplate(name)
  applyBatteryTemplateFields()
}
function applyBatteryTemplateFields() {
  batteryFields.value = selectedBatteryTemplateFieldIds.value
    .map(id => fieldStructure.value.find(field => field.category === 'battery' && field.fieldDefinitionId === id)?.name || enabledBatteryTemplateFields.value.find(field => field.id === id)?.name)
    .filter((name): name is string => Boolean(name))
  specialFields.value = specialFields.value.filter(field => !batteryFields.value.includes(field))
  detailFields.value.forEach(ensureDetailFieldValues)
}
function openFieldTemplate(mode: 'battery' | 'special') {
  void loadFieldTemplates()
  templateMode.value = mode
  if (mode === 'battery') {
    syncBatterySelection()
    if (!batteryTemplateNames.value.includes(selectedBatteryTemplateName.value)) selectedBatteryTemplateName.value = '自定义'
  } else {
    if (!selectedTemplate.value) selectedTemplateKey.value = ''
  }
  templateDialogVisible.value = true
}
function applyFieldTemplate() {
  if (templateMode.value === 'battery') {
    applyBatteryTemplateFields()
  } else {
    const remote = selectedTemplate.value
    if (!remote) { ElMessage.warning('请选择字段模板'); return }
    // 只复制结构;新增字段取当前草稿来源,保留当前草稿已有的人工值。
    replaceFieldStructure(remote.fields)
  }
  templateDialogVisible.value = false
  ElMessage.success(`${templateMode.value === 'battery' ? '电池组字段' : '特殊字段'}模板已应用`)
}
function formContent() {
  return {
    name: reportName.value, description: description.value, experimentName: experimentName.value,
    name: reportName.value, description: description.value, experimentName: '',
    settings: {
      metrics: visibleMetrics.value.map(item => item.key), seriesColors: seriesColors.value.slice(0, selectedTests.value.length),
      hiddenSeriesKeys: [...hiddenSeriesKeys.value],
      batteryTemplateFields: [...batteryFields.value], specialFields: [...specialFields.value],
      fieldStructure: detailFields.value.map(fieldDescriptor),
      ...(sourceDetailOverrides.value === undefined ? {} : { sourceDetailOverrides: Object.fromEntries(Object.entries(sourceDetailOverrides.value)
        .filter(([key]) => detailTests.value.some(item => compareSourceKey(item) === key))) }),
      detailFieldValues: Object.fromEntries(detailFields.value.map(field => [field,
        Object.fromEntries(detailTests.value.map(item => [detailKey(item), detailFieldValues[field]?.[detailKey(item)] ?? ''])),
      ])),
      batteryTemplateFields: [], specialFields: [], fieldStructure: [], detailFieldValues: {},
      sourceDetailOverrides: snapshotReportDetails(reportDetailColumns.value, sourceDetailOverrides.value),
    },
  }
}
@@ -595,45 +345,14 @@
      <button v-if="!editingId && canCreate" class="primary-button compact-button" type="button" :disabled="previewLoading || saving" @click="openAddTest"><el-icon><Plus /></el-icon>添加比较</button>
    </section>
    <section class="detail-panel">
      <header class="panel-heading"><div><strong>测试详情</strong><el-icon><ArrowUp /></el-icon></div></header>
      <div v-if="sourceDetailOverrides === undefined || experimentName" class="detail-meta">
        <label><span>实验</span><el-input v-model="experimentName" :readonly="readonly" maxlength="500" /></label>
      </div>
      <div v-if="sourceDetailOverrides !== undefined" class="field-table-wrap">
        <table class="field-table source-field-table" :style="{ minWidth: `${Math.max(700, sourceTests.length * 220 + 160)}px` }">
          <thead><tr><th>字段</th><th v-for="(item, index) in sourceTests" :key="compareSourceKey(item)">来源电池 {{ index + 1 }}</th></tr></thead>
          <tbody>
            <tr class="detail-meta-row"><td>测试</td><td v-for="item in sourceTests" :key="compareSourceKey(item)">{{ item.name }}</td></tr>
            <tr class="source-experiment-row"><td>实验</td><td v-for="item in sourceTests" :key="compareSourceKey(item)"><el-input :model-value="compareSourceValue(item, sourceDetailOverrides)" :readonly="readonly" maxlength="500" :aria-label="`${item.name} ${item.batteryNo} 实验`" @update:model-value="updateSourceDetail(item, $event)" /></td></tr>
            <tr class="detail-meta-row"><td>电池组</td><td v-for="item in sourceTests" :key="compareSourceKey(item)">{{ item.batteryGroup }}</td></tr>
            <tr class="sample-row"><td>电池号</td><td v-for="item in sourceTests" :key="compareSourceKey(item)">{{ item.batteryNo }}</td></tr>
            <tr v-for="field in sourceFields" :key="field.key" class="source-detail-row" :data-field="field.key"><td>{{ field.name }}<small class="source-field-kind">{{ field.category === 'battery' ? '电池组详情' : '特殊详情' }}</small></td><td v-for="item in sourceTests" :key="compareSourceKey(item)"><el-input :model-value="compareSourceValue(item, sourceDetailOverrides, field)" :readonly="readonly" maxlength="500" :aria-label="`${item.name} ${item.batteryNo} ${field.name}`" @update:model-value="updateSourceDetail(item, $event, field.key)" /></td></tr>
            <tr v-if="!sourceFields.length"><td :colspan="sourceTests.length + 1" class="empty-cell">暂无已保存的测试详情字段</td></tr>
          </tbody>
        </table>
      </div>
      <div v-if="detailFields.length || sourceDetailOverrides === undefined" class="field-table-wrap">
        <table class="field-table" :class="{ 'with-actions': !readonly }" :style="{ minWidth: `${Math.max(900, detailTests.length * 220 + 214)}px` }">
          <thead><tr><th>报表字段</th><th v-for="(item, index) in detailTests" :key="detailKey(item)">{{ rangeDetailValues ? '对比项' : '来源电池' }} {{ index + 1 }}</th><th v-if="!readonly">操作</th></tr></thead>
          <tbody>
            <tr class="detail-meta-row"><td>测试</td><td v-for="item in detailTests" :key="detailKey(item)">{{ item.name }}</td><td v-if="!readonly">-</td></tr>
            <tr class="detail-meta-row"><td>电池组</td><td v-for="item in detailTests" :key="detailKey(item)">{{ item.batteryGroup || batteryGroup }}</td><td v-if="!readonly">-</td></tr>
            <tr class="sample-row"><td>电池号</td><td v-for="item in detailTests" :key="detailKey(item)">{{ item.batteryNo }}</td><td v-if="!readonly">-</td></tr>
            <tr v-for="field in detailFields" :key="field"><td>{{ compareFieldLabel(fieldDescriptor(field)) }}</td><td v-for="item in detailTests" :key="detailKey(item)"><el-input v-model="detailFieldValues[field][detailKey(item)]" :readonly="readonly" size="small" placeholder="请输入" /></td><td v-if="!readonly"><button v-if="specialFields.includes(field)" class="delete-link" type="button" @click="removeCustomField(field)">删除</button><span v-else class="field-lock">-</span></td></tr>
            <tr v-if="!detailFields.length && !sourceFields.length"><td :colspan="detailTests.length + (readonly ? 1 : 2)" class="empty-cell">暂无字段</td></tr>
          </tbody>
        </table>
      </div>
      <div v-if="!readonly" class="detail-actions"><button class="primary-button" type="button" @click="addCustomField"><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="openFieldSettings"><el-icon><Edit /></el-icon>字段设置</button></div>
    </section>
    <ReportTestDetail v-model="sourceDetailOverrides" :columns="reportDetailColumns" :readonly="readonly" :disabled="saving || previewLoading || !!previewError" />
    <section class="metrics-panel">
      <div v-if="!readonly" class="metric-switches"><label v-for="metric in metricDefinitions" :key="metric.key"><span>{{ metric.title }}</span><el-switch v-model="metricVisible[metric.key]" inline-prompt active-text="显" inactive-text="隐" /></label></div>
      <div v-if="chartSeries.length" class="chart-legend-bar"><strong>对比图例</strong><div v-for="(item, index) in chartSeries" :key="detailKey(item)" 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="`修改${item.name}颜色`" @change="updateSeriesColor(index, $event)" /><i v-else class="series-swatch" :style="{ background: seriesColors[index] }" /><button type="button" class="legend-name" :class="{ 'is-hidden': hiddenSeriesKeys.includes(detailKey(item)) }" :aria-pressed="!hiddenSeriesKeys.includes(detailKey(item))" :disabled="readonly" @click="toggleSeries(item)">{{ item.name }} (#{{ item.id }}) · {{ item.batteryGroup }} · {{ item.batteryNo }} · 循环{{ item.cycle }} · {{ item.process }}</button></div></div>
      <div v-if="chartSeries.length" class="chart-legend-bar"><strong>对比图例</strong><div v-for="(item, index) in chartSeries" :key="compareSelectionKey(item.selection)" 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="`修改${item.name}颜色`" @change="updateSeriesColor(index, $event)" /><i v-else class="series-swatch" :style="{ background: seriesColors[index] }" /><button type="button" class="legend-name" :class="{ 'is-hidden': hiddenSeriesKeys.includes(compareSelectionKey(item.selection)) }" :aria-pressed="!hiddenSeriesKeys.includes(compareSelectionKey(item.selection))" :disabled="readonly" @click="toggleSeries(item)">{{ item.name }} (#{{ item.id }}) · {{ item.batteryGroup }} · {{ item.batteryNo }} · 循环{{ item.cycle }} · {{ item.process }}</button></div></div>
      <!-- 报表切换和比对项移除时完整替换配置,避免图表库将颜色数组缩短误判为组件合并。 -->
      <div v-if="!visibleMetrics.length" class="chart-empty">当前未启用曲线指标</div>
      <div class="charts-grid"><article v-for="metric in visibleMetrics" :key="metric.key" class="chart-card"><header><strong>{{ metric.title }}</strong></header><VChart class="metric-chart" :option="compareChartOption(metric, sourceItems, seriesColors, hiddenSeriesKeys)" :update-options="{ notMerge: true }" autoresize /><div v-if="!sourceItems.some(item => !hiddenSeriesKeys.includes(compareSelectionKey(item.source.selection)) && item.records.some(row => (metric.key === 'voltage' || metric.key === 'current' ? row.deviceTime : row.collectorDeviceTime) != null && (metric.key === 'positiveVoltage' ? row.positivePotential : metric.key === 'negativeVoltage' ? row.negativePotential : row[metric.key]) != null))" class="chart-empty">{{ sourceItems.length && hiddenSeriesKeys.length === sourceItems.length ? '当前全部曲线已隐藏' : '所选范围暂无该指标数据' }}</div></article></div>
      <div class="charts-grid"><article v-for="metric in visibleMetrics" :key="metric.key" class="chart-card"><header><strong>{{ metric.title }}</strong></header><VChart class="metric-chart" :option="compareChartOption(metric, sourceItems, seriesColors, hiddenSeriesKeys)" :update-options="{ notMerge: true }" autoresize @legendselectchanged="!readonly && (hiddenSeriesKeys = sourceItems.filter(item => !$event.selected[compareSeriesName(item)]).map(item => compareSelectionKey(item.source.selection)))" /><div v-if="!sourceItems.some(item => !hiddenSeriesKeys.includes(compareSelectionKey(item.source.selection)) && item.records.some(row => (metric.key === 'voltage' || metric.key === 'current' ? row.deviceTime : row.collectorDeviceTime) != null && (metric.key === 'positiveVoltage' ? row.positivePotential : metric.key === 'negativeVoltage' ? row.negativePotential : row[metric.key]) != null))" class="chart-empty">{{ sourceItems.length && hiddenSeriesKeys.length === sourceItems.length ? '当前全部曲线已隐藏' : '所选范围暂无该指标数据' }}</div></article></div>
    </section>
    <footer class="form-actions">
@@ -660,25 +379,6 @@
      <template #footer><div class="dialog-footer-left"><button class="primary-button" type="button" @click="addSelectedTest"><el-icon><Check /></el-icon>添加</button></div></template>
    </el-dialog>
    <el-dialog v-model="fieldSettingsVisible" title="字段设置" width="640px" align-center>
      <div class="field-settings-table"><table><thead><tr><th>字段</th><th>展示名称</th><th>单位</th><th>顺序</th></tr></thead><tbody>
        <tr v-for="(field, index) in fieldSettingsDraft" :key="field.name"><td>{{ field.name }}</td><td><el-input v-model="field.displayName" maxlength="100" aria-label="展示名称" /></td><td><el-input v-model="field.unit" maxlength="30" aria-label="单位" /></td><td><button type="button" :disabled="index === 0" title="上移" aria-label="上移" @click="moveField(index, -1)"><el-icon><ArrowUp /></el-icon></button><button type="button" :disabled="index === fieldSettingsDraft.length - 1" title="下移" aria-label="下移" @click="moveField(index, 1)"><el-icon><ArrowDown /></el-icon></button></td></tr>
      </tbody></table></div>
      <template #footer><button class="secondary-button" type="button" @click="fieldSettingsVisible = false">取消</button><button class="primary-button" type="button" @click="applyFieldSettings">确认</button></template>
    </el-dialog>
    <el-dialog v-model="templateDialogVisible" class="template-dialog" :title="templateMode === 'battery' ? '选择电池组字段模板' : '选择对比字段模板'" :width="templateMode === 'battery' ? '720px' : '560px'" align-center>
      <div v-if="templateError" role="alert">{{ templateError }}<button type="button" class="secondary-button" @click="loadFieldTemplates">重试</button></div>
      <div v-if="templateMode === 'battery'" class="battery-template-editor">
        <label class="battery-template-select"><span>选择模板</span><el-select v-model="selectedBatteryTemplateName" @change="switchBatteryTemplate"><el-option v-for="name in batteryTemplateNames" :key="name" :label="name" :value="name" /></el-select></label>
        <el-checkbox-group v-if="batteryTemplateFieldOptions.length" v-model="selectedBatteryTemplateFieldIds" class="battery-template-fields" @change="applyBatteryTemplateFields">
          <el-checkbox v-for="field in batteryTemplateFieldOptions" :key="field.id" :value="field.id" :title="field.name">{{ field.name }}</el-checkbox>
        </el-checkbox-group>
        <div v-else class="template-empty">暂无字段,请先到电池组字段管理中添加字段</div>
      </div>
      <div v-else><div v-if="templateLoading" class="template-empty">正在加载模板</div><el-radio-group v-else-if="fieldTemplates.length" v-model="selectedTemplateKey" class="template-options"><el-radio v-for="item in fieldTemplates" :key="item.key" :value="item.key" border>{{ item.name }}({{ item.fields.join('、') }})</el-radio></el-radio-group><div v-else-if="!templateError" class="template-empty">暂无已保存模板</div></div>
      <template #footer><div><button v-if="canCreate || canEdit" class="secondary-button" type="button" @click="saveCurrentFieldTemplate" :disabled="templateLoading || templateSaving || !!templateError">保存为模板</button><button class="secondary-button" type="button" @click="templateDialogVisible = false">取消</button><button class="primary-button" type="button" @click="applyFieldTemplate" :disabled="templateLoading || templateSaving || (templateMode === 'special' && !selectedTemplate)">{{ templateMode === 'battery' ? '确认' : '应用字段' }}</button></div></template>
    </el-dialog>
  </div>
</template>
@@ -753,7 +453,7 @@
.chart-card { min-width: 0; border: 1px solid #edf1f5; background: #fff; }
.chart-card header { height: 38px; display: flex; align-items: center; gap: 18px; padding: 0 14px; color: #52606d; font-size: 11px; }
.chart-card header strong { margin-right: auto; color: #26333f; font-size: 13px; }
.metric-chart { width: 100%; height: 210px; }
.metric-chart { width: 100%; height: 230px; }
.form-actions { position: sticky; bottom: 0; z-index: 20; min-height: 62px; display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 10px; margin-top: 16px; padding: 10px 16px; background: rgba(234, 240, 246, .96); border-top: 1px solid #cbd9e5; }
.form-actions .primary-button, .form-actions .secondary-button { min-width: 110px; }
.save-button { min-width: 110px; }