| | |
| | | } from '@/utils/compareReportPrint' |
| | | import type { CompareMetricKey } from '@/stores/compareReports' |
| | | import { useBatteryGroupFields } from '@/stores/batteryGroupFields' |
| | | import { saveTestConsoleDetail, type BtsTestPlanRow, type PersistedTestRecord, type TestConsoleDetail, type TestLogicStepMapping } from '@/api/tests' |
| | | |
| | | interface ConsoleCell { |
| | | no: string |
| | |
| | | } |
| | | |
| | | const props = withDefaults(defineProps<{ |
| | | testId: number |
| | | cells: ConsoleCell[] |
| | | durationMinutes?: number |
| | | testName?: string |
| | | batteryGroup?: string |
| | | records: PersistedTestRecord[] |
| | | stepPlanRows?: BtsTestPlanRow[] |
| | | logicStepMappings?: TestLogicStepMapping[] |
| | | consoleDetail?: TestConsoleDetail | null |
| | | }>(), { |
| | | durationMinutes: 5, |
| | | testName: '', |
| | | batteryGroup: '', |
| | | records: () => [], |
| | | stepPlanRows: () => [], |
| | | logicStepMappings: () => [], |
| | | consoleDetail: null, |
| | | }) |
| | | |
| | | use([CanvasRenderer, LineChart, GridComponent, LegendComponent, TooltipComponent]) |
| | | |
| | | const palette = ['#3978f6', '#7d42d8', '#f08a3e', '#23a983', '#e15268', '#1b9ac8', '#b5892f', '#6957c8', '#4f8d44', '#d264a3', '#46748e', '#a95d37'] |
| | | const seriesColors = ref<string[]>(props.cells.map((_, index) => palette[index % palette.length])) |
| | | const selectedStep = ref('5-21 恒流充电') |
| | | const selectedStep = ref('') |
| | | const selectedCycle = ref('') |
| | | const selectedCellNos = ref<string[]>(props.cells.map(cell => cell.no)) |
| | | const batteryOverrides = reactive<Record<string, { cycle: string; step: string }>>({}) |
| | | const overrideEditing = reactive<Record<string, boolean>>({}) |
| | | const experimentName = ref(props.testName || 'XXX实验') |
| | | const batteryFields = ref(['额定容量', '电流', '内阻']) |
| | | const specialFields = ref(['温升']) |
| | | const detailFieldValues = reactive<Record<string, Record<string, string>>>({}) |
| | | const detailSaving = ref(false) |
| | | const templateDialogVisible = ref(false) |
| | | const templateMode = ref<'battery' | 'special'>('battery') |
| | | const selectedBatteryFields = ref<string[]>([]) |
| | |
| | | 循环寿命模板: ['容量保持率', '能量效率', '循环次数', '内阻变化'], |
| | | 热特性模板: ['最高温度', '温差', '温升速率'], |
| | | } |
| | | const stepOptions = ['5-21 恒流充电', '5-22 搁置', '5-23 恒流放电', '5-24 搁置'] |
| | | const pointCount = 31 |
| | | const chartUpdateOptions = { notMerge: true, lazyUpdate: false } |
| | | |
| | | interface StepOption { |
| | | value: string |
| | | label: string |
| | | cycleNo: number |
| | | stepNo?: number |
| | | logical?: TestLogicStepMapping |
| | | } |
| | | |
| | | const cycleOptions = computed(() => [...new Set(props.records |
| | | .filter(record => record.sourceType === 'SNAPSHOT') |
| | | .map(record => Number(record.cycleNo)) |
| | | .filter(value => Number.isFinite(value) && value > 0))].sort((a, b) => a - b)) |
| | | |
| | | // record.sourceType !== 'SNAPSHOT' 的数据不参与工步图表。 |
| | | const snapshotRecords = computed(() => props.records.filter(record => record.sourceType === 'SNAPSHOT')) |
| | | const cycleStepPositions = computed(() => { |
| | | // 工步号在设备侧跨循环递增;以测试内每个循环最早观测到的工步号为基准,保留缺失工步形成的空位。 |
| | | const stepsByCycle = new Map<number, number[]>() |
| | | snapshotRecords.value.forEach(record => { |
| | | const cycleNo = Number(record.cycleNo) |
| | | const stepNo = Number(record.stepNo) |
| | | if (!Number.isFinite(cycleNo) || !Number.isFinite(stepNo) || cycleNo <= 0 || stepNo <= 0) return |
| | | const key = cycleNo |
| | | const steps = stepsByCycle.get(key) || [] |
| | | if (!steps.includes(stepNo)) steps.push(stepNo) |
| | | stepsByCycle.set(key, steps) |
| | | }) |
| | | const positions = new Map<string, number>() |
| | | let planCycleNo = 1 |
| | | ;(props.stepPlanRows || []).forEach(row => { |
| | | if (row.rowType === 'CYCLE') { |
| | | planCycleNo = Number(row.cycleCount) || planCycleNo + 1 |
| | | return |
| | | } |
| | | if (row.rowType === 'STEP' && row.cyclePosition != null && Number(row.stepNo) > 0) { |
| | | positions.set(`${planCycleNo}::${Number(row.stepNo)}`, Number(row.cyclePosition)) |
| | | } |
| | | }) |
| | | stepsByCycle.forEach((steps, key) => { |
| | | const firstStepNo = Math.min(...steps) |
| | | steps.forEach(stepNo => { |
| | | const positionKey = `${key}::${stepNo}` |
| | | if (!positions.has(positionKey)) positions.set(positionKey, stepNo - firstStepNo + 1) |
| | | }) |
| | | }) |
| | | return positions |
| | | }) |
| | | |
| | | function cycleStepPosition(record: PersistedTestRecord) { |
| | | return cycleStepPositions.value.get(`${Number(record.cycleNo)}::${Number(record.stepNo)}`) || null |
| | | } |
| | | |
| | | function logicalStepMatches(record: PersistedTestRecord, mapping: TestLogicStepMapping) { |
| | | const position = cycleStepPosition(record) |
| | | return position !== null && position >= mapping.startStepNo && position <= mapping.endStepNo |
| | | } |
| | | |
| | | const stepOptions = computed<StepOption[]>(() => { |
| | | const cycleNo = Number(selectedCycle.value) |
| | | if (!Number.isFinite(cycleNo) || cycleNo <= 0) return [] |
| | | const actual = new Map<string, StepOption>() |
| | | for (const record of snapshotRecords.value) { |
| | | const stepNo = Number(record.stepNo) |
| | | if (Number(record.cycleNo) !== cycleNo || !Number.isFinite(stepNo) || stepNo <= 0) continue |
| | | const value = `actual:${cycleNo}:${stepNo}` |
| | | if (!actual.has(value)) actual.set(value, { value, label: `${cycleNo}-${stepNo} ${record.stepName || '-'}`, cycleNo, stepNo }) |
| | | } |
| | | const logical: StepOption[] = [] |
| | | for (const mapping of props.logicStepMappings || []) { |
| | | const hit = snapshotRecords.value.some(record => Number(record.cycleNo) === cycleNo && logicalStepMatches(record, mapping)) |
| | | if (hit) logical.push({ value: `logical:${cycleNo}:${mapping.logicalNo}`, label: mapping.logicalName, cycleNo, logical: mapping }) |
| | | } |
| | | return logical.length ? logical : [...actual.values()] |
| | | }) |
| | | |
| | | function ensureDetailFieldValues() { |
| | | detailFields.value.forEach(field => { |
| | |
| | | if (value) experimentName.value = value |
| | | }) |
| | | |
| | | watch(() => props.consoleDetail, value => applyConsoleDetail(value), { immediate: true, deep: true }) |
| | | |
| | | watch(() => props.cells, cells => { |
| | | const validCellNos = new Set(cells.map(cell => cell.no)) |
| | | const retained = selectedCellNos.value.filter(no => validCellNos.has(no)) |
| | |
| | | }, { deep: true }) |
| | | |
| | | watch(detailFields, ensureDetailFieldValues) |
| | | |
| | | watch(stepOptions, options => { |
| | | if (!options.length) { |
| | | selectedStep.value = '' |
| | | return |
| | | } |
| | | if (!options.some(option => option.value === selectedStep.value)) selectedStep.value = options[0].value |
| | | }, { immediate: true }) |
| | | |
| | | watch(cycleOptions, options => { |
| | | const current = Number(selectedCycle.value) |
| | | if (!options.length) selectedCycle.value = '' |
| | | else if (!options.includes(current)) selectedCycle.value = String(options[0]) |
| | | }, { immediate: true }) |
| | | |
| | | watch(() => props.logicStepMappings, mappings => { |
| | | if (!mappings?.length || !selectedCycle.value || selectedStep.value.startsWith('logical:')) return |
| | | // 保存新的合并规则后立即切到逻辑工步,让合并结果在图表中可见;用户仍可手动切回实际工步。 |
| | | const logical = stepOptions.value.find(option => option.logical) |
| | | if (logical) selectedStep.value = logical.value |
| | | }, { deep: true, immediate: true }) |
| | | |
| | | async function addCustomField() { |
| | | try { |
| | |
| | | delete detailFieldValues[field] |
| | | } |
| | | |
| | | async function saveConsoleDetail() { |
| | | detailSaving.value = true |
| | | try { |
| | | const detail: TestConsoleDetail = { |
| | | experimentName: experimentName.value.trim() || null, |
| | | batteryFields: [...batteryFields.value], |
| | | specialFields: [...specialFields.value], |
| | | fieldValues: JSON.parse(JSON.stringify(detailFieldValues)) as Record<string, Record<string, string>>, |
| | | } |
| | | const saved = await saveTestConsoleDetail(props.testId, detail) |
| | | applyConsoleDetail(saved.consoleDetail) |
| | | ElMessage.success('测试详情已保存') |
| | | } catch (error) { |
| | | ElMessage.error(error instanceof Error ? error.message : '测试详情保存失败') |
| | | } finally { detailSaving.value = false } |
| | | } |
| | | |
| | | function openFieldTemplate(mode: 'battery' | 'special') { |
| | | templateMode.value = mode |
| | | selectedBatteryFields.value = [...batteryFields.value] |
| | |
| | | ElMessage.success('字段模板已应用') |
| | | } |
| | | |
| | | async function saveSpecialTemplate() { |
| | | if (!specialFields.value.length) { |
| | | ElMessage.warning('请先添加特殊字段') |
| | | return |
| | | } |
| | | try { |
| | | const result = await ElMessageBox.prompt('请输入特殊字段模板名称', '另存特殊字段模板', { |
| | | confirmButtonText: '保存', |
| | | cancelButtonText: '取消', |
| | | inputPlaceholder: '输入模板名称', |
| | | inputValidator: value => value.trim() ? true : '请输入模板名称', |
| | | }) |
| | | ElMessage.success(`已另存为“${result.value.trim()}”`) |
| | | } catch { |
| | | // 用户取消另存。 |
| | | } |
| | | } |
| | | |
| | | function updateSeriesColor(index: number, color: string | null) { |
| | | if (color) seriesColors.value[index] = color |
| | | } |
| | | |
| | | function elapsedLabel(index: number) { |
| | | const totalSeconds = Math.round(props.durationMinutes * 60 * index / (pointCount - 1)) |
| | | const hours = Math.floor(totalSeconds / 3600) |
| | | const minutes = Math.floor((totalSeconds % 3600) / 60) |
| | | return `${hours}:${String(minutes).padStart(2, '0')}` |
| | | } |
| | | |
| | | function baseValue(cell: ConsoleCell, metric: CompareMetricDefinition) { |
| | | if (metric.key === 'voltage') return Number(cell.voltage) |
| | | if (metric.key === 'current') return Number(cell.current) / 1000 |
| | | if (metric.key === 'positiveVoltage') return Number(cell.positive) |
| | | if (metric.key === 'negativeVoltage') return Number(cell.negative) |
| | | return Number(cell.temperature) |
| | | } |
| | | |
| | | function seriesValues(cell: ConsoleCell, metric: CompareMetricDefinition, cellIndex: number) { |
| | | const base = baseValue(cell, metric) |
| | | const isRest = selectedStep.value.includes('搁置') |
| | | const isDischarge = selectedStep.value.includes('放电') |
| | | return Array.from({ length: pointCount }, (_, pointIndex) => { |
| | | const progress = pointIndex / (pointCount - 1) |
| | | const wave = Math.sin(progress * Math.PI * 3 + cellIndex * 0.45) |
| | | const ripple = Math.cos(progress * Math.PI * 7 + cellIndex * 0.18) |
| | | const amplitude = metric.key === 'temperature' ? 1.8 : metric.key === 'current' ? 0.55 : 0.12 |
| | | if (isRest) { |
| | | const restBase = metric.key === 'current' ? 0 : base |
| | | const restAmplitude = metric.key === 'temperature' ? 0.35 : metric.key === 'current' ? 0.04 : 0.025 |
| | | return Number((restBase + wave * restAmplitude + ripple * restAmplitude * 0.18).toFixed(3)) |
| | | } |
| | | const trend = metric.key === 'temperature' |
| | | ? progress * (isDischarge ? 1.2 : 2.6) |
| | | : metric.key === 'current' |
| | | ? progress * (isDischarge ? -0.42 : 0.3) |
| | | : progress * (isDischarge ? -0.3 : 0.22) |
| | | return Number((base + wave * amplitude + ripple * amplitude * 0.22 + trend).toFixed(3)) |
| | | function ensureSelectedCell() { |
| | | if (!selectedCellNos.value.length && props.cells.length) selectedCellNos.value = [props.cells[0].no] |
| | | const selected = new Set(selectedCellNos.value) |
| | | Object.keys(overrideEditing).forEach(cellNo => { |
| | | if (!selected.has(cellNo)) overrideEditing[cellNo] = false |
| | | }) |
| | | } |
| | | |
| | | function cellCycleOptions(cellNo: string) { |
| | | return [...new Set(snapshotRecords.value.filter(record => String(record.batteryNo) === cellNo) |
| | | .map(record => Number(record.cycleNo)).filter(value => Number.isFinite(value) && value > 0))].sort((a, b) => a - b) |
| | | } |
| | | |
| | | function cellStepOptions(cellNo: string, cycleValue: string | number) { |
| | | const cycleNo = Number(cycleValue) |
| | | if (!Number.isFinite(cycleNo) || cycleNo <= 0) return [] as StepOption[] |
| | | const actual = new Map<string, StepOption>() |
| | | snapshotRecords.value.filter(record => String(record.batteryNo) === cellNo && Number(record.cycleNo) === cycleNo) |
| | | .forEach(record => { |
| | | const stepNo = Number(record.stepNo) |
| | | if (!Number.isFinite(stepNo) || stepNo <= 0) return |
| | | const value = `actual:${cycleNo}:${stepNo}` |
| | | if (!actual.has(value)) actual.set(value, { value, label: `${cycleNo}-${stepNo} ${record.stepName || '-'}`, cycleNo, stepNo }) |
| | | }) |
| | | const logical = (props.logicStepMappings || []).filter(mapping => snapshotRecords.value.some(record => String(record.batteryNo) === cellNo |
| | | && Number(record.cycleNo) === cycleNo && logicalStepMatches(record, mapping))) |
| | | .map(mapping => ({ value: `logical:${cycleNo}:${mapping.logicalNo}`, label: mapping.logicalName, cycleNo, logical: mapping })) |
| | | return logical.length ? logical : [...actual.values()] |
| | | } |
| | | |
| | | function cellUsesOverride(cellNo: string) { |
| | | return Boolean(batteryOverrides[cellNo] && overrideEditing[cellNo]) |
| | | } |
| | | |
| | | function cellSelection(cellNo: string) { |
| | | const override = batteryOverrides[cellNo] |
| | | if (cellUsesOverride(cellNo) && override) return override |
| | | return { cycle: selectedCycle.value, step: selectedStep.value } |
| | | } |
| | | |
| | | function cellHasPublicData(cellNo: string) { |
| | | const selection = { cycle: selectedCycle.value, step: selectedStep.value } |
| | | return recordsForCellSelection(cellNo, selection).length > 0 |
| | | } |
| | | |
| | | function recordsForCellSelection(cellNo: string, selection: { cycle: string; step: string }) { |
| | | const cycle = Number(selection.cycle) |
| | | const option: StepOption | undefined = selection.step ? (cellStepOptions(cellNo, selection.cycle).find(item => item.value === selection.step) |
| | | || stepOptions.value.find(item => item.value === selection.step && item.cycleNo === cycle)) : undefined |
| | | if (!Number.isFinite(cycle) || cycle <= 0 || !option) return [] |
| | | return snapshotRecords.value.filter(record => { |
| | | if (String(record.batteryNo) !== cellNo || Number(record.cycleNo) !== cycle) return false |
| | | if (option.logical) return logicalStepMatches(record, option.logical) |
| | | return Number(record.stepNo) === option.stepNo |
| | | }) |
| | | } |
| | | |
| | | function toggleBatteryOverride(cellNo: string) { |
| | | if (cellUsesOverride(cellNo)) { |
| | | overrideEditing[cellNo] = false |
| | | return |
| | | } |
| | | const cycles = cellCycleOptions(cellNo) |
| | | const cycle = cycles.includes(Number(selectedCycle.value)) ? selectedCycle.value : String(cycles[0] || '') |
| | | const steps = cellStepOptions(cellNo, cycle) |
| | | batteryOverrides[cellNo] = { cycle, step: steps[0]?.value || '' } |
| | | overrideEditing[cellNo] = true |
| | | } |
| | | |
| | | function onOverrideCycleChange(cellNo: string) { |
| | | const override = batteryOverrides[cellNo] |
| | | if (!override) return |
| | | const options = cellStepOptions(cellNo, override.cycle) |
| | | if (!options.some(option => option.value === override.step)) override.step = options[0]?.value || '' |
| | | } |
| | | |
| | | function selectedRangeLabel(cellNo: string) { |
| | | const selection = cellSelection(cellNo) |
| | | const option = cellStepOptions(cellNo, selection.cycle).find(item => item.value === selection.step) |
| | | || stepOptions.value.find(item => item.value === selection.step && item.cycleNo === Number(selection.cycle)) |
| | | if (!selection.cycle || !option) return '未选择范围' |
| | | return `循环 ${selection.cycle} / ${option.label}` |
| | | } |
| | | |
| | | function metricMatches(metric: CompareMetricKey, record: PersistedTestRecord) { |
| | | const snapshot = record.sourceType === 'SNAPSHOT' |
| | | if (metric === 'voltage') return record.sourceType !== 'COLLECTOR' && record.voltage != null |
| | | if (metric === 'current') return record.sourceType !== 'COLLECTOR' && record.current != null |
| | | if (snapshot) return ({ temperature: record.temperature, positiveVoltage: record.positivePotential, negativeVoltage: record.negativePotential }[metric] != null) |
| | | if (record.sourceType !== 'COLLECTOR') return false |
| | | const code = String(record.metricCode || '').toUpperCase() |
| | | if (metric === 'temperature') return code === 'TEMPERATURE' || code.includes('TEMPERATURE') |
| | | if (metric === 'positiveVoltage') return code === 'POSITIVE_POTENTIAL' || code.includes('POSITIVE_POTENTIAL') |
| | | if (metric === 'negativeVoltage') return code === 'NEGATIVE_POTENTIAL' || code.includes('NEGATIVE_POTENTIAL') |
| | | return false |
| | | } |
| | | |
| | | function applyConsoleDetail(detail: TestConsoleDetail | null | undefined) { |
| | | if (!detail) return |
| | | experimentName.value = detail.experimentName || '' |
| | | if (Array.isArray(detail.batteryFields)) batteryFields.value = [...detail.batteryFields] |
| | | if (Array.isArray(detail.specialFields)) specialFields.value = [...detail.specialFields] |
| | | Object.keys(detailFieldValues).forEach(key => delete detailFieldValues[key]) |
| | | Object.entries(detail.fieldValues || {}).forEach(([field, values]) => { detailFieldValues[field] = { ...values } }) |
| | | ensureDetailFieldValues() |
| | | } |
| | | |
| | | function metricValue(metric: CompareMetricKey, record: PersistedTestRecord) { |
| | | if (metric === 'voltage') return Number(record.voltage) |
| | | if (metric === 'current') return Number(record.current) |
| | | if (record.sourceType === 'SNAPSHOT') return Number(({ temperature: record.temperature, positiveVoltage: record.positivePotential, negativeVoltage: record.negativePotential }[metric])) |
| | | return Number(record.metricValue) |
| | | } |
| | | |
| | | function selectedRecords() { |
| | | return selectedCells.value.flatMap(cell => recordsForCellSelection(cell.no, cellSelection(cell.no))) |
| | | } |
| | | |
| | | const hasSelectedRecords = computed(() => selectedRecords().length > 0) |
| | | |
| | | function recordTime(record: PersistedTestRecord) { |
| | | const value = record.deviceTime ?? record.recordedAt |
| | | return typeof value === 'number' && Number.isFinite(value) ? value : null |
| | | } |
| | | |
| | | function chartOption(metric: CompareMetricDefinition) { |
| | | const source = selectedRecords() |
| | | const cellIndexes = selectedCells.value.map(cell => props.cells.findIndex(item => item.no === cell.no)) |
| | | const seriesData = selectedCells.value.map((cell, index) => seriesValues(cell, metric, Math.max(0, cellIndexes[index]))) |
| | | const values = seriesData.flat() |
| | | const seriesData = selectedCells.value.map(cell => { |
| | | return source.filter(record => record.batteryNo === cell.no && metricMatches(metric.key, record)) |
| | | .map(record => ({ time: recordTime(record), value: metricValue(metric.key, record) })) |
| | | .filter(point => point.time !== null && Number.isFinite(point.value)) |
| | | .sort((a, b) => (a.time as number) - (b.time as number)) |
| | | }) |
| | | const timestamps = [...new Set(seriesData.flat().map(point => point.time as number))].sort((a, b) => a - b) |
| | | const start = timestamps[0] |
| | | const values = seriesData.flat().map(point => point.value) |
| | | const dataMin = values.length ? Math.min(...values) : 0 |
| | | const dataMax = values.length ? Math.max(...values) : 1 |
| | | const margin = Math.max((dataMax - dataMin) * 0.16, metric.key === 'temperature' ? 1 : 0.08) |
| | |
| | | xAxis: { |
| | | type: 'category', |
| | | boundaryGap: false, |
| | | name: '时间/h:mm', |
| | | name: '设备时间', |
| | | nameLocation: 'middle', |
| | | nameGap: 29, |
| | | data: Array.from({ length: pointCount }, (_, index) => elapsedLabel(index)), |
| | | data: timestamps.map(value => start == null ? new Date(value).toISOString() : `${((value - start) / 3600000).toFixed(3)} h`), |
| | | axisLine: { lineStyle: { color: '#cbd8e4' } }, |
| | | axisLabel: { color: '#8795a3', fontSize: 10, interval: 4 }, |
| | | nameTextStyle: { color: '#52606d', fontSize: 10 }, |
| | |
| | | series: selectedCells.value.map((cell, index) => ({ |
| | | name: cell.no, |
| | | type: 'line', |
| | | smooth: true, |
| | | smooth: false, |
| | | showSymbol: false, |
| | | lineStyle: { width: 2 }, |
| | | data: seriesData[index], |
| | | data: timestamps.map(timestamp => { |
| | | const point = seriesData[index].find(item => item.time === timestamp) |
| | | return point ? point.value : null |
| | | }), |
| | | })), |
| | | } |
| | | } |
| | |
| | | </tbody> |
| | | </table> |
| | | </div> |
| | | <div 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 class="detail-actions"><button class="primary-button" type="button" :disabled="detailSaving" @click="saveConsoleDetail"><el-icon><Check /></el-icon>保存详情</button><button class="secondary-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="data-select-panel"> |
| | | <header class="panel-heading"><div><strong>数据选择</strong><el-icon><ArrowUp /></el-icon></div></header> |
| | | <div class="data-select-content"> |
| | | <label class="step-select"><span>循环工步</span><el-select v-model="selectedStep"><el-option v-for="item in stepOptions" :key="item" :label="item" :value="item" /></el-select></label> |
| | | <div class="battery-select"><span>电池号</span><el-checkbox-group v-model="selectedCellNos"><el-checkbox v-for="cell in cells" :key="cell.no" :value="cell.no">{{ cell.no }}</el-checkbox></el-checkbox-group></div> |
| | | <label class="step-select"><span>循环</span><el-select v-model="selectedCycle" :disabled="!cycleOptions.length" placeholder="暂无实时循环"><el-option v-for="item in cycleOptions" :key="item" :label="`循环 ${item}`" :value="String(item)" /></el-select></label> |
| | | <label class="step-select"><span>循环工步</span><el-select v-model="selectedStep" :disabled="!stepOptions.length" placeholder="暂无该循环工步"><el-option v-for="item in stepOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></label> |
| | | <div class="battery-select"><span>电池号</span><el-checkbox-group v-model="selectedCellNos" @change="ensureSelectedCell"><el-checkbox v-for="cell in cells" :key="cell.no" :value="cell.no">{{ cell.no }}</el-checkbox></el-checkbox-group></div> |
| | | <div v-if="selectedCells.length" class="battery-ranges"> |
| | | <div v-for="cell in selectedCells" :key="`range-${cell.no}`" class="battery-range-row"> |
| | | <strong>{{ cell.no }}</strong> |
| | | <span v-if="cellUsesOverride(cell.no)" class="range-status override">已单独选择</span> |
| | | <span v-else :class="['range-status', { missing: !cellHasPublicData(cell.no) }]">{{ cellHasPublicData(cell.no) ? '公共范围' : '暂无公共范围数据' }}</span> |
| | | <span class="range-label">{{ selectedRangeLabel(cell.no) }}</span> |
| | | <button class="secondary-button compact-button" type="button" @click="toggleBatteryOverride(cell.no)">{{ cellUsesOverride(cell.no) ? '使用公共范围' : '单独选择' }}</button> |
| | | <template v-if="cellUsesOverride(cell.no)"> |
| | | <el-select v-model="batteryOverrides[cell.no].cycle" class="range-select" placeholder="循环" @change="onOverrideCycleChange(cell.no)"> |
| | | <el-option v-for="item in cellCycleOptions(cell.no)" :key="item" :label="`循环 ${item}`" :value="String(item)" /> |
| | | </el-select> |
| | | <el-select v-model="batteryOverrides[cell.no].step" class="range-select" placeholder="循环工步"> |
| | | <el-option v-for="item in cellStepOptions(cell.no, batteryOverrides[cell.no].cycle)" :key="item.value" :label="item.label" :value="item.value" /> |
| | | </el-select> |
| | | </template> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </section> |
| | | |
| | |
| | | <strong>电池图例</strong> |
| | | <div v-for="cell in selectedCells" :key="cell.no" class="chart-legend-item"> |
| | | <el-color-picker class="legend-color-picker" :model-value="seriesColors[cells.findIndex(item => item.no === cell.no)]" :show-alpha="false" popper-class="console-color-popper" :aria-label="`修改${cell.no}颜色`" @change="updateSeriesColor(cells.findIndex(item => item.no === cell.no), $event)" /> |
| | | <span>{{ cell.no }}</span> |
| | | <span>{{ cell.no }}</span><small>{{ selectedRangeLabel(cell.no) }}</small> |
| | | </div> |
| | | </div> |
| | | |
| | | <div v-if="selectedCells.length && visibleMetrics.length" class="charts-grid"> |
| | | <div v-if="selectedCells.length && visibleMetrics.length && hasSelectedRecords" class="charts-grid"> |
| | | <article v-for="metric in visibleMetrics" :key="metric.key" class="chart-card"> |
| | | <header><strong>{{ metric.title }}</strong></header> |
| | | <VChart :key="`${metric.key}-${selectedStep}-${selectedCellNos.join('|')}`" class="metric-chart" :option="chartOption(metric)" :update-options="chartUpdateOptions" autoresize /> |
| | | </article> |
| | | </div> |
| | | <div v-else class="chart-empty">暂无可展示数据</div> |
| | | <div v-else class="chart-empty">暂无当前测试实时数据</div> |
| | | </section> |
| | | |
| | | <el-dialog v-model="templateDialogVisible" :title="templateMode === 'battery' ? '选择电池组字段模板' : '选择特殊字段模板'" width="620px" align-center> |
| | |
| | | .report-chart-page{min-width:0}.compare-info-panel,.detail-panel,.metrics-panel{min-width:0;border:1px solid #d8e1e9;background:#fff}.compare-info-panel,.detail-panel{margin-bottom:16px;padding-bottom:18px}.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}.panel-heading strong{color:#26333f;font-size:13px}.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}.secondary-button{color:#52606d;background:#fff;border-color:var(--line-strong)}.secondary-button:hover{color:var(--blue);border-color:#9dc8f5}.compare-table-wrap,.field-table-wrap{width:100%;max-width:100%;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}.compact-button{margin:16px 24px 0}.fixed-item{color:#8795a3;font-size:12px}.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}.detail-meta label>span{color:#52606d;font-size:12px;font-weight:500}.detail-meta :deep(.el-input__wrapper),.field-table :deep(.el-input__wrapper),.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}.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,.field-table .detail-meta-row td{background:#fbfdff}.field-table .detail-meta-row td{color:#26333f}.field-table .detail-meta-row td:not(:first-child){font-weight:500}.field-table :deep(.el-input){width:100%}.delete-link{border:0;color:#d15b5b;background:transparent;font-size:12px;cursor:pointer}.delete-link:hover{text-decoration:underline}.field-lock{color:#8795a3}.empty-cell{height:72px!important;color:#8795a3}.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(.console-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(.console-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:230px}.chart-empty{height:180px;display:flex;align-items:center;justify-content:center;color:#8795a3;font-size:12px}.compare-select-form{display:grid;gap:15px}.compare-select-form label{display:grid;grid-template-columns:84px minmax(0,1fr);align-items:center;gap:12px;color:#52606d;font-size:12px}.template-field-options{display:flex;flex-direction:column;gap:10px;max-height:280px;overflow:auto;padding:14px;border:1px solid #d8e1e9;background:#f8fafc}.template-field-options :deep(.el-checkbox),.template-field-options :deep(.el-radio){height:auto;min-height:34px;margin-right:0}.report-chart-page :deep(.el-dialog){max-width:calc(100vw - 28px);border-radius:4px}.report-chart-page :deep(.el-dialog__header){margin:0;padding:17px 20px;border-bottom:1px solid var(--line)}.report-chart-page :deep(.el-dialog__body){padding:20px}.report-chart-page :deep(.el-dialog__footer){display:flex;justify-content:flex-end;gap:8px;padding:14px 20px;border-top:1px solid var(--line)}@media(max-width:900px){.charts-grid{grid-template-columns:minmax(0,1fr)}}@media(max-width:680px){.panel-heading,.metric-switches,.chart-legend-bar{padding-left:12px;padding-right:12px}.compare-table-wrap,.detail-meta,.field-table-wrap,.detail-actions{padding-left:12px;padding-right:12px}.detail-meta{grid-template-columns:1fr}.metrics-panel{padding-left:12px;padding-right:12px}.chart-legend-bar{align-items:flex-start;flex-direction:column;gap:10px;padding-top:12px;padding-bottom:12px}.metric-switches{gap:14px}.charts-grid{grid-template-columns:1fr}.metric-chart{height:250px}.compare-select-form label{grid-template-columns:1fr}} |
| | | .data-select-panel{min-width:0;margin-bottom:16px;padding-bottom:18px;border:1px solid #d8e1e9;background:#fff}.data-select-content{display:grid;grid-template-columns:minmax(260px,360px) minmax(0,1fr);align-items:start;gap:28px;padding:18px 24px 0}.step-select,.battery-select{display:grid;gap:10px;color:#52606d;font-size:12px}.step-select{grid-template-columns:76px minmax(0,1fr);align-items:center}.battery-select{grid-template-columns:52px minmax(0,1fr);align-items:start}.battery-select>span{padding-top:7px}.step-select :deep(.el-select__wrapper){min-height:34px;border-radius:3px;box-shadow:0 0 0 1px #d8e1e9 inset;background:#f8fafc}.battery-select :deep(.el-checkbox-group){display:flex;flex-wrap:wrap;align-items:center;gap:8px 22px;min-height:34px}.battery-select :deep(.el-checkbox){margin-right:0}@media(max-width:900px){.data-select-content{grid-template-columns:1fr}}@media(max-width:680px){.data-select-content{gap:14px;padding:14px 12px 0}.step-select,.battery-select{grid-template-columns:1fr}.battery-select>span{padding-top:0}} |
| | | </style> |
| | | |
| | | <style scoped> |
| | | .chart-legend-item small{max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#8795a3;font-size:11px} |
| | | .battery-ranges{grid-column:1 / -1;display:grid;gap:8px;padding-top:2px;border-top:1px solid var(--line)} |
| | | .battery-range-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px 12px;min-height:38px;color:#52606d;font-size:12px} |
| | | .battery-range-row strong{min-width:72px;color:#26333f} |
| | | .range-status{color:#2b78a3}.range-status.missing{color:#c27b2c}.range-status.override{color:#3978f6} |
| | | .range-label{color:#8795a3;min-width:180px;max-width:360px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} |
| | | .battery-range-row .compact-button{margin:0;height:30px;padding:0 10px} |
| | | .range-select{width:150px}.range-select :deep(.el-select__wrapper){min-height:30px} |
| | | @media(max-width:680px){.battery-ranges{padding-top:10px}.battery-range-row{align-items:flex-start}.range-label{width:100%;max-width:none}.range-select{width:100%}} |
| | | </style> |