<script setup lang="ts">
|
import { computed, reactive, ref } 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, 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 { useBatteryGroupFields } from '@/stores/batteryGroupFields'
|
import { compareMetricDefinitions as metricDefinitions, openCompareReportPrint, type CompareMetricDefinition } from '@/utils/compareReportPrint'
|
|
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 saving = ref(false)
|
const reportName = ref('')
|
const experimentName = ref('XXX实验')
|
const batteryGroup = ref('电池组1')
|
const selectedTests = ref<CompareTestSelection[]>([
|
{ id: 1, name: '第一次测试', type: '化成', batteryGroup: '电池组1', batteryNo: 'SP26-3', process: '恒流充电', cycle: 3 },
|
])
|
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 candidateTests: CompareTestSelection[] = [
|
{ id: 2, name: '第二次测试', type: '化成', batteryGroup: '电池组1', batteryNo: 'SP26-4', process: '恒流充电', cycle: 3 },
|
{ id: 3, name: '高温循环测试', type: '循环', batteryGroup: '电池组2', batteryNo: 'SP26-5', process: '恒流放电', cycle: 50 },
|
{ id: 4, name: '低温充放电测试', type: '性能', batteryGroup: '电池组2', batteryNo: 'SP26-6', process: '恒流充放电', cycle: 10 },
|
{ id: 5, name: '倍率放电测试', type: '倍率', batteryGroup: '电池组3', batteryNo: 'SP26-7', process: '脉冲放电', cycle: 8 },
|
]
|
|
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,
|
current: true,
|
positiveVoltage: true,
|
negativeVoltage: true,
|
temperature: true,
|
})
|
const seriesColors = ref(['#3978f6', '#7d42d8', '#f08a3e'])
|
|
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.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.slice(0, 3))
|
const batteryChoices = computed(() => [...new Set([selectedCandidate.value?.batteryNo || '', 'SP26-1', 'SP26-2', 'SP26-3', 'SP26-4', 'SP26-5'])].filter(Boolean))
|
const cycleChoices = [1, 2, 3, 5, 10, 20, 50]
|
const processChoices = ['1-恒流充电', '2-恒流放电', '3-静置', '4-脉冲放电']
|
|
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)
|
}
|
|
function updateSeriesColor(index: number, color: string | null) {
|
if (color) seriesColors.value[index] = color
|
}
|
|
detailFields.value.forEach(ensureDetailFieldValues)
|
loadEditingReport()
|
|
function openAddTest() {
|
selectedCandidateId.value = null
|
selectedBatteryNo.value = ''
|
selectedCycle.value = null
|
selectedProcess.value = ''
|
testDialogVisible.value = true
|
}
|
|
function syncDialogDefaults() {
|
if (!selectedCandidate.value) return
|
selectedBatteryNo.value = selectedCandidate.value.batteryNo
|
selectedCycle.value = selectedCandidate.value.cycle
|
selectedProcess.value = selectedCandidate.value.process
|
}
|
|
function addSelectedTest() {
|
const candidate = selectedCandidate.value
|
if (!candidate) {
|
ElMessage.warning('请选择要加入对比的测试')
|
return
|
}
|
if (!selectedBatteryNo.value || !selectedCycle.value || !selectedProcess.value) {
|
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)) {
|
ElMessage.warning('相同测试、电池和工步循环已加入对比信息')
|
return
|
}
|
selectedTests.value.push({ ...candidate, batteryNo: selectedBatteryNo.value, cycle: selectedCycle.value, process: selectedProcess.value })
|
detailFields.value.forEach(ensureDetailFieldValues)
|
testDialogVisible.value = false
|
ElMessage.success('已加入对比测试')
|
}
|
|
function removeTest(itemToRemove: CompareTestSelection) {
|
if (selectedTests.value.length === 1) {
|
ElMessage.warning('至少保留一条对比测试')
|
return
|
}
|
const index = selectedTests.value.indexOf(itemToRemove)
|
if (index >= 0) selectedTests.value.splice(index, 1)
|
}
|
|
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 = metric.values.slice(0, Math.max(1, names.length))
|
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 dataMax = Math.max(...visibleValues)
|
const axisMax = metric.unit === '℃' ? Math.ceil(dataMax / 10) * 10 : Math.ceil(dataMax * 10) / 10
|
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 },
|
})
|
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: true, showSymbol: false, lineStyle: { width: 2 }, data: metric.values[index] || metric.values[0] })),
|
}
|
}
|
|
function exportPdf() {
|
if (!reportName.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”')
|
}
|
|
async function saveReport() {
|
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('请选择电池组')
|
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)
|
ElMessage.success('对比报表已保存')
|
}
|
saving.value = false
|
await router.push('/report-compare')
|
}
|
</script>
|
|
<template>
|
<div class="compare-form-page" :class="{ 'is-readonly': readonly }">
|
<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>
|
</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>
|
<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-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>
|
</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>
|
|
<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>
|
</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>
|
|
<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 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">工步显示为:1-恒流充电、21-恒流充电</small></div></label>
|
</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>
|
|
<style scoped>
|
.compare-form-page { width: 100%; padding-bottom: 80px; }
|
.compare-form-page.is-readonly :deep(.el-input), .compare-form-page.is-readonly :deep(.el-textarea) { pointer-events: none; }
|
.compare-form-page.is-readonly :deep(.el-input__wrapper), .compare-form-page.is-readonly :deep(.el-textarea__inner) { background: #f3f6f8; box-shadow: 0 0 0 1px #d8e1e9 inset; }
|
.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; font-weight: 500; cursor: pointer; white-space: nowrap; }
|
.primary-button { color: #fff; background: var(--blue); border-color: var(--blue); }
|
.primary-button:hover { background: #176fcb; border-color: #176fcb; }
|
.primary-button:disabled { opacity: .55; cursor: wait; }
|
.secondary-button { color: #52606d; background: #fff; border-color: var(--line-strong); }
|
.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 { 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; }
|
.panel-heading { min-height: 48px; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; border-bottom: 1px solid var(--line); }
|
.panel-heading > div { display: flex; align-items: center; gap: 9px; color: #26333f; font-size: 13px; }
|
.panel-heading > div::before { content: ''; width: 3px; height: 16px; background: #2b78a3; }
|
.compare-info-panel, .detail-panel { margin-bottom: 16px; padding-bottom: 18px; }
|
.compare-table-wrap, .field-table-wrap { overflow-x: auto; padding: 18px 24px 0; }
|
.compare-table, .field-table { width: 100%; min-width: 820px; table-layout: fixed; border-collapse: collapse; color: #52606d; font-size: 13px; }
|
.compare-table th, .compare-table td, .field-table th, .field-table td { height: 42px; padding: 0 12px; border: 1px solid #cbd8e4; text-align: center; white-space: nowrap; }
|
.compare-table th, .field-table th { color: #fff; background: #3c6497; font-size: 12px; font-weight: 600; }
|
.compare-table th:nth-child(1) { width: 22%; } .compare-table th:nth-child(2) { width: 17%; } .compare-table th:nth-child(3) { width: 18%; } .compare-table th:nth-child(4) { width: 17%; } .compare-table th:nth-child(5) { width: 14%; } .compare-table th:last-child { width: 12%; }
|
.compare-table tbody tr:hover, .field-table tbody tr:hover { background: #f7fbff; }
|
.delete-link { border: 0; color: #d15b5b; background: transparent; font-size: 12px; cursor: pointer; }
|
.delete-link:hover { text-decoration: underline; }
|
.compact-button { margin: 16px 24px 0; }
|
.detail-meta { display: grid; grid-template-columns: minmax(280px, 520px); gap: 22px; padding: 18px 24px 0; }
|
.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 .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; }
|
.field-table :deep(.el-select), .field-table :deep(.el-input) { width: 100%; }
|
.detail-actions { display: flex; flex-wrap: wrap; gap: 10px; padding: 16px 24px 0; }
|
.metrics-panel { padding: 0 24px 24px; }
|
.metric-switches { display: flex; flex-wrap: wrap; align-items: center; gap: 20px 28px; min-height: 58px; 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; }
|
.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; }
|
.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__color-inner) { border-radius: 2px; }
|
.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), .legend-color-picker :deep(.el-color-picker__trigger .el-icon) { display: none !important; }
|
.legend-color-picker :deep(.el-color-picker__trigger:hover) { box-shadow: 0 0 0 2px rgba(43, 120, 163, .24) !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; }
|
:global(.legend-color-popper .el-color-picker-panel) { border: 0 !important; box-shadow: none !important; }
|
.charts-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; padding-top: 18px; }
|
.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; }
|
.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 .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; }
|
.compare-form-page :deep(.el-dialog__header) { margin: 0; padding: 17px 20px; border-bottom: 1px solid var(--line); }
|
.compare-form-page :deep(.el-dialog__body) { padding: 20px; }
|
.compare-form-page :deep(.el-dialog__footer) { padding: 14px 20px; border-top: 1px solid var(--line); }
|
.compare-select-form { display: grid; gap: 14px; padding: 2px 16px 6px; }
|
.compare-select-form label { display: grid; grid-template-columns: 72px minmax(0, 1fr); align-items: start; gap: 12px; }
|
.compare-select-form label > span { padding-top: 9px; color: #52606d; font-size: 12px; }
|
.compare-select-form :deep(.el-select), .compare-select-form :deep(.el-input) { width: 100%; }
|
.compare-select-form :deep(.el-input__wrapper), .compare-select-form :deep(.el-select__wrapper) { min-height: 34px; border-radius: 3px; box-shadow: 0 0 0 1px #d8e1e9 inset; background: #f8fafc; }
|
.control-stack { display: grid; gap: 8px; }
|
.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; }
|
.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; }
|
.battery-template-select > span { color: #52606d; font-size: 12px; font-weight: 600; }
|
.battery-template-select :deep(.el-select) { width: 100%; }
|
.battery-template-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px 28px; }
|
.battery-template-fields :deep(.el-checkbox) { min-width: 0; margin: 0; overflow: hidden; }
|
.battery-template-fields :deep(.el-checkbox__label) { min-width: 0; overflow: hidden; color: #52606d; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
.template-empty { padding: 56px 18px; color: #8795a3; text-align: center; border: 1px dashed #ccd9e5; }
|
.battery-template-actions, .template-dialog :deep(.el-dialog__footer) > div { display: flex; align-items: center; justify-content: center; gap: 12px; }
|
.battery-template-actions button { min-width: 96px; }
|
@media (max-width: 900px) { .charts-grid { grid-template-columns: minmax(0, 1fr); } }
|
@media (max-width: 680px) {
|
.report-name-panel { padding: 14px 12px; }
|
.report-name-panel label, .detail-meta { grid-template-columns: minmax(0, 1fr); gap: 7px; }
|
.panel-heading, .metrics-panel { padding-left: 12px; padding-right: 12px; }
|
.compare-table-wrap, .field-table-wrap { padding-left: 12px; padding-right: 12px; }
|
.compact-button { margin-left: 12px; }
|
.detail-actions { padding-left: 12px; padding-right: 12px; }
|
.metric-switches { gap: 14px; }
|
.chart-legend-bar { align-items: flex-start; flex-direction: column; gap: 10px; padding: 12px 0; }
|
.form-actions { justify-content: stretch; padding: 12px; }
|
.form-actions button { flex: 1; }
|
.battery-template-editor { padding: 0; }
|
.battery-template-select { grid-template-columns: minmax(0, 1fr); gap: 7px; }
|
.battery-template-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px 12px; }
|
.battery-template-actions { flex-wrap: wrap; }
|
}
|
</style>
|