| | |
| | | import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components' |
| | | import VChart from 'vue-echarts' |
| | | import { getUserInfo } from '@/api/auth' |
| | | import { getFormationSources, previewFormationReport, formationValueKey, type FormationCandidate, type FormationItem, type FormationField } from '@/api/formationReports' |
| | | import { defaultFormationFields, formationFieldLabel, formationSourceValue, formationChargeChoice } from '@/utils/formationReportFields' |
| | | import { formationChartOption, formationCurveMetrics, formationSeriesKey, formationSeriesName, formationSeriesColor } from '@/utils/formationCycleChart' |
| | | import type { ReportMetricKey } from '@/utils/reportMeasurementChart' |
| | | 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' |
| | | import FormationFieldControls from '@/components/FormationFieldControls.vue' |
| | | import ReportTestDetail from '@/components/ReportTestDetail.vue' |
| | | import { reportDetailColumn, snapshotReportDetails } from '@/utils/reportTestDetails' |
| | | import FormationStepCurves from '@/components/FormationStepCurves.vue' |
| | | import { formationChargeSteps, formationCycleKey } from '@/utils/formationStepChart' |
| | | import type { FormationSettings, FormationStepSelection } from '@/api/formationReports' |
| | | import { createFormationReport, getFormationReport, editFormationReport, refreshFormationSource, listFormationVersions, |
| | | type SavedFormationReport, type FormationVersion } from '@/api/formationReports' |
| | | |
| | | use([CanvasRenderer, LineChart, GridComponent, LegendComponent, TooltipComponent]) |
| | | const route = useRoute() |
| | | const router = useRouter() |
| | | const reportName = ref('') |
| | | const description = ref('') |
| | | const loadError = ref('') |
| | | const previewError = ref('') |
| | | const loading = ref(false) |
| | | const previewLoading = ref(false) |
| | | const canCreate = ref(false) |
| | | const canEdit = ref(false) |
| | | const canQuery = ref(false) |
| | | const canExport = ref(false) |
| | | const printing = ref(false) |
| | | const route = useRoute(), router = useRouter() |
| | | const editingId = computed(() => Number(route.params.id) || null) |
| | | const readonly = computed(() => Boolean(editingId.value) && (route.query.mode !== 'edit' || !canEdit.value || Boolean(route.query.version))) |
| | | 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 savedContent = ref('') |
| | | const saving = ref(false) |
| | | const changeSummary = ref('') |
| | | const historyVisible = ref(false) |
| | | const history = ref<FormationVersion[]>([]) |
| | | const historyLoading = ref(false) |
| | | const historyError = ref('') |
| | | const candidates = ref<FormationCandidate[]>([]) |
| | | const selectedKeys = ref<string[]>([]) |
| | | const items = ref<FormationItem[]>([]) |
| | | const cycleNos = ref<number[]>([]) |
| | | const fields = ref<FormationField[]>(structuredClone(defaultFormationFields.filter(field => field.scope === 'cycle'))) |
| | | const values = ref<Record<string, string>>({}) |
| | | const sourceDetailOverrides = ref<NonNullable<FormationSettings['sourceDetailOverrides']>>({}) |
| | | const chargeStepSelections = ref<Record<string, number>>({}) |
| | | const metrics = ref<ReportMetricKey[]>(formationCurveMetrics.map(metric => metric.key)) |
| | | const seriesColors = ref<Record<string, string>>({}) |
| | | const activeCycle = ref<number | null>(null) |
| | | const manualChargeSteps = ref<Record<string, number[]>>({}) |
| | | const curveStepSelections = ref<Record<string, FormationStepSelection>>({}) |
| | | const hiddenSeriesKeys = ref<string[]>([]) |
| | | const actualSteps = computed(() => !saved.value || saved.value.curveMode === 'actual-steps') |
| | | const stepSettings = computed<FormationSettings>(() => ({ fields: fields.value, values: values.value, sourceDetailOverrides: snapshotReportDetails(reportDetailColumns.value, sourceDetailOverrides.value), chargeStepSelections: chargeStepSelections.value, metrics: metrics.value, seriesColors: seriesColors.value, manualChargeSteps: manualChargeSteps.value, curveStepSelections: curveStepSelections.value, hiddenSeriesKeys: hiddenSeriesKeys.value, activeCycle: activeCycle.value })) |
| | | function changeStepSettings(settings: Partial<FormationSettings>) { |
| | | if (settings.curveStepSelections) curveStepSelections.value = settings.curveStepSelections |
| | | if (settings.hiddenSeriesKeys) hiddenSeriesKeys.value = settings.hiddenSeriesKeys |
| | | if (settings.seriesColors) seriesColors.value = settings.seriesColors |
| | | 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='' |
| | | } |
| | | function unknownSteps(item: FormationItem, cycle: number) { return (item.chargeSteps?.[cycle] ?? []).filter(step => step.direction === 'UNKNOWN') } |
| | | function terminationRows(cycle: number) { return Math.max(1, ...items.value.map(item => formationChargeSteps(item, cycle, manualChargeSteps.value).length)) } |
| | | function termination(item: FormationItem, cycle: number, index: number) { |
| | | if (!item.cycleNos.includes(cycle)) return '无该循环' |
| | | const step = formationChargeSteps(item, cycle, manualChargeSteps.value)[index] |
| | | if (!step) return index ? '—' : (item.chargeSteps?.[cycle]?.length ? '未识别充电工步' : '缺工步') |
| | | return `工步${step.stepNo} · ${step.name || '未命名工步'} · ${step.endVoltage ?? '缺失'}${step.endVoltage == null ? '' : ' V'}` |
| | | } |
| | | const visibleMetrics = computed(() => formationCurveMetrics.filter(metric => metrics.value.includes(metric.key))) |
| | | const hasCurves = computed(() => items.value.some(item => item.curves?.[activeCycle.value ?? -1]?.records.length)) |
| | | const cycleFields = computed(() => fields.value.filter(field => field.scope === 'cycle')) |
| | | const reportColumns = computed(() => [...new Set(items.value.map(item => item.source.selection.testId))].map(id => ({ |
| | | id, name: items.value.find(item => item.source.selection.testId === id)!.source.name, |
| | | items: items.value.filter(item => item.source.selection.testId === id), |
| | | }))) |
| | | const reportItems = computed(() => reportColumns.value.flatMap(column => column.items)) |
| | | const reportDetailColumns = computed(() => reportItems.value.map(item => reportDetailColumn(item.source))) |
| | | const tableStyle = computed(() => ({ minWidth: `${Math.max(720, reportItems.value.length * 160 + 180)}px` })) |
| | | function selectionKey(candidate: FormationCandidate) { return `${candidate.selection.testId}|${candidate.selection.testBatteryId}` } |
| | | function inputValue(item: FormationItem, cycle: number, field: FormationField) { |
| | | return values.value[formationValueKey(item, cycle, field)] ?? formationSourceValue(item, cycle, field) |
| | | } |
| | | function setValue(item: FormationItem, cycle: number, field: FormationField, value: string) { |
| | | values.value[formationValueKey(item, cycle, field)] = value |
| | | } |
| | | function chargeChoices(item: FormationItem, cycle: number) { return item.chargeSteps?.[String(cycle)] || [] } |
| | | function chargeChoice(item: FormationItem, cycle: number) { |
| | | return formationChargeChoice(item, cycle, chargeStepSelections.value) |
| | | } |
| | | function chargeKey(item: FormationItem, cycle: number) { return `${item.source.selection.testId}|${item.source.selection.testBatteryId}|${cycle}` } |
| | | function updateSeriesColor(item: FormationItem, color: string | null) { if (color) seriesColors.value[formationSeriesKey(item)] = color } |
| | | function formContent() { return JSON.stringify({ name: reportName.value, description: description.value, settings: actualSteps.value ? stepSettings.value : { fields: fields.value, values: values.value, sourceDetailOverrides: snapshotReportDetails(reportDetailColumns.value, sourceDetailOverrides.value), chargeStepSelections: chargeStepSelections.value, metrics: metrics.value, seriesColors: seriesColors.value } }) } |
| | | function restore(record: SavedFormationReport) { |
| | | saved.value = record; reportName.value = record.name; description.value = record.description || '' |
| | | items.value = record.items; cycleNos.value = record.cycleNos |
| | | fields.value = record.settings.fields.filter(field => field.scope === 'cycle'); values.value = record.settings.values; chargeStepSelections.value = record.settings.chargeStepSelections || {} |
| | | sourceDetailOverrides.value = record.settings.sourceDetailOverrides ?? {} |
| | | metrics.value = record.settings.metrics ?? formationCurveMetrics.map(metric => metric.key) |
| | | seriesColors.value = record.settings.seriesColors ?? {} |
| | | manualChargeSteps.value = record.settings.manualChargeSteps ?? {}; curveStepSelections.value = record.settings.curveStepSelections ?? {} |
| | | hiddenSeriesKeys.value = record.settings.hiddenSeriesKeys ?? []; activeCycle.value = record.settings.activeCycle ?? record.cycleNos[0] ?? null |
| | | changeSummary.value = ''; savedContent.value = formContent() |
| | | } |
| | | function pruneValues() { |
| | | const allowed = new Set(items.value.flatMap(item => fields.value.flatMap(field => |
| | | (field.scope === 'detail' ? [0] : item.cycleNos).map(cycle => formationValueKey(item, cycle, field))))) |
| | | values.value = Object.fromEntries(Object.entries(values.value).filter(([key]) => allowed.has(key))) |
| | | const sourceKeys = new Set(items.value.flatMap(item => [formationSeriesKey(item), ...Object.entries(item.chargeSteps ?? {}).flatMap(([cycle, steps]) => steps.map(step => `${formationSeriesKey(item)}|${cycle}|${step.stepNo}`))])) |
| | | sourceDetailOverrides.value = Object.fromEntries(Object.entries(sourceDetailOverrides.value).filter(([key]) => sourceKeys.has(key))) |
| | | hiddenSeriesKeys.value = hiddenSeriesKeys.value.filter(key => sourceKeys.has(key)) |
| | | const cycleKeys = new Set(items.value.flatMap(item => item.cycleNos.map(cycle => formationCycleKey(item, cycle)))) |
| | | manualChargeSteps.value = Object.fromEntries(Object.entries(manualChargeSteps.value).filter(([key]) => cycleKeys.has(key))) |
| | | curveStepSelections.value = Object.fromEntries(Object.entries(curveStepSelections.value).filter(([key]) => cycleKeys.has(key))) |
| | | seriesColors.value = Object.fromEntries(Object.entries(seriesColors.value).filter(([key]) => sourceKeys.has(key))) |
| | | } |
| | | function applyFields(next: FormationField[]) { |
| | | // 模板仅改变草稿结构;同名换共享身份时不能继承原字段的人工调整。 |
| | | const unchanged = new Set(next.filter(field => fields.value.some(previous => previous.key === field.key && previous.scope === field.scope |
| | | && previous.kind === field.kind && (previous.fieldDefinitionId ?? null) === (field.fieldDefinitionId ?? null))).map(field => `${field.scope}|${field.key}`)) |
| | | values.value = Object.fromEntries(Object.entries(values.value).filter(([key]) => { |
| | | const parts = key.split('|') |
| | | return unchanged.has(`${parts[2] === '0' ? 'detail' : 'cycle'}|${parts[3]}`) |
| | | })) |
| | | fields.value = next |
| | | pruneValues() |
| | | } |
| | | async function saveReport() { |
| | | if (!reportName.value.trim()) { ElMessage.warning('请输入报表名称'); return } |
| | | if (!items.value.length || previewLoading.value || previewError.value) { ElMessage.warning('请先选择有效化成来源'); return } |
| | | if (editingId.value && !changeSummary.value.trim()) { ElMessage.warning('请输入修改说明'); return } |
| | | saving.value = true |
| | | 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 { |
| | | pruneValues() |
| | | const content = { name: reportName.value.trim(), description: description.value, settings: actualSteps.value ? stepSettings.value : { fields: fields.value, values: values.value, sourceDetailOverrides: snapshotReportDetails(reportDetailColumns.value, sourceDetailOverrides.value), chargeStepSelections: chargeStepSelections.value, metrics: metrics.value, seriesColors: seriesColors.value } } |
| | | const result = editingId.value && saved.value |
| | | ? await editFormationReport(editingId.value, { ...content, baseVersionNo: saved.value.versionNo, changeSummary: changeSummary.value.trim() }) |
| | | : await createFormationReport({ ...content, selections: items.value.map(item => item.source.selection) }) |
| | | restore(result) |
| | | ElMessage.success('化成报表已保存') |
| | | if (canQuery.value) await router.replace({ path: `/report-formation/${result.id}/edit`, query: { mode: 'view' } }) |
| | | else if (canEdit.value) await router.replace({ path: `/report-formation/${result.id}/edit`, query: { mode: 'edit' } }) |
| | | else { selectedKeys.value = []; values.value = {}; reportName.value = ''; description.value = ''; saved.value = null } |
| | | } catch (error) { ElMessage.error(error instanceof Error ? error.message : '化成报表保存失败') } |
| | | finally { saving.value = false } |
| | | } |
| | | async function refreshSource() { |
| | | if (!saved.value || !editingId.value) return |
| | | if (formContent() !== savedContent.value) { ElMessage.warning('请先保存当前修改'); return } |
| | | try { |
| | | const { value } = await ElMessageBox.prompt('按最新来源生成新版本,保留仍属于当前循环的人工调整;已消失循环的人工值仅保留在历史版。', '按最新来源生成新版本', { |
| | | inputPlaceholder: '请输入修改说明', inputValidator: value => Boolean(value?.trim()) || '请输入修改说明', confirmButtonText: '生成', cancelButtonText: '取消', |
| | | }) |
| | | saving.value = true |
| | | const result = await refreshFormationSource(editingId.value, saved.value.versionNo, value.trim()) |
| | | restore(result); ElMessage.success('已按最新来源生成新版本') |
| | | } catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '更新来源失败') } |
| | | finally { saving.value = false } |
| | | } |
| | | async function openHistory() { |
| | | if (!editingId.value) return |
| | | historyVisible.value = true; historyLoading.value = true; historyError.value = '' |
| | | try { history.value = await listFormationVersions(editingId.value) } |
| | | catch (error) { historyError.value = error instanceof Error ? error.message : '历史版本加载失败' } |
| | | finally { historyLoading.value = false } |
| | | } |
| | | async function openVersion(versionNo: number) { |
| | | historyVisible.value = false |
| | | await router.push({ path: `/report-formation/${editingId.value}/edit`, query: { mode: 'view', version: versionNo } }) |
| | | } |
| | | async function printReport(versionNo = saved.value?.versionNo) { |
| | | if (printing.value || saving.value || loading.value || loadError.value || !canExport.value) return |
| | | if (!saved.value || versionNo === undefined) { ElMessage.warning('请先保存报表后再打印'); return } |
| | | if (!readonly.value && (formContent() !== savedContent.value || changeSummary.value.trim())) { ElMessage.warning('有未保存修改,请先保存新版本后再打印'); return } |
| | | printing.value = true |
| | | try { await openFormationSavedReportPrint(saved.value.id, versionNo) } |
| | | catch (error) { ElMessage.error(error instanceof Error ? error.message : '打印失败,请重试') } |
| | | finally { printing.value = false } |
| | | } |
| | | // 快速切换来源时只接受最后一次预览,避免旧响应把矩阵归入错误的选择。 |
| | | let previewSequence = 0 |
| | | async function preview() { |
| | | const sequence = ++previewSequence |
| | | previewError.value = '' |
| | | if (!selectedKeys.value.length) { items.value = []; cycleNos.value = []; previewLoading.value = false; return } |
| | | previewLoading.value = true |
| | | try { |
| | | const selections = candidates.value.filter(candidate => selectedKeys.value.includes(selectionKey(candidate))).map(candidate => candidate.selection) |
| | | const result = await previewFormationReport(selections) |
| | | if (sequence !== previewSequence) return |
| | | items.value = result.items |
| | | cycleNos.value = result.cycleNos |
| | | fields.value = fields.value.map(field => field.kind === 'battery' && field.fieldDefinitionId == null |
| | | ? { ...field, fieldDefinitionId: items.value.flatMap(item => item.businessFields).find(value => value.fieldName === field.key)?.fieldDefinitionId ?? null } : field) |
| | | pruneValues() |
| | | } catch (error) { |
| | | if (sequence !== previewSequence) return |
| | | items.value = []; cycleNos.value = [] |
| | | previewError.value = error instanceof Error ? error.message : '化成数据加载失败' |
| | | } finally { if (sequence === previewSequence) previewLoading.value = false } |
| | | } |
| | | async function load() { |
| | | loading.value = true; loadError.value = '' |
| | | previewSequence++; previewLoading.value = false; previewError.value = '' |
| | | saved.value = null; items.value = []; cycleNos.value = []; values.value = {}; chargeStepSelections.value = {}; selectedKeys.value = [] |
| | | sourceDetailOverrides.value = {} |
| | | try { |
| | | const permissions = (await getUserInfo()).permissions || [] |
| | | const has = (action: string) => permissions.includes('*:*:*') || permissions.includes(`system:formation-report:${action}`) |
| | | canCreate.value = has('add'); canEdit.value = has('edit'); canQuery.value = has('query'); canExport.value = has('export') |
| | | if (!editingId.value) { |
| | | if (!canCreate.value) throw new Error('无新增化成报表权限') |
| | | reportName.value = ''; description.value = ''; fields.value = structuredClone(defaultFormationFields.filter(field => field.scope === 'cycle')); chargeStepSelections.value = {} |
| | | metrics.value = formationCurveMetrics.map(metric => metric.key); seriesColors.value = {} |
| | | manualChargeSteps.value = {}; curveStepSelections.value = {}; hiddenSeriesKeys.value = []; activeCycle.value = null |
| | | candidates.value = await getFormationSources() |
| | | } else { |
| | | const editing = route.query.mode === 'edit' && !route.query.version |
| | | if (editing ? !canEdit.value : !canQuery.value) throw new Error(editing ? '无编辑化成报表权限' : '无查看化成报表权限') |
| | | restore(await getFormationReport(editingId.value, route.query.version ? Number(route.query.version) : undefined, editing)) |
| | | 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 (error) { loadError.value = error instanceof Error ? error.message : '化成报表加载失败'; ElMessage.error(loadError.value) } |
| | | finally { loading.value = false } |
| | | }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} |
| | | } |
| | | watch(selectedKeys, () => { if (!editingId.value) void preview() }) |
| | | watch(cycleNos, cycles => { if (activeCycle.value === null || !cycles.includes(activeCycle.value)) activeCycle.value = cycles[0] ?? null }) |
| | | watch(() => route.fullPath, () => { void load() }, { immediate: true }) |
| | | 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 ? '查看化成报表' : editingId ? '编辑化成报表' : '新建化成报表' }}</span></div> |
| | | <el-alert v-if="loadError" :title="loadError" type="error" :closable="false" /> |
| | | <template v-else> |
| | | <section class="report-name-panel"><label><span>报表名称</span><el-input v-model="reportName" :disabled="readonly" maxlength="100" clearable placeholder="请输入报表名称" /></label></section> |
| | | <div v-if="saved" class="version-bar"><span>版本 {{ saved.versionNo }} · {{ saved.operatorName }} · {{ new Date(saved.versionCreatedAt).toLocaleString() }}</span><span>{{ saved.changeSummary }}</span><button v-if="canQuery" class="secondary-button" @click="openHistory">历史版本</button><button v-if="readonly && canEdit" class="secondary-button" @click="router.push({ path: `/report-formation/${editingId}/edit`, query: { mode: 'edit' } })">编辑</button><button v-if="!readonly" class="secondary-button" :disabled="saving" @click="refreshSource">按最新来源生成新版本</button></div> |
| | | <section v-if="!editingId" class="source-panel"> |
| | | <label><span>化成测试 / 电池号</span><el-select v-model="selectedKeys" multiple filterable collapse-tags collapse-tags-tooltip placeholder="选择化成测试及电池号" :disabled="loading" aria-label="化成来源"> |
| | | <el-option v-for="candidate in candidates" :key="selectionKey(candidate)" :value="selectionKey(candidate)" :label="`${candidate.name} / ${candidate.batteryGroup} / ${candidate.batteryNo}`" /> |
| | | </el-select></label> |
| | | <el-empty v-if="!loading && !candidates.length" description="暂无检测完成的化成测试" :image-size="60" /> |
| | | </section> |
| | | <el-alert v-if="previewError" :title="previewError" type="error" :closable="false" /> |
| | | <div v-loading="previewLoading"> |
| | | <ReportTestDetail v-model="sourceDetailOverrides" :columns="reportDetailColumns" :readonly="readonly" :disabled="saving || previewLoading || !!previewError" /> |
| | | <section v-if="!readonly" class="report-data-panel"><header class="panel-heading"><strong>循环指标设置</strong></header><FormationFieldControls :fields="fields" :items="items" :can-create="canCreate" :can-edit="canEdit" @apply="applyFields" /></section> |
| | | <section class="cycle-panel"><div class="cycle-list"> |
| | | <article v-for="cycle in cycleNos" :key="cycle" class="cycle-card" :data-cycle="cycle"> |
| | | <h3>循环{{ cycle }}</h3> |
| | | <div class="cycle-table-wrap"><table class="cycle-table cycle-data-table" :style="tableStyle"> |
| | | <thead><tr><th>测试</th><th v-for="column in reportColumns" :key="column.id" :colspan="column.items.length">{{ column.name }}</th></tr> |
| | | <tr><th>指标</th><th v-for="item in reportItems" :key="selectionKey(item.source)">{{ item.source.batteryGroup }} / {{ item.source.batteryNo }}</th></tr></thead> |
| | | <tbody><template v-if="!actualSteps"><tr><td>充电工步号</td><td v-for="item in reportItems" :key="selectionKey(item.source)"><template v-if="item.cycleNos.includes(cycle)"><el-select v-if="chargeChoices(item, cycle).length && !chargeChoice(item, cycle)" :model-value="chargeStepSelections[chargeKey(item, cycle)]" :disabled="readonly" placeholder="选择工步" size="small" @update:model-value="chargeStepSelections[chargeKey(item, cycle)] = $event"><el-option v-for="step in chargeChoices(item, cycle)" :key="step.stepNo" :label="`${step.stepNo} ${step.name || ''}`" :value="step.stepNo" /></el-select><span v-else>{{ chargeChoice(item, cycle)?.stepNo ?? '未识别' }}</span></template><span v-else class="missing-cycle">无该循环</span></td></tr> |
| | | <tr><td>充电工步名称</td><td v-for="item in reportItems" :key="selectionKey(item.source)"><span v-if="!item.cycleNos.includes(cycle)" class="missing-cycle">无该循环</span><span v-else>{{ chargeChoice(item, cycle)?.name || '未选择' }}</span></td></tr> |
| | | <tr><td>终止电压 (V)</td><td v-for="item in reportItems" :key="selectionKey(item.source)"><span v-if="!item.cycleNos.includes(cycle)" class="missing-cycle">无该循环</span><span v-else>{{ chargeChoice(item, cycle)?.endVoltage ?? '缺失' }}</span></td></tr> |
| | | </template><template v-else> |
| | | <tr v-for="index in terminationRows(cycle)" :key="`termination-${index}`" class="charge-termination-row"><td>充电工步{{ index }} / 终止电压 (V)</td><td v-for="item in reportItems" :key="selectionKey(item.source)">{{ termination(item, cycle, index - 1) }}</td></tr> |
| | | <tr v-if="!readonly && reportItems.some(item => unknownSteps(item, cycle).length)"><td>人工指定未知充电工步</td><td v-for="item in reportItems" :key="selectionKey(item.source)"><el-select v-if="unknownSteps(item, cycle).length" v-model="manualChargeSteps[formationCycleKey(item, cycle)]" multiple :aria-label="`未知充电工步 ${item.source.name} ${item.source.batteryNo} 循环${cycle}`" placeholder="可选择多个实际工步"><el-option v-for="step in unknownSteps(item, cycle)" :key="step.stepNo" :value="step.stepNo" :label="`${step.stepNo} ${step.name || '未命名工步'}`" /></el-select><span v-else>—</span></td></tr> |
| | | </template> |
| | | <tr v-for="field in cycleFields" :key="field.key"><td>{{ formationFieldLabel(field) }}</td><td v-for="item in reportItems" :key="selectionKey(item.source)"> |
| | | <span v-if="!item.cycleNos.includes(cycle)" class="missing-cycle">无该循环</span> |
| | | <el-input v-else :model-value="inputValue(item, cycle, field)" @update:model-value="setValue(item, cycle, field, $event)" :disabled="readonly" :aria-label="`${item.source.name} ${item.source.batteryNo} 循环${cycle} ${field.displayName}`" :placeholder="field.kind === 'manual' ? '人工填写' : '指标缺失'" size="small" /> |
| | | </td></tr></tbody> |
| | | </table></div> |
| | | </article> |
| | | <el-empty v-if="reportItems.length && !cycleNos.length && !previewLoading" description="所选电池暂无循环记录" :image-size="60" /> |
| | | </div></section> |
| | | <section class="metrics-panel"> |
| | | <header class="panel-heading"><div><strong>循环曲线</strong></div><label><el-select v-model="activeCycle" class="curve-cycle-select" aria-label="曲线循环" :disabled="!cycleNos.length"><el-option v-for="cycle in cycleNos" :key="cycle" :label="`循环${cycle}`" :value="cycle" /></el-select></label></header> |
| | | <el-checkbox-group v-if="!readonly" v-model="metrics" class="metric-switches"><el-checkbox v-for="metric in formationCurveMetrics" :key="metric.key" :value="metric.key">{{ metric.title }}</el-checkbox></el-checkbox-group> |
| | | <FormationStepCurves v-if="actualSteps" :items="items" :cycle="activeCycle" :settings="stepSettings" :readonly="readonly" @change="changeStepSettings" /> |
| | | <template v-else><div class="chart-legend-bar"><div v-for="(item, index) in items" :key="formationSeriesKey(item)" class="chart-legend-item"> |
| | | <el-color-picker v-if="!readonly" :model-value="formationSeriesColor(item, index, seriesColors)" :aria-label="`${formationSeriesName(item)}颜色`" @change="updateSeriesColor(item, $event)" /> |
| | | <i v-else class="series-swatch" :style="{ background: formationSeriesColor(item, index, seriesColors) }" /><span>{{ formationSeriesName(item) }}</span> |
| | | </div></div> |
| | | <div v-if="hasCurves && activeCycle !== null" class="charts-grid"><article v-for="metric in visibleMetrics" :key="metric.key" class="chart-card"><header><strong>{{ metric.title }} ({{ metric.unit }})</strong></header><VChart class="metric-chart" :option="formationChartOption(metric, items, activeCycle, seriesColors)" :update-options="{ notMerge: true }" autoresize /></article></div> |
| | | <el-empty v-if="!hasCurves" :description="saved && !items.some(item => item.curves) ? '该版本未保存曲线数据' : '该循环暂无已保存实时数据'" :image-size="60" /> |
| | | <el-empty v-else-if="!visibleMetrics.length" description="当前未启用曲线指标" :image-size="60" /></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 /></label></section> |
| | | <section v-if="editingId && !readonly" class="note-panel"><label><span>修改说明</span><el-input v-model="changeSummary" maxlength="500" placeholder="请输入修改说明" /></label></section> |
| | | <footer class="form-actions"><button v-if="canExport" class="secondary-button" type="button" :disabled="printing || saving || loading || previewLoading" @click="printReport()"><el-icon><Printer /></el-icon>{{ printing ? '正在打开打印' : '打印 / 另存 PDF' }}</button><button class="secondary-button" type="button" @click="router.push('/report-formation')">返回列表</button><button v-if="!readonly && (editingId ? canEdit : canCreate)" class="primary-button" type="button" :disabled="saving || loading || previewLoading || !items.length" @click="saveReport"><el-icon><Check /></el-icon>{{ saving ? '保存中' : editingId ? '保存新版本' : '保存报表' }}</button></footer> |
| | | </template> |
| | | <el-dialog v-model="historyVisible" title="化成报表历史版本" width="min(760px, calc(100vw - 28px))"><el-alert v-if="historyError" :title="historyError" type="error" :closable="false" /><el-table v-loading="historyLoading" :data="history"><el-table-column prop="versionNo" label="版本" width="70" /><el-table-column prop="operatorName" label="操作人" width="110" /><el-table-column label="时间" width="180"><template #default="{ row }">{{ new Date(row.createdAt).toLocaleString() }}</template></el-table-column><el-table-column prop="changeSummary" label="修改说明" /><el-table-column label="操作" width="70"><template #default="{ row }"><el-button link type="primary" @click="openVersion(row.versionNo)">查看</el-button></template></el-table-column></el-table></el-dialog> |
| | | <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> |
| | | .charge-termination-row td{white-space:normal!important;padding:8px 10px!important}.metrics-panel .panel-heading{flex-wrap:wrap} |
| | | .curve-cycle-select{width:130px;flex:0 0 130px;margin:8px 0}.metrics-panel .chart-legend-item span{max-width:100%;overflow:visible;white-space:normal;overflow-wrap:anywhere}.series-swatch{flex:0 0 14px;height:14px}.metric-switches .el-checkbox{margin-right:0}.metrics-panel .chart-legend-item{max-width:100%;align-items:flex-start}.metrics-panel .chart-legend-item .el-color-picker{flex-shrink:0}.metrics-panel .metric-chart{height:260px}.metrics-panel .chart-legend-bar{padding-top:12px;padding-bottom:12px} |
| | | .version-bar{display:flex;align-items:center;flex-wrap:wrap;gap:10px;padding:8px 0 16px;color:#52606d;font-size:12px}.version-bar span{overflow-wrap:anywhere} |
| | | .source-panel{padding:16px;background:#fff;border:1px solid #d8e1e9;margin-bottom:16px}.source-panel label{display:flex;align-items:center;gap:16px}.source-panel .el-select{flex:1;min-width:0}.formation-form-page{min-width:0}.missing-cycle{color:#7b8794}.cycle-card h3{margin:0;padding:12px 16px;font-size:14px}.cycle-data-table,.report-data-table{table-layout:fixed}.cycle-data-table th,.report-data-table th{white-space:normal;overflow-wrap:anywhere}.cycle-data-table th:first-child,.report-data-table th:first-child{width:180px}.cycle-data-table td,.report-data-table td{overflow-wrap:anywhere}.formation-form-page :deep(.el-alert){margin-bottom:12px}@media(max-width:680px){.source-panel label{align-items:stretch;flex-direction:column;gap:8px}} |
| | | .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}} |
| | |
| | | @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; } |
| | | .note-panel :deep(.el-textarea){min-width:0}.report-data-table th,.cycle-data-table th{white-space:normal;overflow-wrap:anywhere}.report-data-table td:last-child,.report-data-table th:last-child{width:auto}.report-data-table th:last-child{color:#fff;background:#3c6497}.cycle-data-table th:nth-child(n){width:auto}.cycle-data-table th:first-child{width:180px}.cycle-data-table td:first-child{white-space:normal;width:180px}.report-data-actions,.form-actions{flex-wrap:wrap}.report-data-table-wrap,.cycle-table-wrap{max-width:100%;box-sizing:border-box} |
| | | .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> |