| | |
| | | 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 { getUserInfo } from '@/api/auth' |
| | | import type { CompareMetricKey, CompareTestSelection } from '@/stores/compareReports' |
| | | import type { PersistedTestRecord } from '@/api/tests' |
| | | import { compareSelectionKey, createCompareReport, getCompareReport, getCompareSources, previewCompareReport, |
| | | type CompareCandidate, type CompareItem, type CompareSelection, type SavedCompareReport } from '@/api/compareReports' |
| | | import { compareMetricDefinitions as metricDefinitions, type CompareMetricDefinition } from '@/utils/compareReportPrint' |
| | | |
| | | use([CanvasRenderer, LineChart, GridComponent, LegendComponent, TooltipComponent]) |
| | | |
| | |
| | | |
| | | 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)) |
| | | const canCreate = ref(false) |
| | | 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 experimentName = ref('') |
| | | const description = ref('') |
| | | const batteryGroup = ref('') |
| | | type SelectedTest = CompareTestSelection & { selection: CompareSelection } |
| | | 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 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 batteryTemplateSelections = reactive<Record<string, number[]>>({}) |
| | | 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 specialTemplateSelections = reactive<Record<string, string[]>>({}) |
| | | |
| | | const fieldTemplates = computed<FieldTemplate[]>(() => [ |
| | | { key: 'cycle', name: '循环寿命模板', fields: ['容量保持率', '能量效率', '循环次数', '内阻变化'] }, |
| | |
| | | '低温性能样品', |
| | | ...Object.keys(batteryTemplateSelections).filter(name => !['自定义', '标准容量样品', '标准循环样品', '低温性能样品'].includes(name)), |
| | | ]) |
| | | const batteryTemplateFieldOptions = computed(() => [...managedGroupFields.value].sort((a, b) => b.createdAt.localeCompare(a.createdAt))) |
| | | const batteryTemplateFieldOptions = computed(() => [...new Map(sourceItems.value.flatMap(item => item.businessFields) |
| | | .map(field => [field.fieldDefinitionId, { id: field.fieldDefinitionId, name: field.fieldName, type: field.fieldType, status: '启用' }])).values()]) |
| | | 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 detailTests = computed(() => selectedTests.value) |
| | | 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 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 { |
| | |
| | | } |
| | | } |
| | | |
| | | 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) { |
| | | function metricValues(key: CompareMetricKey): Array<Array<[number, number | null]>> { |
| | | 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)) |
| | | return sourceItems.value.map(item => item.records.flatMap(row => { |
| | | const time = key === 'voltage' || key === 'current' ? row.deviceTime : row.collectorDeviceTime |
| | | if (time == null || item.zeroTime == null) return [] |
| | | const value = row[field[key]] |
| | | return [[(Number(time) - item.zeroTime) / 1000, value == null ? null : toTestDisplayValue(key, Number(value))] as [number, number | null]] |
| | | }).sort((left, right) => left[0] - right[0])) |
| | | } |
| | | |
| | | function detailKey(item: CompareTestSelection) { |
| | | return `${item.id}|${item.batteryGroup || batteryGroup.value}|${item.batteryNo}` |
| | | function detailKey(item: SelectedTest) { |
| | | return compareSelectionKey(item.selection) |
| | | } |
| | | |
| | | function ensureDetailFieldValues(field: string) { |
| | |
| | | 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] |
| | | experimentName.value = record.experimentName || '' |
| | | 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] |
| | | batteryFields.value = [...record.settings.batteryTemplateFields] |
| | | specialFields.value = [...record.settings.specialFields] |
| | | Object.keys(detailFieldValues).forEach(field => delete detailFieldValues[field]) |
| | | Object.entries(record.detailFieldValues || {}).forEach(([field, values]) => { |
| | | Object.entries(record.settings.detailFieldValues).forEach(([field, values]) => { |
| | | detailFieldValues[field] = { ...values } |
| | | }) |
| | | selectedBatteryTemplateName.value = '自定义' |
| | |
| | | } |
| | | |
| | | detailFields.value.forEach(ensureDetailFieldValues) |
| | | loadEditingReport() |
| | | onMounted(() => { void loadCandidates() }) |
| | | onMounted(async () => { |
| | | reportLoading.value = true |
| | | try { |
| | | if (editingId.value) { |
| | | editingRecord.value = await getCompareReport(editingId.value) |
| | | loadEditingReport() |
| | | } else { |
| | | const info = await getUserInfo() |
| | | canCreate.value = Boolean(info.permissions?.some(item => item === '*:*:*' || item === 'system:compare-report:add')) |
| | | if (!canCreate.value) throw new Error('无新增对比报表权限') |
| | | await loadCandidates() |
| | | } |
| | | } catch (error) { loadError.value = error instanceof Error ? error.message : '报表加载失败' } |
| | | finally { reportLoading.value = false } |
| | | }) |
| | | |
| | | async function refreshPreview() { |
| | | previewLoading.value = true |
| | | previewError.value = '' |
| | | sourceItems.value = [] |
| | | try { |
| | | sourceItems.value = await previewCompareReport(selectedTests.value.map(item => item.selection)) |
| | | detailFields.value.forEach(ensureDetailFieldValues) |
| | | sourceItems.value.forEach(item => item.businessFields.forEach(field => { |
| | | const key = compareSelectionKey(item.source.selection) |
| | | if (batteryFields.value.includes(field.fieldName) && !detailFieldValues[field.fieldName]?.[key]) |
| | | detailFieldValues[field.fieldName][key] = field.fieldValue ?? '' |
| | | })) |
| | | } catch (error) { previewError.value = error instanceof Error ? error.message : '曲线预览失败' } |
| | | finally { previewLoading.value = false } |
| | | } |
| | | |
| | | function openAddTest() { |
| | | selectedCandidateId.value = null |
| | |
| | | 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 |
| | |
| | | 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 => detailKey(item) === compareSelectionKey(candidate.selection))) { |
| | | ElMessage.warning('相同测试、电池和工步循环已加入对比信息') |
| | | return |
| | | } |
| | | selectedTests.value.push({ ...candidate, batteryNo: selectedBatteryNo.value, cycle: selectedCycle.value, process: selectedProcess.value }) |
| | | selectedTests.value.push(toSelectedTest(candidate)) |
| | | if (!seriesColors.value[selectedTests.value.length - 1]) seriesColors.value.push('#109854') |
| | | detailFields.value.forEach(ensureDetailFieldValues) |
| | | 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) |
| | | if (index >= 0) { selectedTests.value.splice(index, 1); seriesColors.value.splice(index, 1) } |
| | | await refreshPreview() |
| | | } |
| | | |
| | | function addCustomField() { |
| | |
| | | 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)) |
| | |
| | | .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') { |
| | |
| | | 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 names = chartSeries.value.map(item => `${item.name} · ${item.batteryNo} · 循环${item.cycle} · ${item.process}`) |
| | | 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 visibleSeries = metricValues(metric.key) |
| | | const visibleValues = visibleSeries.flat().map(point => point[1]).filter((value): value is number => value !== null) |
| | | const dataMin = Math.min(0, ...visibleValues) |
| | | const dataMax = Math.max(0, ...visibleValues) |
| | | const range = Math.max(dataMax - dataMin, 1) |
| | |
| | | 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 } }, |
| | | legend: { show: false }, |
| | | xAxis: { type: 'value', min: 0, name: '相对时间 / s', nameLocation: 'middle', nameGap: 28, axisLine: { lineStyle: { color: '#cbd8e4' } }, axisLabel: { color: '#8795a3', fontSize: 10 }, 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] || [] })), |
| | | series: names.map((name, index) => ({ name, type: 'line', smooth: false, connectNulls: false, showSymbol: true, symbolSize: 5, lineStyle: { width: 2 }, data: visibleSeries[index] || [] })), |
| | | } |
| | | } |
| | | |
| | | 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 (saving.value || readonly.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('请选择电池组') |
| | | 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 { |
| | | await createCompareReport({ |
| | | name: reportName.value.trim(), description: description.value, experimentName: experimentName.value, |
| | | selections: selectedTests.value.map(item => item.selection), |
| | | settings: { |
| | | metrics: visibleMetrics.value.map(item => item.key), seriesColors: seriesColors.value.slice(0, selectedTests.value.length), |
| | | batteryTemplateFields: [...batteryFields.value], specialFields: [...specialFields.value], |
| | | detailFieldValues: Object.fromEntries(detailFields.value.map(field => [field, |
| | | Object.fromEntries(selectedTests.value.map(item => [detailKey(item), detailFieldValues[field]?.[detailKey(item)] || ''])), |
| | | ])), |
| | | }, |
| | | }) |
| | | ElMessage.success('对比报表已保存') |
| | | await router.push('/report-compare') |
| | | } catch (error) { |
| | | ElMessage.error(error instanceof Error ? error.message : '保存失败,请重试') |
| | | } finally { |
| | | saving.value = false |
| | | } |
| | | saving.value = false |
| | | await router.push('/report-compare') |
| | | } |
| | | </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> |
| | | <span v-if="editingRecord">版本 {{ editingRecord.versionNo }} · {{ editingRecord.creator }}</span> |
| | | </section> |
| | | |
| | | <section class="compare-info-panel"> |
| | |
| | | </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="!readonly && 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> |
| | | <label><span>实验</span><el-input v-model="experimentName" :readonly="readonly" maxlength="500" /></label> |
| | | </div> |
| | | <div class="field-table-wrap"> |
| | | <table class="field-table" :style="{ minWidth: `${Math.max(900, detailTests.length * 220 + 214)}px` }"> |
| | |
| | | <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-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)]" :readonly="readonly" size="small" placeholder="请输入" /></td><td v-if="!readonly"><button v-if="specialFields.includes(field)" class="delete-link" type="button" @click="removeCustomField(field)">删除</button><span v-else class="field-lock">-</span></td></tr> |
| | | <tr v-if="!detailFields.length"><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> |
| | | <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></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 v-if="chartSeries.length" class="chart-legend-bar"><strong>对比图例</strong><div v-for="(item, index) in chartSeries" :key="detailKey(item)" class="chart-legend-item"><el-color-picker v-if="!readonly" class="legend-color-picker" :model-value="seriesColors[index]" :show-alpha="false" popper-class="legend-color-popper" :aria-label="`修改${item.name}颜色`" @change="updateSeriesColor(index, $event)" /><i v-else class="series-swatch" :style="{ background: seriesColors[index] }" /><span>{{ item.name }} · {{ item.batteryNo }} · 循环{{ item.cycle }} · {{ item.process }}</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> |
| | | <footer class="form-actions"><button v-if="readonly" class="primary-button" type="button" @click="router.push('/report-compare')">返回列表</button><button v-else-if="canCreate" class="primary-button save-button" type="button" :disabled="saving || previewLoading || !!previewError" @click="saveReport"><el-icon><Check /></el-icon>{{ saving ? '保存中' : '保存报表' }}</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 :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> |
| | |
| | | <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> |
| | | <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></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> |
| | |
| | | .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; } |
| | | .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; } |
| | |
| | | .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-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); } |