| | |
| | | import VChart from 'vue-echarts' |
| | | import CompareReportHistoryDialog from '@/components/CompareReportHistoryDialog.vue' |
| | | import { getUserInfo } from '@/api/auth' |
| | | import type { CompareMetricKey, CompareTestSelection } from '@/stores/compareReports' |
| | | import type { CompareMetricKey } from '@/stores/compareReports' |
| | | import { compareSelectionKey, createCompareReport, editCompareReport, getCompareReport, getCompareSources, previewCompareReport, refreshCompareSource, |
| | | type CompareCandidate, type CompareItem, type CompareSelection, type SavedCompareReport } from '@/api/compareReports' |
| | | import { listCompareFieldTemplates, createCompareFieldTemplate, updateCompareFieldTemplate, type CompareField, type CompareFieldTemplate } from '@/api/compareReports' |
| | | import { compareFields, compareFieldLabel, compareBusinessFieldValue } from '@/utils/compareReportFields' |
| | | 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]) |
| | | |
| | |
| | | const reportLoading = ref(false) |
| | | const saving = ref(false) |
| | | const reportName = ref('') |
| | | const experimentName = ref('') |
| | | const sourceDetailOverrides = ref<CompareSettings['sourceDetailOverrides']>({}) |
| | | const description = ref('') |
| | | const batteryGroup = ref('') |
| | | type SelectedTest = CompareTestSelection & { selection: CompareSelection } |
| | | 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 candidatesLoading = ref(false) |
| | | const candidatesError = ref('') |
| | | const testDialogVisible = ref(false) |
| | | const templateDialogVisible = ref(false) |
| | | const selectedTemplateKey = ref('') |
| | | const selectedCandidateId = ref<number | null>(null) |
| | | const selectedBatteryNo = ref('') |
| | | const selectedCycle = ref<number | null>(null) |
| | | const selectedProcess = ref('') |
| | | const templateMode = ref<'battery' | 'special'>('battery') |
| | | const batteryFields = ref(['额定容量', '电流', '内阻']) |
| | | const specialFields = ref(['温升']) |
| | | const detailFieldValues = reactive<Record<string, Record<string, string>>>({}) |
| | | const selectedBatteryTemplateName = ref('自定义') |
| | | const selectedBatteryTemplateFieldIds = ref<number[]>([]) |
| | | |
| | | const persistedTemplates = ref<CompareFieldTemplate[]>([]) |
| | | const templateLoading = ref(false) |
| | | const templateSaving = ref(false) |
| | | const templateError = ref('') |
| | | const fieldStructure = ref<CompareField[]>([]) |
| | | const fieldSettingsVisible = ref(false) |
| | | const fieldSettingsDraft = ref<CompareField[]>([]) |
| | | const fieldTemplates = computed(() => persistedTemplates.value.map(item => ({ key: `remote:${item.id}`, name: item.name, fields: item.fields.map(compareFieldLabel) }))) |
| | | |
| | | const metricVisible = reactive<Record<CompareMetricKey, boolean>>({ |
| | | voltage: true, |
| | |
| | | temperature: true, |
| | | }) |
| | | const seriesColors = ref(['#3978f6', '#7d42d8', '#f08a3e']) |
| | | const hiddenSeriesKeys = ref<string[]>([]) |
| | | |
| | | const visibleMetrics = computed(() => metricDefinitions.filter(metric => metricVisible[metric.key])) |
| | | const selectedTemplate = computed(() => persistedTemplates.value.find(item => `remote:${item.id}` === selectedTemplateKey.value)) |
| | | const batteryTemplateNames = computed(() => ['自定义', ...persistedTemplates.value.map(item => item.name)]) |
| | | const batteryTemplateFieldOptions = computed(() => [...new Map([ |
| | | ...sourceItems.value.flatMap(item => item.businessFields).map(field => [field.fieldDefinitionId, { id: field.fieldDefinitionId, name: field.fieldName, status: '启用' }] as const), |
| | | ...fieldStructure.value.filter(field => field.category === 'battery' && field.fieldDefinitionId != null) |
| | | .map(field => [field.fieldDefinitionId!, { id: field.fieldDefinitionId!, name: field.name, status: '启用' }] as const), |
| | | ]).values()]) |
| | | const enabledBatteryTemplateFields = computed(() => batteryTemplateFieldOptions.value.filter(field => field.status === '启用')) |
| | | const detailStructure = computed(() => compareFields({ batteryTemplateFields: batteryFields.value, specialFields: specialFields.value, fieldStructure: fieldStructure.value })) |
| | | const detailFields = computed(() => detailStructure.value.map(field => field.name)) |
| | | const selectedCandidate = computed(() => candidateTests.value.find(item => item.id === selectedCandidateId.value) || null) |
| | | const detailTests = computed(() => selectedTests.value) |
| | | const chartSeries = computed(() => detailTests.value) |
| | | 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)) |
| | |
| | | } |
| | | } |
| | | |
| | | async function loadFieldTemplates() { |
| | | templateLoading.value = true |
| | | templateError.value = '' |
| | | try { persistedTemplates.value = await listCompareFieldTemplates() } |
| | | catch (error) { persistedTemplates.value = []; templateError.value = error instanceof Error ? error.message : '字段模板加载失败' } |
| | | finally { templateLoading.value = false } |
| | | } |
| | | |
| | | function detailKey(item: SelectedTest) { |
| | | return compareSelectionKey(item.selection) |
| | | } |
| | | |
| | | function ensureDetailFieldValues(field: string) { |
| | | if (!detailFieldValues[field]) detailFieldValues[field] = {} |
| | | detailTests.value.forEach(item => { |
| | | const key = detailKey(item) |
| | | // 未填写才读取当前预览或保存快照的业务值,显式清空不被后续预览覆盖。 |
| | | if (detailFieldValues[field][key] === undefined) { |
| | | const source = sourceItems.value.find(value => compareSelectionKey(value.source.selection) === key) |
| | | const sourceDetail = source?.source.consoleDetail |
| | | const sourceValue = sourceDetail?.fieldValues?.[field]?.[source.source.batteryNo] |
| | | detailFieldValues[field][key] = sourceValue !== undefined |
| | | ? sourceValue |
| | | : compareBusinessFieldValue(fieldDescriptor(field), source) |
| | | } |
| | | }) |
| | | } |
| | | |
| | | function loadEditingReport() { |
| | | const record = editingRecord.value |
| | | if (!record) return |
| | | |
| | | reportName.value = record.name |
| | | experimentName.value = record.experimentName || '' |
| | | sourceDetailOverrides.value = record.settings.sourceDetailOverrides === undefined ? undefined : JSON.parse(JSON.stringify(record.settings.sourceDetailOverrides)) |
| | | 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] |
| | | fieldStructure.value = (record.settings.fieldStructure || []).map(field => ({ ...field })) |
| | | Object.keys(detailFieldValues).forEach(field => delete detailFieldValues[field]) |
| | | Object.entries(record.settings.detailFieldValues).forEach(([field, values]) => { |
| | | detailFieldValues[field] = { ...values } |
| | | }) |
| | | selectedBatteryTemplateName.value = '自定义' |
| | | syncBatterySelection() |
| | | detailFields.value.forEach(ensureDetailFieldValues) |
| | | hiddenSeriesKeys.value = [...(record.settings.hiddenSeriesKeys || [])] |
| | | savedFormContent.value = JSON.stringify(formContent()) |
| | | } |
| | | |
| | |
| | | if (color) seriesColors.value[index] = color |
| | | } |
| | | |
| | | detailFields.value.forEach(ensureDetailFieldValues) |
| | | 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 |
| | |
| | | sourceItems.value = [] |
| | | reportName.value = '' |
| | | description.value = '' |
| | | experimentName.value = '' |
| | | batteryFields.value = ['额定容量', '电流', '内阻'] |
| | | specialFields.value = ['温升'] |
| | | fieldStructure.value = [] |
| | | templateDialogVisible.value = false |
| | | fieldSettingsVisible.value = false |
| | | persistedTemplates.value = [] |
| | | selectedTemplateKey.value = '' |
| | | selectedBatteryTemplateName.value = '自定义' |
| | | sourceDetailOverrides.value = {} |
| | | seriesColors.value = ['#3978f6', '#7d42d8', '#f08a3e'] |
| | | hiddenSeriesKeys.value = [] |
| | | metricDefinitions.forEach(metric => { metricVisible[metric.key] = true }) |
| | | Object.keys(detailFieldValues).forEach(field => delete detailFieldValues[field]) |
| | | loadError.value = '' |
| | | detailFields.value.forEach(ensureDetailFieldValues) |
| | | previewError.value = '' |
| | | canCreate.value = false |
| | | canEdit.value = false |
| | |
| | | const items = await previewCompareReport(selections) |
| | | if (editingId.value) return |
| | | sourceItems.value = items |
| | | detailFields.value.forEach(ensureDetailFieldValues) |
| | | } catch (error) { previewError.value = error instanceof Error ? error.message : '曲线预览失败' } |
| | | finally { previewLoading.value = false } |
| | | } |
| | | |
| | | async function saveCurrentFieldTemplate() { |
| | | if (readonly.value || templateLoading.value || templateSaving.value || (!canCreate.value && !canEdit.value)) return |
| | | let result: { value: string } |
| | | try { |
| | | result = await ElMessageBox.prompt('请输入模板名称', '保存对比字段模板', { inputValue: selectedTemplate.value?.name || '', inputValidator: value => value?.trim() && value.trim().length <= 100 ? true : '请输入不超过100字的模板名称', confirmButtonText: '保存', cancelButtonText: '取消' }) |
| | | } catch { return } |
| | | const name = result.value.trim() |
| | | const fields = detailFields.value.map(fieldDescriptor) |
| | | const existing = persistedTemplates.value.find(item => item.name === name) |
| | | if (existing && !canEdit.value) { ElMessage.warning('无编辑对比报表权限,不能覆盖模板'); return } |
| | | if (!existing && !canCreate.value) { ElMessage.warning('无新增对比报表权限'); return } |
| | | if (existing) { |
| | | try { await ElMessageBox.confirm(`更新模板“${name}”的字段结构?`, '更新字段模板', { confirmButtonText: '更新', cancelButtonText: '取消' }) } |
| | | catch { return } |
| | | } |
| | | templateSaving.value = true |
| | | try { |
| | | const id = existing ? existing.id : await createCompareFieldTemplate(name, fields) |
| | | if (existing) await updateCompareFieldTemplate(existing.id, name, fields) |
| | | await loadFieldTemplates() |
| | | selectedTemplateKey.value = `remote:${id}` |
| | | ElMessage.success('字段模板已保存') |
| | | } |
| | | catch (error) { ElMessage.error(error instanceof Error ? error.message : '模板保存失败') } |
| | | finally { templateSaving.value = false } |
| | | } |
| | | |
| | | function fieldDescriptor(name: string): CompareField { |
| | | const field = detailStructure.value.find(item => item.name === name)! |
| | | const definition = batteryTemplateFieldOptions.value.find(item => item.name === name) |
| | | return { ...field, fieldDefinitionId: field.fieldDefinitionId ?? (field.category === 'battery' ? definition?.id : null) } |
| | | } |
| | | |
| | | function syncBatterySelection() { |
| | | selectedBatteryTemplateFieldIds.value = batteryFields.value.flatMap(name => { |
| | | const id = fieldDescriptor(name).fieldDefinitionId |
| | | return id == null ? [] : [id] |
| | | }) |
| | | } |
| | | |
| | | function replaceFieldStructure(fields: CompareField[]) { |
| | | const previous = detailFields.value.map(fieldDescriptor) |
| | | // 同名不代表同一共享字段;身份改变必须清除旧值,不能把旧自动值或人工值归入新身份。 |
| | | for (const name of Object.keys(detailFieldValues)) { |
| | | const before = previous.find(field => field.name === name) |
| | | const after = fields.find(field => field.name === name) |
| | | if (!before || !after || before.category !== after.category || (before.fieldDefinitionId ?? null) !== (after.fieldDefinitionId ?? null)) delete detailFieldValues[name] |
| | | } |
| | | batteryFields.value = fields.filter(field => field.category === 'battery').map(field => field.name) |
| | | specialFields.value = fields.filter(field => field.category === 'special').map(field => field.name) |
| | | fieldStructure.value = fields.map(field => ({ ...field })) |
| | | syncBatterySelection() |
| | | detailFields.value.forEach(ensureDetailFieldValues) |
| | | } |
| | | |
| | | function openFieldSettings() { |
| | | fieldSettingsDraft.value = detailFields.value.map(fieldDescriptor) |
| | | fieldSettingsVisible.value = true |
| | | } |
| | | |
| | | function moveField(index: number, offset: number) { |
| | | const target = index + offset |
| | | if (target < 0 || target >= fieldSettingsDraft.value.length) return |
| | | const [field] = fieldSettingsDraft.value.splice(index, 1) |
| | | if (field) fieldSettingsDraft.value.splice(target, 0, field) |
| | | } |
| | | |
| | | function applyFieldSettings() { |
| | | if (fieldSettingsDraft.value.some(field => !field.displayName?.trim())) { ElMessage.warning('展示名称不能为空'); return } |
| | | fieldStructure.value = fieldSettingsDraft.value.map(field => ({ ...field, displayName: field.displayName!.trim(), unit: field.unit.trim() })) |
| | | fieldSettingsVisible.value = false |
| | | } |
| | | |
| | | function openAddTest() { |
| | |
| | | ElMessage.warning('请完整选择电池、循环和工步') |
| | | return |
| | | } |
| | | if (selectedTests.value.some(item => detailKey(item) === compareSelectionKey(candidate.selection))) { |
| | | if (selectedTests.value.some(item => compareSelectionKey(item.selection) === compareSelectionKey(candidate.selection))) { |
| | | ElMessage.warning('相同测试、电池和工步循环已加入对比信息') |
| | | return |
| | | } |
| | |
| | | return |
| | | } |
| | | const index = selectedTests.value.indexOf(itemToRemove) |
| | | hiddenSeriesKeys.value = hiddenSeriesKeys.value.filter(key => key !== compareSelectionKey(itemToRemove.selection)) |
| | | if (index >= 0) { selectedTests.value.splice(index, 1); seriesColors.value.splice(index, 1) } |
| | | await refreshPreview() |
| | | } |
| | | |
| | | function addCustomField() { |
| | | ElMessageBox.prompt('请输入要添加的字段名称', '添加项', { |
| | | confirmButtonText: '添加', |
| | | cancelButtonText: '取消', |
| | | inputPlaceholder: '例如:库仑效率', |
| | | inputValidator: value => value.trim() ? true : '请输入字段名称', |
| | | }).then(({ value }) => { |
| | | const name = value.trim() |
| | | if (detailFields.value.includes(name)) { |
| | | ElMessage.warning('该字段已经存在') |
| | | return |
| | | } |
| | | specialFields.value.push(name) |
| | | ensureDetailFieldValues(name) |
| | | ElMessage.success(`已添加字段“${name}”`) |
| | | }).catch(() => { |
| | | // 用户取消添加。 |
| | | }) |
| | | } |
| | | |
| | | function removeCustomField(field: string) { |
| | | if (batteryFields.value.includes(field)) { |
| | | ElMessage.warning('电池组模板字段请在电池组字段模板中调整') |
| | | return |
| | | } |
| | | specialFields.value = specialFields.value.filter(item => item !== field) |
| | | delete detailFieldValues[field] |
| | | } |
| | | |
| | | function resolveBatteryTemplateFieldIds(name: string) { |
| | | const template = persistedTemplates.value.find(item => item.name === name) |
| | | return (template?.fields || []).filter(field => field.category === 'battery').flatMap(field => { |
| | | const option = enabledBatteryTemplateFields.value.find(item => field.fieldDefinitionId != null ? item.id === field.fieldDefinitionId : item.name === field.name) |
| | | return option ? [option.id] : [] |
| | | }) |
| | | } |
| | | |
| | | function loadBatteryTemplate(name: string) { |
| | | selectedBatteryTemplateFieldIds.value = resolveBatteryTemplateFieldIds(name) |
| | | } |
| | | |
| | | function switchBatteryTemplate(name: string) { |
| | | const template = persistedTemplates.value.find(item => item.name === name) |
| | | if (template) { |
| | | const battery = template.fields.filter(field => field.category === 'battery') |
| | | replaceFieldStructure([...battery, ...detailFields.value.map(fieldDescriptor).filter(field => field.category === 'special' && !battery.some(item => item.name === field.name))]) |
| | | return |
| | | } |
| | | loadBatteryTemplate(name) |
| | | applyBatteryTemplateFields() |
| | | } |
| | | |
| | | function applyBatteryTemplateFields() { |
| | | batteryFields.value = selectedBatteryTemplateFieldIds.value |
| | | .map(id => fieldStructure.value.find(field => field.category === 'battery' && field.fieldDefinitionId === id)?.name || enabledBatteryTemplateFields.value.find(field => field.id === id)?.name) |
| | | .filter((name): name is string => Boolean(name)) |
| | | specialFields.value = specialFields.value.filter(field => !batteryFields.value.includes(field)) |
| | | detailFields.value.forEach(ensureDetailFieldValues) |
| | | } |
| | | |
| | | function openFieldTemplate(mode: 'battery' | 'special') { |
| | | void loadFieldTemplates() |
| | | templateMode.value = mode |
| | | if (mode === 'battery') { |
| | | syncBatterySelection() |
| | | if (!batteryTemplateNames.value.includes(selectedBatteryTemplateName.value)) selectedBatteryTemplateName.value = '自定义' |
| | | } else { |
| | | if (!selectedTemplate.value) selectedTemplateKey.value = '' |
| | | } |
| | | templateDialogVisible.value = true |
| | | } |
| | | |
| | | function applyFieldTemplate() { |
| | | if (templateMode.value === 'battery') { |
| | | applyBatteryTemplateFields() |
| | | } else { |
| | | const remote = selectedTemplate.value |
| | | if (!remote) { ElMessage.warning('请选择字段模板'); return } |
| | | // 只复制结构;新增字段取当前草稿来源,保留当前草稿已有的人工值。 |
| | | replaceFieldStructure(remote.fields) |
| | | } |
| | | templateDialogVisible.value = false |
| | | ElMessage.success(`${templateMode.value === 'battery' ? '电池组字段' : '特殊字段'}模板已应用`) |
| | | } |
| | | |
| | | function formContent() { |
| | | return { |
| | | name: reportName.value, description: description.value, experimentName: experimentName.value, |
| | | name: reportName.value, description: description.value, experimentName: '', |
| | | settings: { |
| | | metrics: visibleMetrics.value.map(item => item.key), seriesColors: seriesColors.value.slice(0, selectedTests.value.length), |
| | | batteryTemplateFields: [...batteryFields.value], specialFields: [...specialFields.value], |
| | | fieldStructure: detailFields.value.map(fieldDescriptor), |
| | | detailFieldValues: Object.fromEntries(detailFields.value.map(field => [field, |
| | | Object.fromEntries(selectedTests.value.map(item => [detailKey(item), detailFieldValues[field]?.[detailKey(item)] || ''])), |
| | | ])), |
| | | hiddenSeriesKeys: [...hiddenSeriesKeys.value], |
| | | batteryTemplateFields: [], specialFields: [], fieldStructure: [], detailFieldValues: {}, |
| | | sourceDetailOverrides: snapshotReportDetails(reportDetailColumns.value, sourceDetailOverrides.value), |
| | | }, |
| | | } |
| | | } |
| | |
| | | <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" :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` }"> |
| | | <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.consoleDetail?.experimentName || item.experimentName || '' }}</td><td v-if="!readonly">-</td></tr> |
| | | <tr class="detail-meta-row"><td>电池组</td><td v-for="item in detailTests" :key="detailKey(item)">{{ item.batteryGroup || batteryGroup }}</td><td v-if="!readonly">-</td></tr> |
| | | <tr class="sample-row"><td>电池号</td><td v-for="item in detailTests" :key="detailKey(item)">{{ item.batteryNo }}</td><td v-if="!readonly">-</td></tr> |
| | | <tr v-for="field in detailFields" :key="field"><td>{{ compareFieldLabel(fieldDescriptor(field)) }}</td><td v-for="item in detailTests" :key="detailKey(item)"><el-input v-model="detailFieldValues[field][detailKey(item)]" :readonly="readonly" size="small" placeholder="请输入" /></td><td v-if="!readonly"><button v-if="specialFields.includes(field)" class="delete-link" type="button" @click="removeCustomField(field)">删除</button><span v-else class="field-lock">-</span></td></tr> |
| | | <tr v-if="!detailFields.length"><td :colspan="detailTests.length + (readonly ? 1 : 2)" class="empty-cell">暂无字段</td></tr> |
| | | </tbody> |
| | | </table> |
| | | </div> |
| | | <div v-if="!readonly" class="detail-actions"><button class="primary-button" type="button" @click="addCustomField"><el-icon><Plus /></el-icon>添加项</button><button class="secondary-button" type="button" @click="openFieldTemplate('battery')">电池组字段</button><button class="secondary-button" type="button" @click="openFieldTemplate('special')">选择特殊字段</button><button class="secondary-button" type="button" @click="openFieldSettings"><el-icon><Edit /></el-icon>字段设置</button></div> |
| | | </section> |
| | | <ReportTestDetail v-model="sourceDetailOverrides" :columns="reportDetailColumns" :readonly="readonly" :disabled="saving || previewLoading || !!previewError" /> |
| | | |
| | | <section class="metrics-panel"> |
| | | <div v-if="!readonly" class="metric-switches"><label v-for="metric in metricDefinitions" :key="metric.key"><span>{{ metric.title }}</span><el-switch v-model="metricVisible[metric.key]" inline-prompt active-text="显" inactive-text="隐" /></label></div> |
| | | <div v-if="chartSeries.length" class="chart-legend-bar"><strong>对比图例</strong><div v-for="(item, index) in chartSeries" :key="detailKey(item)" class="chart-legend-item"><el-color-picker v-if="!readonly" class="legend-color-picker" :model-value="seriesColors[index]" :show-alpha="false" popper-class="legend-color-popper" :aria-label="`修改${item.name}颜色`" @change="updateSeriesColor(index, $event)" /><i v-else class="series-swatch" :style="{ background: seriesColors[index] }" /><span>{{ item.name }} · {{ item.batteryNo }} · 循环{{ item.cycle }} · {{ item.process }}</span></div></div> |
| | | <div v-if="chartSeries.length" class="chart-legend-bar"><strong>对比图例</strong><div v-for="(item, index) in chartSeries" :key="compareSelectionKey(item.selection)" class="chart-legend-item"><el-color-picker v-if="!readonly" class="legend-color-picker" :model-value="seriesColors[index]" :show-alpha="false" popper-class="legend-color-popper" :aria-label="`修改${item.name}颜色`" @change="updateSeriesColor(index, $event)" /><i v-else class="series-swatch" :style="{ background: seriesColors[index] }" /><button type="button" class="legend-name" :class="{ 'is-hidden': hiddenSeriesKeys.includes(compareSelectionKey(item.selection)) }" :aria-pressed="!hiddenSeriesKeys.includes(compareSelectionKey(item.selection))" :disabled="readonly" @click="toggleSeries(item)">{{ item.name }} (#{{ item.id }}) · {{ item.batteryGroup }} · {{ item.batteryNo }} · 循环{{ item.cycle }} · {{ item.process }}</button></div></div> |
| | | <!-- 报表切换和比对项移除时完整替换配置,避免图表库将颜色数组缩短误判为组件合并。 --> |
| | | <div 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)" :update-options="{ notMerge: true }" autoresize /></article></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"> |
| | |
| | | <template #footer><div class="dialog-footer-left"><button class="primary-button" type="button" @click="addSelectedTest"><el-icon><Check /></el-icon>添加</button></div></template> |
| | | </el-dialog> |
| | | |
| | | <el-dialog v-model="fieldSettingsVisible" title="字段设置" width="640px" align-center> |
| | | <div class="field-settings-table"><table><thead><tr><th>字段</th><th>展示名称</th><th>单位</th><th>顺序</th></tr></thead><tbody> |
| | | <tr v-for="(field, index) in fieldSettingsDraft" :key="field.name"><td>{{ field.name }}</td><td><el-input v-model="field.displayName" maxlength="100" aria-label="展示名称" /></td><td><el-input v-model="field.unit" maxlength="30" aria-label="单位" /></td><td><button type="button" :disabled="index === 0" title="上移" aria-label="上移" @click="moveField(index, -1)"><el-icon><ArrowUp /></el-icon></button><button type="button" :disabled="index === fieldSettingsDraft.length - 1" title="下移" aria-label="下移" @click="moveField(index, 1)"><el-icon><ArrowDown /></el-icon></button></td></tr> |
| | | </tbody></table></div> |
| | | <template #footer><button class="secondary-button" type="button" @click="fieldSettingsVisible = false">取消</button><button class="primary-button" type="button" @click="applyFieldSettings">确认</button></template> |
| | | </el-dialog> |
| | | |
| | | <el-dialog v-model="templateDialogVisible" class="template-dialog" :title="templateMode === 'battery' ? '选择电池组字段模板' : '选择对比字段模板'" :width="templateMode === 'battery' ? '720px' : '560px'" align-center> |
| | | <div v-if="templateError" role="alert">{{ templateError }}<button type="button" class="secondary-button" @click="loadFieldTemplates">重试</button></div> |
| | | <div v-if="templateMode === 'battery'" class="battery-template-editor"> |
| | | <label class="battery-template-select"><span>选择模板</span><el-select v-model="selectedBatteryTemplateName" @change="switchBatteryTemplate"><el-option v-for="name in batteryTemplateNames" :key="name" :label="name" :value="name" /></el-select></label> |
| | | <el-checkbox-group v-if="batteryTemplateFieldOptions.length" v-model="selectedBatteryTemplateFieldIds" class="battery-template-fields" @change="applyBatteryTemplateFields"> |
| | | <el-checkbox v-for="field in batteryTemplateFieldOptions" :key="field.id" :value="field.id" :title="field.name">{{ field.name }}</el-checkbox> |
| | | </el-checkbox-group> |
| | | <div v-else class="template-empty">暂无字段,请先到电池组字段管理中添加字段</div> |
| | | </div> |
| | | <div v-else><div v-if="templateLoading" class="template-empty">正在加载模板</div><el-radio-group v-else-if="fieldTemplates.length" v-model="selectedTemplateKey" class="template-options"><el-radio v-for="item in fieldTemplates" :key="item.key" :value="item.key" border>{{ item.name }}({{ item.fields.join('、') }})</el-radio></el-radio-group><div v-else-if="!templateError" class="template-empty">暂无已保存模板</div></div> |
| | | <template #footer><div><button v-if="canCreate || canEdit" class="secondary-button" type="button" @click="saveCurrentFieldTemplate" :disabled="templateLoading || templateSaving || !!templateError">保存为模板</button><button class="secondary-button" type="button" @click="templateDialogVisible = false">取消</button><button class="primary-button" type="button" @click="applyFieldTemplate" :disabled="templateLoading || templateSaving || (templateMode === 'special' && !selectedTemplate)">{{ templateMode === 'battery' ? '确认' : '应用字段' }}</button></div></template> |
| | | </el-dialog> |
| | | </div> |
| | | </template> |
| | | |
| | |
| | | .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; } |
| | |
| | | .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: 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); } |