统一三类报表测试详情的字段与交互
共用详情表格和添加删除、字段模板操作,移除实验及独立详情保存入口。详情随整份报表保存并统一查看打印,保留化成循环指标功能,增加浏览器交互验证。
| | |
| | | import type { ReportTestDetails } from '@/utils/reportTestDetails' |
| | | import { request, type ApiResponse } from './request' |
| | | import type { PersistedTestRecord, TestLogicStepMapping } from './tests' |
| | | import type { CompareMetricKey } from '@/stores/compareReports' |
| | |
| | | specialFields: string[] |
| | | detailFieldValues: Record<string, Record<string, string>> |
| | | fieldStructure?: CompareField[] |
| | | sourceDetailOverrides?: Record<string, { experimentName?: string; fieldValues?: Record<string, string> }> |
| | | sourceDetailOverrides?: ReportTestDetails |
| | | } |
| | | export interface CreateCompareReport { |
| | | name: string |
| | |
| | | import type { ReportTestDetails } from '@/utils/reportTestDetails' |
| | | import { request, type ApiResponse } from './request' |
| | | import type { PersistedTestRecord, TestConsoleDetail } from './tests' |
| | | import type { ReportMetricKey } from '@/utils/reportMeasurementChart' |
| | |
| | | } |
| | | export interface FormationMatrix { items: FormationItem[]; cycleNos: number[] } |
| | | export interface FormationStepSelection { charge: number[]; discharge: number[] } |
| | | export interface FormationSettings { fields: FormationField[]; values: Record<string, string>; chargeStepSelections: Record<string, number>; metrics?: ReportMetricKey[]; seriesColors?: Record<string, string>; manualChargeSteps?: Record<string, number[]>; curveStepSelections?: Record<string, FormationStepSelection>; hiddenSeriesKeys?: string[]; activeCycle?: number | null; sourceDetailOverrides?: Record<string, { experimentName?: string; fieldValues?: Record<string, string> }> } |
| | | export interface FormationSettings { fields: FormationField[]; values: Record<string, string>; chargeStepSelections: Record<string, number>; metrics?: ReportMetricKey[]; seriesColors?: Record<string, string>; manualChargeSteps?: Record<string, number[]>; curveStepSelections?: Record<string, FormationStepSelection>; hiddenSeriesKeys?: string[]; activeCycle?: number | null; sourceDetailOverrides?: ReportTestDetails } |
| | | export interface FormationSummary { id: number; name: string; creator: string; createdAt: number; testName?: string; batteryGroup?: string } |
| | | export interface FormationVersion { versionNo: number; operatorName: string; createdAt: number; changeSummary: string } |
| | | export interface SavedFormationReport extends FormationSummary, FormationMatrix { |
| | |
| | | import type { ReportTestDetails } from '@/utils/reportTestDetails' |
| | | import { request, type ApiResponse } from './request' |
| | | export interface SpliceSelection { testId: number; testBatteryId: number } |
| | | export interface SpliceCandidate { selection: SpliceSelection; name: string; type: string; groupId: number; group: string; batteryNo: string; startedAt: number } |
| | |
| | | export interface SpliceBusinessField { fieldDefinitionId: number; fieldName: string; fieldValue: string | null } |
| | | export interface SpliceSegment { source: SpliceCandidate; offset: number; originalCycleNos: number[]; statistics: Record<string, Record<string, number | null>>; businessFields?: SpliceBusinessField[]; consoleDetail?: { experimentName?: string; batteryFields: string[]; specialFields: string[]; fieldValues: Record<string, string> } } |
| | | export interface SpliceMatrix { segments: SpliceSegment[]; cycleNos: number[] } |
| | | export interface SpliceSettings { sourceDetailOverrides?: Record<string, { experimentName?: string; fieldValues?: Record<string, string> }>; sourceExperimentNames?: Record<string,string>; hiddenSeries?: boolean; fieldStructure?: SpliceField[]; statisticValues?: Record<string, string>; batteryFields?: string[]; specialFields?: string[]; detailFieldValues?: Record<string, Record<string, string>>; metrics?: string[]; seriesColors?: string[] } |
| | | export interface SpliceSettings { sourceDetailOverrides?: ReportTestDetails; sourceExperimentNames?: Record<string,string>; hiddenSeries?: boolean; fieldStructure?: SpliceField[]; statisticValues?: Record<string, string>; batteryFields?: string[]; specialFields?: string[]; detailFieldValues?: Record<string, Record<string, string>>; metrics?: string[]; seriesColors?: string[] } |
| | | export interface SpliceSummary { id: number; name: string; creator: string; createdAt: number } |
| | | export interface SavedSpliceReport extends SpliceSummary { spliceMode?: 'battery-segments'; description?: string; selections: SpliceSelection[]; segments: SpliceSegment[]; cycleNos: number[]; settings: SpliceSettings; versionNo: number; operatorName: string; versionCreatedAt: number; changeSummary: string } |
| | | const root='/system/splice-report' |
| | |
| | | return '' |
| | | } |
| | | /** 来源详情按分类和名称保持原结构;更新来源取消字段时仍保留已经人工改过的格。 */ |
| | | export function spliceSourceFields(segments: SpliceSegment[], overrides: SpliceSettings['sourceDetailOverrides']) { |
| | | const fields = segments.flatMap(segment => (['battery', 'special'] as const).flatMap(category => |
| | | (segment.consoleDetail?.[category === 'battery' ? 'batteryFields' : 'specialFields'] || []) |
| | | .map(name => ({ key: `${category}|${name}`, name, category })))) |
| | | for (const segment of segments) for (const key of Object.keys(overrides?.[spliceSourceKey(segment.source)]?.fieldValues || {})) { |
| | | const category = key.startsWith('battery|') ? 'battery' : 'special' |
| | | fields.push({ key, name: key.slice(category.length + 1), category }) |
| | | } |
| | | return [...new Map(fields.map(field => [field.key, field])).values()] |
| | | } |
| | | /** 明确空字符串优先,缺失来源格不借用同名共享字段或其他电池值。 */ |
| | | export function spliceDetailValue(segment: SpliceSegment, overrides: SpliceSettings['sourceDetailOverrides'], field?: ReturnType<typeof spliceSourceFields>[number]) { |
| | | const manual = overrides?.[spliceSourceKey(segment.source)] |
| | | if (!field) return manual?.experimentName ?? segment.consoleDetail?.experimentName ?? '' |
| | | if (manual?.fieldValues?.[field.key] !== undefined) return manual.fieldValues[field.key]! |
| | | const names = segment.consoleDetail?.[field.category === 'battery' ? 'batteryFields' : 'specialFields'] || [] |
| | | return names.includes(field.name) ? segment.consoleDetail?.fieldValues[field.name] ?? '' : '' |
| | | } |
| | | export function spliceExperiment(segment: SpliceSegment, settings: SpliceSettings) { |
| | | return settings.sourceExperimentNames?.[spliceSourceKey(segment.source)] ?? segment.consoleDetail?.experimentName ?? '' |
| | | } |
| | | /** 人工统计绑定测试电池和原循环身份,来源重排不改变归属。 */ |
| | | export function spliceStatisticKey(segment: SpliceSegment, cycle: number, metric: string) { |
| | | return `${segment.source.selection.testId}|${segment.source.selection.testBatteryId}|${cycle}|${metric}` |
| | | } |
| | | |
| | | /** 拼接快照已按电池展平详情,只转换来源标识,不补取当前控制台值。 */ |
| | | export function spliceReportDetailColumn(segment: SpliceSegment) { |
| | | const detail = segment.consoleDetail |
| | | const fieldValues: Record<string, string> = {} |
| | | for (const category of ['battery', 'special'] as const) for (const name of detail?.[`${category}Fields`] ?? []) { |
| | | const value = detail?.fieldValues[name] |
| | | if (value !== undefined) fieldValues[`${category}|${name}`] = value |
| | | } |
| | | return { key: spliceSourceKey(segment.source), testName: segment.source.name, batteryGroup: segment.source.group, |
| | | batteryNo: segment.source.batteryNo, |
| | | detail: { batteryFields: detail?.batteryFields ?? [], specialFields: detail?.specialFields ?? [], fieldValues } } |
| | | } |
| | |
| | | const identity = (field: FormationField) => `${field.scope}|${field.key}` |
| | | const fieldName = (field: FormationField) => defaultFormationFields.find(value => value.key === field.key)?.displayName || field.key |
| | | function openSettings() { |
| | | const available: FormationField[] = props.items.flatMap(item => item.businessFields.map(field => ({ key: field.fieldName, displayName: field.fieldName, |
| | | unit: '', kind: 'battery' as const, scope: 'detail' as const, fieldDefinitionId: field.fieldDefinitionId }))) |
| | | const combined = new Map<string, FormationField>() |
| | | for (const field of [...props.fields, ...defaultFormationFields, ...available]) if (!combined.has(identity(field))) combined.set(identity(field), field) |
| | | for (const field of [...props.fields, ...defaultFormationFields].filter(field => field.scope === 'cycle')) if (!combined.has(identity(field))) combined.set(identity(field), field) |
| | | draft.value = [...combined.values()].map(field => ({ ...field, enabled: props.fields.some(selected => identity(selected) === identity(field)) })) |
| | | settingsVisible.value = true |
| | | } |
| | |
| | | const { value } = await ElMessageBox.prompt('请输入字段名称', '添加人工项', { confirmButtonText: '添加', cancelButtonText: '取消', inputValidator: value => Boolean(value?.trim()) || '请输入字段名称' }) |
| | | const key = value.trim() |
| | | if (key.includes('|') || key.length > 100 || draft.value.some(field => field.key === key)) { ElMessage.warning('字段名重复或无效'); return } |
| | | draft.value.push({ key, displayName: key, unit: '', kind: 'manual', scope: 'detail', enabled: true }) |
| | | draft.value.push({ key, displayName: key, unit: '', kind: 'manual', scope: 'cycle', enabled: true }) |
| | | } catch (cause) { if (cause !== 'cancel' && cause !== 'close') ElMessage.error('添加字段失败') } |
| | | } |
| | | async function openTemplates() { |
| | |
| | | function applyTemplate() { |
| | | const template = templates.value.find(item => item.id === selectedTemplate.value) |
| | | if (!template) { ElMessage.warning('请选择字段模板'); return } |
| | | emit('apply', template.fields.map(field => ({ ...field }))) |
| | | const fields = template.fields.filter(field => field.scope === 'cycle') |
| | | if (!fields.length) { ElMessage.warning('模板中没有循环指标'); return } |
| | | emit('apply', fields.map(field => ({ ...field }))) |
| | | templatesVisible.value = false; ElMessage.success('字段模板已应用') |
| | | } |
| | | async function saveTemplate() { |
| | |
| | | <el-dialog v-model="settingsVisible" title="字段设置" width="min(850px, calc(100vw - 28px))"> |
| | | <div class="field-settings-table"><table><thead><tr><th>选择</th><th>字段</th><th>范围</th><th>展示名称</th><th>单位</th><th>顺序</th></tr></thead><tbody> |
| | | <tr v-for="(field, index) in draft" :key="identity(field)"><td><el-checkbox v-model="field.enabled" :aria-label="`选择${fieldName(field)}`" /></td><td>{{ fieldName(field) }}</td> |
| | | <td><el-select v-if="field.kind === 'manual'" v-model="field.scope" aria-label="字段范围"><el-option label="详情" value="detail" /><el-option label="循环" value="cycle" /></el-select><span v-else>{{ field.scope === 'detail' ? '详情' : '循环' }}</span></td> |
| | | <td><span>循环</span></td> |
| | | <td><el-input v-model="field.displayName" aria-label="展示名称" maxlength="100" /></td><td><el-input v-model="field.unit" aria-label="单位" maxlength="30" :disabled="field.kind === 'metric'" /></td> |
| | | <td class="move-buttons"><el-tooltip content="上移"><el-button aria-label="上移" :disabled="index === 0" @click="move(index, -1)"><el-icon><ArrowUp /></el-icon></el-button></el-tooltip><el-tooltip content="下移"><el-button aria-label="下移" :disabled="index === draft.length - 1" @click="move(index, 1)"><el-icon><ArrowDown /></el-icon></el-button></el-tooltip></td> |
| | | </tr></tbody></table></div> |
| New file |
| | |
| | | <script setup lang="ts"> |
| | | import { computed, ref } from 'vue' |
| | | import { ElMessage, ElMessageBox } from 'element-plus' |
| | | import { useBatteryGroupFields } from '@/stores/batteryGroupFields' |
| | | import { reportDetailFields, snapshotReportDetails, type ReportDetailColumn, type ReportTestDetails } from '@/utils/reportTestDetails' |
| | | |
| | | const props = defineProps<{ columns: ReportDetailColumn[]; modelValue?: ReportTestDetails; readonly?: boolean; disabled?: boolean }>() |
| | | const emit = defineEmits<{ 'update:modelValue': [ReportTestDetails] }>() |
| | | const details = computed(() => snapshotReportDetails(props.columns, props.modelValue)) |
| | | const fields = computed(() => reportDetailFields(details.value)) |
| | | const { fields: managedFields } = useBatteryGroupFields() |
| | | const batteryOptions = computed(() => { |
| | | const enabled = managedFields.value.filter(field => field.status === '启用').map(field => field.name) |
| | | return [...new Set([...(enabled.length ? enabled : ['额定容量', '电流', '内阻', '质量', '材料批次']), |
| | | ...fields.value.filter(field => field.category === 'battery').map(field => field.name)])] |
| | | }) |
| | | const specialTemplates: Record<string, string[]> = { |
| | | 循环寿命模板: ['容量保持率', '能量效率', '循环次数', '内阻变化'], |
| | | 热特性模板: ['最高温度', '温差', '温升速率'], |
| | | } |
| | | const templateVisible = ref(false) |
| | | const templateMode = ref<'battery' | 'special'>('battery') |
| | | const selectedBatteryFields = ref<string[]>([]) |
| | | const selectedSpecialTemplate = ref('循环寿命模板') |
| | | const locked = computed(() => props.readonly || props.disabled || !props.columns.length) |
| | | |
| | | function updateValue(column: ReportDetailColumn, field: typeof fields.value[number], value: string) { |
| | | if (locked.value) return |
| | | const next = snapshotReportDetails(props.columns, details.value) |
| | | const detail = next[column.key]! |
| | | const names = detail[`${field.category}Fields`]! |
| | | if (!names.includes(field.name)) names.push(field.name) |
| | | detail.fieldValues![field.key] = value |
| | | emit('update:modelValue', next) |
| | | } |
| | | |
| | | async function addField() { |
| | | if (locked.value) return |
| | | try { |
| | | const { value } = await ElMessageBox.prompt('请输入要添加的字段名称', '添加项', { |
| | | confirmButtonText: '添加', cancelButtonText: '取消', inputPlaceholder: '例如:库仑效率', |
| | | inputValidator: value => Boolean(value?.trim()) && value.trim().length <= 100 && !/[\r\n]/.test(value) || '请输入不超过100字的字段名称', |
| | | }) |
| | | if (locked.value) return |
| | | const name = value.trim() |
| | | if (fields.value.some(field => field.name === name)) { ElMessage.warning('该字段已经存在'); return } |
| | | const next = snapshotReportDetails(props.columns, details.value) |
| | | for (const detail of Object.values(next)) { detail.specialFields!.push(name); detail.fieldValues![`special|${name}`] = '' } |
| | | emit('update:modelValue', next) |
| | | } catch { /* 用户取消添加。 */ } |
| | | } |
| | | |
| | | function removeField(name: string) { |
| | | if (locked.value) return |
| | | const next = snapshotReportDetails(props.columns, details.value) |
| | | for (const detail of Object.values(next)) { |
| | | detail.specialFields = detail.specialFields!.filter(field => field !== name) |
| | | delete detail.fieldValues![`special|${name}`] |
| | | } |
| | | emit('update:modelValue', next) |
| | | } |
| | | |
| | | function openTemplate(mode: 'battery' | 'special') { |
| | | templateMode.value = mode |
| | | selectedBatteryFields.value = fields.value.filter(field => field.category === 'battery').map(field => field.name) |
| | | templateVisible.value = true |
| | | } |
| | | |
| | | function applyTemplate() { |
| | | if (locked.value) return |
| | | const next = snapshotReportDetails(props.columns, details.value) |
| | | for (const detail of Object.values(next)) { |
| | | if (templateMode.value === 'battery') { |
| | | detail.batteryFields = [...selectedBatteryFields.value] |
| | | detail.specialFields = detail.specialFields!.filter(name => !detail.batteryFields!.includes(name)) |
| | | } else detail.specialFields = [...specialTemplates[selectedSpecialTemplate.value]!].filter(name => !detail.batteryFields!.includes(name)) |
| | | } |
| | | // 去除模板已取消的格,避免保存后恢复;仍选字段的值不会被模板覆盖。 |
| | | emit('update:modelValue', snapshotReportDetails(props.columns, next)) |
| | | templateVisible.value = false |
| | | ElMessage.success('字段模板已应用') |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <section class="detail-panel report-test-detail"> |
| | | <header class="panel-heading"><div><strong>测试详情</strong><el-icon><ArrowUp /></el-icon></div></header> |
| | | <div class="field-table-wrap"> |
| | | <table class="field-table" :class="{ 'with-actions': !readonly }" :style="{ minWidth: `${Math.max(900, columns.length * 220 + 214)}px` }"> |
| | | <thead><tr><th>字段</th><th v-for="column in columns" :key="column.key">{{ column.batteryNo }}</th><th v-if="!readonly">操作</th></tr></thead> |
| | | <tbody> |
| | | <tr class="detail-meta-row"><td>测试</td><td v-for="column in columns" :key="column.key">{{ column.testName }}</td><td v-if="!readonly">-</td></tr> |
| | | <tr class="detail-meta-row"><td>电池组</td><td v-for="column in columns" :key="column.key">{{ column.batteryGroup }}</td><td v-if="!readonly">-</td></tr> |
| | | <tr class="sample-row"><td>电池号</td><td v-for="column in columns" :key="column.key">{{ column.batteryNo }}</td><td v-if="!readonly">-</td></tr> |
| | | <tr v-for="field in fields" :key="field.key" class="source-detail-row" :data-field="field.key"><td>{{ field.name }}</td> |
| | | <td v-for="column in columns" :key="column.key"><el-input :model-value="details[column.key]?.fieldValues?.[field.key] ?? ''" :readonly="readonly" :disabled="disabled" maxlength="500" size="small" :placeholder="details[column.key]?.fieldValues?.[field.key] === undefined ? '缺失' : '请输入'" :aria-label="`${column.testName} ${column.batteryNo} ${field.name}`" @update:model-value="updateValue(column, field, $event)" /></td> |
| | | <td v-if="!readonly"><button v-if="field.category === 'special'" class="delete-link" type="button" :disabled="locked" @click="removeField(field.name)">删除</button><span v-else class="field-lock">-</span></td> |
| | | </tr> |
| | | <tr v-if="!fields.length"><td :colspan="columns.length + (readonly ? 1 : 2)" class="empty-cell">暂无字段,请先选择电池组模板或添加特殊字段</td></tr> |
| | | </tbody> |
| | | </table> |
| | | </div> |
| | | <div v-if="!readonly" class="detail-actions"><button class="secondary-button" type="button" :disabled="locked" @click="addField"><el-icon><Plus /></el-icon>添加项</button><button class="secondary-button" type="button" :disabled="locked" @click="openTemplate('battery')">电池组字段模板</button><button class="secondary-button" type="button" :disabled="locked" @click="openTemplate('special')">选择特殊字段模板</button></div> |
| | | <el-dialog v-model="templateVisible" :title="templateMode === 'battery' ? '选择电池组字段模板' : '选择特殊字段模板'" width="620px" align-center> |
| | | <el-checkbox-group v-if="templateMode === 'battery'" v-model="selectedBatteryFields" class="template-field-options"><el-checkbox v-for="field in batteryOptions" :key="field" :value="field">{{ field }}</el-checkbox></el-checkbox-group> |
| | | <el-radio-group v-else v-model="selectedSpecialTemplate" class="template-field-options"><el-radio v-for="(names, name) in specialTemplates" :key="name" :value="name" border>{{ name }}({{ names.join('、') }})</el-radio></el-radio-group> |
| | | <template #footer><button class="secondary-button" type="button" @click="templateVisible=false">取消</button><button class="primary-button" type="button" :disabled="locked" @click="applyTemplate"><el-icon><Check /></el-icon>应用模板</button></template> |
| | | </el-dialog> |
| | | </section> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .detail-panel{min-width:0;border:1px solid #d8e1e9;background:#fff;margin-bottom:16px;padding-bottom:18px}.panel-heading{min-height:48px;display:flex;align-items:center;padding:0 24px;border-bottom:1px solid var(--line)}.panel-heading>div{display:flex;align-items:center;gap:9px;color:#26333f;font-size:13px}.panel-heading>div::before{content:'';width:3px;height:16px;background:#2b78a3}.field-table-wrap{width:100%;max-width:100%;overflow-x:auto;padding:18px 24px 0}.field-table{width:100%;table-layout:fixed;border-collapse:collapse;color:#52606d;font-size:13px}.field-table th,.field-table td{height:42px;padding:0 12px;border:1px solid #cbd8e4;text-align:center;white-space:nowrap}.field-table th{color:#fff;background:#3c6497;font-size:12px;font-weight:600}.field-table th:first-child,.field-table td:first-child{width:140px;text-align:left}.field-table.with-actions th:last-child,.field-table.with-actions td:last-child{width:74px}.field-table tr:hover{background:#f7fafc}.detail-meta-row td,.sample-row td{background:#fbfdff}.detail-meta-row td:not(:first-child){font-weight:500;color:#26333f}.field-table :deep(.el-input__wrapper){min-height:34px;border-radius:3px;box-shadow:0 0 0 1px #d8e1e9 inset;background:#f8fafc}.field-lock,.empty-cell{color:#8795a3}.empty-cell{height:72px!important}.delete-link{border:0;color:#d15b5b;background:transparent;font-size:12px;cursor:pointer}.detail-actions{display:flex;flex-wrap:wrap;gap:10px;padding:16px 24px 0}.primary-button,.secondary-button{height:34px;display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:0 13px;border:1px solid var(--line-strong);border-radius:3px;font-size:13px;font-weight:500;cursor:pointer;white-space:nowrap;background:#fff;color:#52606d}.primary-button{color:#fff;background:var(--blue);border-color:var(--blue)}button:disabled{opacity:.55;cursor:not-allowed}.template-field-options{display:flex;flex-direction:column;gap:10px;max-height:280px;overflow:auto;padding:14px;border:1px solid #d8e1e9;background:#f8fafc}.template-field-options :deep(.el-checkbox),.template-field-options :deep(.el-radio){height:auto;min-height:34px;margin-right:0}.report-test-detail :deep(.el-dialog){max-width:calc(100vw - 28px)}@media(max-width:680px){.panel-heading,.field-table-wrap,.detail-actions{padding-left:12px;padding-right:12px}} |
| | | </style> |
| | |
| | | import { LineChart } from 'echarts/charts' |
| | | import { GridComponent } from 'echarts/components' |
| | | import { SVGRenderer } from 'echarts/renderers' |
| | | import { compareSelectionKey, getComparePrintVersion, type CompareItem, type SavedCompareReport } from '@/api/compareReports' |
| | | import { compareSelectionKey, getComparePrintVersion, type SavedCompareReport } from '@/api/compareReports' |
| | | import type { CompareMetricKey } from '@/stores/compareReports' |
| | | import { compareChartOption, compareSeriesName } from './compareReportChart' |
| | | import { compareFields, compareFieldLabel, compareBusinessFieldValue } from './compareReportFields' |
| | | import { openSavedReportPrint } from './savedReportPrint' |
| | | import { compareSourceKey, compareDetailSources, compareSourceFields, compareSourceValue, compareHasRangeDetailValues } from './compareSourceDetails' |
| | | import { compareDetailSources } from './compareSourceDetails' |
| | | import { reportDetailColumn, reportDetailsHtml } from './reportTestDetails' |
| | | |
| | | use([LineChart, GridComponent, SVGRenderer]) |
| | | |
| | |
| | | |
| | | function printHtml(record: SavedCompareReport) { |
| | | const cell = (value: unknown) => `<td>${escapeHtml(value === '' || value == null ? '-' : value)}</td>` |
| | | const fields = compareFields(record.settings) |
| | | const showSource = record.settings.sourceDetailOverrides !== undefined |
| | | const bySource = !compareHasRangeDetailValues(record.settings) |
| | | const detailSources = bySource ? compareDetailSources(record.items) : record.items.map(item => item.source) |
| | | const sources = compareDetailSources(record.items) |
| | | const sourceFields = compareSourceFields(sources, record.settings.sourceDetailOverrides) |
| | | const detailTables = reportDetailsHtml(compareDetailSources(record.items).map(reportDetailColumn), record.settings.sourceDetailOverrides) |
| | | const compareRows = record.items.map(item => { |
| | | const source = item.source |
| | | return `<tr>${[source.name, source.type, source.batteryGroup, source.batteryNo, source.process, source.selection.cycleNo].map(cell).join('')}</tr>` |
| | | }).join('') |
| | | const fieldValue = (field: typeof fields[number], item: CompareItem) => { |
| | | const value = record.settings.detailFieldValues[field.name]?.[bySource ? compareSourceKey(item.source) : compareSelectionKey(item.source.selection)] |
| | | // 与重开表单相同:未填写才使用冻结业务字段,显式清空仍为空。 |
| | | return value === undefined ? compareBusinessFieldValue(field, item) : value |
| | | } |
| | | // 横向每组最多四项,所有组顺序输出,长表可跨页并重复列头。 |
| | | const detailTables: string[] = [] |
| | | if (showSource) for (let start = 0; start < sources.length; start += 4) { |
| | | const columns = sources.slice(start, start + 4) |
| | | const rows = [ |
| | | ['测试', ...columns.map(source => source.name)], |
| | | ['电池组', ...columns.map(source => source.batteryGroup)], |
| | | ['电池号', ...columns.map(source => source.batteryNo)], |
| | | ['实验', ...columns.map(source => compareSourceValue(source, record.settings.sourceDetailOverrides))], |
| | | ...sourceFields.map(field => [`${field.name}(${field.category === 'battery' ? '电池组详情' : '特殊详情'})`, ...columns.map(source => compareSourceValue(source, record.settings.sourceDetailOverrides, field))]), |
| | | ] |
| | | detailTables.push(`<table class="detail-table source-detail-table"><thead><tr><th>字段</th>${columns.map((_, index) => `<th>来源电池 ${start + index + 1}</th>`).join('')}</tr></thead><tbody>${rows.map(row => `<tr>${row.map(cell).join('')}</tr>`).join('')}</tbody></table>`) |
| | | } |
| | | for (let start = 0; (fields.length || !showSource) && start < detailSources.length; start += 4) { |
| | | const items = detailSources.slice(start, start + 4).map(source => record.items.find(item => compareSelectionKey(item.source.selection) === compareSelectionKey(source.selection))!) |
| | | const rows = [ |
| | | ['测试', ...items.map(item => item.source.name)], |
| | | ['电池组', ...items.map(item => item.source.batteryGroup)], |
| | | ['电池号', ...items.map(item => item.source.batteryNo)], |
| | | ...(!bySource ? [['循环 / 工步', ...items.map(item => `${item.source.selection.cycleNo} / ${item.source.process}`)]] : []), |
| | | ...fields.map(field => [compareFieldLabel(field), ...items.map(item => fieldValue(field, item))]), |
| | | ] |
| | | detailTables.push(`<table class="detail-table"><thead><tr><th>字段</th>${items.map((_, index) => `<th>${bySource ? '来源电池' : '对比项'} ${start + index + 1}</th>`).join('')}</tr></thead><tbody>${rows.map(row => `<tr>${row.map(cell).join('')}</tr>`).join('')}</tbody></table>`) |
| | | } |
| | | const legend = record.items.map((item, index) => { |
| | | const color = record.settings.seriesColors[index % record.settings.seriesColors.length] |
| | | const safeColor = color && /^#[0-9a-f]{6}$/i.test(color) ? color : '#52606d' |
| | |
| | | </style></head><body> |
| | | <header class="report-header"><div><h1>${escapeHtml(record.name)}</h1><p>检测室数据可视化系统 · 对比报表</p></div><div class="saved-meta"><span>报表ID <strong>#${record.id}</strong></span><span>版本 <strong>V${record.versionNo}</strong></span><span>创建人 <strong>${escapeHtml(record.creator)}</strong></span><span>创建时间 <strong>${date(record.createdAt)}</strong></span><span>版本操作人 <strong>${escapeHtml(record.operatorName)}</strong></span><span>版本时间 <strong>${date(record.versionCreatedAt)}</strong></span><span>修改说明 <strong>${escapeHtml(record.changeSummary)}</strong></span></div></header> |
| | | <section class="section"><h2 class="section-title">对比信息</h2><table class="compare-table"><thead><tr><th>测试名称</th><th>测试类型</th><th>电池组</th><th>电池号</th><th>工步</th><th>循环</th></tr></thead><tbody>${compareRows}</tbody></table></section> |
| | | <section class="section"><h2 class="section-title">测试详情</h2>${!showSource || record.experimentName ? `<div class="experiment">实验<strong>${escapeHtml(record.experimentName || '-')}</strong></div>` : ''}${detailTables.join('')}</section> |
| | | <section class="section"><h2 class="section-title">测试详情</h2>${detailTables}</section> |
| | | <section class="section"><h2 class="section-title">对比曲线</h2><div class="legend">${legend}</div><div class="charts">${charts || '<div class="empty">当前未启用曲线指标</div>'}</div></section> |
| | | <section class="section"><h2 class="section-title">报表说明</h2><div class="note">${escapeHtml(record.description || '-')}</div></section> |
| | | </body></html>` |
| | |
| | | import type { CompareCandidate, CompareItem, CompareSettings } from '@/api/compareReports' |
| | | import type { CompareCandidate, CompareItem } from '@/api/compareReports' |
| | | |
| | | /** 同一来源的不同循环和工步共用详情,测量比对项仍保留完整范围身份。 */ |
| | | export function compareSourceKey(source: CompareCandidate) { |
| | |
| | | |
| | | export function compareDetailSources(items: CompareItem[]) { |
| | | return [...new Map(items.map(item => [compareSourceKey(item.source), item.source])).values()] |
| | | } |
| | | |
| | | /** 控制台名称不映射为共享字段编号或单位;两类同名字段也分别保留。 */ |
| | | export function compareSourceFields(sources: CompareCandidate[], overrides?: CompareSettings['sourceDetailOverrides']) { |
| | | const fields = sources.flatMap(source => (['battery', 'special'] as const).flatMap(category => |
| | | (source.consoleDetail?.[category === 'battery' ? 'batteryFields' : 'specialFields'] || []) |
| | | .map(name => ({ key: `${category}|${name}`, name, category })))) |
| | | // 主动更新时来源字段可以消失,已经人工改过的报表格仍保留其名称和分类。 |
| | | for (const source of sources) for (const key of Object.keys(overrides?.[compareSourceKey(source)]?.fieldValues || {})) { |
| | | const category = key.startsWith('battery|') ? 'battery' : 'special' |
| | | fields.push({ key, name: key.slice(category.length + 1), category }) |
| | | } |
| | | return [...new Map(fields.map(field => [field.key, field])).values()] |
| | | } |
| | | |
| | | /** 旧版人工格以完整范围保存,不能在来源详情去重时合并或丢弃这些值。 */ |
| | | export function compareHasRangeDetailValues(settings: Pick<CompareSettings, 'sourceDetailOverrides' | 'detailFieldValues'>) { |
| | | return settings.sourceDetailOverrides === undefined || Object.values(settings.detailFieldValues).some(values => Object.keys(values).some(key => key.split('|').length > 2)) |
| | | } |
| | | |
| | | export function compareSourceValue(source: CompareCandidate, overrides: CompareSettings['sourceDetailOverrides'], field?: ReturnType<typeof compareSourceFields>[number]) { |
| | | const manual = overrides?.[compareSourceKey(source)] |
| | | if (!field) return manual?.experimentName ?? source.consoleDetail?.experimentName ?? '' |
| | | const value = manual?.fieldValues?.[field.key] |
| | | if (value !== undefined) return value |
| | | const fields = source.consoleDetail?.[field.category === 'battery' ? 'batteryFields' : 'specialFields'] || [] |
| | | return fields.includes(field.name) ? source.consoleDetail?.fieldValues[field.name]?.[source.batteryNo] ?? '' : '' |
| | | } |
| | |
| | | import { formationChartOption, formationCurveMetrics, formationSeriesColor, formationSeriesName } from './formationCycleChart' |
| | | import { formationChargeSteps, formationStepGroups, formationStepSeries, formationStepChartOption } from './formationStepChart' |
| | | import { openSavedReportPrint } from './savedReportPrint' |
| | | import { formationSourceFields, formationDetailValue } from './formationSourceDetails' |
| | | import { reportDetailColumn, reportDetailsHtml } from './reportTestDetails' |
| | | |
| | | use([LineChart, GridComponent, LegendComponent, SVGRenderer]) |
| | | function escapeHtml(value: unknown) { |
| | |
| | | function printHtml(record: SavedFormationReport) { |
| | | const colors = record.settings.seriesColors ?? {} |
| | | const items = [...new Set(record.items.map(item => item.source.selection.testId))].flatMap(id => record.items.filter(item => item.source.selection.testId === id)) |
| | | const cell = (value: unknown, preserveEmpty = false) => `<td>${escapeHtml(value == null || (!preserveEmpty && value === '') ? '缺失' : value)}</td>` |
| | | const sourceFields = formationSourceFields(items.map(item => item.source), record.settings.sourceDetailOverrides) |
| | | const cell = (value: unknown) => `<td>${escapeHtml(value == null || value === '' ? '缺失' : value)}</td>` |
| | | const tables = (cycle: number) => { |
| | | if (!cycle) return reportDetailsHtml(items.map(item => reportDetailColumn(item.source)), record.settings.sourceDetailOverrides) |
| | | const result: string[] = [] |
| | | // 每组最多四列,按原测试分组顺序打印全部矩阵;长表重复列头并允许跨页。 |
| | | for (let start = 0; start < items.length; start += 4) { |
| | |
| | | return `工步${step.stepNo} · ${step.name || '未命名工步'} · ${step.endVoltage ?? '缺失'}${step.endVoltage == null ? '' : ' V'}` |
| | | }))]) |
| | | } |
| | | if (!cycle) { |
| | | rows.push(['实验名称', ...group.map(item => formationDetailValue(item.source, record.settings.sourceDetailOverrides))]) |
| | | for (const field of sourceFields) |
| | | rows.push([`${field.name}(${field.category === 'battery' ? '电池字段' : '特殊字段'})`, ...group.map(item => formationDetailValue(item.source, record.settings.sourceDetailOverrides, field))]) |
| | | } |
| | | for (const field of record.settings.fields.filter(field => field.scope === (cycle ? 'cycle' : 'detail'))) { |
| | | for (const field of record.settings.fields.filter(field => field.scope === 'cycle')) { |
| | | rows.push([formationFieldLabel(field), ...group.map(item => value(item, () => |
| | | record.settings.values[formationValueKey(item, cycle, field)] ?? formationSourceValue(item, cycle, field)))]) |
| | | } |
| | | // 来源详情的明确空字符串保持空白,既有指标缺失仍使用缺失标记。 |
| | | result.push(`<table class="matrix-table"><thead><tr><th>${cycle ? `循环${cycle} / 测试` : '测试'}</th>${group.map(item => `<th>${escapeHtml(item.source.name)}</th>`).join('')}</tr><tr><th>电池组 / 电池号</th>${group.map(item => `<th>${escapeHtml(item.source.batteryGroup)} / ${escapeHtml(item.source.batteryNo)}</th>`).join('')}</tr></thead><tbody>${rows.map((row, index) => `<tr>${row.map(value => cell(value, !cycle && index <= sourceFields.length)).join('')}</tr>`).join('')}</tbody></table>`) |
| | | // 循环指标缺失继续使用缺失标记,人工详情由独立详情表保留空字符串。 |
| | | result.push(`<table class="matrix-table"><thead><tr><th>${cycle ? `循环${cycle} / 测试` : '测试'}</th>${group.map(item => `<th>${escapeHtml(item.source.name)}</th>`).join('')}</tr><tr><th>电池组 / 电池号</th>${group.map(item => `<th>${escapeHtml(item.source.batteryGroup)} / ${escapeHtml(item.source.batteryNo)}</th>`).join('')}</tr></thead><tbody>${rows.map(row => `<tr>${row.map(value => cell(value)).join('')}</tr>`).join('')}</tbody></table>`) |
| | | } |
| | | return result.join('') |
| | | } |
| | |
| | | @page{size:A4 landscape;margin:10mm}*{box-sizing:border-box}body{margin:0;padding:16px;font:11px "Microsoft YaHei",sans-serif;color:#26333f;background:#fff;letter-spacing:0;print-color-adjust:exact;-webkit-print-color-adjust:exact} |
| | | .report-header{display:flex;flex-wrap:wrap;gap:16px;justify-content:space-between;border-bottom:2px solid #2b78a3;padding-bottom:12px;margin-bottom:16px}.report-header>div{min-width:0;max-width:100%}h1{font-size:22px;margin:0;overflow-wrap:anywhere}.saved-meta{display:flex;flex-wrap:wrap;gap:8px 18px;max-width:50%;overflow-wrap:anywhere}h2{font-size:14px;background:#f4f8fb;padding:10px;border:1px solid #d8e1e9;break-after:avoid}h3{margin:0;padding:8px 12px;font-size:13px}table{width:100%;table-layout:fixed;border-collapse:collapse;margin:0 0 16px}thead{display:table-header-group}tr{break-inside:avoid}td,th{border:1px solid #cbd8e4;padding:7px;white-space:pre-wrap;overflow-wrap:anywhere;text-align:center}th{background:#3c6497;color:#fff}td:first-child,th:first-child{width:150px;text-align:left}td:first-child{background:#f4f8fb}.charts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.chart-card{min-width:0;border:1px solid #d8e1e9;break-inside:avoid}.chart-card svg{display:block;width:100%;height:auto}.legend{display:flex;flex-wrap:wrap;gap:8px 16px;padding:10px}.legend span{display:flex;align-items:flex-start;gap:6px;overflow-wrap:anywhere;max-width:100%;break-inside:avoid}.hidden-series{text-decoration:line-through;opacity:.55}.legend i{flex:0 0 12px;height:12px;margin-top:2px}.note{white-space:pre-wrap;overflow-wrap:anywhere} |
| | | @media(max-width:680px){.charts{grid-template-columns:1fr}.saved-meta{max-width:100%}}@media print{body{padding:0}.charts{display:block}.chart-card{margin-bottom:12px}.report-header{break-inside:avoid}} |
| | | </style></head><body><header class="report-header"><div><h1>${escapeHtml(record.name)}</h1><p>检测室数据可视化系统 · 化成报表</p></div><div class="saved-meta"><span>报表ID #${record.id}</span><span>版本 V${record.versionNo}</span><span>创建人 ${escapeHtml(record.creator)}</span><span>创建时间 ${date(record.createdAt)}</span><span>版本操作人 ${escapeHtml(record.operatorName)}</span><span>版本时间 ${date(record.versionCreatedAt)}</span><span>修改说明 ${escapeHtml(record.changeSummary)}</span></div></header><section><h2>报表数据</h2>${tables(0)}</section>${cycles}<section><h2>报表说明</h2><div class="note">${escapeHtml(record.description || '-')}</div></section></body></html>` |
| | | </style></head><body><header class="report-header"><div><h1>${escapeHtml(record.name)}</h1><p>检测室数据可视化系统 · 化成报表</p></div><div class="saved-meta"><span>报表ID #${record.id}</span><span>版本 V${record.versionNo}</span><span>创建人 ${escapeHtml(record.creator)}</span><span>创建时间 ${date(record.createdAt)}</span><span>版本操作人 ${escapeHtml(record.operatorName)}</span><span>版本时间 ${date(record.versionCreatedAt)}</span><span>修改说明 ${escapeHtml(record.changeSummary)}</span></div></header><section><h2>测试详情</h2>${tables(0)}</section>${cycles}<section><h2>报表说明</h2><div class="note">${escapeHtml(record.description || '-')}</div></section></body></html>` |
| | | } |
| | | |
| | | /** 指定当前或历史保存版本打印,不读取页面草稿和来源测试。 */ |
| New file |
| | |
| | | /** 详情结构和值属于报表;字段名称不推断共享编号、单位或计算公式。 */ |
| | | export interface ReportSourceDetail { |
| | | batteryFields?: string[] |
| | | specialFields?: string[] |
| | | fieldValues?: Record<string, string> |
| | | } |
| | | export type ReportTestDetails = Record<string, ReportSourceDetail> |
| | | export interface ReportDetailColumn { |
| | | key: string |
| | | testName: string |
| | | batteryGroup: string |
| | | batteryNo: string |
| | | detail: ReportSourceDetail |
| | | } |
| | | |
| | | /** 控制台只在带入时提供字段与值;实验名称不进入报表详情。 */ |
| | | export function reportDetailColumn(source: { |
| | | selection: { testId: number; testBatteryId: number }; name: string; batteryGroup: string; batteryNo: string |
| | | consoleDetail?: { batteryFields: string[]; specialFields: string[]; fieldValues: Record<string, Record<string, string>> } | null |
| | | }): ReportDetailColumn { |
| | | const detail = source.consoleDetail |
| | | const fieldValues: Record<string, string> = {} |
| | | for (const category of ['battery', 'special'] as const) for (const name of detail?.[`${category}Fields`] || []) { |
| | | const value = detail?.fieldValues[name]?.[source.batteryNo] |
| | | if (value !== undefined) fieldValues[`${category}|${name}`] = value |
| | | } |
| | | return { key: `${source.selection.testId}|${source.selection.testBatteryId}`, testName: source.name, |
| | | batteryGroup: source.batteryGroup, batteryNo: source.batteryNo, |
| | | detail: { batteryFields: detail?.batteryFields || [], specialFields: detail?.specialFields || [], fieldValues } } |
| | | } |
| | | |
| | | /** 完整结构随整份报表保存;显式空列表表示已删除,不能从来源重新长回字段。 */ |
| | | export function snapshotReportDetails(columns: ReportDetailColumn[], details: ReportTestDetails = {}): ReportTestDetails { |
| | | return Object.fromEntries(columns.map(column => { |
| | | const current = details[column.key] ?? column.detail |
| | | const batteryFields = [...(current.batteryFields ?? [])] |
| | | const specialFields = [...(current.specialFields ?? [])] |
| | | const keys = new Set([...batteryFields.map(name => `battery|${name}`), ...specialFields.map(name => `special|${name}`)]) |
| | | return [column.key, { batteryFields, specialFields, |
| | | fieldValues: Object.fromEntries(Object.entries(current.fieldValues ?? {}).filter(([key]) => keys.has(key))) }] |
| | | })) |
| | | } |
| | | |
| | | export function reportDetailFields(details: ReportTestDetails) { |
| | | return (['battery', 'special'] as const).flatMap(category => [...new Set(Object.values(details) |
| | | .flatMap(detail => detail[`${category}Fields`] ?? []))].map(name => ({ key: `${category}|${name}`, name, category }))) |
| | | } |
| | | |
| | | /** 三类报表打印只呈现本版本详情,空字符串保持空白,缺失值明确标识。 */ |
| | | export function reportDetailsHtml(columns: ReportDetailColumn[], values: ReportTestDetails = {}) { |
| | | const details = snapshotReportDetails(columns, values) |
| | | const fields = reportDetailFields(details) |
| | | const escape = (value: string) => value.replace(/[&<>"']/g, character => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]!) |
| | | const tables: string[] = [] |
| | | for (let start = 0; start < columns.length; start += 4) { |
| | | const group = columns.slice(start, start + 4) |
| | | const rows = [ |
| | | ['测试', ...group.map(column => column.testName)], ['电池组', ...group.map(column => column.batteryGroup)], |
| | | ['电池号', ...group.map(column => column.batteryNo)], |
| | | ...fields.map(field => [field.name, ...group.map(column => details[column.key]?.fieldValues?.[field.key] ?? '缺失')]), |
| | | ] |
| | | tables.push(`<table class="detail-table source-detail-table"><thead><tr><th>字段</th>${group.map(column => `<th>${escape(column.batteryNo)}</th>`).join('')}</tr></thead><tbody>${rows.map(row => `<tr>${row.map(value => `<td>${escape(value)}</td>`).join('')}</tr>`).join('')}</tbody></table>`) |
| | | } |
| | | return tables.join('') |
| | | } |
| | |
| | | import { reportDetailsHtml } from './reportTestDetails' |
| | | import { init, use } from 'echarts/core' |
| | | import { LineChart } from 'echarts/charts' |
| | | import { GridComponent, MarkLineComponent } from 'echarts/components' |
| | | import { SVGRenderer } from 'echarts/renderers' |
| | | import { getSplicePrintVersion, spliceFields, spliceFieldValue, spliceStatisticKey, spliceSourceValue, spliceExperiment, spliceSourceFields, spliceDetailValue, type SavedSpliceReport } from '@/api/spliceReports' |
| | | import { getSplicePrintVersion, spliceFields, spliceFieldValue, spliceStatisticKey, spliceReportDetailColumn, type SavedSpliceReport } from '@/api/spliceReports' |
| | | import { openSavedReportPrint } from './savedReportPrint' |
| | | import css from './spliceSavedReport.css?inline' |
| | | |
| | |
| | | return `<article class="splice-chart" data-series-count="1"><h3>${metric.title} (${metric.unit})</h3><div class="splice-chart-plot">${svg}</div><div class="splice-legend"><span><i style="background:${color}"></i>接续曲线${record.settings.hiddenSeries ? '(已隐藏)' : ''}</span></div><div class="splice-legend">${segments.map((segment, index) => `<span>${cell(sourceName(index))} · ${segment.originalCycleNos.length ? `循环${segment.originalCycleNos[0]! + segment.offset}~${segment.originalCycleNos.at(-1)! + segment.offset}` : '无已保存循环'}</span>`).join('')}</div></article>` |
| | | }).join('') |
| | | if (chartsOnly) return `<div class="splice-snapshot">${charts}</div>` |
| | | const fields = spliceFields(record.settings) |
| | | const headers = segments.map((_, index) => `<th>${cell(sourceName(index))}</th>`).join('') |
| | | const overrides = record.settings.sourceDetailOverrides |
| | | const sourceDetails = overrides === undefined ? '' : `<section class="source-detail-section"><h2>来源测试详情</h2><table><thead><tr><th>字段</th>${headers}</tr></thead><tbody><tr><td>实验</td>${segments.map(segment => `<td>${escapeHtml(spliceDetailValue(segment, overrides))}</td>`).join('')}</tr>${spliceSourceFields(segments, overrides).map(field => `<tr><td>${cell(field.name)}(${field.category === 'battery' ? '电池字段' : '特殊字段'})</td>${segments.map(segment => `<td>${escapeHtml(spliceDetailValue(segment, overrides, field))}</td>`).join('')}</tr>`).join('')}</tbody></table></section>` |
| | | // 无独立详情设置的旧版仍使用其原字段结构,不猜补来源字段或改变历史行含义。 |
| | | const legacyExperiment = overrides === undefined ? `<tr><td>实验</td>${segments.map(segment => `<td>${escapeHtml(spliceExperiment(segment, record.settings))}</td>`).join('')}</tr>` : '' |
| | | const details = `${sourceDetails}<section class="report-field-section"><h2>${overrides === undefined ? '测试详情' : '报表字段'}</h2><table><thead><tr><th>字段</th>${headers}</tr></thead><tbody>${legacyExperiment}${fields.map(field => `<tr><td>${cell(field.displayName)}${field.unit ? ` (${cell(field.unit)})` : ''}</td>${segments.map(segment => `<td>${escapeHtml(spliceSourceValue(segment, record.settings, field))}</td>`).join('')}</tr>`).join('')}</tbody></table></section>` |
| | | const details = `<section class="source-detail-section"><h2>测试详情</h2>${reportDetailsHtml(segments.map(spliceReportDetailColumn), record.settings.sourceDetailOverrides)}</section>` |
| | | const sources = `<section><h2>来源顺序与循环对应</h2><table><thead><tr><th>来源</th><th>原循环号 → 拼接循环号</th></tr></thead><tbody>${segments.map((segment, index) => `<tr><td>${cell(sourceName(index))}</td><td>${segment.originalCycleNos.map(no => `${no} → ${no + segment.offset}`).join('、') || '无已保存循环'}</td></tr>`).join('')}</tbody></table></section>` |
| | | const statistics = segments.map((segment, index) => `<section class="splice-statistics"><h2>${cell(sourceName(index))}</h2><table><thead><tr><th>原循环号</th><th>拼接循环号</th>${metrics.map(metric => `<th>${metric.title} (${metric.unit})</th>`).join('')}</tr></thead><tbody>${segment.originalCycleNos.map(no => `<tr><td>${no}</td><td>${no + segment.offset}</td>${metrics.map(metric => { |
| | | const original = segment.statistics[String(no)]?.[metric.key] |
| | |
| | | import type { ReportMetricKey } from '@/utils/reportMeasurementChart' |
| | | import { openFormationSavedReportPrint } from '@/utils/formationSavedReportPrint' |
| | | import FormationFieldControls from '@/components/FormationFieldControls.vue' |
| | | import { formationSourceKey, formationSourceFields, formationDetailValue } from '@/utils/formationSourceDetails' |
| | | 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' |
| | |
| | | const selectedKeys = ref<string[]>([]) |
| | | const items = ref<FormationItem[]>([]) |
| | | const cycleNos = ref<number[]>([]) |
| | | const fields = ref<FormationField[]>(structuredClone(defaultFormationFields)) |
| | | const fields = ref<FormationField[]>(structuredClone(defaultFormationFields.filter(field => field.scope === 'cycle'))) |
| | | const values = ref<Record<string, string>>({}) |
| | | const sourceDetailOverrides = ref<NonNullable<FormationSettings['sourceDetailOverrides']>>({}) |
| | | const sourceFields = computed(() => formationSourceFields(reportItems.value.map(item => item.source), sourceDetailOverrides.value)) |
| | | function setDetail(source: FormationCandidate, value: string, field?: typeof sourceFields.value[number]) { |
| | | const key = formationSourceKey(source) |
| | | const current = sourceDetailOverrides.value[key] ?? {} |
| | | sourceDetailOverrides.value[key] = field ? { ...current, fieldValues: { ...current.fieldValues, [field.key]: value } } : { ...current, experimentName: value } |
| | | } |
| | | const chargeStepSelections = ref<Record<string, number>>({}) |
| | | const metrics = ref<ReportMetricKey[]>(formationCurveMetrics.map(metric => metric.key)) |
| | | const seriesColors = ref<Record<string, string>>({}) |
| | |
| | | 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: sourceDetailOverrides.value, chargeStepSelections: chargeStepSelections.value, metrics: metrics.value, seriesColors: seriesColors.value, manualChargeSteps: manualChargeSteps.value, curveStepSelections: curveStepSelections.value, hiddenSeriesKeys: hiddenSeriesKeys.value, activeCycle: activeCycle.value })) |
| | | 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 |
| | |
| | | } |
| | | 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 detailFields = computed(() => fields.value.filter(field => field.scope === 'detail')) |
| | | 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) { |
| | |
| | | } |
| | | 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: sourceDetailOverrides.value, chargeStepSelections: chargeStepSelections.value, metrics: metrics.value, seriesColors: seriesColors.value } }) } |
| | | 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; values.value = record.settings.values; chargeStepSelections.value = record.settings.chargeStepSelections || {} |
| | | 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 ?? {} |
| | |
| | | saving.value = true |
| | | try { |
| | | pruneValues() |
| | | const content = { name: reportName.value.trim(), description: description.value, settings: actualSteps.value ? stepSettings.value : { fields: fields.value, values: values.value, sourceDetailOverrides: sourceDetailOverrides.value, chargeStepSelections: chargeStepSelections.value, metrics: metrics.value, seriesColors: seriesColors.value } } |
| | | 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) }) |
| | |
| | | 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); chargeStepSelections.value = {} |
| | | 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() |
| | |
| | | </section> |
| | | <el-alert v-if="previewError" :title="previewError" type="error" :closable="false" /> |
| | | <div v-loading="previewLoading"> |
| | | <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="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> |
| | | <tr><td>实验名称</td><td v-for="item in reportItems" :key="selectionKey(item.source)"><el-input :model-value="formationDetailValue(item.source, sourceDetailOverrides)" :disabled="readonly" :aria-label="`${item.source.name} ${item.source.batteryNo} 实验名称`" @update:model-value="setDetail(item.source, $event)" /></td></tr> |
| | | <tr v-for="field in sourceFields" :key="`console:${field.key}`"><td>{{ field.name }}({{ field.category === 'battery' ? '电池字段' : '特殊字段' }})</td><td v-for="item in reportItems" :key="selectionKey(item.source)"><el-input :model-value="formationDetailValue(item.source, sourceDetailOverrides, field)" :disabled="readonly" :aria-label="`${item.source.name} ${item.source.batteryNo} 详情 ${field.name}`" @update:model-value="setDetail(item.source, $event, field)" /></td></tr> |
| | | <tr v-for="field in detailFields" :key="field.key"><td>{{ formationFieldLabel(field) }}</td><td v-for="item in reportItems" :key="selectionKey(item.source)"> |
| | | <el-input :model-value="inputValue(item, 0, field)" @update:model-value="setValue(item, 0, field, $event)" :disabled="readonly" :aria-label="`${item.source.name} ${item.source.batteryNo} ${field.displayName}`" :placeholder="field.kind === 'manual' ? '人工填写' : '指标缺失'" size="small" /> |
| | | </td></tr></tbody> |
| | | </table></div> |
| | | <el-empty v-if="!reportItems.length && !previewLoading" description="暂无所选测试数据" :image-size="60" /> |
| | | <FormationFieldControls v-if="!readonly" :fields="fields" :items="items" :can-create="canCreate" :can-edit="canEdit" @apply="applyFields" /> |
| | | </section> |
| | | <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> |
| | |
| | | import type { CompareMetricKey } from '@/stores/compareReports' |
| | | import { compareSelectionKey, createCompareReport, editCompareReport, getCompareReport, getCompareSources, previewCompareReport, refreshCompareSource, |
| | | type CompareCandidate, type CompareItem, type SavedCompareReport } from '@/api/compareReports' |
| | | import { listCompareFieldTemplates, createCompareFieldTemplate, updateCompareFieldTemplate, type CompareField, type CompareFieldTemplate } from '@/api/compareReports' |
| | | import { compareFields, compareFieldLabel, compareBusinessFieldValue } from '@/utils/compareReportFields' |
| | | import { compareMetricDefinitions as metricDefinitions, openCompareReportPrint } from '@/utils/compareReportPrint' |
| | | import { compareChartOption } from '@/utils/compareReportChart' |
| | | import { compareSourceKey, compareDetailSources, compareSourceFields, compareSourceValue, compareHasRangeDetailValues } from '@/utils/compareSourceDetails' |
| | | import { compareDetailSources } from '@/utils/compareSourceDetails' |
| | | import ReportTestDetail from '@/components/ReportTestDetail.vue' |
| | | import { reportDetailColumn, snapshotReportDetails } from '@/utils/reportTestDetails' |
| | | import type { CompareSettings } from '@/api/compareReports' |
| | | |
| | | use([CanvasRenderer, LineChart, GridComponent, LegendComponent, TooltipComponent]) |
| | |
| | | const reportLoading = ref(false) |
| | | const saving = ref(false) |
| | | const reportName = ref('') |
| | | const experimentName = ref('') |
| | | const sourceDetailOverrides = ref<CompareSettings['sourceDetailOverrides']>({}) |
| | | const rangeDetailValues = ref(false) |
| | | const description = ref('') |
| | | const batteryGroup = ref('') |
| | | type SelectedTest = CompareCandidate & { id: number; cycle: number } |
| | |
| | | const candidatesLoading = ref(false) |
| | | const candidatesError = ref('') |
| | | const testDialogVisible = ref(false) |
| | | const templateDialogVisible = ref(false) |
| | | const selectedTemplateKey = ref('') |
| | | const selectedCandidateId = ref<number | null>(null) |
| | | const selectedBatteryNo = ref('') |
| | | const selectedCycle = ref<number | null>(null) |
| | | const selectedProcess = ref('') |
| | | const templateMode = ref<'battery' | 'special'>('battery') |
| | | const batteryFields = ref<string[]>([]) |
| | | const specialFields = ref<string[]>([]) |
| | | const detailFieldValues = reactive<Record<string, Record<string, string>>>({}) |
| | | const selectedBatteryTemplateName = ref('自定义') |
| | | const selectedBatteryTemplateFieldIds = ref<number[]>([]) |
| | | |
| | | const persistedTemplates = ref<CompareFieldTemplate[]>([]) |
| | | const templateLoading = ref(false) |
| | | const templateSaving = ref(false) |
| | | const templateError = ref('') |
| | | const fieldStructure = ref<CompareField[]>([]) |
| | | const fieldSettingsVisible = ref(false) |
| | | const fieldSettingsDraft = ref<CompareField[]>([]) |
| | | const fieldTemplates = computed(() => persistedTemplates.value.map(item => ({ key: `remote:${item.id}`, name: item.name, fields: item.fields.map(compareFieldLabel) }))) |
| | | |
| | | const metricVisible = reactive<Record<CompareMetricKey, boolean>>({ |
| | | voltage: true, |
| | |
| | | const hiddenSeriesKeys = ref<string[]>([]) |
| | | |
| | | const visibleMetrics = computed(() => metricDefinitions.filter(metric => metricVisible[metric.key])) |
| | | const selectedTemplate = computed(() => persistedTemplates.value.find(item => `remote:${item.id}` === selectedTemplateKey.value)) |
| | | const batteryTemplateNames = computed(() => ['自定义', ...persistedTemplates.value.map(item => item.name)]) |
| | | const batteryTemplateFieldOptions = computed(() => [...new Map([ |
| | | ...sourceItems.value.flatMap(item => item.businessFields).map(field => [field.fieldDefinitionId, { id: field.fieldDefinitionId, name: field.fieldName, status: '启用' }] as const), |
| | | ...fieldStructure.value.filter(field => field.category === 'battery' && field.fieldDefinitionId != null) |
| | | .map(field => [field.fieldDefinitionId!, { id: field.fieldDefinitionId!, name: field.name, status: '启用' }] as const), |
| | | ]).values()]) |
| | | const enabledBatteryTemplateFields = computed(() => batteryTemplateFieldOptions.value.filter(field => field.status === '启用')) |
| | | const detailStructure = computed(() => compareFields({ batteryTemplateFields: batteryFields.value, specialFields: specialFields.value, fieldStructure: fieldStructure.value })) |
| | | const detailFields = computed(() => detailStructure.value.map(field => field.name)) |
| | | const selectedCandidate = computed(() => candidateTests.value.find(item => item.id === selectedCandidateId.value) || null) |
| | | const sourceTests = computed(() => compareDetailSources(sourceItems.value).map(toSelectedTest)) |
| | | const detailTests = computed(() => rangeDetailValues.value ? selectedTests.value : sourceTests.value) |
| | | const sourceFields = computed(() => compareSourceFields(sourceTests.value, sourceDetailOverrides.value)) |
| | | const reportDetailColumns = computed(() => sourceTests.value.map(reportDetailColumn)) |
| | | const chartSeries = computed(() => selectedTests.value) |
| | | const testCandidates = computed(() => candidates.value.filter(item => item.selection.testId === selectedCandidateId.value)) |
| | | const batteryChoices = computed(() => [...new Set(testCandidates.value.map(item => item.batteryNo))]) |
| | |
| | | } |
| | | } |
| | | |
| | | async function loadFieldTemplates() { |
| | | templateLoading.value = true |
| | | templateError.value = '' |
| | | try { persistedTemplates.value = await listCompareFieldTemplates() } |
| | | catch (error) { persistedTemplates.value = []; templateError.value = error instanceof Error ? error.message : '字段模板加载失败' } |
| | | finally { templateLoading.value = false } |
| | | } |
| | | |
| | | function detailKey(item: SelectedTest) { |
| | | return rangeDetailValues.value ? compareSelectionKey(item.selection) : compareSourceKey(item) |
| | | } |
| | | |
| | | function updateSourceDetail(item: SelectedTest, value: string, field?: string) { |
| | | sourceDetailOverrides.value ||= {} |
| | | const manual = sourceDetailOverrides.value[compareSourceKey(item)] ||= {} |
| | | if (field) (manual.fieldValues ||= {})[field] = value |
| | | else manual.experimentName = value |
| | | } |
| | | |
| | | function ensureDetailFieldValues(field: string) { |
| | | if (!detailFieldValues[field]) detailFieldValues[field] = {} |
| | | detailTests.value.forEach(item => { |
| | | const key = detailKey(item) |
| | | // 未填写才读取当前预览或保存快照的业务值,显式清空不被后续预览覆盖。 |
| | | if (detailFieldValues[field][key] === undefined) { |
| | | const source = sourceItems.value.find(value => detailKey(toSelectedTest(value.source)) === key) |
| | | detailFieldValues[field][key] = compareBusinessFieldValue(fieldDescriptor(field), source) |
| | | } |
| | | }) |
| | | } |
| | | |
| | | function loadEditingReport() { |
| | | const record = editingRecord.value |
| | | if (!record) return |
| | | |
| | | reportName.value = record.name |
| | | experimentName.value = record.experimentName || '' |
| | | sourceDetailOverrides.value = record.settings.sourceDetailOverrides === undefined ? undefined : JSON.parse(JSON.stringify(record.settings.sourceDetailOverrides)) |
| | | rangeDetailValues.value = compareHasRangeDetailValues(record.settings) |
| | | description.value = record.description || '' |
| | | sourceItems.value = record.items |
| | | selectedTests.value = record.items.map(item => toSelectedTest(item.source)) |
| | | metricDefinitions.forEach(metric => { metricVisible[metric.key] = record.settings.metrics.includes(metric.key) }) |
| | | seriesColors.value = [...record.settings.seriesColors] |
| | | hiddenSeriesKeys.value = [...(record.settings.hiddenSeriesKeys || [])] |
| | | batteryFields.value = [...record.settings.batteryTemplateFields] |
| | | specialFields.value = [...record.settings.specialFields] |
| | | fieldStructure.value = (record.settings.fieldStructure || []).map(field => ({ ...field })) |
| | | Object.keys(detailFieldValues).forEach(field => delete detailFieldValues[field]) |
| | | Object.entries(record.settings.detailFieldValues).forEach(([field, values]) => { |
| | | detailFieldValues[field] = { ...values } |
| | | }) |
| | | selectedBatteryTemplateName.value = '自定义' |
| | | syncBatterySelection() |
| | | detailFields.value.forEach(ensureDetailFieldValues) |
| | | savedFormContent.value = JSON.stringify(formContent()) |
| | | } |
| | | |
| | |
| | | ? hiddenSeriesKeys.value.filter(value => value !== key) : [...hiddenSeriesKeys.value, key] |
| | | } |
| | | |
| | | detailFields.value.forEach(ensureDetailFieldValues) |
| | | // 搜索跳转和浏览器后退会复用详情组件,旧请求的结果不能覆盖新报表。 |
| | | watch(() => [editingId.value, route.query.mode, route.query.version] as const, async ([id, mode, version], _previous, onCleanup) => { |
| | | let cancelled = false |
| | |
| | | sourceItems.value = [] |
| | | reportName.value = '' |
| | | description.value = '' |
| | | experimentName.value = '' |
| | | sourceDetailOverrides.value = {} |
| | | rangeDetailValues.value = false |
| | | batteryFields.value = [] |
| | | specialFields.value = [] |
| | | fieldStructure.value = [] |
| | | templateDialogVisible.value = false |
| | | fieldSettingsVisible.value = false |
| | | persistedTemplates.value = [] |
| | | selectedTemplateKey.value = '' |
| | | selectedBatteryTemplateName.value = '自定义' |
| | | seriesColors.value = ['#3978f6', '#7d42d8', '#f08a3e'] |
| | | hiddenSeriesKeys.value = [] |
| | | metricDefinitions.forEach(metric => { metricVisible[metric.key] = true }) |
| | | Object.keys(detailFieldValues).forEach(field => delete detailFieldValues[field]) |
| | | loadError.value = '' |
| | | detailFields.value.forEach(ensureDetailFieldValues) |
| | | previewError.value = '' |
| | | canCreate.value = false |
| | | canEdit.value = false |
| | |
| | | const items = await previewCompareReport(selections) |
| | | if (editingId.value) return |
| | | sourceItems.value = items |
| | | detailFields.value.forEach(ensureDetailFieldValues) |
| | | } catch (error) { previewError.value = error instanceof Error ? error.message : '曲线预览失败' } |
| | | finally { previewLoading.value = false } |
| | | } |
| | | |
| | | async function saveCurrentFieldTemplate() { |
| | | if (readonly.value || templateLoading.value || templateSaving.value || (!canCreate.value && !canEdit.value)) return |
| | | let result: { value: string } |
| | | try { |
| | | result = await ElMessageBox.prompt('请输入模板名称', '保存对比字段模板', { inputValue: selectedTemplate.value?.name || '', inputValidator: value => value?.trim() && value.trim().length <= 100 ? true : '请输入不超过100字的模板名称', confirmButtonText: '保存', cancelButtonText: '取消' }) |
| | | } catch { return } |
| | | const name = result.value.trim() |
| | | const fields = detailFields.value.map(fieldDescriptor) |
| | | const existing = persistedTemplates.value.find(item => item.name === name) |
| | | if (existing && !canEdit.value) { ElMessage.warning('无编辑对比报表权限,不能覆盖模板'); return } |
| | | if (!existing && !canCreate.value) { ElMessage.warning('无新增对比报表权限'); return } |
| | | if (existing) { |
| | | try { await ElMessageBox.confirm(`更新模板“${name}”的字段结构?`, '更新字段模板', { confirmButtonText: '更新', cancelButtonText: '取消' }) } |
| | | catch { return } |
| | | } |
| | | templateSaving.value = true |
| | | try { |
| | | const id = existing ? existing.id : await createCompareFieldTemplate(name, fields) |
| | | if (existing) await updateCompareFieldTemplate(existing.id, name, fields) |
| | | await loadFieldTemplates() |
| | | selectedTemplateKey.value = `remote:${id}` |
| | | ElMessage.success('字段模板已保存') |
| | | } |
| | | catch (error) { ElMessage.error(error instanceof Error ? error.message : '模板保存失败') } |
| | | finally { templateSaving.value = false } |
| | | } |
| | | |
| | | function fieldDescriptor(name: string): CompareField { |
| | | const field = detailStructure.value.find(item => item.name === name)! |
| | | const definition = batteryTemplateFieldOptions.value.find(item => item.name === name) |
| | | return { ...field, fieldDefinitionId: field.fieldDefinitionId ?? (field.category === 'battery' ? definition?.id : null) } |
| | | } |
| | | |
| | | function syncBatterySelection() { |
| | | selectedBatteryTemplateFieldIds.value = batteryFields.value.flatMap(name => { |
| | | const id = fieldDescriptor(name).fieldDefinitionId |
| | | return id == null ? [] : [id] |
| | | }) |
| | | } |
| | | |
| | | function replaceFieldStructure(fields: CompareField[]) { |
| | | const previous = detailFields.value.map(fieldDescriptor) |
| | | // 同名不代表同一共享字段;身份改变必须清除旧值,不能把旧自动值或人工值归入新身份。 |
| | | for (const name of Object.keys(detailFieldValues)) { |
| | | const before = previous.find(field => field.name === name) |
| | | const after = fields.find(field => field.name === name) |
| | | if (!before || !after || before.category !== after.category || (before.fieldDefinitionId ?? null) !== (after.fieldDefinitionId ?? null)) delete detailFieldValues[name] |
| | | } |
| | | batteryFields.value = fields.filter(field => field.category === 'battery').map(field => field.name) |
| | | specialFields.value = fields.filter(field => field.category === 'special').map(field => field.name) |
| | | fieldStructure.value = fields.map(field => ({ ...field })) |
| | | syncBatterySelection() |
| | | detailFields.value.forEach(ensureDetailFieldValues) |
| | | } |
| | | |
| | | function openFieldSettings() { |
| | | fieldSettingsDraft.value = detailFields.value.map(fieldDescriptor) |
| | | fieldSettingsVisible.value = true |
| | | } |
| | | |
| | | function moveField(index: number, offset: number) { |
| | | const target = index + offset |
| | | if (target < 0 || target >= fieldSettingsDraft.value.length) return |
| | | const [field] = fieldSettingsDraft.value.splice(index, 1) |
| | | if (field) fieldSettingsDraft.value.splice(target, 0, field) |
| | | } |
| | | |
| | | function applyFieldSettings() { |
| | | if (fieldSettingsDraft.value.some(field => !field.displayName?.trim())) { ElMessage.warning('展示名称不能为空'); return } |
| | | fieldStructure.value = fieldSettingsDraft.value.map(field => ({ ...field, displayName: field.displayName!.trim(), unit: field.unit.trim() })) |
| | | fieldSettingsVisible.value = false |
| | | } |
| | | |
| | | function openAddTest() { |
| | |
| | | await refreshPreview() |
| | | } |
| | | |
| | | function addCustomField() { |
| | | ElMessageBox.prompt('请输入要添加的字段名称', '添加项', { |
| | | confirmButtonText: '添加', |
| | | cancelButtonText: '取消', |
| | | inputPlaceholder: '例如:库仑效率', |
| | | inputValidator: value => value.trim() ? true : '请输入字段名称', |
| | | }).then(({ value }) => { |
| | | const name = value.trim() |
| | | if (detailFields.value.includes(name)) { |
| | | ElMessage.warning('该字段已经存在') |
| | | return |
| | | } |
| | | specialFields.value.push(name) |
| | | ensureDetailFieldValues(name) |
| | | ElMessage.success(`已添加字段“${name}”`) |
| | | }).catch(() => { |
| | | // 用户取消添加。 |
| | | }) |
| | | } |
| | | |
| | | function removeCustomField(field: string) { |
| | | if (batteryFields.value.includes(field)) { |
| | | ElMessage.warning('电池组模板字段请在电池组字段模板中调整') |
| | | return |
| | | } |
| | | specialFields.value = specialFields.value.filter(item => item !== field) |
| | | delete detailFieldValues[field] |
| | | } |
| | | |
| | | function resolveBatteryTemplateFieldIds(name: string) { |
| | | const template = persistedTemplates.value.find(item => item.name === name) |
| | | return (template?.fields || []).filter(field => field.category === 'battery').flatMap(field => { |
| | | const option = enabledBatteryTemplateFields.value.find(item => field.fieldDefinitionId != null ? item.id === field.fieldDefinitionId : item.name === field.name) |
| | | return option ? [option.id] : [] |
| | | }) |
| | | } |
| | | |
| | | function loadBatteryTemplate(name: string) { |
| | | selectedBatteryTemplateFieldIds.value = resolveBatteryTemplateFieldIds(name) |
| | | } |
| | | |
| | | function switchBatteryTemplate(name: string) { |
| | | const template = persistedTemplates.value.find(item => item.name === name) |
| | | if (template) { |
| | | const battery = template.fields.filter(field => field.category === 'battery') |
| | | replaceFieldStructure([...battery, ...detailFields.value.map(fieldDescriptor).filter(field => field.category === 'special' && !battery.some(item => item.name === field.name))]) |
| | | return |
| | | } |
| | | loadBatteryTemplate(name) |
| | | applyBatteryTemplateFields() |
| | | } |
| | | |
| | | function applyBatteryTemplateFields() { |
| | | batteryFields.value = selectedBatteryTemplateFieldIds.value |
| | | .map(id => fieldStructure.value.find(field => field.category === 'battery' && field.fieldDefinitionId === id)?.name || enabledBatteryTemplateFields.value.find(field => field.id === id)?.name) |
| | | .filter((name): name is string => Boolean(name)) |
| | | specialFields.value = specialFields.value.filter(field => !batteryFields.value.includes(field)) |
| | | detailFields.value.forEach(ensureDetailFieldValues) |
| | | } |
| | | |
| | | function openFieldTemplate(mode: 'battery' | 'special') { |
| | | void loadFieldTemplates() |
| | | templateMode.value = mode |
| | | if (mode === 'battery') { |
| | | syncBatterySelection() |
| | | if (!batteryTemplateNames.value.includes(selectedBatteryTemplateName.value)) selectedBatteryTemplateName.value = '自定义' |
| | | } else { |
| | | if (!selectedTemplate.value) selectedTemplateKey.value = '' |
| | | } |
| | | templateDialogVisible.value = true |
| | | } |
| | | |
| | | function applyFieldTemplate() { |
| | | if (templateMode.value === 'battery') { |
| | | applyBatteryTemplateFields() |
| | | } else { |
| | | const remote = selectedTemplate.value |
| | | if (!remote) { ElMessage.warning('请选择字段模板'); return } |
| | | // 只复制结构;新增字段取当前草稿来源,保留当前草稿已有的人工值。 |
| | | replaceFieldStructure(remote.fields) |
| | | } |
| | | templateDialogVisible.value = false |
| | | ElMessage.success(`${templateMode.value === 'battery' ? '电池组字段' : '特殊字段'}模板已应用`) |
| | | } |
| | | |
| | | function formContent() { |
| | | return { |
| | | name: reportName.value, description: description.value, experimentName: experimentName.value, |
| | | name: reportName.value, description: description.value, experimentName: '', |
| | | settings: { |
| | | metrics: visibleMetrics.value.map(item => item.key), seriesColors: seriesColors.value.slice(0, selectedTests.value.length), |
| | | hiddenSeriesKeys: [...hiddenSeriesKeys.value], |
| | | batteryTemplateFields: [...batteryFields.value], specialFields: [...specialFields.value], |
| | | fieldStructure: detailFields.value.map(fieldDescriptor), |
| | | ...(sourceDetailOverrides.value === undefined ? {} : { sourceDetailOverrides: Object.fromEntries(Object.entries(sourceDetailOverrides.value) |
| | | .filter(([key]) => detailTests.value.some(item => compareSourceKey(item) === key))) }), |
| | | detailFieldValues: Object.fromEntries(detailFields.value.map(field => [field, |
| | | Object.fromEntries(detailTests.value.map(item => [detailKey(item), detailFieldValues[field]?.[detailKey(item)] ?? ''])), |
| | | ])), |
| | | batteryTemplateFields: [], specialFields: [], fieldStructure: [], detailFieldValues: {}, |
| | | sourceDetailOverrides: snapshotReportDetails(reportDetailColumns.value, sourceDetailOverrides.value), |
| | | }, |
| | | } |
| | | } |
| | |
| | | <button v-if="!editingId && canCreate" class="primary-button compact-button" type="button" :disabled="previewLoading || saving" @click="openAddTest"><el-icon><Plus /></el-icon>添加比较</button> |
| | | </section> |
| | | |
| | | <section class="detail-panel"> |
| | | <header class="panel-heading"><div><strong>测试详情</strong><el-icon><ArrowUp /></el-icon></div></header> |
| | | <div v-if="sourceDetailOverrides === undefined || experimentName" class="detail-meta"> |
| | | <label><span>实验</span><el-input v-model="experimentName" :readonly="readonly" maxlength="500" /></label> |
| | | </div> |
| | | <div v-if="sourceDetailOverrides !== undefined" class="field-table-wrap"> |
| | | <table class="field-table source-field-table" :style="{ minWidth: `${Math.max(700, sourceTests.length * 220 + 160)}px` }"> |
| | | <thead><tr><th>字段</th><th v-for="(item, index) in sourceTests" :key="compareSourceKey(item)">来源电池 {{ index + 1 }}</th></tr></thead> |
| | | <tbody> |
| | | <tr class="detail-meta-row"><td>测试</td><td v-for="item in sourceTests" :key="compareSourceKey(item)">{{ item.name }}</td></tr> |
| | | <tr class="source-experiment-row"><td>实验</td><td v-for="item in sourceTests" :key="compareSourceKey(item)"><el-input :model-value="compareSourceValue(item, sourceDetailOverrides)" :readonly="readonly" maxlength="500" :aria-label="`${item.name} ${item.batteryNo} 实验`" @update:model-value="updateSourceDetail(item, $event)" /></td></tr> |
| | | <tr class="detail-meta-row"><td>电池组</td><td v-for="item in sourceTests" :key="compareSourceKey(item)">{{ item.batteryGroup }}</td></tr> |
| | | <tr class="sample-row"><td>电池号</td><td v-for="item in sourceTests" :key="compareSourceKey(item)">{{ item.batteryNo }}</td></tr> |
| | | <tr v-for="field in sourceFields" :key="field.key" class="source-detail-row" :data-field="field.key"><td>{{ field.name }}<small class="source-field-kind">{{ field.category === 'battery' ? '电池组详情' : '特殊详情' }}</small></td><td v-for="item in sourceTests" :key="compareSourceKey(item)"><el-input :model-value="compareSourceValue(item, sourceDetailOverrides, field)" :readonly="readonly" maxlength="500" :aria-label="`${item.name} ${item.batteryNo} ${field.name}`" @update:model-value="updateSourceDetail(item, $event, field.key)" /></td></tr> |
| | | <tr v-if="!sourceFields.length"><td :colspan="sourceTests.length + 1" class="empty-cell">暂无已保存的测试详情字段</td></tr> |
| | | </tbody> |
| | | </table> |
| | | </div> |
| | | <div v-if="detailFields.length || sourceDetailOverrides === undefined" class="field-table-wrap"> |
| | | <table class="field-table" :class="{ 'with-actions': !readonly }" :style="{ minWidth: `${Math.max(900, detailTests.length * 220 + 214)}px` }"> |
| | | <thead><tr><th>报表字段</th><th v-for="(item, index) in detailTests" :key="detailKey(item)">{{ rangeDetailValues ? '对比项' : '来源电池' }} {{ index + 1 }}</th><th v-if="!readonly">操作</th></tr></thead> |
| | | <tbody> |
| | | <tr class="detail-meta-row"><td>测试</td><td v-for="item in detailTests" :key="detailKey(item)">{{ item.name }}</td><td v-if="!readonly">-</td></tr> |
| | | <tr class="detail-meta-row"><td>电池组</td><td v-for="item in detailTests" :key="detailKey(item)">{{ item.batteryGroup || batteryGroup }}</td><td v-if="!readonly">-</td></tr> |
| | | <tr class="sample-row"><td>电池号</td><td v-for="item in detailTests" :key="detailKey(item)">{{ item.batteryNo }}</td><td v-if="!readonly">-</td></tr> |
| | | <tr v-for="field in detailFields" :key="field"><td>{{ compareFieldLabel(fieldDescriptor(field)) }}</td><td v-for="item in detailTests" :key="detailKey(item)"><el-input v-model="detailFieldValues[field][detailKey(item)]" :readonly="readonly" size="small" placeholder="请输入" /></td><td v-if="!readonly"><button v-if="specialFields.includes(field)" class="delete-link" type="button" @click="removeCustomField(field)">删除</button><span v-else class="field-lock">-</span></td></tr> |
| | | <tr v-if="!detailFields.length && !sourceFields.length"><td :colspan="detailTests.length + (readonly ? 1 : 2)" class="empty-cell">暂无字段</td></tr> |
| | | </tbody> |
| | | </table> |
| | | </div> |
| | | <div v-if="!readonly" class="detail-actions"><button class="primary-button" type="button" @click="addCustomField"><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="openFieldSettings"><el-icon><Edit /></el-icon>字段设置</button></div> |
| | | </section> |
| | | <ReportTestDetail v-model="sourceDetailOverrides" :columns="reportDetailColumns" :readonly="readonly" :disabled="saving || previewLoading || !!previewError" /> |
| | | |
| | | <section class="metrics-panel"> |
| | | <div v-if="!readonly" class="metric-switches"><label v-for="metric in metricDefinitions" :key="metric.key"><span>{{ metric.title }}</span><el-switch v-model="metricVisible[metric.key]" inline-prompt active-text="显" inactive-text="隐" /></label></div> |
| | |
| | | <template #footer><div class="dialog-footer-left"><button class="primary-button" type="button" @click="addSelectedTest"><el-icon><Check /></el-icon>添加</button></div></template> |
| | | </el-dialog> |
| | | |
| | | <el-dialog v-model="fieldSettingsVisible" title="字段设置" width="640px" align-center> |
| | | <div class="field-settings-table"><table><thead><tr><th>字段</th><th>展示名称</th><th>单位</th><th>顺序</th></tr></thead><tbody> |
| | | <tr v-for="(field, index) in fieldSettingsDraft" :key="field.name"><td>{{ field.name }}</td><td><el-input v-model="field.displayName" maxlength="100" aria-label="展示名称" /></td><td><el-input v-model="field.unit" maxlength="30" aria-label="单位" /></td><td><button type="button" :disabled="index === 0" title="上移" aria-label="上移" @click="moveField(index, -1)"><el-icon><ArrowUp /></el-icon></button><button type="button" :disabled="index === fieldSettingsDraft.length - 1" title="下移" aria-label="下移" @click="moveField(index, 1)"><el-icon><ArrowDown /></el-icon></button></td></tr> |
| | | </tbody></table></div> |
| | | <template #footer><button class="secondary-button" type="button" @click="fieldSettingsVisible = false">取消</button><button class="primary-button" type="button" @click="applyFieldSettings">确认</button></template> |
| | | </el-dialog> |
| | | |
| | | <el-dialog v-model="templateDialogVisible" class="template-dialog" :title="templateMode === 'battery' ? '选择电池组字段模板' : '选择对比字段模板'" :width="templateMode === 'battery' ? '720px' : '560px'" align-center> |
| | | <div v-if="templateError" role="alert">{{ templateError }}<button type="button" class="secondary-button" @click="loadFieldTemplates">重试</button></div> |
| | | <div v-if="templateMode === 'battery'" class="battery-template-editor"> |
| | | <label class="battery-template-select"><span>选择模板</span><el-select v-model="selectedBatteryTemplateName" @change="switchBatteryTemplate"><el-option v-for="name in batteryTemplateNames" :key="name" :label="name" :value="name" /></el-select></label> |
| | | <el-checkbox-group v-if="batteryTemplateFieldOptions.length" v-model="selectedBatteryTemplateFieldIds" class="battery-template-fields" @change="applyBatteryTemplateFields"> |
| | | <el-checkbox v-for="field in batteryTemplateFieldOptions" :key="field.id" :value="field.id" :title="field.name">{{ field.name }}</el-checkbox> |
| | | </el-checkbox-group> |
| | | <div v-else class="template-empty">暂无字段,请先到电池组字段管理中添加字段</div> |
| | | </div> |
| | | <div v-else><div v-if="templateLoading" class="template-empty">正在加载模板</div><el-radio-group v-else-if="fieldTemplates.length" v-model="selectedTemplateKey" class="template-options"><el-radio v-for="item in fieldTemplates" :key="item.key" :value="item.key" border>{{ item.name }}({{ item.fields.join('、') }})</el-radio></el-radio-group><div v-else-if="!templateError" class="template-empty">暂无已保存模板</div></div> |
| | | <template #footer><div><button v-if="canCreate || canEdit" class="secondary-button" type="button" @click="saveCurrentFieldTemplate" :disabled="templateLoading || templateSaving || !!templateError">保存为模板</button><button class="secondary-button" type="button" @click="templateDialogVisible = false">取消</button><button class="primary-button" type="button" @click="applyFieldTemplate" :disabled="templateLoading || templateSaving || (templateMode === 'special' && !selectedTemplate)">{{ templateMode === 'battery' ? '确认' : '应用字段' }}</button></div></template> |
| | | </el-dialog> |
| | | </div> |
| | | </template> |
| | | |
| | |
| | | import { useRoute, useRouter } from 'vue-router' |
| | | import { getUserInfo } from '@/api/auth' |
| | | import { getSpliceSources, previewSpliceReport, createSpliceReport, editSpliceReport, refreshSpliceSource, |
| | | getSpliceReport, getSpliceVersion, listSpliceVersions, spliceFields, spliceFieldValue, spliceStatisticKey, spliceSourceKey, spliceSourceValue, spliceExperiment, spliceSourceFields, spliceDetailValue, |
| | | type SpliceCandidate, type SpliceMatrix, type SpliceField, type SpliceSettings, type SpliceSegment, type SavedSpliceReport } from '@/api/spliceReports' |
| | | getSpliceReport, getSpliceVersion, listSpliceVersions, spliceStatisticKey, spliceSourceKey, spliceReportDetailColumn, |
| | | type SpliceCandidate, type SpliceMatrix, type SpliceSettings, type SavedSpliceReport } from '@/api/spliceReports' |
| | | import { openSpliceSavedReportPrint } from '@/utils/spliceSavedReport' |
| | | import SpliceSavedReport from '@/components/SpliceSavedReport.vue' |
| | | import SpliceFieldControls from '@/components/SpliceFieldControls.vue' |
| | | import ReportTestDetail from '@/components/ReportTestDetail.vue' |
| | | import { snapshotReportDetails } from '@/utils/reportTestDetails' |
| | | |
| | | const route = useRoute() |
| | | const router = useRouter() |
| | |
| | | const sources = ref<SpliceCandidate[]>([]) |
| | | const selectedTests = ref<SpliceCandidate[]>([]) |
| | | const samples = ref<string[]>([]) |
| | | const fields = ref<SpliceField[]>([]) |
| | | const detailFieldValues = ref<Record<string, Record<string, string>>>({}) |
| | | const statisticValues = ref<Record<string, string>>({}) |
| | | const sourceExperimentNames = ref<Record<string,string>>({}) |
| | | const sourceDetailOverrides = ref<SpliceSettings['sourceDetailOverrides']>({}) |
| | | const hiddenSeries = ref(false) |
| | | const statisticMetrics = [{ key: 'finalVoltage1', title: '最终电压1', unit: 'V' }, { key: 'energyEfficiency', title: '能量效率', unit: '%' }] as const |
| | |
| | | const selectedCandidateId = ref<string | null>(null) |
| | | const candidateGroupId = ref<number | null>(null) |
| | | const candidateQuery = ref('') |
| | | const batteryGroup = computed(() => [...new Set(selectedTests.value.map(item => item.group))].join('、')) |
| | | const candidateTests = computed(() => sources.value.filter(source => |
| | | (!candidateGroupId.value || source.groupId === candidateGroupId.value) && |
| | | (source.name.includes(candidateQuery.value) || String(source.selection.testId).includes(candidateQuery.value) || source.batteryNo.includes(candidateQuery.value)))) |
| | |
| | | const matrix = computed(() => savedReport.value || draftMatrix.value) |
| | | const segmentMode = computed(() => !savedReport.value || savedReport.value.spliceMode === 'battery-segments') |
| | | const detailColumns = computed(() => matrix.value?.segments || []) |
| | | const sourceFields = computed(() => spliceSourceFields(detailColumns.value, sourceDetailOverrides.value)) |
| | | const independentDetails = computed(() => segmentMode.value && sourceDetailOverrides.value !== undefined) |
| | | const reportDetailColumns = computed(() => detailColumns.value.map(spliceReportDetailColumn)) |
| | | const statisticSettings = computed<SpliceSettings>(() => ({ |
| | | ...(sourceDetailOverrides.value === undefined ? {} : { sourceDetailOverrides: sourceDetailOverrides.value }), |
| | | sourceExperimentNames: sourceExperimentNames.value, |
| | | sourceDetailOverrides: snapshotReportDetails(reportDetailColumns.value, sourceDetailOverrides.value), |
| | | hiddenSeries: hiddenSeries.value, |
| | | fieldStructure: fields.value, |
| | | batteryFields: fields.value.filter(field => field.category === 'battery').map(field => field.name), |
| | | specialFields: fields.value.filter(field => field.category === 'special').map(field => field.name), |
| | | detailFieldValues: Object.fromEntries(fields.value.map(field => [field.name, Object.fromEntries((segmentMode.value ? selectedTests.value.map(spliceSourceKey) : samples.value) |
| | | .filter(battery => detailFieldValues.value[field.name]?.[battery] !== undefined) |
| | | .map(battery => [battery, detailFieldValues.value[field.name]![battery]!]))])), |
| | | fieldStructure: [], batteryFields: [], specialFields: [], detailFieldValues: {}, |
| | | statisticValues: statisticValues.value, |
| | | metrics: statisticMetrics.filter(metric => statisticVisible[metric.key]).map(metric => metric.key), seriesColors: seriesColors.value, |
| | | })) |
| | |
| | | function applySaved(saved: SavedSpliceReport) { |
| | | savedReport.value = saved |
| | | reportName.value = saved.name; experimentName.value = saved.description || '' |
| | | fields.value = spliceFields(saved.settings).map(field => ({ ...field })) |
| | | detailFieldValues.value = structuredClone(saved.settings.detailFieldValues || {}) |
| | | statisticValues.value = { ...saved.settings.statisticValues } |
| | | sourceExperimentNames.value = { ...saved.settings.sourceExperimentNames } |
| | | sourceDetailOverrides.value = saved.settings.sourceDetailOverrides === undefined ? undefined : structuredClone(saved.settings.sourceDetailOverrides) |
| | | hiddenSeries.value = saved.settings.hiddenSeries === true |
| | | seriesColors.value = [...(saved.settings.seriesColors || ['#3978f6', '#109854', '#d25b45', '#7754a8'])] |
| | |
| | | } else { |
| | | if (!canCreate.value) throw new Error('无新增拼接报表权限') |
| | | selectedTests.value = []; samples.value = []; reportName.value = ''; experimentName.value = '' |
| | | fields.value = []; detailFieldValues.value = {}; statisticValues.value = {}; reordered = false |
| | | sourceExperimentNames.value = {}; hiddenSeries.value = false |
| | | statisticValues.value = {}; reordered = false |
| | | hiddenSeries.value = false |
| | | sourceDetailOverrides.value = {} |
| | | sources.value = await getSpliceSources() |
| | | savedContent.value = formContent() |
| | |
| | | const item = selectedTests.value[index] |
| | | if (!item) return |
| | | selectedTests.value.splice(index, 1); selectedTests.value.splice(index + direction, 0, item); reordered = true |
| | | } |
| | | function fieldValue(field: SpliceField, battery: string) { return spliceFieldValue(matrix.value?.segments || [], statisticSettings.value, field, battery) } |
| | | function setFieldValue(field: SpliceField, battery: string, value: string) { |
| | | detailFieldValues.value[field.name] ||= {} |
| | | detailFieldValues.value[field.name][battery] = value |
| | | } |
| | | function setDetailValue(segment: SpliceSegment, value: string, field?: typeof sourceFields.value[number]) { |
| | | sourceDetailOverrides.value ||= {} |
| | | const key = spliceSourceKey(segment.source) |
| | | const current = sourceDetailOverrides.value[key] || {} |
| | | sourceDetailOverrides.value[key] = field ? { ...current, fieldValues: { ...current.fieldValues, [field.key]: value } } : { ...current, experimentName: value } |
| | | } |
| | | function applyFields(selected: SpliceField[]) { |
| | | // 模板不携值;同名但身份或单位不同的字段不能继承原模板人工值。来源详情覆盖完全独立。 |
| | | for (const field of selected) { |
| | | const previous = fields.value.find(item => item.name === field.name) |
| | | if (!previous || (previous.fieldDefinitionId ?? null) !== (field.fieldDefinitionId ?? null) || previous.category !== field.category || previous.unit !== field.unit) delete detailFieldValues.value[field.name] |
| | | } |
| | | fields.value = selected |
| | | } |
| | | async function addSpecialField() { |
| | | try { |
| | | const { value } = await ElMessageBox.prompt('请输入字段名称', '添加项', { confirmButtonText: '添加', cancelButtonText: '取消', inputValidator: value => Boolean(value?.trim()) || '请输入字段名称' }) |
| | | const name = value.trim() |
| | | if (fields.value.some(field => field.name === name)) { ElMessage.warning('该字段已经存在'); return } |
| | | fields.value.push({ name, displayName: name, category: 'special', unit: '' }) |
| | | } catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error('添加字段失败') } |
| | | } |
| | | function updateSeriesColor(index: number, color: string | null) { if (color) seriesColors.value[index] = color } |
| | | async function printReport() { |
| | |
| | | </div> |
| | | </section> |
| | | |
| | | <section class="detail-panel"> |
| | | <header class="panel-heading"><div><strong>测试详情</strong></div></header> |
| | | <div class="detail-meta"><label><span>报表说明</span><el-input v-model="experimentName" /></label><div><span>电池组</span><strong>{{ batteryGroup }}</strong></div></div> |
| | | <div v-if="independentDetails" class="detail-table-wrap"><table class="detail-table source-detail-table" :style="{ minWidth: `${Math.max(760, detailColumns.length * 190 + 170)}px` }"><thead><tr><th>来源详情字段</th><th v-for="segment in detailColumns" :key="spliceSourceKey(segment.source)">{{ segment.source.name }}<br>{{ segment.source.group }} · {{ segment.source.batteryNo }}</th></tr></thead><tbody> |
| | | <tr><td>实验</td><td v-for="segment in detailColumns" :key="spliceSourceKey(segment.source)"><el-input :model-value="spliceDetailValue(segment, sourceDetailOverrides)" :aria-label="`${segment.source.name} ${segment.source.batteryNo} 实验名称`" placeholder="缺失" @update:model-value="setDetailValue(segment, $event)" /></td></tr> |
| | | <tr v-for="field in sourceFields" :key="field.key"><td>{{ field.name }}({{ field.category === 'battery' ? '电池字段' : '特殊字段' }})</td><td v-for="segment in detailColumns" :key="spliceSourceKey(segment.source)"><el-input :model-value="spliceDetailValue(segment, sourceDetailOverrides, field)" :aria-label="`${segment.source.name} ${segment.source.batteryNo} ${field.category === 'battery' ? '电池字段' : '特殊字段'} ${field.name}`" size="small" placeholder="缺失" @update:model-value="setDetailValue(segment, $event, field)" /></td></tr> |
| | | </tbody></table></div> |
| | | <div v-if="segmentMode" class="detail-table-wrap"><table class="detail-table report-field-table" :style="{ minWidth: `${Math.max(760, detailColumns.length * 190 + 170)}px` }"><thead><tr><th>{{ independentDetails ? '报表字段' : '字段' }}</th><th v-for="segment in detailColumns" :key="spliceSourceKey(segment.source)">{{ segment.source.name }}<br>{{ segment.source.group }} · {{ segment.source.batteryNo }}</th></tr></thead><tbody> |
| | | <tr v-if="!independentDetails"><td>实验</td><td v-for="segment in detailColumns" :key="spliceSourceKey(segment.source)"><el-input :model-value="spliceExperiment(segment, statisticSettings)" placeholder="缺失" @update:model-value="sourceExperimentNames[spliceSourceKey(segment.source)] = $event" /></td></tr> |
| | | <tr v-for="field in fields" :key="field.name"><td>{{ field.displayName }}{{ field.unit ? ` (${field.unit})` : '' }}</td><td v-for="segment in detailColumns" :key="spliceSourceKey(segment.source)"><el-input :model-value="spliceSourceValue(segment, statisticSettings, field)" :aria-label="`${segment.source.name} ${segment.source.batteryNo} 报表字段 ${field.displayName}`" size="small" placeholder="缺失" @update:model-value="setFieldValue(field, spliceSourceKey(segment.source), $event)" /></td></tr> |
| | | </tbody></table></div> |
| | | <div v-else class="detail-table-wrap"><table class="detail-table" :style="{ minWidth: `${Math.max(760, samples.length * 190 + 170)}px` }"><thead><tr><th>字段</th><th v-for="sample in samples" :key="sample">{{ sample }}</th></tr></thead><tbody> |
| | | <tr v-for="field in fields" :key="field.name"><td>{{ field.displayName }}{{ field.unit ? ` (${field.unit})` : '' }}</td><td v-for="sample in samples" :key="sample"><el-input :model-value="fieldValue(field, sample)" size="small" placeholder="缺失" @update:model-value="setFieldValue(field, sample, $event)" /></td></tr> |
| | | </tbody></table></div> |
| | | <div class="detail-actions"><button class="primary-button" type="button" @click="addSpecialField"><el-icon><Plus /></el-icon>添加项</button></div> |
| | | <SpliceFieldControls :fields="fields" :segments="matrix?.segments || []" :can-create="canCreate" :can-edit="canEdit" @apply="applyFields" /> |
| | | </section> |
| | | <ReportTestDetail v-model="sourceDetailOverrides" :columns="reportDetailColumns" :disabled="saving || previewing || !!previewError" /> |
| | | <div class="detail-meta"><label><span>报表说明</span><el-input v-model="experimentName" /></label></div> |
| | | |
| | | <section v-if="statisticPreview" class="chart-panel"> |
| | | <div class="metric-switches"><label v-for="metric in statisticMetrics" :key="metric.key"><span>{{ metric.title }}</span><el-switch v-model="statisticVisible[metric.key]" /></label></div> |
| New file |
| | |
| | | import assert from 'node:assert/strict' |
| | | import { readFile, mkdir } from 'node:fs/promises' |
| | | import { extname, resolve } from 'node:path' |
| | | import { chromium, expect } from '@playwright/test' |
| | | |
| | | // 使用构建产物和浏览器请求接缝验证交互;不启动服务、不访问开发数据库。 |
| | | const dist = resolve(import.meta.dirname, '../dist') |
| | | const artifacts = resolve(import.meta.dirname, 'e2e/.artifacts/report-test-detail') |
| | | await mkdir(artifacts, { recursive: true }) |
| | | const browser = await chromium.launch({ channel: 'chrome', headless: true }) |
| | | const source = id => ({ selection: { testId: id, testBatteryId: id, cycleNo: 1, stepNo: 1, logicalNo: null }, |
| | | name: `测试${id}`, type: '化成', batteryGroup: `组${id}`, group: `组${id}`, groupId: id, batteryNo: '同名电池', process: '1-充电', startedAt: 1, |
| | | consoleDetail: { experimentName: '控制台实验', batteryFields: ['额定容量'], specialFields: ['待删除'], fieldValues: { 额定容量: { 同名电池: `来源${id}` }, 待删除: { 同名电池: '旧值' } } } }) |
| | | function fixture(type) { |
| | | const sources = [source(1), source(2)] |
| | | const sourceDetailOverrides = Object.fromEntries(sources.map(source => [`${source.selection.testId}|${source.selection.testBatteryId}`, { |
| | | batteryFields: ['额定容量'], specialFields: ['待删除'], fieldValues: { 'battery|额定容量': source.selection.testId === 1 ? '' : '报表乙', 'special|待删除': '独立值' }, |
| | | }])) |
| | | const settings = { metrics: [], seriesColors: type === 'formation' ? {} : ['#3978f6', '#109854'], sourceDetailOverrides, |
| | | fields: [{ key: 'chargeCapacity', scope: 'cycle', kind: 'metric', displayName: '充电容量', unit: 'mAh' }], |
| | | values: {}, chargeStepSelections: {}, batteryTemplateFields: [], batteryFields: [], specialFields: [], fieldStructure: [], detailFieldValues: {} } |
| | | const items = sources.map(source => ({ source, records: [], zeroTime: 1000, logicalMapping: null, businessFields: [], cycleNos: [], cycles: [], metrics: {}, chargeSteps: {}, curves: {}, stepCurves: {} })) |
| | | const segments = sources.map(source => ({ source, offset: 0, originalCycleNos: [], statistics: {}, businessFields: [], |
| | | consoleDetail: { ...source.consoleDetail, fieldValues: { 额定容量: `来源${source.selection.testId}`, 待删除: '旧值' } } })) |
| | | return { id: 7, name: `${type}详情`, description: '', versionNo: 1, creator: '操作员', operatorName: '操作员', createdAt: 1, versionCreatedAt: 1, changeSummary: '首次保存', |
| | | settings, items, segments, cycleNos: [], curveMode: 'actual-steps', spliceMode: 'battery-segments', units: {}, displayUnits: {} } |
| | | } |
| | | try { |
| | | for (const type of ['compare', 'formation', 'splice']) { |
| | | const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } }) |
| | | await context.addInitScript(() => { localStorage.setItem('battery_detection_auth_token', 'ui-fixture'); window.print = () => {} }) |
| | | let saved = fixture(type), submitted, failSave = true |
| | | const requests = [], errors = [] |
| | | await context.route('http://report.test/**', async route => { |
| | | const url = new URL(route.request().url()), path = url.pathname |
| | | if (path.startsWith('/dev-api')) { |
| | | requests.push({ path, method: route.request().method() }) |
| | | let response = { code: 200, msg: '成功', rows: [], total: 0 } |
| | | if (path.endsWith('/getInfo')) Object.assign(response, { user: { userName: 'admin', nickName: '操作员' }, roles: ['admin'], permissions: ['*:*:*'] }) |
| | | else if (path.includes('/batteryGroupField/')) response.rows = [{ fieldId: 1, fieldName: '额定容量', fieldType: 'text', status: '0' }, { fieldId: 2, fieldName: '质量', fieldType: 'text', status: '0' }] |
| | | else if (path.includes(`/${type}-report/7`)) { |
| | | if (route.request().method() === 'POST') { |
| | | if (failSave) { failSave = false; response = { code: 500, msg: '测试保存失败' } } |
| | | else { submitted = route.request().postDataJSON(); saved = { ...saved, ...submitted, versionNo: saved.versionNo + 1 }; response.data = saved } |
| | | } else response.data = saved |
| | | } |
| | | await route.fulfill({ json: response }); return |
| | | } |
| | | const file = path.startsWith('/assets/') ? resolve(dist, '.' + path) : resolve(dist, 'index.html') |
| | | assert.ok(file.startsWith(dist)) |
| | | await route.fulfill({ body: await readFile(file), contentType: { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml' }[extname(file)] || 'application/octet-stream' }) |
| | | }) |
| | | const page = await context.newPage() |
| | | page.on('pageerror', error => errors.push(error.message)) |
| | | await page.goto(`http://report.test/report-${type}/7/edit?mode=edit`) |
| | | const detail = page.locator('.report-test-detail') |
| | | await expect(detail).toBeVisible() |
| | | await expect(detail.getByText('实验', { exact: true })).toHaveCount(0) |
| | | await expect(detail.getByRole('button', { name: /保存/ })).toHaveCount(0) |
| | | await expect(detail.getByRole('button', { name: '字段设置', exact: true })).toHaveCount(0) |
| | | const row = name => detail.locator('.source-detail-row').filter({ has: page.locator('td:first-child', { hasText: name }) }) |
| | | await expect(row('额定容量').locator('input').nth(0)).toHaveValue('') |
| | | await expect(row('额定容量').locator('input').nth(1)).toHaveValue('报表乙') |
| | | await row('待删除').getByRole('button', { name: '删除', exact: true }).click() |
| | | await expect(row('待删除')).toHaveCount(0) |
| | | await detail.getByRole('button', { name: '添加项', exact: true }).click() |
| | | await page.getByRole('dialog', { name: '添加项' }).getByRole('textbox').fill('新字段') |
| | | await page.getByRole('button', { name: '添加', exact: true }).click() |
| | | await row('新字段').locator('input').nth(0).fill('甲填写') |
| | | await row('新字段').locator('input').nth(1).fill('乙填写') |
| | | await detail.getByRole('button', { name: '电池组字段模板', exact: true }).click() |
| | | let dialog = page.getByRole('dialog', { name: '选择电池组字段模板' }) |
| | | await dialog.locator('.el-checkbox').filter({ hasText: '额定容量' }).click() |
| | | await dialog.getByRole('button', { name: '取消', exact: true }).click() |
| | | await expect(row('额定容量')).toHaveCount(1) |
| | | await detail.getByRole('button', { name: '电池组字段模板', exact: true }).click() |
| | | await dialog.locator('.el-checkbox').filter({ hasText: '质量' }).click() |
| | | await dialog.getByRole('button', { name: '应用模板', exact: true }).click() |
| | | await expect(dialog).not.toBeVisible() |
| | | await expect(row('额定容量').locator('input').nth(1)).toHaveValue('报表乙') |
| | | await expect(row('新字段').locator('input').nth(0)).toHaveValue('甲填写') |
| | | await detail.getByRole('button', { name: '选择特殊字段模板', exact: true }).click() |
| | | dialog = page.getByRole('dialog', { name: '选择特殊字段模板' }) |
| | | await dialog.locator('.el-radio').filter({ hasText: '热特性模板' }).click() |
| | | await dialog.getByRole('button', { name: '应用模板', exact: true }).click() |
| | | await expect(dialog).not.toBeVisible() |
| | | await expect(row('新字段')).toHaveCount(0) |
| | | await row('最高温度').locator('input').nth(0).fill('报表最高温度') |
| | | await row('最高温度').locator('input').nth(1).fill('清空前') |
| | | await row('最高温度').locator('input').nth(1).fill('') |
| | | await page.evaluate(() => window.scrollTo(0, 0)) |
| | | await page.screenshot({ path: resolve(artifacts, `${type}-desktop.png`), fullPage: true, animations: 'disabled' }) |
| | | await page.setViewportSize({ width: 390, height: 844 }) |
| | | await expect(detail.getByRole('button', { name: '选择特殊字段模板', exact: true })).toBeVisible() |
| | | await expect.poll(async () => (await page.locator('.app-main').boundingBox()).x).toBe(0) |
| | | await page.screenshot({ path: resolve(artifacts, `${type}-mobile.png`), fullPage: true, animations: 'disabled' }) |
| | | await page.setViewportSize({ width: 1440, height: 1000 }) |
| | | if (type !== 'splice') await page.getByPlaceholder('请输入修改说明', { exact: true }).fill('统一详情') |
| | | const save = page.getByRole('button', { name: type === 'splice' ? '保存修改' : '保存新版本', exact: true }) |
| | | await save.click() |
| | | await expect(page.getByText('测试保存失败', { exact: true })).toBeVisible() |
| | | await expect(row('最高温度').locator('input').nth(0)).toHaveValue('报表最高温度') |
| | | await save.click() |
| | | await expect.poll(() => submitted).toBeTruthy() |
| | | assert.deepEqual(submitted.settings.sourceDetailOverrides['1|1'], { batteryFields: ['额定容量', '质量'], specialFields: ['最高温度', '温差', '温升速率'], fieldValues: { 'battery|额定容量': '', 'special|最高温度': '报表最高温度' } }) |
| | | assert.equal(submitted.settings.sourceDetailOverrides['2|2'].fieldValues['special|最高温度'], '') |
| | | if (type === 'formation') assert.ok(submitted.settings.fields.every(field => field.scope === 'cycle')) |
| | | else assert.deepEqual(submitted.settings.fieldStructure, []) |
| | | await page.goto(`http://report.test/report-${type}/7/edit?mode=edit`) |
| | | await expect(row('待删除')).toHaveCount(0) |
| | | await expect(row('新字段')).toHaveCount(0) |
| | | await expect(row('最高温度').locator('input').nth(0)).toHaveValue('报表最高温度') |
| | | const popupPromise = page.waitForEvent('popup') |
| | | await page.getByRole('button', { name: '打印 / 另存 PDF', exact: true }).click() |
| | | const popup = await popupPromise |
| | | await expect(popup.locator('.source-detail-table')).toBeVisible() |
| | | await expect(popup.locator('body')).toContainText('报表最高温度') |
| | | await expect(popup.locator('body')).not.toContainText('控制台实验') |
| | | await expect(popup.locator('body')).not.toContainText('报表字段') |
| | | await expect(popup.locator('body')).not.toContainText('待删除') |
| | | assert.ok(!requests.some(request => request.path.includes('console-detail'))) |
| | | assert.deepEqual(errors, []) |
| | | await context.close() |
| | | console.log(`${type}: 详情添加/删除、模板取消/应用、保存失败/成功、重开与打印通过`) |
| | | } |
| | | } finally { await browser.close() } |