yeg
2026-09-09 4641046ffe58de84d046cb81ad2a90ebb08438eb
化成详情先显示表格并按循环加载曲线

测量数据只保留当前循环,切换版本或循环时取消旧请求并丢弃迟到响应。曲线失败独立重试,表格编辑不等待曲线;缓存绘图选项避免每次输入重排采样,并补充真实页面交互回归。
3个文件已修改
92 ■■■■■ 已修改文件
src/api/formationReports.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/FormationReportForm.vue 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/formation-form-interaction.test.mjs 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/formationReports.ts
@@ -23,11 +23,13 @@
export interface FormationSummary { id: number; name: string; creator: string; createdAt: number; testName?: string; batteryGroup?: string; versionNo: number }
export interface FormationVersion { versionNo: number; operatorName: string; createdAt: number; changeSummary: string }
export interface SavedFormationReport extends FormationSummary, FormationMatrix {
  curvesDeferred?: boolean
  description: string; settings: FormationSettings; versionNo: number; operatorName: string; versionCreatedAt: number; changeSummary: string
  units: Record<string, string>; displayUnits: Record<string, string>
  curveMode?: 'actual-steps'
  calculatedValues: Record<string, number | null>
}
export interface FormationCycleCurve { selection: FormationSelection; curve: { zeroTime: number | null; records: PersistedTestRecord[] } | null }
export interface FormationTemplate { id: number; name: string; type: 'formation'; fields: FormationField[]; creator: string; updatedAt: number }
export interface CreateFormationReport { name: string; description: string; selections: FormationSelection[]; settings: FormationSettings }
@@ -44,6 +46,10 @@
export function getFormationReport(id: number, version?: number, editing = false) {
  return data<SavedFormationReport>(`/${id}${editing ? '/editing' : version === undefined ? '' : `/versions/${version}`}`)
}
/** 只加载首屏固定版本的当前循环;编辑与查看保留独立权限入口。 */
export async function getFormationCycleCurves(id: number, version: number, cycle: number, editing = false, signal?: AbortSignal) {
  return (await request<ApiResponse & { data: FormationCycleCurve[] }>(`${root}/${id}${editing ? '/editing' : ''}/versions/${version}/cycles/${cycle}`, {signal})).data
}
export function editFormationReport(id: number, body: Omit<CreateFormationReport, 'selections'> & { baseVersionNo: number; changeSummary: string }) {
  return data<SavedFormationReport>(`/${id}/versions`, 'POST', body)
}
src/views/FormationReportForm.vue
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, ref, shallowRef, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { use } from 'echarts/core'
@@ -8,9 +8,9 @@
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
import VChart from 'vue-echarts'
import { getUserInfo } from '@/api/auth'
import { getFormationSources, previewFormationReport, getFormationReport, createFormationReport, editFormationReport, refreshFormationSource,
import { getFormationSources, previewFormationReport, getFormationReport, getFormationCycleCurves, createFormationReport, editFormationReport, refreshFormationSource,
  listFormationVersions, listFormationTemplates, createFormationTemplate, updateFormationTemplate,
  type FormationCandidate, type FormationMatrix, type FormationSettings, type FormationItem, type FormationField, type FormationVersion, type FormationTemplate, type SavedFormationReport } from '@/api/formationReports'
  type FormationCandidate, type FormationMatrix, type FormationSettings, type FormationItem, type FormationField, type FormationVersion, type FormationTemplate, type SavedFormationReport, type FormationCycleCurve } from '@/api/formationReports'
import { defaultFormationFields, formationSourceValue } from '@/utils/formationReportFields'
import { formationCellKey, formationInputValue, formationCalculatedValue, formationNumberText } from '@/utils/formationReportCalculations'
import { formationCurveMetrics, formationSeriesKey, formationSeriesColor } from '@/utils/formationCycleChart'
@@ -47,6 +47,30 @@
  return steps.length ? [{test:item.source.name,battery:item.source.batteryNo,cycle,count:steps.length,steps:steps.map(step=>`${step.stepNo}(${step.name || step.stepType || '类型缺失'})`).join('、')}] : []
})))
const unknownStepCount = computed(()=>unknownStepRows.value.reduce((count,row)=>count+row.count,0))
const curveLoading = ref(false), curveError = ref(''), curveRetry = ref(0)
// 测量点只读且仅保留当前循环,避免数万采样进入表单深层响应式对象。
const currentCurves = shallowRef<FormationCycleCurve[]>([])
watch(()=>[saved.value?.id,saved.value?.versionNo,saved.value?.curvesDeferred,activeCycle.value,readonly.value,curveRetry.value] as const,
  async ([id,version,deferred,cycle,isReadonly],_previous,onCleanup)=>{
    const controller=new AbortController()
    onCleanup(()=>controller.abort())
    currentCurves.value=[];curveError.value='';curveLoading.value=false
    if(!id||!version||!deferred||cycle==null)return
    curveLoading.value=true
    try{
      const rows=await getFormationCycleCurves(id,version,cycle,!isReadonly,controller.signal)
      if(!controller.signal.aborted)currentCurves.value=rows
    }catch(cause){if(!controller.signal.aborted)curveError.value=message(cause)}
    finally{if(!controller.signal.aborted)curveLoading.value=false}
  })
const chartItems = computed(()=>{
  if(!saved.value?.curvesDeferred)return matrix.value.items
  const curves=new Map(currentCurves.value.map(row=>[`${row.selection.testId}|${row.selection.testBatteryId}`,row.curve]))
  return matrix.value.items.map(item=>({...item,curves:{[activeCycle.value??0]:curves.get(formationSeriesKey(item))??{zeroTime:null,records:[]}}}))
})
// 输入报表字段不改变曲线;缓存图表选项,避免每输入一个字符都重排全部采样。
const chartOptions = computed(()=>Object.fromEntries(formationStepGroups.map(group=>[group.key,
  Object.fromEntries(visibleMetrics.value.map(metric=>[metric.key,formationStepChartOption(metric,chartItems.value,activeCycle.value??0,group.key,settings.value)]))])))
let loadSequence = 0, previewSequence = 0
const message = (cause: unknown) => cause instanceof Error ? cause.message : '请求失败,请重试'
@@ -198,7 +222,9 @@
    </tbody></table></div></article></div></section>
    <section class="metrics-panel"><div class="metric-switches"><label v-for="metric in formationCurveMetrics" :key="metric.key"><span>{{ metric.title }}</span><el-switch :model-value="settings.metrics?.includes(metric.key)" inline-prompt active-text="显" inactive-text="隐" @update:model-value="setMetric(metric.key,!!$event)" /></label></div><div class="cycle-chart-tabs"><button v-for="cycle in matrix.cycleNos" :key="cycle" type="button" :class="{active:activeCycle===cycle}" @click="activeCycle=cycle">循环{{ cycle }}</button></div>
      <div class="chart-legend-bar"><strong>化成图例</strong><div v-for="(item,index) in matrix.items" :key="formationSeriesKey(item)" class="chart-legend-item"><el-color-picker v-if="!readonly" class="legend-color-picker" :model-value="formationSeriesColor(item,index,settings.seriesColors??{})" :show-alpha="false" :aria-label="`修改${item.source.name}-${item.source.batteryNo}颜色`" @change="setColor(item,$event)" /><button type="button" :class="{ 'hidden-legend':settings.hiddenSeriesKeys?.includes(formationSeriesKey(item)) }" @click="toggleSeries(item)">{{ item.source.name }}-{{ item.source.batteryNo }}</button></div></div>
      <template v-if="activeCycle!=null"><div v-for="group in formationStepGroups" :key="group.key" class="chart-zone"><h3>{{ group.title }}工步曲线</h3><div class="charts-grid"><article v-for="metric in visibleMetrics" :key="metric.key" class="chart-card"><header><strong>{{ metric.title }}</strong><span>{{ metric.unit }}</span></header><VChart class="metric-chart" :option="formationStepChartOption(metric,matrix.items,activeCycle,group.key,settings)" autoresize @legendselectchanged="legendChanged" /></article></div></div></template>
      <div v-if="curveLoading" class="curve-loading" v-loading="true" aria-live="polite">正在加载当前循环曲线</div>
      <el-alert v-else-if="curveError" :title="curveError" type="error" :closable="false"><el-button @click="curveRetry++">重试曲线</el-button></el-alert>
      <template v-else-if="activeCycle!=null"><div v-for="group in formationStepGroups" :key="group.key" class="chart-zone"><h3>{{ group.title }}工步曲线</h3><div class="charts-grid"><article v-for="metric in visibleMetrics" :key="metric.key" class="chart-card"><header><strong>{{ metric.title }}</strong><span>{{ metric.unit }}</span></header><VChart class="metric-chart" :option="chartOptions[group.key]?.[metric.key]" autoresize @legendselectchanged="legendChanged" /></article></div></div></template>
    </section>
    </div>
    <section class="note-panel"><label><span>报表说明</span><el-input v-model="description" :disabled="readonly" type="textarea" :rows="3" maxlength="2000" show-word-limit @input="dirty=true" /></label></section>
@@ -211,6 +237,7 @@
</template>
<style scoped>
.curve-loading{min-height:160px;display:flex;align-items:flex-end;justify-content:center;padding:24px;color:#52606d}
.unknown-step-warning{margin-bottom:16px}.unknown-step-warning .el-button{margin-left:12px}
.formation-form-page{width:100%;padding-bottom:78px}.page-tools{height:34px;display:flex;align-items:center;justify-content:space-between}.page-tools>span{color:#7f8d99;font-size:10px;font-weight:600}.back-button{display:inline-flex;align-items:center;gap:6px;border:0;color:#52606d;background:transparent;font-size:13px;cursor:pointer}.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;cursor:pointer;white-space:nowrap}.primary-button{color:#fff;background:var(--blue);border-color:var(--blue)}.secondary-button{color:#52606d;background:#fff;border-color:var(--line-strong)}.report-name-panel,.setup-panel,.cycle-panel,.metrics-panel,.note-panel{background:#fff;border:1px solid #d8e1e9;margin-bottom:16px}.report-name-panel{padding:18px 22px}.report-name-panel label,.note-panel label{display:grid;grid-template-columns:72px minmax(260px,520px);align-items:center;gap:12px}.report-name-panel label>span,.note-panel label>span,.setup-grid label>span{color:#52606d;font-size:12px;font-weight:500}.report-name-panel :deep(.el-input__wrapper),.setup-grid :deep(.el-input__wrapper),.setup-grid :deep(.el-select__wrapper),.note-panel :deep(.el-textarea__inner),.cycle-table :deep(.el-input__wrapper){border-radius:3px;box-shadow:0 0 0 1px #d8e1e9 inset;background:#f8fafc}.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}.panel-heading>strong,.panel-heading>div>strong{color:#26333f;font-size:13px}.panel-heading span{color:#8795a3;font-size:11px;margin-left:10px}.setup-grid{display:grid;grid-template-columns:repeat(2,minmax(240px,1fr));gap:18px;padding:20px 24px 12px}.setup-grid label{display:grid;grid-template-columns:72px minmax(0,1fr);align-items:center;gap:10px}.sample-toolbar{display:flex;align-items:center;justify-content:space-between;padding:4px 24px 10px;color:#52606d;font-size:12px}.sample-list{display:flex;flex-wrap:wrap;gap:10px;padding:0 24px 20px}.sample-chip{display:flex;align-items:center;gap:6px}.sample-chip :deep(.el-select){width:150px}.delete-link{border:0;color:#c45454;background:transparent;font-size:12px;cursor:pointer}.cycle-tabs{display:flex;gap:0;padding:16px 24px 0}.cycle-tabs button{min-width:84px;height:30px;color:#1677dd;background:#fff;border:1px solid #78b2f9;font-size:12px;cursor:pointer}.cycle-tabs button:first-child{border-radius:3px 0 0 3px}.cycle-tabs button:last-child{border-radius:0 3px 3px 0}.cycle-tabs button+button{border-left:0}.cycle-tabs button.active{color:#fff;background:#2188f5}.cycle-table-wrap{overflow-x:auto;padding:14px 24px 22px}.cycle-table{width:100%;min-width:900px;border-collapse:collapse;color:#52606d;font-size:12px}.cycle-table th,.cycle-table td{height:42px;padding:0 10px;border:1px solid #cbd8e4;text-align:center;white-space:nowrap}.cycle-table th{color:#fff;background:#3c6497;font-weight:600}.cycle-table th:nth-child(1){width:90px}.cycle-table th:nth-child(2){width:240px}.cycle-table th:nth-child(3){width:150px}.cycle-table th:nth-child(4),.cycle-table th:nth-child(5){width:130px}.charts-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;padding:18px 24px 24px}.chart-legend-bar{display:flex;align-items:center;gap:20px;padding:12px 24px;border-bottom:1px solid var(--line);color:#52606d;font-size:12px}.legend-item{display:inline-flex;align-items:center;gap:7px}.legend-color{width:14px;height:14px;border-radius:2px}.chart-card{border:1px solid #d8e1e9;background:#fff}.chart-card header{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;color:#26333f;font-size:12px}.chart-card header span{color:#8795a3;font-size:10px}.metric-chart{width:100%;height:230px}.note-panel{padding:18px 24px}.note-panel :deep(.el-textarea){min-width:260px}.form-actions{position:sticky;bottom:0;z-index:4;display:flex;justify-content:flex-end;gap:10px;padding:14px 0;background:rgba(244,248,251,.96);border-top:1px solid #d8e1e9}@media(max-width:900px){.setup-grid,.charts-grid{grid-template-columns:1fr}.report-name-panel label,.note-panel label{grid-template-columns:1fr}.report-name-panel label>span,.note-panel label>span{margin-bottom:6px}.form-actions{position:static}}
.cycle-matrix{width:100%;border-collapse:collapse;color:#52606d;font-size:12px}.cycle-matrix th,.cycle-matrix td{height:40px;padding:0 10px;border:1px solid #cbd8e4;text-align:center;white-space:nowrap}.cycle-matrix th{color:#fff;background:#3c6497;font-weight:600}.cycle-matrix th:first-child{width:190px}.cycle-matrix th:last-child{width:90px}.cycle-matrix td:first-child{color:#31404d;background:#f2f7fb;font-weight:500;text-align:left}.cycle-matrix td:last-child{color:#8795a3;background:#fafcfe}.cycle-matrix :deep(.el-input__wrapper){border-radius:3px;box-shadow:0 0 0 1px #d8e1e9 inset;background:#f8fafc}.method-row td:first-child{border-left:3px solid #2188f5}.raw-value{color:#405261;font-family:'Cascadia Mono',Consolas,monospace}.auto-value{color:#2188f5;font-weight:500}.cycle-summary{display:flex;align-items:center;gap:28px;margin:0 24px 20px;padding:12px 14px;border:1px solid #d8e1e9;background:#f7fafc}.cycle-summary>div{display:flex;flex-direction:column;gap:3px}.cycle-summary span{color:#8795a3;font-size:10px}.cycle-summary strong{color:#33414e;font-size:12px}.cycle-summary>button{margin-left:auto}@media(max-width:900px){.cycle-summary{align-items:flex-start;flex-wrap:wrap}.cycle-summary>button{margin-left:0}}
tests/formation-form-interaction.test.mjs
@@ -13,7 +13,7 @@
let browser, bundle, css
// 挂载真实页面及 Element Plus,仅替换 API 边界;不启动服务、不触碰用户报表。
before(async () => {
  const apiNames = ['getFormationSources','previewFormationReport','getFormationReport','createFormationReport','editFormationReport','refreshFormationSource','listFormationVersions','listFormationTemplates','createFormationTemplate','updateFormationTemplate']
  const apiNames = ['getFormationSources','previewFormationReport','getFormationReport','getFormationCycleCurves','createFormationReport','editFormationReport','refreshFormationSource','listFormationVersions','listFormationTemplates','createFormationTemplate','updateFormationTemplate']
  const virtual = {
    'formation-test-entry': `
      import {createApp} from 'vue'
@@ -24,6 +24,8 @@
      import zhCn from 'element-plus/es/locale/lang/zh-cn'
      import '${root}/src/style.css'
      import Form from '${root}/src/views/FormationReportForm.vue'
      import {getInstanceByDom} from 'echarts/core'
      window.formationChartData=()=>Array.from(document.querySelectorAll('.metric-chart')).map(node=>getInstanceByDom(node)?.getOption().series)
      const router=createRouter({history:createMemoryHistory(),routes:[{path:'/report-formation/:id/edit',component:Form}]})
      router.push('/report-formation/1/edit').then(()=>{
        const app=createApp(Form)
@@ -48,12 +50,13 @@
  return {id:1,name:'化成编辑回归',versionNo:1,description:'',calculatedValues:{},settings:{fields:defaultFormationFields,values:{},metrics:[],chargeStepSelections:{},activeCycle:1},cycleNos,
    items:Array.from({length:4},(_,i)=>({source:{selection:{testId:i<2?1:2,testBatteryId:i+1},name:i<2?'化成甲':'化成乙',batteryGroup:'电池组',batteryNo:`电池${i%2+1}`},cycleNos,metrics:{},businessFields:[{fieldName:'F',fieldValue:'2'}],chargeSteps:Object.fromEntries(cycleNos.map(cycle=>[cycle,Array.from({length:4},(_,n)=>({stepNo:(cycle-1)*4+n+1,name:'未识别',stepType:'unknown',direction:'UNKNOWN',endVoltage:null}))])),curves:{}}))}
}
async function mount(record) {
async function mount(record, cycleReply) {
  const page=await browser.newPage({viewport:{width:1600,height:1000}}), errors=[], calls=[]
  page.setDefaultTimeout(5000)
  page.on('pageerror',error=>errors.push(error.message))
  await page.exposeFunction('reportApi',async(name,args)=>{
    calls.push({name,args})
    if(name==='getFormationCycleCurves')return cycleReply(args)
    if(name==='editFormationReport'){record={...record,...args[1],versionNo:record.versionNo+1};return record}
    if(name==='getFormationReport')return record
    if(name==='listFormationVersions')return []
@@ -66,6 +69,50 @@
  return {page,errors,calls}
}
const detailInput=(page,key,column)=>page.locator('.report-data-table tbody tr').filter({has:page.locator('td:first-child',{hasText:new RegExp(`^${key}$`)})}).locator('input').nth(column)
test('首屏不等待曲线,循环请求固定版本且迟到响应不覆盖当前循环',async()=>{
  const record=fixture(3)
  record.curvesDeferred=true
  record.settings.metrics=['voltage']
  for(const item of record.items){
    delete item.curves
    for(const cycle of record.cycleNos)item.chargeSteps[cycle]=[{stepNo:1,name:'恒流充电',direction:'CHARGE',endVoltage:4.2}]
  }
  let finishFirst
  const first=new Promise(resolve=>{finishFirst=resolve})
  const reply=cycle=>record.items.map(item=>({selection:item.source.selection,curve:{zeroTime:1000,records:[{stepNo:1,deviceTime:1000,sourceType:'SNAPSHOT',voltage:cycle*2}]}}))
  const {page,errors,calls}=await mount(record,args=>args[2]===1?first:reply(args[2]))
  try{
    await page.waitForFunction(()=>document.querySelector('.curve-loading'))
    await detailInput(page,'Z',0).fill('3')
    assert.equal(await detailInput(page,'Z',0).inputValue(),'3')
    await page.locator('.cycle-chart-tabs').getByRole('button',{name:'循环2',exact:true}).click()
    await page.waitForFunction(()=>window.formationChartData()[0]?.[0]?.data[0]?.[1]===4)
    finishFirst(reply(1))
    await page.waitForTimeout(80)
    assert.equal(await page.evaluate(()=>window.formationChartData()[0][0].data[0][1]),4)
    const requests=calls.filter(call=>call.name==='getFormationCycleCurves')
    assert.deepEqual(requests.map(call=>call.args.slice(0,4)),[[1,1,1,true],[1,1,2,true]])
    assert.deepEqual(errors,[])
  }finally{finishFirst(reply(1));await page.close()}
})
test('曲线失败可独立重试,不阻止表格编辑',async()=>{
  const record=fixture();record.curvesDeferred=true;record.settings.metrics=['voltage']
  let attempts=0
  const {page,errors}=await mount(record,()=>{
    if(++attempts===1)throw new Error('曲线请求失败')
    return record.items.map(item=>({selection:item.source.selection,curve:null}))
  })
  try{
    await page.getByRole('button',{name:'重试曲线',exact:true}).waitFor()
    await detailInput(page,'A',0).fill('4')
    await page.getByRole('button',{name:'重试曲线',exact:true}).click()
    await page.locator('.chart-card canvas').first().waitFor()
    assert.equal(attempts,2)
    assert.equal(await detailInput(page,'A',0).inputValue(),'4')
    assert.deepEqual(errors,[])
  }finally{await page.close()}
})
async function screenshot(page,name){
  const directory=process.env.FORMATION_REPRO_ARTIFACT_DIR
  if(directory){await mkdir(directory,{recursive:true});await page.screenshot({path:join(directory,name+'.png'),animations:'disabled'})}