yeg
2026-09-09 4641046ffe58de84d046cb81ad2a90ebb08438eb
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}}