| | |
| | | <script setup lang="ts"> |
| | | import { toTestDisplayValue } from '@/utils/testDataUnits' |
| | | import { computed, reactive, ref } from 'vue' |
| | | import { computed, onMounted, reactive, ref } from 'vue' |
| | | import { ElMessage, ElMessageBox } from 'element-plus' |
| | | import { useRoute, useRouter } from 'vue-router' |
| | | import { use } from 'echarts/core' |
| | |
| | | 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' |
| | | |
| | |
| | | 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 selectedTests = ref<CompareTestSelection[]>([]) |
| | | const candidateTests = ref<CompareTestSelection[]>([]) |
| | | const sourceRecords = reactive<Record<number, PersistedTestRecord[]>>({}) |
| | | const candidatesLoading = ref(false) |
| | | const candidatesError = ref('') |
| | | const testDialogVisible = ref(false) |
| | | const templateDialogVisible = ref(false) |
| | | const selectedTemplateKey = ref('cycle') |
| | |
| | | })()) |
| | | 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[]>>((() => { |
| | |
| | | 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 selectedCandidate = computed(() => candidateTests.value.find(item => item.id === selectedCandidateId.value) || null) |
| | | const detailTests = computed(() => { |
| | | const seen = new Set<string>() |
| | | return selectedTests.value.filter(item => { |
| | |
| | | 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-脉冲放电'] |
| | | 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()]) |
| | | |
| | | 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[] |
| | | } catch (error) { |
| | | candidatesError.value = error instanceof Error ? error.message : '真实测试来源加载失败' |
| | | } finally { |
| | | candidatesLoading.value = false |
| | | } |
| | | } |
| | | |
| | | 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}` |
| | |
| | | |
| | | detailFields.value.forEach(ensureDetailFieldValues) |
| | | loadEditingReport() |
| | | onMounted(() => { void loadCandidates() }) |
| | | |
| | | function openAddTest() { |
| | | selectedCandidateId.value = null |
| | |
| | | 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)).map(series => series.map(value => toTestDisplayValue(metric.key, 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 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 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: 0, |
| | | min: dataMin - range * 0.05, |
| | | max: axisMax, |
| | | nameTextStyle: { color: '#8795a3', fontSize: 10 }, |
| | | axisLine: { show: true, lineStyle: { color: '#cbd8e4' } }, |
| | |
| | | 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: visibleSeries[index] || visibleSeries[0] })), |
| | | series: names.map((name, index) => ({ name, type: 'line', smooth: false, showSymbol: false, lineStyle: { width: 2 }, data: visibleSeries[index] || [] })), |
| | | } |
| | | } |
| | | |
| | |
| | | |
| | | <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="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">工步显示为:1-恒流充电、21-恒流充电</small></div></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> |
| | | <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> |