yeg
2026-09-03 b2f8041203c8eaf9bbba0a0cb439cae4519d29c4
实现工步图表当前有效数据与测试详情保存
4个文件已修改
129 ■■■■■ 已修改文件
src/api/tests.ts 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/ConsoleChartPanel.vue 108 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/TestConsole.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/test-console-step-chart.contract.test.mjs 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/tests.ts
@@ -85,6 +85,14 @@
  }>
  runtimeAttach?: boolean
  logicStepMappings: TestLogicStepMapping[]
  consoleDetail?: TestConsoleDetail | null
}
export interface TestConsoleDetail {
  experimentName: string | null
  batteryFields: string[]
  specialFields: string[]
  fieldValues: Record<string, Record<string, string>>
}
export interface TestLogicStepMapping {
@@ -322,6 +330,10 @@
  return getSimulatorChannelData<BtsCycleRecord>(deviceId, channelId, 'cycles')
}
export async function saveTestConsoleDetail(testId: number, detail: TestConsoleDetail): Promise<TestConfigurationDetail> {
  return (await request<TestDetailResponse>(`/system/test/${testId}/console-detail`, { method: 'PUT', body: JSON.stringify(detail) })).data
}
export interface PersistedTestRecord {
  id: number | string
  stableIdentity?: string
src/components/ConsoleChartPanel.vue
@@ -12,7 +12,7 @@
} from '@/utils/compareReportPrint'
import type { CompareMetricKey } from '@/stores/compareReports'
import { useBatteryGroupFields } from '@/stores/batteryGroupFields'
import type { PersistedTestRecord, TestLogicStepMapping } from '@/api/tests'
import { saveTestConsoleDetail, type PersistedTestRecord, type TestConsoleDetail, type TestLogicStepMapping } from '@/api/tests'
interface ConsoleCell {
  no: string
@@ -25,18 +25,21 @@
}
const props = withDefaults(defineProps<{
  testId: number
  cells: ConsoleCell[]
  durationMinutes?: number
  testName?: string
  batteryGroup?: string
  records: PersistedTestRecord[]
  logicStepMappings?: TestLogicStepMapping[]
  consoleDetail?: TestConsoleDetail | null
}>(), {
  durationMinutes: 5,
  testName: '',
  batteryGroup: '',
  records: () => [],
  logicStepMappings: () => [],
  consoleDetail: null,
})
use([CanvasRenderer, LineChart, GridComponent, LegendComponent, TooltipComponent])
@@ -50,6 +53,7 @@
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[]>([])
@@ -86,41 +90,27 @@
}
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))
const stepOptions = computed<StepOption[]>(() => {
  const cycleNo = Number(selectedCycle.value)
  if (!Number.isFinite(cycleNo) || cycleNo <= 0) return []
  const actual = new Map<string, StepOption>()
  const actualAcrossCycles = new Map<number, StepOption>()
  for (const record of props.records) {
    const cycleNo = Number(record.cycleNo)
  for (const record of props.records.filter(item => item.sourceType === 'SNAPSHOT')) {
    const stepNo = Number(record.stepNo)
    if (!Number.isFinite(cycleNo) || !Number.isFinite(stepNo) || cycleNo <= 0 || stepNo <= 0) continue
    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 })
    if (!actualAcrossCycles.has(stepNo)) actualAcrossCycles.set(stepNo, {
      value: `actual:all:${stepNo}`,
      label: `全部循环-${stepNo} ${record.stepName || '-'}`,
      cycleNo: 0,
      stepNo,
    })
  }
  const logical: StepOption[] = []
  const logicalAcrossCycles: StepOption[] = []
  for (const cycleNo of cycleOptions.value) {
    for (const mapping of props.logicStepMappings || []) {
      logical.push({ value: `logical:${cycleNo}:${mapping.logicalNo}`, label: `${cycleNo}-逻辑${mapping.logicalNo} ${mapping.logicalName}`, cycleNo, logical: mapping })
    }
  }
  for (const mapping of props.logicStepMappings || []) {
    logicalAcrossCycles.push({
      value: `logical:all:${mapping.logicalNo}`,
      label: `全部循环-逻辑${mapping.logicalNo} ${mapping.logicalName}`,
      cycleNo: 0,
      logical: mapping,
    })
    const hit = props.records.some(record => record.sourceType === 'SNAPSHOT' && Number(record.cycleNo) === cycleNo
      && Number(record.stepNo) >= mapping.startStepNo && Number(record.stepNo) <= mapping.endStepNo)
    if (hit) logical.push({ value: `logical:${cycleNo}:${mapping.logicalNo}`, label: `${cycleNo}-逻辑${mapping.logicalNo} ${mapping.logicalName}`, cycleNo, logical: mapping })
  }
  return [...actualAcrossCycles.values(), ...actual.values(), ...logicalAcrossCycles, ...logical]
  return [...actual.values(), ...logical]
})
function ensureDetailFieldValues() {
@@ -137,6 +127,8 @@
watch(() => props.testName, value => {
  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))
@@ -158,8 +150,9 @@
watch(cycleOptions, options => {
  const current = Number(selectedCycle.value)
  if (selectedCycle.value && !options.includes(current)) selectedCycle.value = ''
})
  if (!options.length) selectedCycle.value = ''
  else if (!options.includes(current)) selectedCycle.value = String(options[0])
}, { immediate: true })
async function addCustomField() {
  try {
@@ -186,6 +179,23 @@
  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]
@@ -204,26 +214,12 @@
  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 ensureSelectedCell() {
  if (!selectedCellNos.value.length && props.cells.length) selectedCellNos.value = [props.cells[0].no]
}
function metricMatches(metric: CompareMetricKey, record: PersistedTestRecord) {
@@ -239,6 +235,16 @@
  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)
@@ -249,9 +255,11 @@
function selectedRecords() {
  const option = stepOptions.value.find(item => item.value === selectedStep.value)
  if (!option) return []
  const cycle = selectedCycle.value ? Number(selectedCycle.value) : option.cycleNo > 0 ? option.cycleNo : null
  const cycle = Number(selectedCycle.value)
  if (!Number.isFinite(cycle) || cycle <= 0) return []
  return props.records.filter(record => {
    if (cycle !== null && Number(record.cycleNo) !== cycle) return false
    if (record.sourceType !== 'SNAPSHOT') return false
    if (Number(record.cycleNo) !== cycle) return false
    const stepNo = Number(record.stepNo)
    if (option.logical) return stepNo >= option.logical.startStepNo && stepNo <= option.logical.endStepNo
    return stepNo === option.stepNo
@@ -357,15 +365,15 @@
          </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="selectedCycle" clearable 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"><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>
    </section>
src/views/TestConsole.vue
@@ -404,7 +404,7 @@
    </div>
    </div>
    <ConsoleChartPanel v-if="activeTab === 'step-chart'" :cells="cells" :records="realtimeRecords" :logic-step-mappings="stepSettings" :test-name="profile.name" :battery-group="profile.batteryGroup" />
    <ConsoleChartPanel v-if="activeTab === 'step-chart'" :test-id="testId" :cells="cells" :records="realtimeRecords" :logic-step-mappings="stepSettings" :console-detail="backendDetail?.consoleDetail" :test-name="profile.name" :battery-group="profile.batteryGroup" />
    <ConsoleProjectChartPanel v-if="activeTab === 'project-chart'" :cells="cells" />
<ConsoleDataListPanel v-if="activeTab === 'data-list' && backendDetail" :test-id="testId" :test-name="profile.name" :cells="cells" :can-import="state === '检测完成'" :reception-status="backendDetail.receptionStatus" :bindings="backendDetail.batteries.map(item => ({ batteryNo: item.batteryNo, bindingId: item.binding?.bindingId, channelId: item.binding?.channelId, devid: item.binding?.devid, chlid: item.binding?.chlid }))" />
tests/test-console-step-chart.contract.test.mjs
@@ -10,7 +10,12 @@
assert.match(chart, /POSITIVE_POTENTIAL/)
assert.match(chart, /NEGATIVE_POTENTIAL/)
assert.match(chart, /selectedRecords\(\)/)
assert.match(chart, /全部循环/)
assert.match(chart, /record\.sourceType !== 'SNAPSHOT'/)
assert.doesNotMatch(chart, /全部循环/)
assert.match(chart, /saveTestConsoleDetail/)
assert.match(chart, /保存详情/)
assert.doesNotMatch(chart, /另存特殊字段模板/)
assert.match(chart, /Number\(selectedCycle\.value\)/)
assert.match(chart, /smooth: false/)
assert.match(chart, /暂无当前测试实时数据/)
assert.doesNotMatch(chart, /const stepOptions = \['5-21 恒流充电'/)