yeg
2026-09-09 1b0179bba53bcc75e8ae358a4a58e8c8fb33c043
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { use } from 'echarts/core'
import { LineChart } from 'echarts/charts'
import { CanvasRenderer } from 'echarts/renderers'
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
import VChart from 'vue-echarts'
import { getUserInfo } from '@/api/auth'
import { getFormationSources, previewFormationReport, getFormationReport, 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'
import { defaultFormationFields, formationSourceValue } from '@/utils/formationReportFields'
import { formationCellKey, formationInputValue, formationCalculatedValue, formationNumberText } from '@/utils/formationReportCalculations'
import { formationCurveMetrics, formationSeriesKey, formationSeriesColor } from '@/utils/formationCycleChart'
import { formationChargeSteps, formationStepChartOption, formationStepGroups } from '@/utils/formationStepChart'
import { openFormationSavedReportPrint } from '@/utils/formationSavedReportPrint'
 
use([CanvasRenderer, LineChart, GridComponent, LegendComponent, TooltipComponent])
const route = useRoute(), router = useRouter()
const editingId = computed(() => Number(route.params.id) || null)
const readonly = computed(() => route.query.mode === 'view')
const reportName = ref(''), description = ref(''), error = ref('')
const loading = ref(false), saving = ref(false), previewLoading = ref(false)
const permissions = ref<string[]>([])
const can = (action: string) => permissions.value.some(value => value === '*:*:*' || value === `system:formation-report:${action}`)
const candidates = ref<FormationCandidate[]>([]), selectedTestIds = ref<number[]>([])
const matrix = ref<FormationMatrix>({ items: [], cycleNos: [] })
const saved = ref<SavedFormationReport | null>(null)
const makeSettings = (): FormationSettings => ({fields:defaultFormationFields.map(field=>({...field})),values:{},chargeStepSelections:{},metrics:formationCurveMetrics.map(metric=>metric.key),seriesColors:{},hiddenSeriesKeys:[],activeCycle:null})
const settings = ref<FormationSettings>(makeSettings())
const dirty = ref(false)
const historyVisible=ref(false),history=ref<FormationVersion[]>([])
const busy = computed(()=>loading.value || saving.value || previewLoading.value)
const testOptions = computed(()=>[...new Map((editingId.value ? matrix.value.items.map(item=>item.source) : candidates.value).map(source=>[source.selection.testId,{id:source.selection.testId,name:source.name}])).values()])
const reportColumns = computed(()=>[...new Set(matrix.value.items.map(item=>item.source.selection.testId))].map(id=>({id,name:matrix.value.items.find(item=>item.source.selection.testId===id)!.source.name,items:matrix.value.items.filter(item=>item.source.selection.testId===id)})))
const detailFields = computed(()=>settings.value.fields.filter(field=>field.scope==='detail'))
const methodFields = computed(()=>settings.value.fields.filter(field=>field.scope==='cycle' && ['method','method2'].includes(field.key)))
const cycleFields = computed(()=>settings.value.fields.filter(field=>field.scope==='cycle' && !['method','method2'].includes(field.key)))
const activeCycle = computed({get:()=>settings.value.activeCycle??matrix.value.cycleNos[0]??null,set:(value:number|null)=>{settings.value.activeCycle=value;if(!readonly.value)dirty.value=true}})
const visibleMetrics = computed(()=>formationCurveMetrics.filter(metric=>settings.value.metrics?.includes(metric.key)))
const unknownSteps = computed(()=>matrix.value.items.flatMap(item=>matrix.value.cycleNos.flatMap(cycle=>(item.chargeSteps?.[cycle]??[]).filter(step=>!step.direction || step.direction==='UNKNOWN').map(step=>`${item.source.name}-${item.source.batteryNo} 循环${cycle} 工步${step.stepNo}`))).join(';'))
let loadSequence = 0, previewSequence = 0
const message = (cause: unknown) => cause instanceof Error ? cause.message : '请求失败,请重试'
 
function loadSaved(record: SavedFormationReport) {
  saved.value=record;reportName.value=record.name;description.value=record.description??''
  matrix.value={items:record.items,cycleNos:record.cycleNos}
  settings.value=structuredClone(record.settings)
  selectedTestIds.value=[...new Set(record.items.map(item=>item.source.selection.testId))]
  dirty.value=false;error.value=''
}
watch(()=>route.fullPath,async()=>{
  const seq=++loadSequence; ++previewSequence
  loading.value=true;previewLoading.value=false;error.value='';saved.value=null;matrix.value={items:[],cycleNos:[]};settings.value=makeSettings();selectedTestIds.value=[]
  reportName.value='';description.value='';dirty.value=false;historyVisible.value=false
  try {
    const info=await getUserInfo();if(seq!==loadSequence)return
    permissions.value=info.permissions??[]
    if(!can(editingId.value ? readonly.value ? 'query':'edit':'add'))throw new Error('无当前化成报表操作权限')
    if(editingId.value){
      const record=await getFormationReport(editingId.value,readonly.value&&route.query.version?Number(route.query.version):undefined,!readonly.value)
      if(seq===loadSequence)loadSaved(record)
    }else{
      const sources=await getFormationSources();if(seq===loadSequence)candidates.value=sources
    }
  }catch(cause){if(seq===loadSequence)error.value=message(cause)}finally{if(seq===loadSequence)loading.value=false}
},{immediate:true})
 
/** 多选异步返回只归属当前选择,旧请求不能把另一测试数据带入现有表格。 */
async function selectTests() {
  const seq=++previewSequence
  const selections=selectedTestIds.value.flatMap(id=>candidates.value.filter(source=>source.selection.testId===id).map(source=>source.selection))
  error.value='';previewLoading.value=true;dirty.value=true
  if(!selections.length){matrix.value={items:[],cycleNos:[]};settings.value=makeSettings();previewLoading.value=false;return}
  try{
    const next=await previewFormationReport(selections);if(seq!==previewSequence)return
    settings.value.values=Object.fromEntries(Object.entries(settings.value.values).filter(([key])=>{
      const parts=key.split('|'),item=next.items.find(item=>formationSeriesKey(item)===parts.slice(0,2).join('|'))
      return item&&(parts[2]==='0'||item.cycleNos.includes(Number(parts[2])))
    }))
    matrix.value=next
    const extras=next.items.flatMap(item=>item.businessFields).filter(field=>!settings.value.fields.some(existing=>existing.key===field.fieldName))
    for(const field of extras)if(!settings.value.fields.some(existing=>existing.key===field.fieldName))settings.value.fields.push({key:field.fieldName,scope:'detail',kind:'battery',displayName:field.fieldName,unit:'',fieldDefinitionId:field.fieldDefinitionId})
    const sourceKeys=new Set(next.items.map(formationSeriesKey))
    settings.value.seriesColors=Object.fromEntries(Object.entries(settings.value.seriesColors??{}).filter(([key])=>sourceKeys.has(key)))
    settings.value.hiddenSeriesKeys=(settings.value.hiddenSeriesKeys??[]).filter(key=>sourceKeys.has(key))
    if(!next.cycleNos.includes(activeCycle.value??-1))settings.value.activeCycle=next.cycleNos[0]??null
  }catch(cause){if(seq===previewSequence){matrix.value={items:[],cycleNos:[]};error.value=message(cause)}}finally{if(seq===previewSequence)previewLoading.value=false}
}
function updateValue(item:FormationItem,cycle:number,field:FormationField,value:string){settings.value.values[formationCellKey(item,cycle,field.key)]=value;dirty.value=true}
function inputValue(item:FormationItem,cycle:number,field:FormationField){return formationInputValue(item,field,settings.value.values,cycle)}
function calculation(item:FormationItem,cycle:number,field:FormationField){return formationCalculatedValue(item,cycle,field.key,settings.value)}
function displayValue(item:FormationItem,cycle:number,field:FormationField){
  if(field.kind!=='calculated')return formationSourceValue(item,cycle,field)||'—'
  const key=formationCellKey(item,cycle,field.key)
  return formationNumberText(readonly.value&&saved.value ? saved.value.calculatedValues?.[key] : calculation(item,cycle,field).value)
}
function chargeCount(cycle:number){return Math.max(0,...matrix.value.items.map(item=>formationChargeSteps(item,cycle).length))}
function voltageTitle(item:FormationItem,cycle:number,index:number){const step=formationChargeSteps(item,cycle)[index];return step?`工步${step.stepNo} ${step.name||''} · 终止电压 (V)`:'无对应充电工步'}
function setMetric(key:typeof formationCurveMetrics[number]['key'],show:boolean){settings.value.metrics=formationCurveMetrics.filter(metric=>metric.key===key?show:settings.value.metrics?.includes(metric.key)).map(metric=>metric.key);if(!readonly.value)dirty.value=true}
function setColor(item:FormationItem,color:string|null){if(color){settings.value.seriesColors={...settings.value.seriesColors,[formationSeriesKey(item)]:color};dirty.value=true}}
function toggleSeries(item:FormationItem){const key=formationSeriesKey(item),hidden=new Set(settings.value.hiddenSeriesKeys??[]);hidden.has(key)?hidden.delete(key):hidden.add(key);settings.value.hiddenSeriesKeys=[...hidden];if(!readonly.value)dirty.value=true}
function legendChanged(event:{selected:Record<string,boolean>}){settings.value.hiddenSeriesKeys=Object.entries(event.selected).filter(([,shown])=>!shown).map(([key])=>key);if(!readonly.value)dirty.value=true}
 
async function saveReport(){
  if(!reportName.value.trim()){ElMessage.warning('请输入报表名称');return}
  if(!matrix.value.items.length || error.value){ElMessage.warning('请先选择测试并完成数据加载');return}
  if(Object.entries(settings.value.values).some(([key,value])=>key.endsWith('|manual')&&value.trim()&&!/^(?:\d+(?:\.\d*)?|\.\d+)$/.test(value.trim()))){ElMessage.warning('数字仅允许非负整数或小数');return}
  let reason=''
  if(saved.value){try{reason=(await ElMessageBox.prompt('请输入本次修改说明','保存修改',{inputValidator:value=>Boolean(value?.trim())||'请输入修改说明'})).value.trim()}catch{return}}
  saving.value=true
  try{
    // Vue 的响应式代理先序列化成普通提交对象;只提交字段值,测量和公式由后端冻结。
    const payload={name:reportName.value.trim(),description:description.value,settings:JSON.parse(JSON.stringify(settings.value)) as FormationSettings}
    const record=saved.value?await editFormationReport(saved.value.id,{...payload,baseVersionNo:saved.value.versionNo,changeSummary:reason}):await createFormationReport({...payload,selections:matrix.value.items.map(item=>item.source.selection)})
    loadSaved(record);ElMessage.success('化成报表已保存')
    await router.replace({path:`/report-formation/${record.id}/edit`,query:{mode:'view'}})
  }catch(cause){ElMessage.error(message(cause))}finally{saving.value=false}
}
async function refreshSource(){
  if(!saved.value)return
  if(dirty.value){ElMessage.warning('请先保存当前修改,再更新来源');return}
  let reason='';try{reason=(await ElMessageBox.prompt('重新读取来源数据并生成新版本,保留报表人工字段值。请输入说明。','按最新来源生成',{inputValidator:value=>Boolean(value?.trim())||'请输入说明'})).value.trim()}catch{return}
  saving.value=true
  try{loadSaved(await refreshFormationSource(saved.value.id,saved.value.versionNo,reason));ElMessage.success('已生成最新来源版本')}catch(cause){ElMessage.error(message(cause))}finally{saving.value=false}
}
async function exportPdf(){
  if(!saved.value||dirty.value){ElMessage.warning('请先保存报表后再导出');return}
  try{await openFormationSavedReportPrint(saved.value.id,saved.value.versionNo)}catch(cause){ElMessage.error(message(cause))}
}
async function openHistory(){if(!saved.value)return;try{history.value=await listFormationVersions(saved.value.id);historyVisible.value=true}catch(cause){ElMessage.error(message(cause))}}
function viewVersion(version:number){historyVisible.value=false;router.push({path:`/report-formation/${editingId.value}/edit`,query:{mode:'view',version}})}
 
const templateDialogVisible=ref(false),templateMode=ref<'battery'|'special'>('battery'),templates=ref<FormationTemplate[]>([]),templateId=ref<number|null>(null),templateLoading=ref(false)
const templateFieldKeys=ref<string[]>([])
const availableFields=computed(()=>[...new Map([...settings.value.fields,...matrix.value.items.flatMap(item=>item.businessFields.map(field=>({key:field.fieldName,scope:'detail' as const,kind:'battery' as const,displayName:field.fieldName,unit:'',fieldDefinitionId:field.fieldDefinitionId})))].filter(field=>field.scope==='detail'&&field.kind=== (templateMode.value==='battery'?'battery':'manual')).map(field=>[field.key,field])).values()])
async function addReportField(){
  try{const name=(await ElMessageBox.prompt('请输入字段名称','添加项',{inputValidator:value=>Boolean(value?.trim())||'请输入字段名称'})).value.trim()
    if(name.includes('|')||name.length>100||settings.value.fields.some(field=>field.key===name)){ElMessage.warning('字段名称重复或无效');return}
    settings.value.fields.push({key:name,scope:'detail',kind:'manual',displayName:name,unit:''});dirty.value=true
  }catch{/* 用户取消 */}
}
async function openFieldTemplate(mode:'battery'|'special'){
  templateMode.value=mode;templateId.value=null;templateFieldKeys.value=settings.value.fields.filter(field=>field.scope==='detail'&&field.kind===(mode==='battery'?'battery':'manual')).map(field=>field.key)
  templateDialogVisible.value=true;templateLoading.value=true
  try{templates.value=await listFormationTemplates()}catch(cause){ElMessage.error(message(cause))}finally{templateLoading.value=false}
}
function selectTemplate(id:number|null){const template=templates.value.find(value=>value.id===id);if(template)templateFieldKeys.value=template.fields.filter(field=>field.scope==='detail'&&field.kind===(templateMode.value==='battery'?'battery':'manual')).map(field=>field.key)}
function applyFieldTemplate(){
  const kind=templateMode.value==='battery'?'battery':'manual',required=new Set(defaultFormationFields.filter(field=>field.scope==='detail').map(field=>field.key))
  const options=[...availableFields.value,...(templates.value.find(value=>value.id===templateId.value)?.fields??[])].filter(field=>field.scope==='detail'&&field.kind===kind)
  settings.value.fields=settings.value.fields.filter(field=>field.scope!=='detail'||field.kind!==kind||required.has(field.key))
  for(const key of templateFieldKeys.value){const field=options.find(field=>field.key===key);if(field&&!settings.value.fields.some(value=>value.key===key))settings.value.fields.push({...field})}
  const allowed=new Set(settings.value.fields.map(field=>field.key));settings.value.values=Object.fromEntries(Object.entries(settings.value.values).filter(([key])=>allowed.has(key.split('|').at(-1)!)))
  dirty.value=true;templateDialogVisible.value=false
}
async function saveFieldTemplate(asNew=false){
  try{const selected=templates.value.find(value=>value.id===templateId.value)
    const name=(await ElMessageBox.prompt('模板只保存字段选择,不包含测试和填写值。','保存化成字段模板',{inputValue:asNew?'':selected?.name??'',inputValidator:value=>Boolean(value?.trim())||'请输入名称'})).value.trim()
    applyFieldTemplate();const fields=settings.value.fields.filter(field=>field.scope==='detail'&&field.kind===(templateMode.value==='battery'?'battery':'manual'))
    if(!fields.length){ElMessage.warning('请先选择或添加字段');return}
    const existing=templates.value.find(value=>value.name===name)
    if(existing){if(!can('edit'))throw new Error('无编辑模板权限');await updateFormationTemplate(existing.id,name,fields)}else{if(!can('add'))throw new Error('无新增模板权限');await createFormationTemplate(name,fields)}
    ElMessage.success('模板已保存')
  }catch(cause){if(cause!=='cancel'&&cause!=='close')ElMessage.error(message(cause))}
}
</script>
 
<template>
  <div class="formation-form-page" :class="{ 'is-readonly': readonly }" v-loading="loading">
    <div class="page-tools"><button class="back-button" type="button" @click="router.push('/report-formation')"><el-icon><ArrowLeft /></el-icon>返回化成报表</button><span>{{ readonly ? 'VIEW FORMATION REPORT' : editingId ? 'EDIT FORMATION REPORT' : 'NEW FORMATION REPORT' }}</span></div>
    <el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
    <fieldset :disabled="busy || !!error" class="report-fieldset">
    <section class="report-name-panel"><label><span>报表名称</span><el-input v-model="reportName" :disabled="readonly" clearable maxlength="100" placeholder="请输入报表名称" @input="dirty=true" /></label><label><span>选择测试</span><el-select v-model="selectedTestIds" multiple filterable collapse-tags collapse-tags-tooltip :disabled="!!editingId || busy" placeholder="选择已完成的化成测试" @change="selectTests"><el-option v-for="test in testOptions" :key="test.id" :label="test.name" :value="test.id" /></el-select></label></section>
    <el-empty v-if="!matrix.items.length && !previewLoading" :description="editingId ? '该版本暂无来源数据' : '请选择化成测试'" />
    <div v-loading="previewLoading" v-if="matrix.items.length">
    <section class="report-data-panel"><header class="panel-heading"><div><strong>报表数据</strong><span>电池组字段</span></div></header>
      <div class="report-data-table-wrap"><table class="report-data-table" :style="{minWidth:`${Math.max(980,matrix.items.length*150+160)}px`}"><thead><tr><th>测试 / 电池组</th><th v-for="column in reportColumns" :key="column.id" :colspan="column.items.length">{{ column.name }} / {{ column.items[0]?.source.batteryGroup }}</th></tr><tr><th>电池号</th><th v-for="item in matrix.items" :key="formationSeriesKey(item)">{{ item.source.batteryNo }}</th></tr></thead><tbody>
        <tr v-for="field in detailFields" :key="field.key"><td>{{ field.displayName }}</td><td v-for="item in matrix.items" :key="formationSeriesKey(item)"><span v-if="field.kind==='calculated'" class="auto-value" :title="calculation(item,0,field).reason">{{ displayValue(item,0,field) }}</span><el-input v-else :model-value="inputValue(item,0,field)" :disabled="readonly" size="small" maxlength="500" placeholder="请输入" @update:model-value="updateValue(item,0,field,$event)" /></td></tr>
      </tbody></table></div>
      <div v-if="!readonly" class="report-data-actions"><button class="primary-button" type="button" @click="addReportField"><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="openFieldTemplate('special')">另存特殊字段模板</button></div>
    </section>
    <el-alert v-if="unknownSteps" :title="`以下工步方向未识别,未纳入充放电统计:${unknownSteps}`" type="warning" :closable="false" />
    <section class="cycle-panel"><header class="panel-heading"><div><strong>化成循环明细</strong><span>拐点两项暂为演示占位</span></div></header><el-empty v-if="!matrix.cycleNos.length" description="所选测试暂无已保存循环数据" /><div class="cycle-list"><article v-for="cycle in matrix.cycleNos" :key="cycle" class="cycle-block"><div class="cycle-block-heading"><strong>循环{{ cycle }}</strong></div><div class="cycle-table-wrap"><table class="cycle-matrix" :style="{minWidth:`${Math.max(920,matrix.items.length*170+190)}px`}"><thead><tr><th>指标字段</th><th v-for="item in matrix.items" :key="formationSeriesKey(item)">{{ item.source.name }}<br>{{ item.source.batteryNo }}</th></tr></thead><tbody>
      <tr v-for="field in methodFields" :key="field.key" class="method-row"><td>{{ field.displayName }}</td><td v-for="item in matrix.items" :key="formationSeriesKey(item)"><span v-if="!item.cycleNos.includes(cycle)">无该循环</span><el-input v-else :model-value="inputValue(item,cycle,field)" :disabled="readonly" size="small" maxlength="500" placeholder="请输入" @update:model-value="updateValue(item,cycle,field,$event)" /></td></tr>
      <tr v-for="index in chargeCount(cycle)" :key="`voltage-${index}`"><td>终止电压</td><td v-for="item in matrix.items" :key="formationSeriesKey(item)" :title="voltageTitle(item,cycle,index-1)"><span class="raw-value">{{ !item.cycleNos.includes(cycle)?'无该循环':formationNumberText(formationChargeSteps(item,cycle)[index-1]?.endVoltage) }}</span></td></tr>
      <tr v-for="field in cycleFields" :key="field.key"><td>{{ field.displayName }}{{ field.unit?` (${field.unit})`:'' }}</td><td v-for="item in matrix.items" :key="formationSeriesKey(item)"><span v-if="!item.cycleNos.includes(cycle)">无该循环</span><el-input v-else-if="field.kind==='manual'" :model-value="inputValue(item,cycle,field)" :type="field.key==='manual'?'number':'text'" :min="field.key==='manual'?0:undefined" step="any" :disabled="readonly" size="small" maxlength="500" placeholder="请输入" @update:model-value="updateValue(item,cycle,field,$event)" /><span v-else :class="field.kind==='calculated'?'auto-value':'raw-value'" :title="calculation(item,cycle,field).reason">{{ displayValue(item,cycle,field) }}</span></td></tr>
    </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>
    </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>
    </fieldset>
    <footer class="form-actions"><span v-if="saved">V{{ saved.versionNo }}</span><button v-if="saved&&can('query')" class="secondary-button" type="button" :disabled="busy" @click="openHistory">历史版本</button><button v-if="saved&&!readonly&&can('edit')" class="secondary-button" type="button" :disabled="busy||!!error" @click="refreshSource">按最新来源生成</button><button v-if="can('export')" class="secondary-button" type="button" :disabled="busy||!!error" @click="exportPdf"><el-icon><Download /></el-icon>导出PDF</button><button class="secondary-button" type="button" @click="router.push('/report-formation')">{{ readonly?'返回列表':'取消' }}</button><button v-if="!readonly&&can(editingId?'edit':'add')" class="primary-button" type="button" :disabled="busy||!!error||!matrix.items.length" @click="saveReport"><el-icon><Check /></el-icon>{{ saving?'保存中':editingId?'保存修改':'保存报表' }}</button></footer>
    <el-dialog v-model="templateDialogVisible" class="formation-template-dialog" :title="templateMode==='battery'?'选择电池组字段模板':'选择特殊字段模板'" width="720px" align-center><div v-loading="templateLoading" class="battery-template-editor"><label class="battery-template-select"><span>选择模板</span><el-select v-model="templateId" clearable placeholder="自定义" @change="selectTemplate"><el-option v-for="template in templates" :key="template.id" :label="template.name" :value="template.id" /></el-select></label><el-checkbox-group v-model="templateFieldKeys" class="battery-template-fields"><el-checkbox v-for="field in availableFields" :key="field.key" :value="field.key" :disabled="['Z','H','F','A','B'].includes(field.key)">{{ field.displayName }}</el-checkbox></el-checkbox-group></div><template #footer><div class="battery-template-actions"><button class="primary-button" type="button" :disabled="templateLoading" @click="applyFieldTemplate">确认</button><button class="primary-button" type="button" :disabled="templateLoading" @click="saveFieldTemplate()">保存</button><button class="secondary-button" type="button" :disabled="templateLoading" @click="saveFieldTemplate(true)">另存模板</button></div></template></el-dialog>
    <el-dialog v-model="historyVisible" title="历史版本" width="min(800px,calc(100vw - 28px))"><el-table :data="history"><el-table-column prop="versionNo" label="版本" width="80"/><el-table-column prop="operatorName" label="操作人"/><el-table-column label="时间"><template #default="{row}">{{ new Date(row.createdAt).toLocaleString() }}</template></el-table-column><el-table-column prop="changeSummary" label="修改说明"/><el-table-column label="操作" width="100"><template #default="{row}"><el-button link type="primary" @click="viewVersion(row.versionNo)">查看版本</el-button></template></el-table-column></el-table></el-dialog>
  </div>
</template>
 
<style scoped>
.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}}
.report-data-panel{background:#fff;border:1px solid #d8e1e9;margin-bottom:16px}.report-data-meta{display:flex;align-items:center;gap:12px}.report-data-meta label{display:flex;align-items:center;gap:6px;color:#52606d;font-size:11px;font-weight:500}.report-data-meta :deep(.el-select){width:190px}.report-data-meta :deep(.el-input){width:150px}.report-data-meta :deep(.el-input__wrapper),.report-data-meta :deep(.el-select__wrapper){min-height:30px;border-radius:3px;box-shadow:0 0 0 1px #d8e1e9 inset;background:#f8fafc}.report-data-table-wrap{overflow-x:auto;padding:18px 24px 0}.report-data-table{width:100%;border-collapse:collapse;color:#52606d;font-size:12px}.report-data-table th,.report-data-table td{height:38px;padding:0 10px;border:1px solid #cbd8e4;text-align:center;white-space:nowrap}.report-data-table th{color:#fff;background:#3c6497;font-weight:600}.report-data-table th:first-child,.report-data-table td:first-child{width:150px}.report-data-table td:first-child{color:#31404d;background:#f7fafc;font-weight:500}.report-data-table td:last-child,.report-data-table th:last-child{width:90px;color:#8795a3;background:#fafcfe}.report-data-table :deep(.el-select){width:100px}.report-data-table :deep(.el-select__wrapper),.report-data-table :deep(.el-input__wrapper){min-height:28px;border-radius:3px;box-shadow:0 0 0 1px #d8e1e9 inset;background:#fff}.group-index{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;margin-right:8px;border:1px solid #cbd8e4;border-radius:50%;color:#647380;background:#fff;font-size:10px}.report-data-actions{display:flex;align-items:center;gap:10px;padding:18px 24px 20px}.report-data-actions .primary-button{height:30px;padding:0 12px;font-size:12px}@media(max-width:900px){.report-data-meta{align-items:flex-start;flex-direction:column;gap:6px}.report-data-actions{flex-wrap:wrap}}
.fixed-meta-value,.fixed-sample{color:#405261;font-weight:500}.fixed-meta-value{min-width:110px;padding:6px 10px;border:1px solid #d8e1e9;background:#f8fafc}.fixed-sample{display:inline-block;min-width:72px;padding:5px 8px;border:1px solid #d8e1e9;border-radius:3px;background:#f8fafc}
.formation-form-page :deep(.formation-template-dialog){max-width:calc(100vw - 28px);border-radius:4px}.formation-form-page :deep(.formation-template-dialog .el-dialog__header){margin:0;padding:17px 20px;border-bottom:1px solid var(--line)}.formation-form-page :deep(.formation-template-dialog .el-dialog__body){padding:20px}.formation-form-page :deep(.formation-template-dialog .el-dialog__footer){padding:14px 20px;border-top:1px solid var(--line)}.battery-template-select{display:flex;align-items:center;gap:12px;margin-bottom:18px;color:#52606d;font-size:12px}.battery-template-select :deep(.el-select){width:260px}.battery-template-fields{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px 18px;max-height:260px;overflow:auto;padding:14px;border:1px solid #d8e1e9;background:#f8fafc}.template-options{display:flex;flex-direction:column;gap:10px}.template-options :deep(.el-radio){height:auto;min-height:42px;margin-right:0;white-space:normal}.battery-template-actions{display:flex;justify-content:flex-end;gap:8px}.template-empty{padding:30px 0;color:#8795a3;text-align:center}@media(max-width:680px){.battery-template-fields{grid-template-columns:repeat(2,minmax(0,1fr))}.battery-template-actions{flex-wrap:wrap}}
.report-data-table th:last-child{width:auto;color:#fff;background:#3c6497}.report-data-table td:last-child{width:auto;color:#52606d;background:#fff}
.cycle-list{display:flex;flex-direction:column}.cycle-block{padding-top:18px;border-bottom:8px solid #eef3f7}.cycle-block:last-child{border-bottom:0}.cycle-block-heading{display:flex;align-items:center;gap:12px;margin:0 24px;padding:9px 12px;border-left:3px solid #2188f5;background:#f2f7fb}.cycle-block-heading strong{color:#26333f;font-size:13px}.cycle-block-heading span{color:#8795a3;font-size:11px}.readonly-cycle-value{color:#405261;font-size:12px}.cycle-block .cycle-table-wrap{padding-top:12px}.cycle-block .cycle-summary{margin-bottom:18px}
.cycle-matrix th:last-child{width:auto;color:#fff;background:#3c6497}.cycle-matrix td:last-child{width:auto;color:#52606d;background:#fff}
.metric-switches{display:flex;flex-wrap:wrap;align-items:center;gap:20px 28px;min-height:58px;padding:0 24px;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}.cycle-chart-tabs{display:flex;gap:0;padding:16px 24px 0}.cycle-chart-tabs button{min-width:84px;height:30px;color:#1677dd;background:#fff;border:1px solid #78b2f9;font-size:12px;cursor:pointer}.cycle-chart-tabs button:first-child{border-radius:3px 0 0 3px}.cycle-chart-tabs button:last-child{border-radius:0 3px 3px 0}.cycle-chart-tabs button+button{border-left:0}.cycle-chart-tabs button.active{color:#fff;background:#2188f5}.metrics-panel>.chart-legend-bar{min-height:52px;display:flex;flex-wrap:wrap;align-items:center;gap:12px 24px;padding:0 24px;border-bottom:1px solid var(--line)}.metrics-panel>.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:220px;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(.legend-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(.legend-color-popper .el-color-picker-panel){border:0!important;box-shadow:none!important}.metrics-panel .charts-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;padding:18px 24px 24px}.metrics-panel .chart-card{min-width:0;border:1px solid #edf1f5;background:#fff}.metrics-panel .chart-card header{height:38px;display:flex;align-items:center;gap:18px;padding:0 14px;color:#52606d;font-size:11px}.metrics-panel .chart-card header strong{margin-right:auto;color:#26333f;font-size:13px}.metrics-panel .metric-chart{height:210px}
@media(max-width:900px){.metrics-panel .charts-grid{grid-template-columns:minmax(0,1fr)}}@media(max-width:680px){.metric-switches{gap:14px;padding:0 12px}.metrics-panel>.chart-legend-bar{align-items:flex-start;flex-direction:column;gap:10px;padding:12px 12px}.cycle-chart-tabs{padding-left:12px}}
.formation-form-page.is-readonly :deep(.el-input), .formation-form-page.is-readonly :deep(.el-textarea) { pointer-events: none; }
.formation-form-page.is-readonly :deep(.el-input__wrapper), .formation-form-page.is-readonly :deep(.el-textarea__inner) { background: #f3f6f8; box-shadow: 0 0 0 1px #d8e1e9 inset; }
.report-fieldset{border:0;margin:0;padding:0;min-width:0}.report-name-panel{display:flex;flex-wrap:wrap;gap:24px}.report-name-panel label{flex:1;min-width:280px}.report-name-panel .el-select{width:100%}.chart-zone h3{padding:16px 24px 0;margin:0;color:#26333f;font-size:14px}.chart-legend-item button{border:0;background:transparent;color:#52606d;cursor:pointer}.hidden-legend{text-decoration:line-through;opacity:.5}.cycle-chart-tabs{flex-wrap:wrap;row-gap:8px}.form-actions{flex-wrap:wrap}.auto-value{font-variant-numeric:tabular-nums}@media(max-width:680px){.report-name-panel label{min-width:0;flex-basis:100%}.form-actions{position:static}.formation-form-page{padding-bottom:0}}
</style>