yeg
2026-08-31 fd756bea02d9f6cb1b6a5385e61511dc27d75994
接入控制台工步设置与工步视图

增加逻辑工步映射接口与编辑弹窗,按检测柜实际 step 数据聚合展示全部循环,并支持刷新后恢复规则。
2个文件已修改
69 ■■■■■ 已修改文件
src/api/tests.ts 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/TestConsole.vue 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/tests.ts
@@ -94,6 +94,14 @@
    boundaryNote: string | null
    intervalStatus: 'OPEN' | 'CLOSED' | string
  }>
  logicStepMappings: TestLogicStepMapping[]
}
export interface TestLogicStepMapping {
  logicalNo: number
  logicalName: string
  startStepNo: number
  endStepNo: number
}
export interface TestConfigurationOptions {
@@ -230,6 +238,10 @@
  exceptions: Array<Record<string, unknown>>
}
export async function saveLogicStepMappings(testId: number, mappings: TestLogicStepMapping[]): Promise<TestConfigurationDetail> {
  return (await request<TestDetailResponse>(`/system/test/${testId}/logic-step-mappings`, { method: 'PUT', body: JSON.stringify(mappings) })).data
}
interface TestLiveStateResponse extends ApiResponse { data: TestLiveState }
export async function getTestLiveState(testId: number): Promise<TestLiveState> {
@@ -259,6 +271,11 @@
  endedAt: string
  capacityAh: number
  energyWh: number
  durationSeconds: number
  startVoltageV: number
  endVoltageV: number
  chargePeakVoltageV: number
  dischargeValleyVoltageV: number
}
export interface BtsCycleRecord {
@@ -270,6 +287,7 @@
  dischargeCapacityAh: number
  chargeEnergyWh: number
  dischargeEnergyWh: number
  efficiency: number
}
async function getSimulatorChannelData<T>(deviceId: number, channelId: number, kind: 'records' | 'steps' | 'cycles'): Promise<T[]> {
src/views/TestConsole.vue
@@ -6,7 +6,7 @@
import ConsoleProjectChartPanel from '@/components/ConsoleProjectChartPanel.vue'
import ConsoleDataListPanel from '@/components/ConsoleDataListPanel.vue'
import { useTests } from '@/stores/tests'
import { endTestReception, getTestDetail, getTestLiveState, openTestLiveStream, pauseTestReception, resumeTestReception, startTestReception, type TestConfigurationDetail, type TestLiveState } from '@/api/tests'
import { endTestReception, getBtsSteps, getTestDetail, getTestLiveState, openTestLiveStream, pauseTestReception, resumeTestReception, saveLogicStepMappings, startTestReception, type BtsStepRecord, type TestConfigurationDetail, type TestLiveState, type TestLogicStepMapping } from '@/api/tests'
type ConsoleState = '未开始接收' | '检测中' | '暂停' | '已结束'
type ReceptionState = '未开始接收' | '接收中' | '暂停接收' | '已结束接收'
@@ -50,6 +50,9 @@
const state = ref<ConsoleState>(initialStatus === '检测完成' || initialStatus === '完成确认中' || initialStatus === '已终止' || route.query.state === 'ended' ? '已结束' : initialStatus === '暂停' ? '暂停' : initialStatus === '待测试' ? '未开始接收' : '检测中')
const elapsedSeconds = ref(42 * 60 + 25)
const stepDialogVisible = ref(false)
const stepSettings = ref<TestLogicStepMapping[]>([])
const stepDraft = ref<TestLogicStepMapping[]>([])
const stepRows = ref<Array<BtsStepRecord & { batteryNo: string; deviceId: number; channelId: number }>>([])
const activeTab = ref<ConsoleTab>(route.query.tab === 'data-list' ? 'data-list' : 'realtime')
const consoleTabs: Array<{ key: ConsoleTab; label: string }> = [
  { key: 'realtime', label: '实时数据' },
@@ -111,6 +114,8 @@
  try {
    const detail = await getTestDetail(testId.value)
    backendDetail.value = detail
    stepSettings.value = detail.logicStepMappings || []
    await loadStepRows(detail)
    runtimeAttach.value = detail.runtimeAttach === true
    if (detail.receptionStatus === 'NOT_STARTED') {
      state.value = '未开始接收'
@@ -179,15 +184,35 @@
const currentExceptions = computed(() => (liveState.value?.exceptions || [])
  .filter(item => item.sourceType !== 'COLLECTOR' || !item.status || item.status === 'OPEN'))
const steps = [
  { no: 1, name: '恒流充电', time: '-', voltage: '0.9000 V', state: '已完成' },
  { no: 2, name: '搁置', time: '00:05:00', voltage: '-', state: '已完成' },
  { no: 3, name: '恒流充电', time: '00:00:00', voltage: '2.4000 V', state: '进行中' },
  { no: 4, name: '搁置', time: '00:05:00', voltage: '-', state: '待执行' },
  { no: 5, name: '恒流放电', time: '-', voltage: '0.9000 V', state: '待执行' },
  { no: 6, name: '循环', time: '起始工步 2', voltage: '循环次数 32', state: '待执行' },
  { no: 7, name: '结束', time: '-', voltage: '-', state: '待执行' },
]
async function loadStepRows(detail: TestConfigurationDetail) {
  const bindings = detail.batteries || []
  const batches = await Promise.all(bindings.map(async battery => {
    const binding = battery.binding
    if (!binding?.devid || !binding.chlid) return []
    return (await getBtsSteps(binding.devid, binding.chlid)).map(item => ({ ...item, batteryNo: battery.batteryNo, deviceId: binding.devid!, channelId: binding.chlid! }))
  }))
  stepRows.value = batches.flat()
}
function formatDuration(seconds: number) {
  const millis = Math.round(Number(seconds || 0) * 1000)
  const h = Math.floor(millis / 3600000); const m = Math.floor((millis % 3600000) / 60000); const s = Math.floor((millis % 60000) / 1000); const ms = millis % 1000
  return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.${String(ms).padStart(3, '0')}`
}
function mappingFor(stepNo: number) { return stepSettings.value.find(item => stepNo >= item.startStepNo && stepNo <= item.endStepNo) }
const mappedStepRows = computed(() => {
  const groups = new Map<string, (typeof stepRows.value[number] & { displayNo: number; displayName: string })[]>()
  stepRows.value.forEach(row => { const mapping = mappingFor(row.stepNo); const displayNo = mapping?.logicalNo || row.stepNo; const displayName = mapping?.logicalName || row.stepName; const key = `${row.batteryNo}:${row.cycleNo}:${displayNo}`; const group = groups.get(key) || []; group.push({ ...row, displayNo, displayName }); groups.set(key, group) })
  return [...groups.values()].map(group => { group.sort((a, b) => a.stepNo - b.stepNo); const first = group[0]; const last = group[group.length - 1]; return { ...last, displayNo: first.displayNo, displayName: first.displayName, durationSeconds: group.reduce((sum, item) => sum + Number(item.durationSeconds || 0), 0), startVoltageV: first.startVoltageV, endVoltageV: last.endVoltageV, chargePeakVoltageV: Math.max(...group.map(item => item.chargePeakVoltageV || 0)), dischargeValleyVoltageV: Math.min(...group.map(item => item.dischargeValleyVoltageV || 0)) } })
})
const visibleStepRows = computed(() => [...mappedStepRows.value].sort((a, b) => a.batteryNo.localeCompare(b.batteryNo) || a.cycleNo - b.cycleNo || a.displayNo - b.displayNo))
function openStepSettings() { stepDraft.value = stepSettings.value.map(item => ({ ...item })) ; stepDialogVisible.value = true }
function addStepMapping() { const next = stepDraft.value.length + 1; stepDraft.value.push({ logicalNo: next, logicalName: `逻辑工步 ${next}`, startStepNo: 1, endStepNo: 1 }) }
function removeStepMapping(index: number) { stepDraft.value.splice(index, 1); stepDraft.value.forEach((item, i) => { item.logicalNo = i + 1 }) }
async function saveSteps() {
  try { const detail = await saveLogicStepMappings(testId.value, stepDraft.value); stepSettings.value = detail.logicStepMappings || []; backendDetail.value = detail; stepDialogVisible.value = false; ElMessage.success('工步合并规则已保存') }
  catch (error) { ElMessage.error(error instanceof Error ? error.message : '工步设置保存失败') }
}
const parameters = [
  ['测试名称', profile.value.name], ['创建者', '陈师傅'], ['负责人', profile.value.owner], ['测试项目', profile.value.purpose],
@@ -282,7 +307,7 @@
    <div class="console-layout">
      <main>
        <section class="panel step-panel"><header class="panel-heading"><div><i></i><strong>项目参数</strong><span>STEP PARAMETERS</span></div><button class="primary-button compact-button" type="button" @click="stepDialogVisible=true"><el-icon><Setting /></el-icon>工步设置</button></header><div class="project-meta"><span>测试项目 <strong>{{ profile.purpose }}</strong></span><span>当前循环 <strong>循环 36 / 计划 60</strong></span></div><div class="step-table-wrap"><table><thead><tr><th>工步号</th><th>工步名称</th><th>时间(h:min:s.ms)</th><th>电压(V)</th><th>状态</th></tr></thead><tbody><tr v-for="step in steps" :key="step.no" :class="{ active: step.state === '进行中' }"><td>{{ step.no }}</td><td><strong>{{ step.name }}</strong></td><td>{{ step.time }}</td><td>{{ step.voltage }}</td><td><span class="step-state" :class="step.state">{{ step.state }}</span></td></tr></tbody></table></div></section>
        <section class="panel step-panel"><header class="panel-heading"><div><i></i><strong>工步数据</strong><span>STEP SUMMARY</span></div><button class="primary-button compact-button" type="button" @click="openStepSettings"><el-icon><Setting /></el-icon>工步设置</button></header><div class="project-meta"><span>测试项目 <strong>{{ profile.purpose }}</strong></span><span>按当前测试规则映射,覆盖全部循环</span></div><div class="step-table-wrap"><table><thead><tr><th>电池号</th><th>循环</th><th>逻辑工步</th><th>实际工步</th><th>工步时间</th><th>容量(Ah)</th><th>能量(Wh)</th><th>起始电压(V)</th><th>终止电压(V)</th><th>充电峰值(V)</th><th>放电谷值(V)</th></tr></thead><tbody><tr v-for="row in visibleStepRows" :key="`${row.deviceId}:${row.channelId}:${row.cycleNo}:${row.stepNo}`"><td><strong>{{ row.batteryNo }}</strong></td><td>{{ row.cycleNo }}</td><td>{{ row.displayNo }} · {{ row.displayName }}</td><td>{{ row.stepNo }} · {{ row.stepName }}</td><td>{{ formatDuration(row.durationSeconds) }}</td><td>{{ Number(row.capacityAh).toFixed(4) }}</td><td>{{ Number(row.energyWh).toFixed(4) }}</td><td>{{ Number(row.startVoltageV).toFixed(4) }}</td><td>{{ Number(row.endVoltageV).toFixed(4) }}</td><td>{{ Number(row.chargePeakVoltageV).toFixed(4) }}</td><td>{{ Number(row.dischargeValleyVoltageV).toFixed(4) }}</td></tr><tr v-if="!visibleStepRows.length"><td colspan="11" class="empty-cell">暂无检测柜实际工步</td></tr></tbody></table></div></section>
        <section class="panel alarm-panel"><header class="panel-heading"><div><i></i><strong>异常警告</strong><span>ALARM</span></div></header><div v-if="currentExceptions.length" class="alarm-list"><div v-for="(item, index) in currentExceptions" :key="`${item.sourceId || item.sourceType}-${item.channelCode || index}-${index}`"><el-icon><WarningFilled /></el-icon><span><strong>{{ item.sourceType === 'COLLECTOR' ? `采集器${item.collectorName || item.collectorCode || `#${item.sourceId}`}${item.status === 'OPEN' ? '离线' : '断线已恢复'} · ${item.channelCode || '来源'}` : (item.message || '数据来源当前不可观测') }}</strong><small>{{ item.measurementName ? `${item.measurementName}受影响 · ` : '' }}{{ item.status === 'OPEN' ? '缺失区间开放' : (item.message || '当前异常') }} · {{ item.missingFrom || item.openedAt || '-' }}<template v-if="item.missingTo"> 至 {{ item.missingTo }}</template></small></span></div></div><div v-else class="alarm-empty"><el-icon><CircleCheckFilled /></el-icon>暂无异常</div></section>
         <section class="panel channel-panel"><header class="panel-heading"><div><i></i><strong>通道</strong><span>CHANNEL STATUS</span></div></header><div class="channel-table-wrap"><table><thead><tr><th>电池号</th><th>测试柜</th><th>柜状态</th><th>检测通道</th><th>采集器</th><th>采集器状态</th><th>温度</th><th>正电位</th><th>负电位</th></tr></thead><tbody><tr v-for="item in channels" :key="item.no"><td><strong>{{ item.no }}</strong></td><td>{{ item.cabinet }}</td><td><span class="device-state" :class="item.cabinetState">{{ item.cabinetState }}</span></td><td>{{ item.channel }}</td><td>{{ item.collector }}</td><td><span class="device-state" :class="{ 在线: item.collectorState === '在线', 异常: item.collectorState !== '在线' }">{{ item.collectorState }}</span></td><td>{{ item.temperature }}</td><td>{{ item.positive }}</td><td>{{ item.negative }}</td></tr></tbody></table></div></section>
      </main>
@@ -295,7 +320,7 @@
<ConsoleDataListPanel v-if="activeTab === 'data-list'" :test-id="testId" :test-name="profile.name" :cells="cells" :can-import="state === '已结束'" :bindings="backendDetail?.batteries.map(item => ({ batteryNo: item.batteryNo, devid: item.binding?.devid, chlid: item.binding?.chlid }))" />
    <el-dialog v-model="stepDialogVisible" title="工步设置" width="620px" align-center><div class="step-dialog-content"><p>当前执行工步</p><strong>工步 3 · 恒流充电</strong><dl><div><dt>目标电压</dt><dd>2.4000 V</dd></div><div><dt>循环进度</dt><dd>36 / 60</dd></div><div><dt>执行时间</dt><dd>{{ elapsedText }}</dd></div></dl></div><template #footer><button class="secondary-button" type="button" @click="stepDialogVisible=false">取消</button><button class="primary-button" type="button" @click="stepDialogVisible=false; ElMessage.success('工步设置已保存')"><el-icon><Check /></el-icon>确认</button></template></el-dialog>
    <el-dialog v-model="stepDialogVisible" title="工步设置" width="760px" align-center><div class="step-dialog-content"><p>将连续实际工步合并为逻辑工步;循环和结束统计行不参与,原始检测柜数据保持不变。</p><div class="mapping-list"><div v-for="(item, index) in stepDraft" :key="index" class="mapping-row"><strong>逻辑工步 {{ item.logicalNo }}</strong><el-input v-model="item.logicalName" placeholder="逻辑工步名称" /><el-input-number v-model="item.startStepNo" :min="1" controls-position="right" /><span>至</span><el-input-number v-model="item.endStepNo" :min="1" controls-position="right" /><button class="secondary-button" type="button" aria-label="取消合并" @click="removeStepMapping(index)"><el-icon><Delete /></el-icon></button></div></div><button class="secondary-button" type="button" @click="addStepMapping"><el-icon><Plus /></el-icon>新增合并组</button></div><template #footer><button class="secondary-button" type="button" @click="stepDialogVisible=false">取消</button><button class="primary-button" type="button" @click="saveSteps"><el-icon><Check /></el-icon>保存规则</button></template></el-dialog>
  </div>
</template>
@@ -307,5 +332,5 @@
/* 双栏收缩边界:宽表只能在所属面板内横向滚动,不能撑开主网格。 */
.console-tabs{min-height:52px;display:flex;align-items:center;gap:10px;margin-bottom:14px;padding:0 4px;border-bottom:1px solid #d5e0e9}.console-tabs button{height:30px;min-width:88px;padding:0 16px;border:1px solid transparent;border-radius:16px;color:#52606d;background:#f5f7fa;font-size:12px;cursor:pointer;transition:.16s ease}.console-tabs button:hover{color:var(--blue);background:#edf5fd}.console-tabs button.active{color:#1677dd;background:#dff0ff;border-color:#c4e3ff;font-weight:600}.tab-content{min-width:0}.data-list-panel{min-width:0;border:1px solid #d8e1e9;background:#fff}.data-list-panel .panel-heading>small{color:#8795a3;font-size:10px}.data-table-wrap{width:100%;overflow-x:auto;padding:16px 18px 20px}.data-table-wrap table{min-width:1260px}.data-table-wrap td strong{color:#1268dc}.data-voltage{color:#7f35c5;font-weight:600}.data-temperature{color:#bf6100;font-weight:600}.console-layout{grid-template-columns:minmax(0,2.35fr) minmax(300px,1fr);gap:24px;min-width:0}.console-layout>main,.console-layout>.parameter-panel,.console-layout main>.panel{min-width:0;max-width:100%}.step-table-wrap,.channel-table-wrap{width:100%;max-width:100%;min-width:0;overflow-x:auto;overflow-y:hidden}.step-table-wrap table{min-width:720px}.channel-table-wrap table{width:1120px;min-width:1120px;max-width:none}.parameter-panel{position:relative;z-index:1;overflow:hidden}@media(max-width:1280px){.console-layout{grid-template-columns:minmax(0,1fr) 300px;gap:20px}}@media(max-width:1050px){.console-layout{grid-template-columns:minmax(0,1fr);gap:16px}.parameter-panel{grid-row:2}}@media(max-width:600px){.console-tabs{overflow-x:auto}.console-tabs button{flex:0 0 auto}.data-table-wrap{padding:12px}}
.device-state.离线{color:#b34f4f;background:#fff1f1;border-color:#e9bcbc}
.device-state.未知{color:#7b8794;background:#f2f4f6;border-color:#d8dde2}
.device-state.未知{color:#7b8794;background:#f2f4f6;border-color:#d8dde2}.mapping-list{display:grid;gap:10px;max-height:360px;overflow:auto}.mapping-row{display:grid;grid-template-columns:100px minmax(180px,1fr) 110px 20px 110px 40px;align-items:center;gap:8px}.mapping-row strong{color:#52606d;font-size:12px}.mapping-row :deep(.el-input-number){width:100%}
</style>