yeg
2026-09-08 cfd4511378aefa04d4e06058cee628b6edd8e9c1
src/views/ReportCompareForm.vue
@@ -1,6 +1,5 @@
<script setup lang="ts">
import { toTestDisplayValue } from '@/utils/testDataUnits'
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { use } from 'echarts/core'
@@ -8,88 +7,57 @@
import { CanvasRenderer } from 'echarts/renderers'
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
import VChart from 'vue-echarts'
import { getCurrentUser } from '@/utils/auth'
import { useCompareReports, type CompareMetricKey, type CompareReportPayload, type CompareTestSelection } from '@/stores/compareReports'
import { getPersistedTestRecords, getTestDetail, getTestList, type PersistedTestRecord } from '@/api/tests'
import { useBatteryGroupFields } from '@/stores/batteryGroupFields'
import { compareMetricDefinitions as metricDefinitions, openCompareReportPrint, type CompareMetricDefinition } from '@/utils/compareReportPrint'
import CompareReportHistoryDialog from '@/components/CompareReportHistoryDialog.vue'
import { getUserInfo } from '@/api/auth'
import type { CompareMetricKey } from '@/stores/compareReports'
import { compareSelectionKey, createCompareReport, editCompareReport, getCompareReport, getCompareSources, previewCompareReport, refreshCompareSource,
  type CompareCandidate, type CompareItem, type SavedCompareReport } from '@/api/compareReports'
import { compareMetricDefinitions as metricDefinitions, openCompareReportPrint } from '@/utils/compareReportPrint'
import { compareChartOption } 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])
interface FieldTemplate {
  key: string
  name: string
  fields: string[]
}
const route = useRoute()
const router = useRouter()
const currentUser = getCurrentUser()
const { records, createReport, updateReport } = useCompareReports()
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 { fields: managedGroupFields } = useBatteryGroupFields()
const editingRecord = ref<SavedCompareReport | null>(null)
const readonly = computed(() => Boolean(editingId.value) && (route.query.mode !== 'edit' || !canEdit.value || Boolean(loadError.value)))
const canCreate = ref(false)
const canEdit = ref(false)
const canQuery = ref(false)
const canExport = ref(false)
const printing = ref(false)
const savedFormContent = ref('')
const historyVisible = ref(false)
const changeSummary = ref('')
const loadError = ref('')
const previewLoading = ref(false)
const previewError = ref('')
const reportLoading = ref(false)
const saving = ref(false)
const reportName = ref('')
const experimentName = ref('XXX实验')
const batteryGroup = ref('电池组1')
const selectedTests = ref<CompareTestSelection[]>([])
const candidateTests = ref<CompareTestSelection[]>([])
const sourceRecords = reactive<Record<number, PersistedTestRecord[]>>({})
const sourceDetailOverrides = ref<CompareSettings['sourceDetailOverrides']>({})
const description = ref('')
const batteryGroup = ref('')
type SelectedTest = CompareCandidate & { id: number; cycle: number }
const selectedTests = ref<SelectedTest[]>([])
const candidates = ref<CompareCandidate[]>([])
const candidateTests = computed(() => [...new Map(candidates.value.map(item => [item.selection.testId, toSelectedTest(item)])).values()])
const sourceItems = ref<CompareItem[]>([])
const candidatesLoading = ref(false)
const candidatesError = ref('')
const testDialogVisible = ref(false)
const templateDialogVisible = ref(false)
const selectedTemplateKey = ref('cycle')
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(['额定容量', '电流', '内阻'])
const specialFields = ref(['温升'])
const detailFieldValues = reactive<Record<string, Record<string, string>>>({})
const batteryTemplateStorageKey = 'battery_detection_group_field_templates_v1'
const presetBatteryTemplateLabels: Record<string, string[]> = {
  '标准容量样品': ['额定容量', '电流', '内阻'],
  '标准循环样品': ['额定容量', '循环温度', '内阻'],
  '低温性能样品': ['目标温度', '静置时间', '环境备注'],
}
const batteryTemplateSelections = reactive<Record<string, number[]>>((() => {
  try {
    const stored = localStorage.getItem(batteryTemplateStorageKey)
    return stored ? JSON.parse(stored) as Record<string, number[]> : {}
  } catch {
    localStorage.removeItem(batteryTemplateStorageKey)
    return {}
  }
})())
const selectedBatteryTemplateName = ref('标准容量样品')
const selectedBatteryTemplateFieldIds = ref<number[]>([])
const specialTemplateStorageKey = 'battery_detection_compare_special_templates_v2'
const specialTemplateSelections = reactive<Record<string, string[]>>((() => {
  try {
    const stored = localStorage.getItem(specialTemplateStorageKey)
    if (stored) return JSON.parse(stored) as Record<string, string[]>
    const legacyStored = localStorage.getItem('battery_detection_compare_special_template_v1')
    const legacyFields = legacyStored ? JSON.parse(legacyStored) as string[] : []
    return legacyFields.length ? { 已保存特殊字段模板: legacyFields } : {}
  } catch {
    localStorage.removeItem(specialTemplateStorageKey)
    return {}
  }
})())
const fieldTemplates = computed<FieldTemplate[]>(() => [
  { key: 'cycle', name: '循环寿命模板', fields: ['容量保持率', '能量效率', '循环次数', '内阻变化'] },
  { key: 'thermal', name: '热特性模板', fields: ['最高温度', '温差', '温升速率'] },
  ...Object.entries(specialTemplateSelections).map(([name, fields]) => ({ key: `saved:${name}`, name, fields })),
])
const metricVisible = reactive<Record<CompareMetricKey, boolean>>({
  voltage: true,
@@ -99,47 +67,27 @@
  temperature: true,
})
const seriesColors = ref(['#3978f6', '#7d42d8', '#f08a3e'])
const hiddenSeriesKeys = ref<string[]>([])
const visibleMetrics = computed(() => metricDefinitions.filter(metric => metricVisible[metric.key]))
const selectedTemplate = computed(() => fieldTemplates.value.find(item => item.key === selectedTemplateKey.value) || fieldTemplates.value[0])
const batteryTemplateNames = computed(() => [
  '自定义',
  '标准容量样品',
  '标准循环样品',
  '低温性能样品',
  ...Object.keys(batteryTemplateSelections).filter(name => !['自定义', '标准容量样品', '标准循环样品', '低温性能样品'].includes(name)),
])
const batteryTemplateFieldOptions = computed(() => [...managedGroupFields.value].sort((a, b) => b.createdAt.localeCompare(a.createdAt)))
const enabledBatteryTemplateFields = computed(() => batteryTemplateFieldOptions.value.filter(field => field.status === '启用'))
const detailFields = computed(() => [...batteryFields.value, ...specialFields.value])
const selectedCandidate = computed(() => candidateTests.value.find(item => item.id === selectedCandidateId.value) || null)
const detailTests = computed(() => {
  const seen = new Set<string>()
  return selectedTests.value.filter(item => {
    const key = detailKey(item)
    if (seen.has(key)) return false
    seen.add(key)
    return true
  })
})
const chartSeries = computed(() => detailTests.value)
const batteryChoices = computed(() => [...new Set((sourceRecords[selectedCandidateId.value || 0] || []).map(item => item.batteryNo).filter(Boolean))])
const cycleChoices = computed(() => [...new Set((sourceRecords[selectedCandidateId.value || 0] || []).map(item => Number(item.cycleNo)).filter(Number.isFinite))].sort((a, b) => a - b))
const processChoices = computed(() => [...new Map((sourceRecords[selectedCandidateId.value || 0] || []).map(item => [`${item.stepNo}-${item.stepName}`, `${item.stepNo}-${item.stepName}`])).values()])
const sourceTests = computed(() => compareDetailSources(sourceItems.value).map(toSelectedTest))
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))])
const cycleChoices = computed(() => [...new Set(testCandidates.value.filter(item => item.batteryNo === selectedBatteryNo.value).map(item => item.selection.cycleNo))].sort((a, b) => a - b))
const processChoices = computed(() => testCandidates.value.filter(item => item.batteryNo === selectedBatteryNo.value && item.selection.cycleNo === selectedCycle.value).map(item => item.process))
function toSelectedTest(item: CompareCandidate): SelectedTest {
  return { ...item, id: item.selection.testId, cycle: item.selection.cycleNo }
}
async function loadCandidates() {
  candidatesLoading.value = true
  candidatesError.value = ''
  try {
    const list = await getTestList({ pageNum: 1, pageSize: 200, status: 'COMPLETED' })
    const completed = list.rows.filter(item => item.status === '检测完成')
    const loaded = await Promise.all(completed.map(async item => {
      const [detail, rows] = await Promise.all([getTestDetail(item.id), getPersistedTestRecords(item.id)])
      sourceRecords[item.id] = rows
      const first = rows[0]
      return first ? { id: item.id, name: item.name, type: item.type, batteryGroup: detail.batteries[0]?.groupName || item.batteryGroup, batteryNo: first.batteryNo, process: `${first.stepNo}-${first.stepName}`, cycle: first.cycleNo } : null
    }))
    candidateTests.value = loaded.filter(Boolean) as CompareTestSelection[]
    candidates.value = await getCompareSources()
  } catch (error) {
    candidatesError.value = error instanceof Error ? error.message : '真实测试来源加载失败'
  } finally {
@@ -147,66 +95,92 @@
  }
}
function recordsFor(item: CompareTestSelection) {
  return (sourceRecords[item.id] || []).filter(row => row.batteryNo === item.batteryNo && Number(row.cycleNo) === Number(item.cycle) && `${row.stepNo}-${row.stepName}` === item.process)
}
function metricValues(key: CompareMetricKey) {
  const field: Record<CompareMetricKey, keyof PersistedTestRecord> = { voltage: 'voltage', current: 'current', positiveVoltage: 'positivePotential', negativeVoltage: 'negativePotential', temperature: 'temperature' }
  return chartSeries.value.map(item => recordsFor(item).map(row => row[field[key]]).filter((value): value is number => value !== null && value !== undefined && value !== '' && Number.isFinite(Number(value))).map(Number))
}
function detailKey(item: CompareTestSelection) {
  return `${item.id}|${item.batteryGroup || batteryGroup.value}|${item.batteryNo}`
}
function ensureDetailFieldValues(field: string) {
  if (!detailFieldValues[field]) detailFieldValues[field] = {}
  detailTests.value.forEach(item => {
    const key = detailKey(item)
    if (detailFieldValues[field][key] === undefined) detailFieldValues[field][key] = ''
  })
}
function loadEditingReport() {
  const record = editingRecord.value
  if (!record) return
  reportName.value = record.name
  batteryGroup.value = record.batteryGroup || '电池组1'
  const descriptionParts = record.description.split(' · ')
  experimentName.value = descriptionParts[0] || 'XXX实验'
  const fallbackTests: CompareTestSelection[] = [
    { id: 100000 + record.id * 2, name: record.baselineTest, type: '化成', batteryGroup: batteryGroup.value, batteryNo: record.batterySamples?.[0] || 'SP26-3', process: '恒流充电', cycle: 3 },
    { id: 100001 + record.id * 2, name: record.comparisonTest, type: '化成', batteryGroup: batteryGroup.value, batteryNo: record.batterySamples?.[1] || 'SP26-4', process: '恒流放电', cycle: 3 },
  ]
  selectedTests.value = (record.compareTests?.length ? record.compareTests : fallbackTests).map(item => ({ ...item }))
  if (record.metrics) {
    metricDefinitions.forEach(metric => { metricVisible[metric.key] = record.metrics!.includes(metric.key) })
  }
  if (record.seriesColors?.length) seriesColors.value = [...record.seriesColors]
  if (record.batteryTemplateFields) batteryFields.value = [...record.batteryTemplateFields]
  if (record.specialFields) specialFields.value = [...record.specialFields]
  Object.keys(detailFieldValues).forEach(field => delete detailFieldValues[field])
  Object.entries(record.detailFieldValues || {}).forEach(([field, values]) => {
    detailFieldValues[field] = { ...values }
  })
  selectedBatteryTemplateName.value = '自定义'
  selectedBatteryTemplateFieldIds.value = enabledBatteryTemplateFields.value
    .filter(field => batteryFields.value.includes(field.name))
    .map(field => field.id)
  detailFields.value.forEach(ensureDetailFieldValues)
  sourceDetailOverrides.value = record.settings.sourceDetailOverrides === undefined ? undefined : JSON.parse(JSON.stringify(record.settings.sourceDetailOverrides))
  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 || [])]
  savedFormContent.value = JSON.stringify(formContent())
}
function updateSeriesColor(index: number, color: string | null) {
  if (color) seriesColors.value[index] = color
}
detailFields.value.forEach(ensureDetailFieldValues)
loadEditingReport()
onMounted(() => { void loadCandidates() })
function toggleSeries(item: SelectedTest) {
  if (readonly.value) return
  // 详情按来源合列,曲线显隐仍按完整循环和工步范围独立保存。
  const key = compareSelectionKey(item.selection)
  hiddenSeriesKeys.value = hiddenSeriesKeys.value.includes(key)
    ? hiddenSeriesKeys.value.filter(value => value !== key) : [...hiddenSeriesKeys.value, key]
}
// 搜索跳转和浏览器后退会复用详情组件,旧请求的结果不能覆盖新报表。
watch(() => [editingId.value, route.query.mode, route.query.version] as const, async ([id, mode, version], _previous, onCleanup) => {
  let cancelled = false
  onCleanup(() => { cancelled = true })
  editingRecord.value = null
  selectedTests.value = []
  sourceItems.value = []
  reportName.value = ''
  description.value = ''
  sourceDetailOverrides.value = {}
  seriesColors.value = ['#3978f6', '#7d42d8', '#f08a3e']
  hiddenSeriesKeys.value = []
  metricDefinitions.forEach(metric => { metricVisible[metric.key] = true })
  loadError.value = ''
  previewError.value = ''
  canCreate.value = false
  canEdit.value = false
  canQuery.value = false
  canExport.value = false
  savedFormContent.value = ''
  historyVisible.value = false
  changeSummary.value = ''
  reportLoading.value = true
  try {
    const info = await getUserInfo()
    if (cancelled) return
    canEdit.value = Boolean(info.permissions?.some(item => item === '*:*:*' || item === 'system:compare-report:edit'))
    canQuery.value = Boolean(info.permissions?.some(item => item === '*:*:*' || item === 'system:compare-report:query'))
    canExport.value = Boolean(info.permissions?.some(item => item === '*:*:*' || item === 'system:compare-report:export'))
    canCreate.value = Boolean(info.permissions?.some(item => item === '*:*:*' || item === 'system:compare-report:add'))
    if (id) {
      if (mode === 'edit' && (!canEdit.value || version !== undefined)) throw new Error('历史版本只读或无编辑权限')
      // 编辑权限包含读取最新编辑基准,取消或保存后仍可预览;历史查看单独要求查看权限。
      const editingAccess = mode === 'edit' || (!canQuery.value && canEdit.value && version === undefined)
      const record = await getCompareReport(id, version === undefined ? undefined : Number(version), editingAccess)
      if (cancelled) return
      editingRecord.value = record
      loadEditingReport()
    } else {
      canCreate.value = Boolean(info.permissions?.some(item => item === '*:*:*' || item === 'system:compare-report:add'))
      if (!canCreate.value) throw new Error('无新增对比报表权限')
      await loadCandidates()
    }
  } catch (error) { if (!cancelled) loadError.value = error instanceof Error ? error.message : '报表加载失败' }
  finally { if (!cancelled) reportLoading.value = false }
}, { immediate: true })
async function refreshPreview() {
  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
    sourceItems.value = items
  } catch (error) { previewError.value = error instanceof Error ? error.message : '曲线预览失败' }
  finally { previewLoading.value = false }
}
function openAddTest() {
  selectedCandidateId.value = null
@@ -223,8 +197,9 @@
  selectedProcess.value = selectedCandidate.value.process
}
function addSelectedTest() {
  const candidate = selectedCandidate.value
async function addSelectedTest() {
  if (previewLoading.value || saving.value) return
  const candidate = testCandidates.value.find(item => item.batteryNo === selectedBatteryNo.value && item.selection.cycleNo === selectedCycle.value && item.process === selectedProcess.value)
  if (!candidate) {
    ElMessage.warning('请选择要加入对比的测试')
    return
@@ -233,343 +208,174 @@
    ElMessage.warning('请完整选择电池、循环和工步')
    return
  }
  if (selectedTests.value.some(item => item.id === candidate.id && (item.batteryGroup || batteryGroup.value) === (candidate.batteryGroup || batteryGroup.value) && item.batteryNo === selectedBatteryNo.value && item.process === selectedProcess.value && item.cycle === selectedCycle.value)) {
  if (selectedTests.value.some(item => compareSelectionKey(item.selection) === compareSelectionKey(candidate.selection))) {
    ElMessage.warning('相同测试、电池和工步循环已加入对比信息')
    return
  }
  selectedTests.value.push({ ...candidate, batteryNo: selectedBatteryNo.value, cycle: selectedCycle.value, process: selectedProcess.value })
  detailFields.value.forEach(ensureDetailFieldValues)
  selectedTests.value.push(toSelectedTest(candidate))
  if (!seriesColors.value[selectedTests.value.length - 1]) seriesColors.value.push('#109854')
  testDialogVisible.value = false
  ElMessage.success('已加入对比测试')
  await refreshPreview()
}
function removeTest(itemToRemove: CompareTestSelection) {
async function removeTest(itemToRemove: SelectedTest) {
  if (previewLoading.value || saving.value) return
  if (selectedTests.value.length === 1) {
    ElMessage.warning('至少保留一条对比测试')
    return
  }
  const index = selectedTests.value.indexOf(itemToRemove)
  if (index >= 0) selectedTests.value.splice(index, 1)
  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 persistBatteryTemplates() {
  localStorage.setItem(batteryTemplateStorageKey, JSON.stringify(batteryTemplateSelections))
}
function resolveBatteryTemplateFieldIds(name: string) {
  const savedIds = batteryTemplateSelections[name]
  if (savedIds) return savedIds.filter(id => enabledBatteryTemplateFields.value.some(field => field.id === id))
  const labels = presetBatteryTemplateLabels[name] || []
  return enabledBatteryTemplateFields.value.filter(field => labels.includes(field.name)).map(field => field.id)
}
function loadBatteryTemplate(name: string) {
  selectedBatteryTemplateFieldIds.value = resolveBatteryTemplateFieldIds(name)
}
function switchBatteryTemplate(name: string) {
  loadBatteryTemplate(name)
  applyBatteryTemplateFields()
}
function applyBatteryTemplateFields() {
  batteryFields.value = selectedBatteryTemplateFieldIds.value
    .map(id => 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 saveBatteryTemplate() {
  applyBatteryTemplateFields()
  batteryTemplateSelections[selectedBatteryTemplateName.value] = [...selectedBatteryTemplateFieldIds.value]
  persistBatteryTemplates()
  ElMessage.success(`模板“${selectedBatteryTemplateName.value}”已保存`)
}
async function saveBatteryTemplateAs() {
  try {
    const result = await ElMessageBox.prompt('请输入新模板名称,当前字段勾选会保存为新模板。', '另存电池组字段模板', {
      confirmButtonText: '保存', cancelButtonText: '取消', inputPlaceholder: '输入模板名称',
      inputValidator: value => value.trim() ? true : '请输入模板名称',
    })
    const name = result.value.trim()
    if (batteryTemplateNames.value.includes(name)) {
      ElMessage.warning('模板名称已存在,请使用其他名称')
      return
    }
    selectedBatteryTemplateName.value = name
    applyBatteryTemplateFields()
    batteryTemplateSelections[name] = [...selectedBatteryTemplateFieldIds.value]
    persistBatteryTemplates()
    ElMessage.success(`已另存为“${name}”`)
  } catch {
    // 用户取消另存模板。
  }
}
function openFieldTemplate(mode: 'battery' | 'special') {
  templateMode.value = mode
  if (mode === 'battery') {
    if (!batteryTemplateNames.value.includes(selectedBatteryTemplateName.value)) selectedBatteryTemplateName.value = '自定义'
    loadBatteryTemplate(selectedBatteryTemplateName.value)
  } else {
    selectedTemplateKey.value = 'cycle'
  }
  templateDialogVisible.value = true
}
function applyFieldTemplate() {
  if (templateMode.value === 'battery') {
    applyBatteryTemplateFields()
  } else {
    const nextFields = selectedTemplate.value.fields
    specialFields.value = nextFields.filter(field => !batteryFields.value.includes(field))
    detailFields.value.forEach(ensureDetailFieldValues)
  }
  templateDialogVisible.value = false
  ElMessage.success(`${templateMode.value === 'battery' ? '电池组字段' : '特殊字段'}模板已应用`)
}
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 (fieldTemplates.value.some(item => item.name === name)) {
      ElMessage.warning('特殊字段模板名称已存在,请使用其他名称')
      return
    }
    specialTemplateSelections[name] = [...specialFields.value]
    localStorage.setItem(specialTemplateStorageKey, JSON.stringify(specialTemplateSelections))
    selectedTemplateKey.value = `saved:${name}`
    ElMessage.success(`已另存为“${name}”`)
  } catch {
    // 用户取消另存模板。
  }
}
function chartOption(metric: CompareMetricDefinition) {
    const names = chartSeries.value.map(item => `${item.name} · ${item.batteryNo}`)
    const colors = seriesColors.value
    const visibleSeries = metricValues(metric.key).map(series => series.map(value => toTestDisplayValue(metric.key, value)))
    const visibleValues = visibleSeries.flat()
    const maxDataLength = Math.max(1, ...visibleSeries.map(series => series.length))
    const timeLabels = Array.from({ length: maxDataLength }, (_, index) => {
      const hours = Math.floor(index / 60)
      const minutes = String(index % 60).padStart(2, '0')
      return `${hours}:${minutes}`
    })
    const dataMin = Math.min(0, ...visibleValues)
    const dataMax = Math.max(0, ...visibleValues)
    const range = Math.max(dataMax - dataMin, 1)
  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: dataMin - range * 0.05,
    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 },
  })
function formContent() {
  return {
    animation: false,
    color: colors,
      grid: { top: 40, right: 46, bottom: 42, left: 46 },
    tooltip: { trigger: 'axis' },
    legend: { top: 4, right: 6, icon: 'roundRect', itemWidth: 14, itemHeight: 8, textStyle: { fontSize: 11, color: '#52606d' } },
      xAxis: { type: 'category', boundaryGap: false, name: '时间/h:mm', nameLocation: 'middle', nameGap: 28, data: timeLabels, axisLine: { lineStyle: { color: '#cbd8e4' } }, axisLabel: { color: '#8795a3', fontSize: 10, interval: 'auto' }, nameTextStyle: { color: '#26333f', fontSize: 11, fontWeight: 600 } },
    yAxis: [createYAxis('left'), createYAxis('right')],
    series: names.map((name, index) => ({ name, type: 'line', smooth: false, showSymbol: false, lineStyle: { width: 2 }, data: visibleSeries[index] || [] })),
    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: [], specialFields: [], fieldStructure: [], detailFieldValues: {},
      sourceDetailOverrides: snapshotReportDetails(reportDetailColumns.value, sourceDetailOverrides.value),
    },
  }
}
function exportPdf() {
  if (!reportName.value.trim()) {
    ElMessage.warning('请先输入报告名称')
async function printReport() {
  if (printing.value || saving.value || reportLoading.value || loadError.value || !canExport.value) return
  const record = editingRecord.value
  if (!record) { ElMessage.warning('请先保存报表后再打印'); return }
  if (JSON.stringify(formContent()) !== savedFormContent.value || changeSummary.value.trim()) {
    ElMessage.warning('有未保存修改,请先保存新版本后再打印')
    return
  }
  const opened = openCompareReportPrint({
    name: reportName.value.trim(),
    reportId: editingRecord.value?.id,
    creator: editingRecord.value?.creator || currentUser?.displayName || 'Admin',
    createdAt: editingRecord.value?.createdAt,
    experimentName: experimentName.value,
    batteryGroup: batteryGroup.value,
    tests: selectedTests.value.map(item => ({ ...item })),
    detailTests: detailTests.value.map(item => ({ ...item })),
    fields: [...detailFields.value],
    detailFieldValues: Object.fromEntries(Object.entries(detailFieldValues).map(([field, values]) => [field, { ...values }])),
    metrics: visibleMetrics.value.map(item => item.key),
    seriesColors: [...seriesColors.value],
  })
  if (!opened) {
    ElMessage.warning('浏览器阻止了报表窗口,请允许弹出窗口后重试')
    return
  }
  ElMessage.info('已打开打印版报表,请在打印窗口选择“另存为 PDF”')
  printing.value = true
  try {
    await openCompareReportPrint(record.id, record.versionNo)
  } catch (error) { ElMessage.error(error instanceof Error ? error.message : '打印失败,请重试') }
  finally { printing.value = false }
}
async function saveReport() {
  if (saving.value || reportLoading.value || loadError.value || readonly.value || !(editingId.value ? canEdit.value : canCreate.value)) return
  if (!reportName.value.trim()) {
    ElMessage.warning('请输入报告名称')
    return
  }
  if (records.value.some(item => item.id !== editingId.value && item.name.trim().toLowerCase() === reportName.value.trim().toLowerCase())) {
    ElMessage.warning('报告名称已存在')
    return
  }
  if (selectedTests.value.length < 2) {
    ElMessage.warning('请至少添加两条对比测试')
    return
  }
  if (!batteryGroup.value.trim()) {
    ElMessage.warning('请选择电池组')
  if (editingId.value && !changeSummary.value.trim()) {
    ElMessage.warning('请输入修改说明')
    return
  }
  saving.value = true
  await new Promise(resolve => window.setTimeout(resolve, 250))
  const payload: CompareReportPayload = {
    name: reportName.value.trim(),
    creator: currentUser?.displayName || 'Admin',
    baselineTest: selectedTests.value[0].name,
    comparisonTest: selectedTests.value[1].name,
    description: `${experimentName.value.trim()} · ${batteryGroup.value.trim()}`,
    compareTests: selectedTests.value.map(item => ({ ...item })),
    metrics: visibleMetrics.value.map(item => item.key),
    batteryGroup: batteryGroup.value,
    batterySamples: detailTests.value.map(item => item.batteryNo),
    customFields: detailFields.value,
    batteryTemplateFields: [...batteryFields.value],
    specialFields: [...specialFields.value],
    seriesColors: [...seriesColors.value],
    detailFieldValues: Object.fromEntries(
      Object.entries(detailFieldValues).map(([field, values]) => [field, { ...values }]),
    ),
  }
  if (editingId.value) {
    updateReport(editingId.value, payload)
    ElMessage.success('对比报表已更新')
  } else {
    createReport(payload)
  try {
    const content = { ...formContent(), name: reportName.value.trim() }
    if (editingId.value && editingRecord.value) {
      await editCompareReport(editingId.value, { ...content, baseVersionNo: editingRecord.value.versionNo, changeSummary: changeSummary.value.trim() })
    } else {
      await createCompareReport({ ...content, selections: selectedTests.value.map(item => item.selection) })
    }
    ElMessage.success('对比报表已保存')
    await router.push(editingId.value ? { path: `/report-compare/${editingId.value}/edit`, query: { mode: 'view' } } : '/report-compare')
  } catch (error) {
    ElMessage.error(error instanceof Error ? error.message : '保存失败,请重试')
  } finally {
    saving.value = false
  }
  saving.value = false
  await router.push('/report-compare')
}
async function generateFromLatestSource() {
  if (saving.value || !editingRecord.value || !editingId.value) return
  // 确认框与更新请求都绑定当前版本,导航或重载后不再用旧结果替换页面。
  const baseRecord = editingRecord.value
  let reason: string
  try {
    const result = await ElMessageBox.prompt('将重新读取当前来源并生成新版本,保留已有人工表格值。', '按最新来源生成版本', {
      inputPlaceholder: '请输入更新来源说明', inputValidator: value => value?.trim() && value.trim().length <= 480 ? true : '请填写不超过480字的说明',
      confirmButtonText: '生成新版本', cancelButtonText: '取消',
    })
    reason = result.value.trim()
  } catch { return }
  if (editingRecord.value !== baseRecord) return
  saving.value = true
  try {
    const record = await refreshCompareSource(baseRecord.id, baseRecord.versionNo, reason)
    if (editingRecord.value !== baseRecord) return
    editingRecord.value = record
    loadEditingReport()
    ElMessage.success(`已生成版本 ${record.versionNo}`)
  } catch (error) {
    ElMessage.error(error instanceof Error ? error.message : '更新来源失败')
  } finally { saving.value = false }
}
</script>
<template>
  <div class="compare-form-page" :class="{ 'is-readonly': readonly }">
    <el-alert v-if="loadError" :title="loadError" type="error" :closable="false" show-icon />
    <div v-if="reportLoading">正在加载报表</div>
    <el-alert v-if="previewError" :title="previewError" type="error" :closable="false" show-icon />
    <div class="page-tools"><button class="back-button" type="button" @click="router.push('/report-compare')"><el-icon><ArrowLeft /></el-icon>返回对比报表</button><span>{{ readonly ? 'VIEW COMPARISON REPORT' : editingId ? 'EDIT COMPARISON REPORT' : 'NEW COMPARISON REPORT' }}</span></div>
    <section class="report-name-panel">
      <label><span>报表名称</span><el-input v-model="reportName" clearable placeholder="请输入报表名称" /></label>
      <label><span>报表名称</span><el-input v-model="reportName" :readonly="readonly" maxlength="100" clearable placeholder="请输入报表名称" /></label>
      <label><span>说明</span><el-input v-model="description" :readonly="readonly" maxlength="2000" type="textarea" placeholder="请输入说明" /></label>
      <label v-if="editingId && !readonly"><span>修改说明</span><el-input v-model="changeSummary" maxlength="500" placeholder="请输入修改说明" /></label>
      <span v-if="editingRecord">版本 {{ editingRecord.versionNo }} · {{ editingRecord.operatorName }} · {{ new Date(editingRecord.versionCreatedAt).toLocaleString() }} · {{ editingRecord.changeSummary }}</span>
    </section>
    <section class="compare-info-panel">
      <header class="panel-heading"><div><strong>对比信息</strong><el-icon><ArrowUp /></el-icon></div></header>
      <div class="compare-table-wrap">
        <table class="compare-table">
          <thead><tr><th>测试名称</th><th>测试类型</th><th>电池号</th><th>工步</th><th>循环</th><th v-if="!readonly">操作</th></tr></thead>
          <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="`${item.id}-${item.batteryGroup || batteryGroup}-${item.batteryNo}-${index}`"><td>{{ item.name }}</td><td>{{ item.type }}</td><td>{{ item.batteryNo }}</td><td>{{ item.process }}</td><td>{{ item.cycle }}</td><td v-if="!readonly"><button class="delete-link" type="button" @click="removeTest(item)">删除</button></td></tr>
            <tr v-for="(item, index) in selectedTests" :key="`${item.id}-${item.batteryGroup || batteryGroup}-${item.batteryNo}-${index}`"><td>{{ item.name }}</td><td>{{ item.type }}</td><td>{{ item.batteryNo }}</td><td>{{ item.process }}</td><td>{{ item.cycle }}</td><td v-if="!editingId"><button class="delete-link" type="button" @click="removeTest(item)">删除</button></td></tr>
            <tr v-if="!selectedTests.length"><td :colspan="readonly ? 5 : 6" class="empty-cell">暂无对比测试</td></tr>
          </tbody>
        </table>
      </div>
      <button v-if="!readonly" class="primary-button compact-button" type="button" @click="openAddTest"><el-icon><Plus /></el-icon>添加比较</button>
      <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 class="detail-meta">
        <label><span>实验</span><el-input v-model="experimentName" /></label>
      </div>
      <div class="field-table-wrap">
        <table class="field-table" :style="{ minWidth: `${Math.max(900, detailTests.length * 220 + 214)}px` }">
          <thead><tr><th>字段</th><th v-for="(item, index) in detailTests" :key="detailKey(item)">对比项 {{ 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>{{ field }}</td><td v-for="item in detailTests" :key="detailKey(item)"><el-input v-model="detailFieldValues[field][detailKey(item)]" 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"><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="saveSpecialTemplate">另存特殊字段模板</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)" /><span>{{ item.name }} · {{ item.batteryNo }}</span></div></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="chartOption(metric)" autoresize /></article></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>
    </section>
    <footer class="form-actions"><button class="secondary-button" type="button" @click="exportPdf"><el-icon><Download /></el-icon>导出PDF</button><button v-if="readonly" class="primary-button" type="button" @click="router.push('/report-compare')">返回列表</button><button v-else class="primary-button save-button" type="button" :disabled="saving" @click="saveReport"><el-icon><Check /></el-icon>{{ saving ? '保存中' : editingId ? '保存修改' : '保存报表' }}</button></footer>
    <footer class="form-actions">
      <button v-if="canExport" class="secondary-button" type="button" :disabled="printing || saving || reportLoading || !!loadError" @click="printReport"><el-icon><Printer /></el-icon>{{ printing ? '正在打开打印' : '打印 / 另存 PDF' }}</button>
      <button v-if="readonly && canQuery && editingRecord" class="secondary-button" type="button" :disabled="reportLoading || saving" @click="historyVisible = true"><el-icon><Clock /></el-icon>历史版本</button>
      <button v-if="readonly && editingRecord && route.query.version !== undefined" class="secondary-button" type="button" @click="router.push({ query: { mode: 'view' } })">查看最新版本</button>
      <button v-if="readonly && canEdit && editingRecord && route.query.version === undefined" class="secondary-button" type="button" :disabled="reportLoading || saving || !!loadError" @click="generateFromLatestSource"><el-icon><Refresh /></el-icon>按最新来源生成版本</button>
      <button v-if="readonly && canEdit && editingRecord && route.query.version === undefined" class="secondary-button" type="button" :disabled="reportLoading || saving || !!loadError" @click="router.push({ query: { mode: 'edit' } })"><el-icon><Edit /></el-icon>编辑报表</button>
      <button v-if="readonly" class="primary-button" type="button" @click="router.push('/report-compare')">返回列表</button>
      <button v-else-if="editingId" class="secondary-button" type="button" :disabled="saving" @click="router.push({ query: { mode: 'view' } })">取消编辑</button>
      <button v-if="!readonly && (canCreate || canEdit)" class="primary-button save-button" type="button" :disabled="saving || reportLoading || !!loadError || previewLoading || !!previewError" @click="saveReport"><el-icon><Check /></el-icon>{{ saving ? '保存中' : editingId ? '保存新版本' : '保存报表' }}</button>
    </footer>
    <CompareReportHistoryDialog v-model="historyVisible" :report-id="editingId" :can-export="canExport" />
    <el-dialog v-model="testDialogVisible" class="test-dialog" title="添加对比" width="540px" align-center destroy-on-close>
      <div class="compare-select-form">
        <label><span>选择测试</span><el-select v-model="selectedCandidateId" filterable clearable :loading="candidatesLoading" placeholder="输入测试名称或ID搜索" @change="syncDialogDefaults"><el-option v-for="item in candidateTests" :key="item.id" :label="`${item.name}(${item.id})`" :value="item.id" /></el-select></label>
        <label><span>选择电池</span><el-select v-model="selectedBatteryNo" placeholder="请选择" :disabled="!selectedCandidateId"><el-option v-for="item in batteryChoices" :key="item" :label="item" :value="item" /></el-select></label>
        <label><span>选择循环</span><el-select v-model="selectedCycle" placeholder="请选择" :disabled="!selectedCandidateId"><el-option v-for="item in cycleChoices" :key="item" :label="`${item}`" :value="item" /></el-select></label>
        <label><span>选择工步</span><div class="control-stack"><el-select v-model="selectedProcess" placeholder="请选择" :disabled="!selectedCandidateId"><el-option v-for="item in processChoices" :key="item" :label="item" :value="item" /></el-select><small class="select-hint">仅显示所选检测完成测试中已保存的实际工步</small></div></label>
        <label><span>选择电池</span><el-select v-model="selectedBatteryNo" placeholder="请选择" :disabled="!selectedCandidateId" @change="selectedCycle = cycleChoices[0] ?? null; selectedProcess = processChoices[0] || ''"><el-option v-for="item in batteryChoices" :key="item" :label="item" :value="item" /></el-select></label>
        <label><span>选择循环</span><el-select v-model="selectedCycle" placeholder="请选择" :disabled="!selectedCandidateId" @change="selectedProcess = processChoices[0] || ''"><el-option v-for="item in cycleChoices" :key="item" :label="`${item}`" :value="item" /></el-select></label>
        <label><span>选择工步</span><div class="control-stack"><el-select v-model="selectedProcess" placeholder="请选择" :disabled="!selectedCandidateId"><el-option v-for="item in processChoices" :key="item" :label="item" :value="item" /></el-select></div></label>
        <p v-if="candidatesError" class="select-error">{{ candidatesError }}</p><p v-else-if="!candidatesLoading && !candidateTests.length" class="select-empty">暂无检测完成且有已保存数据的测试</p>
      </div>
      <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="templateDialogVisible" class="template-dialog" :title="templateMode === 'battery' ? '选择电池组字段模板' : '选择特殊字段模板'" :width="templateMode === 'battery' ? '720px' : '560px'" align-center>
      <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" :disabled="field.status === '禁用'" :title="`${field.name}(${field.type},${field.status})`">{{ field.name }}</el-checkbox>
        </el-checkbox-group>
        <div v-else class="template-empty">暂无字段,请先到电池组字段管理中添加字段</div>
      </div>
      <el-radio-group v-else 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>
      <template #footer><div v-if="templateMode === 'battery'" class="battery-template-actions"><button class="primary-button" type="button" @click="applyFieldTemplate"><el-icon><Check /></el-icon>确认</button><button class="primary-button" type="button" @click="saveBatteryTemplate"><el-icon><Check /></el-icon>保存</button><button class="secondary-button" type="button" @click="saveBatteryTemplateAs"><el-icon><DocumentCopy /></el-icon>另存模板</button></div><div v-else><button class="secondary-button" type="button" @click="templateDialogVisible = false">取消</button><button class="primary-button" type="button" @click="applyFieldTemplate">应用模板</button></div></template>
    </el-dialog>
  </div>
</template>
@@ -589,6 +395,9 @@
.secondary-button:hover { color: var(--blue); border-color: #9dc8f5; }
.report-name-panel, .compare-info-panel, .detail-panel, .metrics-panel { background: #fff; border: 1px solid #d8e1e9; }
.report-name-panel { padding: 18px 22px; margin-bottom: 16px; }
.report-name-panel label + label { margin-top: 12px; }
.report-name-panel > span { display: block; margin-top: 12px; overflow-wrap: anywhere; }
.series-swatch { flex: 0 0 12px; width: 12px; height: 12px; display: inline-block; }
.report-name-panel label { display: grid; grid-template-columns: 72px minmax(260px, 420px); align-items: center; gap: 12px; }
.report-name-panel label > span, .detail-meta label > span { color: #52606d; font-size: 12px; font-weight: 500; }
.report-name-panel :deep(.el-input__wrapper), .detail-meta :deep(.el-input__wrapper), .detail-meta :deep(.el-select__wrapper), .field-table :deep(.el-input__wrapper), .field-table :deep(.el-select__wrapper) { min-height: 34px; border-radius: 3px; box-shadow: 0 0 0 1px #d8e1e9 inset; background: #f8fafc; }
@@ -609,7 +418,9 @@
.detail-meta label { display: grid; grid-template-columns: 48px minmax(0, 1fr); align-items: center; gap: 10px; }
.field-table { min-width: 900px; }
.field-table th:first-child, .field-table td:first-child { width: 140px; text-align: left; }
.field-table th:last-child, .field-table td:last-child { width: 74px; }
.field-table.with-actions th:last-child, .field-table.with-actions td:last-child { width: 74px; }
.field-table th, .field-table td { white-space: normal; overflow-wrap: anywhere; padding-top: 6px; padding-bottom: 6px; }
.source-field-kind { display: block; color: #71808d; font-size: 11px; margin-top: 4px; }
.field-table .sample-row td { background: #fbfdff; }
.field-table .detail-meta-row td { background: #fbfdff; color: #26333f; }
.field-table .detail-meta-row td:not(:first-child) { font-weight: 500; }
@@ -622,7 +433,11 @@
.chart-legend-bar { min-height: 52px; display: flex; flex-wrap: wrap; align-items: center; gap: 12px 24px; border-bottom: 1px solid var(--line); }
.chart-legend-bar > strong { color: #26333f; font-size: 12px; font-weight: 600; }
.chart-legend-item { display: inline-flex; align-items: center; gap: 7px; min-width: 0; color: #52606d; font-size: 12px; }
.chart-legend-item span { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chart-legend-item span { max-width: 360px; overflow-wrap: anywhere; }
.legend-name { max-width: 100%; border: 0; padding: 4px 0; background: transparent; color: inherit; font: inherit; text-align: left; overflow-wrap: anywhere; cursor: pointer; }
.legend-name.is-hidden { color: #8795a3; text-decoration: line-through; }
.legend-name:disabled { cursor: default; }
.chart-empty { padding: 12px; color: #8795a3; font-size: 12px; text-align: center; }
.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); }
@@ -636,7 +451,7 @@
.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; }
.form-actions { position: sticky; bottom: 0; z-index: 20; min-height: 62px; display: flex; 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 { 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; }
.compare-form-page :deep(.el-dialog) { max-width: calc(100vw - 28px); border-radius: 4px; }
@@ -652,6 +467,14 @@
.select-hint { color: #8a97a3; font-size: 11px; line-height: 1.4; }
.dialog-footer-left { display: flex; justify-content: flex-start; }
.template-options { display: grid; gap: 12px; }
.field-settings-table { overflow-x: auto; }
.field-settings-table table { width: 100%; min-width: 480px; border-collapse: collapse; table-layout: fixed; }
.field-settings-table th, .field-settings-table td { padding: 8px; text-align: left; border-bottom: 1px solid var(--line); overflow-wrap: anywhere; }
.field-settings-table th:nth-child(3) { width: 90px; }
.field-settings-table th:last-child { width: 80px; }
.field-settings-table button { width: 28px; height: 28px; padding: 4px; border: 0; background: transparent; cursor: pointer; }
.field-settings-table button:disabled { opacity: .35; cursor: default; }
.field-table td:first-child { white-space: normal; overflow-wrap: anywhere; }
.template-options :deep(.el-radio) { width: 100%; height: auto; margin: 0; padding: 12px; white-space: normal; }
.battery-template-editor { min-height: 340px; padding: 4px 22px 18px; }
.battery-template-select { width: min(390px, 100%); display: grid; grid-template-columns: 88px minmax(0, 1fr); align-items: center; gap: 12px; margin: 0 auto 28px; }