| | |
| | | specialFields: string[] |
| | | detailFieldValues: Record<string, Record<string, string>> |
| | | fieldStructure?: CompareField[] |
| | | sourceDetailOverrides?: Record<string, { experimentName?: string; fieldValues?: Record<string, string> }> |
| | | } |
| | | export interface CreateCompareReport { |
| | | name: string |
| | |
| | | import { compareChartOption, compareSeriesName } from './compareReportChart' |
| | | import { compareFields, compareFieldLabel, compareBusinessFieldValue } from './compareReportFields' |
| | | import { openSavedReportPrint } from './savedReportPrint' |
| | | import { compareSourceKey, compareDetailSources, compareSourceFields, compareSourceValue, compareHasRangeDetailValues } from './compareSourceDetails' |
| | | |
| | | 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 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]?.[compareSelectionKey(item.source.selection)] |
| | | 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[] = [] |
| | | for (let start = 0; start < record.items.length; start += 4) { |
| | | const items = record.items.slice(start, start + 4) |
| | | 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)], |
| | | ['循环 / 工步', ...items.map(item => `${item.source.selection.cycleNo} / ${item.source.process}`)], |
| | | ...(!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>对比项 ${start + index + 1}</th>`).join('')}</tr></thead><tbody>${rows.map(row => `<tr>${row.map(cell).join('')}</tr>`).join('')}</tbody></table>`) |
| | | 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] |
| | |
| | | </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><div class="experiment">实验<strong>${escapeHtml(record.experimentName || '-')}</strong></div>${detailTables.join('')}</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><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>` |
| New file |
| | |
| | | import type { CompareCandidate, CompareItem, CompareSettings } from '@/api/compareReports' |
| | | |
| | | /** 同一来源的不同循环和工步共用详情,测量比对项仍保留完整范围身份。 */ |
| | | export function compareSourceKey(source: CompareCandidate) { |
| | | return `${source.selection.testId}|${source.selection.testBatteryId}` |
| | | } |
| | | |
| | | 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 VChart from 'vue-echarts' |
| | | import CompareReportHistoryDialog from '@/components/CompareReportHistoryDialog.vue' |
| | | import { getUserInfo } from '@/api/auth' |
| | | import type { CompareMetricKey, CompareTestSelection } from '@/stores/compareReports' |
| | | import type { CompareMetricKey } from '@/stores/compareReports' |
| | | import { compareSelectionKey, createCompareReport, editCompareReport, getCompareReport, getCompareSources, previewCompareReport, refreshCompareSource, |
| | | type CompareCandidate, type CompareItem, type CompareSelection, type SavedCompareReport } from '@/api/compareReports' |
| | | 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 type { CompareSettings } from '@/api/compareReports' |
| | | |
| | | use([CanvasRenderer, LineChart, GridComponent, LegendComponent, TooltipComponent]) |
| | | |
| | |
| | | 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 = CompareTestSelection & { selection: CompareSelection } |
| | | type SelectedTest = CompareCandidate & { id: number; cycle: number } |
| | | const selectedTests = ref<SelectedTest[]>([]) |
| | | const candidates = ref<CompareCandidate[]>([]) |
| | | const candidateTests = computed(() => [...new Map(candidates.value.map(item => [item.selection.testId, toSelectedTest(item)])).values()]) |
| | |
| | | const selectedCycle = ref<number | null>(null) |
| | | const selectedProcess = ref('') |
| | | const templateMode = ref<'battery' | 'special'>('battery') |
| | | const batteryFields = ref(['额定容量', '电流', '内阻']) |
| | | const specialFields = ref(['温升']) |
| | | const batteryFields = ref<string[]>([]) |
| | | const specialFields = ref<string[]>([]) |
| | | const detailFieldValues = reactive<Record<string, Record<string, string>>>({}) |
| | | const selectedBatteryTemplateName = ref('自定义') |
| | | const selectedBatteryTemplateFieldIds = ref<number[]>([]) |
| | |
| | | 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 detailTests = computed(() => selectedTests.value) |
| | | const chartSeries = computed(() => detailTests.value) |
| | | 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 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))]) |
| | | const cycleChoices = computed(() => [...new Set(testCandidates.value.filter(item => item.batteryNo === selectedBatteryNo.value).map(item => item.selection.cycleNo))].sort((a, b) => a - b)) |
| | |
| | | } |
| | | |
| | | function detailKey(item: SelectedTest) { |
| | | return compareSelectionKey(item.selection) |
| | | 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) { |
| | |
| | | const key = detailKey(item) |
| | | // 未填写才读取当前预览或保存快照的业务值,显式清空不被后续预览覆盖。 |
| | | if (detailFieldValues[field][key] === undefined) { |
| | | const source = sourceItems.value.find(value => compareSelectionKey(value.source.selection) === key) |
| | | const sourceDetail = source?.source.consoleDetail |
| | | const sourceValue = sourceDetail?.fieldValues?.[field]?.[source.source.batteryNo] |
| | | detailFieldValues[field][key] = sourceValue !== undefined |
| | | ? sourceValue |
| | | : compareBusinessFieldValue(fieldDescriptor(field), source) |
| | | const source = sourceItems.value.find(value => detailKey(toSelectedTest(value.source)) === key) |
| | | detailFieldValues[field][key] = compareBusinessFieldValue(fieldDescriptor(field), source) |
| | | } |
| | | }) |
| | | } |
| | |
| | | |
| | | 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)) |
| | |
| | | reportName.value = '' |
| | | description.value = '' |
| | | experimentName.value = '' |
| | | batteryFields.value = ['额定容量', '电流', '内阻'] |
| | | specialFields.value = ['温升'] |
| | | sourceDetailOverrides.value = {} |
| | | rangeDetailValues.value = false |
| | | batteryFields.value = [] |
| | | specialFields.value = [] |
| | | fieldStructure.value = [] |
| | | templateDialogVisible.value = false |
| | | fieldSettingsVisible.value = false |
| | |
| | | ElMessage.warning('请完整选择电池、循环和工步') |
| | | return |
| | | } |
| | | if (selectedTests.value.some(item => detailKey(item) === compareSelectionKey(candidate.selection))) { |
| | | if (selectedTests.value.some(item => compareSelectionKey(item.selection) === compareSelectionKey(candidate.selection))) { |
| | | ElMessage.warning('相同测试、电池和工步循环已加入对比信息') |
| | | return |
| | | } |
| | |
| | | metrics: visibleMetrics.value.map(item => item.key), seriesColors: seriesColors.value.slice(0, selectedTests.value.length), |
| | | 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(selectedTests.value.map(item => [detailKey(item), detailFieldValues[field]?.[detailKey(item)] || ''])), |
| | | Object.fromEntries(detailTests.value.map(item => [detailKey(item), detailFieldValues[field]?.[detailKey(item)] ?? ''])), |
| | | ])), |
| | | }, |
| | | } |
| | |
| | | |
| | | <section class="detail-panel"> |
| | | <header class="panel-heading"><div><strong>测试详情</strong><el-icon><ArrowUp /></el-icon></div></header> |
| | | <div class="detail-meta"> |
| | | <div v-if="sourceDetailOverrides === undefined || experimentName" class="detail-meta"> |
| | | <label><span>实验</span><el-input v-model="experimentName" :readonly="readonly" maxlength="500" /></label> |
| | | </div> |
| | | <div class="field-table-wrap"> |
| | | <table class="field-table" :style="{ minWidth: `${Math.max(900, detailTests.length * 220 + 214)}px` }"> |
| | | <thead><tr><th>字段</th><th v-for="(item, index) in detailTests" :key="detailKey(item)">对比项 {{ index + 1 }}</th><th v-if="!readonly">操作</th></tr></thead> |
| | | <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.consoleDetail?.experimentName || item.experimentName || '' }}</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"><td :colspan="detailTests.length + (readonly ? 1 : 2)" class="empty-cell">暂无字段</td></tr> |
| | | <tr v-if="!detailFields.length && !sourceFields.length"><td :colspan="detailTests.length + (readonly ? 1 : 2)" class="empty-cell">暂无字段</td></tr> |
| | | </tbody> |
| | | </table> |
| | | </div> |
| | |
| | | |
| | | <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> |
| | | <div v-if="chartSeries.length" class="chart-legend-bar"><strong>对比图例</strong><div v-for="(item, index) in chartSeries" :key="detailKey(item)" class="chart-legend-item"><el-color-picker v-if="!readonly" class="legend-color-picker" :model-value="seriesColors[index]" :show-alpha="false" popper-class="legend-color-popper" :aria-label="`修改${item.name}颜色`" @change="updateSeriesColor(index, $event)" /><i v-else class="series-swatch" :style="{ background: seriesColors[index] }" /><span>{{ item.name }} · {{ item.batteryNo }} · 循环{{ item.cycle }} · {{ item.process }}</span></div></div> |
| | | <div v-if="chartSeries.length" class="chart-legend-bar"><strong>对比图例</strong><div v-for="(item, index) in chartSeries" :key="compareSelectionKey(item.selection)" class="chart-legend-item"><el-color-picker v-if="!readonly" class="legend-color-picker" :model-value="seriesColors[index]" :show-alpha="false" popper-class="legend-color-popper" :aria-label="`修改${item.name}颜色`" @change="updateSeriesColor(index, $event)" /><i v-else class="series-swatch" :style="{ background: seriesColors[index] }" /><span>{{ item.name }} · {{ item.batteryNo }} · 循环{{ item.cycle }} · {{ item.process }}</span></div></div> |
| | | <!-- 报表切换和比对项移除时完整替换配置,避免图表库将颜色数组缩短误判为组件合并。 --> |
| | | <div class="charts-grid"><article v-for="metric in visibleMetrics" :key="metric.key" class="chart-card"><header><strong>{{ metric.title }}</strong></header><VChart class="metric-chart" :option="compareChartOption(metric, sourceItems, seriesColors)" :update-options="{ notMerge: true }" autoresize /></article></div> |
| | | </section> |
| | |
| | | .detail-meta label { display: grid; grid-template-columns: 48px minmax(0, 1fr); align-items: center; gap: 10px; } |
| | | .field-table { min-width: 900px; } |
| | | .field-table th:first-child, .field-table td:first-child { width: 140px; text-align: left; } |
| | | .field-table th:last-child, .field-table td:last-child { width: 74px; } |
| | | .field-table.with-actions th:last-child, .field-table.with-actions td:last-child { width: 74px; } |
| | | .field-table th, .field-table td { white-space: normal; overflow-wrap: anywhere; padding-top: 6px; padding-bottom: 6px; } |
| | | .source-field-kind { display: block; color: #71808d; font-size: 11px; margin-top: 4px; } |
| | | .field-table .sample-row td { background: #fbfdff; } |
| | | .field-table .detail-meta-row td { background: #fbfdff; color: #26333f; } |
| | | .field-table .detail-meta-row td:not(:first-child) { font-weight: 500; } |
| New file |
| | |
| | | import { expect, test, type Page } from '@playwright/test' |
| | | import { Ticket04Stack } from './support/ticket04-stack' |
| | | |
| | | const stack = new Ticket04Stack() |
| | | test.use({ actionTimeout: 20_000 }) |
| | | test.beforeAll(async () => { |
| | | await stack.start() |
| | | stack.executeIsolatedSql(` |
| | | INSERT INTO t_battery_group(group_id,group_name,model) VALUES(9301,'来源甲组','型号'),(9302,'来源乙组','型号'); |
| | | INSERT INTO t_battery_group_field(field_id,field_name,field_type) VALUES(9301,'额定容量','text'); |
| | | INSERT INTO t_battery_group_relation_field(group_id,battery_group_field_id,field_value) VALUES(9301,9301,'不得冒充详情'),(9302,9301,'不得补空'); |
| | | INSERT INTO t_test_type(test_type_id,test_type_name) VALUES(9301,'详情对比'); |
| | | INSERT INTO t_test(test_id,test_name,creator_id,principal_id,test_type_id,test_purpose,test_status,reception_status,logic_step_mappings_json) |
| | | VALUES(9301,'来源甲测试',1,1,9301,'隔离验收','COMPLETED','ENDED','[{"logicalNo":1,"logicalName":"合并范围","startStepNo":1,"endStepNo":2}]'), |
| | | (9302,'来源乙测试',1,1,9301,'隔离验收','COMPLETED','ENDED',NULL), |
| | | (9303,'缺详情测试',1,1,9301,'隔离验收','COMPLETED','ENDED',NULL), |
| | | (9304,'未完成测试',1,1,9301,'隔离验收','WAITING','NOT_STARTED',NULL); |
| | | INSERT INTO t_test_battery(test_battery_id,test_id,group_id,battery_no,collector_id) |
| | | VALUES(9301,9301,9301,'BAT-A',9301),(9302,9301,9301,'BAT-B',9302),(9303,9302,9302,'BAT-A',9303),(9304,9303,9302,'BAT-C',9304); |
| | | INSERT INTO t_detection_cabinet(cabinet_id,adapter_code,cabinet_code,cabinet_name,vendor_device_type,vendor_device_id) |
| | | VALUES(9301,'detail-fixture','detail-fixture','详情柜',27,1); |
| | | ${[9301, 9302, 9303, 9304].map((battery, index) => { |
| | | const testId = index < 2 ? 9301 : index === 2 ? 9302 : 9303 |
| | | return `INSERT INTO t_detection_channel(channel_id,adapter_code,cabinet_id,devtype,devid,subdevid,chlid) VALUES(${battery},'detail-fixture',9301,27,1,1,${index + 1}); |
| | | INSERT INTO t_test_channel_binding(binding_id,test_id,test_battery_id,channel_id) VALUES(${battery},${testId},${battery},${battery}); |
| | | INSERT INTO t_test_realtime_record(test_id,test_battery_id,binding_id,adapter_code,source_identity,source_record_id,cabinet_devtype,cabinet_devid,cabinet_subdevid,cabinet_chlid,cabinet_sequence_id,cabinet_device_time_utc,collector_device_time_utc,server_received_at_utc,cycle_no,step_no,step_name,voltage_v,current_a,content_sha256) |
| | | VALUES ${[1, 2].flatMap(step => [0, 1].map(point => `(${testId},${battery},${battery},'detail-fixture','detail:${battery}','${step}-${point}',27,1,1,${index + 1},${step * 10 + point},'2026-09-01 00:0${step}:0${point}.123','2026-09-01 00:0${step}:0${point}.123','2026-09-01 00:0${step}:0${point}.123',1,${step},'${step === 1 ? '充电' : '放电'}',${3 + index / 10 + point / 10},-1,UNHEX(SHA2('${battery}-${step}-${point}',256)))`)).join(',')};` |
| | | }).join('\n')} |
| | | `) |
| | | }) |
| | | test.afterAll(async () => stack.stop()) |
| | | |
| | | async function login(page: Page) { |
| | | await page.goto('/login') |
| | | await page.locator('input[autocomplete="username"]').fill('admin') |
| | | await page.locator('input[autocomplete="current-password"]').fill('admin123') |
| | | await page.getByRole('button', { name: '登录', exact: true }).click() |
| | | await expect(page).toHaveURL(/\/dashboard$/) |
| | | } |
| | | |
| | | async function api(page: Page, path: string, method = 'GET', data?: unknown, code = 200) { |
| | | const token = await page.evaluate(() => localStorage.getItem('battery_detection_auth_token')) |
| | | const response = await page.request.fetch(`${stack.backendUrl}/system/${path}`, { method, data, headers: { Authorization: `Bearer ${token}` } }) |
| | | const body = await response.json() |
| | | expect(body.code, `${path}: ${body.msg}`).toBe(code) |
| | | return body |
| | | } |
| | | |
| | | const detailA = { experimentName: '实验甲', batteryFields: ['额定容量'], specialFields: ['说明', '甲独有'], |
| | | fieldValues: { 额定容量: { 'BAT-A': '人工甲', 'BAT-B': '' }, 说明: { 'BAT-A': '甲说明', 'BAT-B': '乙说明' }, 甲独有: { 'BAT-A': '独有值' } } } |
| | | const detailB = { experimentName: '实验乙', batteryFields: ['额定容量'], specialFields: ['说明'], |
| | | fieldValues: { 额定容量: { 'BAT-A': '人工乙' }, 说明: { 'BAT-A': '跨组说明' } } } |
| | | const sourceRow = (page: Page, field: string) => page.locator(`.source-detail-row[data-field="${field}"]`) |
| | | const selection = (testId = 9301, testBatteryId = 9301, stepNo = 1) => ({ testId, testBatteryId, cycleNo: 1, stepNo, logicalNo: null }) |
| | | |
| | | async function add(page: Page, name: string, battery: string, process = '1-充电') { |
| | | await page.getByRole('button', { name: '添加比较', exact: true }).click() |
| | | const dialog = page.getByRole('dialog') |
| | | await dialog.locator('.el-select').nth(0).click() |
| | | await page.getByRole('option').filter({ hasText: name }).click() |
| | | await dialog.locator('.el-select').nth(1).click() |
| | | await page.getByRole('option', { name: battery, exact: true }).click() |
| | | await dialog.locator('.el-select').nth(3).click() |
| | | await page.getByRole('option', { name: process, exact: true }).click() |
| | | const response = page.waitForResponse(r => r.url().endsWith('/compare-report/preview') && r.request().method() === 'POST') |
| | | await dialog.getByRole('button', { name: '添加', exact: true }).click() |
| | | expect((await (await response).json()).code).toBe(200) |
| | | } |
| | | |
| | | test('详情从控制台按来源冻结,范围独立、人工值保存重开及删源打印完整', async ({ page }) => { |
| | | const errors: string[] = [] |
| | | page.on('pageerror', error => errors.push(error.message)) |
| | | await login(page) |
| | | await api(page, 'test/9301/console-detail', 'PUT', detailA) |
| | | await api(page, 'test/9302/console-detail', 'PUT', detailB) |
| | | await page.goto('/test-list/9301/console?tab=step-chart') |
| | | await expect(page.locator('.detail-meta input')).toHaveValue('实验甲') |
| | | await page.locator('.detail-meta input').fill('控制台实验甲') |
| | | const consoleResponse = page.waitForResponse(r => r.url().endsWith('/console-detail') && r.request().method() === 'PUT') |
| | | await page.getByRole('button', { name: '保存详情', exact: true }).click() |
| | | expect((await (await consoleResponse).json()).code).toBe(200) |
| | | await page.reload() |
| | | await expect(page.locator('.detail-meta input')).toHaveValue('控制台实验甲') |
| | | const candidates = (await api(page, 'compare-report/sources')).data |
| | | expect(candidates.some((item: any) => item.selection.testId === 9304)).toBe(false) |
| | | await api(page, 'compare-report/preview', 'POST', [selection(9301, 9303)], 500) |
| | | await api(page, 'compare-report/preview', 'POST', [selection(9304, 9301)], 500) |
| | | await api(page, 'compare-report/preview', 'POST', [selection(9301, 9301, 99)], 500) |
| | | const logical = (await api(page, 'compare-report/preview', 'POST', [{ ...selection(), stepNo: null, logicalNo: 1 }])).data[0] |
| | | expect(logical.records).toHaveLength(4) |
| | | |
| | | await page.goto('/report-compare/new') |
| | | await page.getByPlaceholder('请输入报表名称').fill('来源详情独立报表') |
| | | await add(page, '来源甲测试', 'BAT-A') |
| | | await add(page, '来源甲测试', 'BAT-A', '2-放电') |
| | | await add(page, '来源甲测试', 'BAT-B') |
| | | await add(page, '来源乙测试', 'BAT-A') |
| | | await add(page, '缺详情测试', 'BAT-C') |
| | | await expect(page.locator('.compare-table tbody tr')).toHaveCount(5) |
| | | await expect(page.locator('.source-experiment-row input')).toHaveCount(4) |
| | | await expect(page.locator('.source-experiment-row input').nth(0)).toHaveValue('控制台实验甲') |
| | | await expect(page.locator('.source-experiment-row input').nth(2)).toHaveValue('实验乙') |
| | | await expect(sourceRow(page, 'battery|额定容量').locator('input').nth(0)).toHaveValue('人工甲') |
| | | await expect(sourceRow(page, 'battery|额定容量').locator('input').nth(1)).toHaveValue('') |
| | | await expect(sourceRow(page, 'battery|额定容量').locator('input').nth(2)).toHaveValue('人工乙') |
| | | await expect(sourceRow(page, 'battery|额定容量').locator('input').nth(3)).toHaveValue('') |
| | | await expect(sourceRow(page, 'special|甲独有').locator('input').nth(2)).toHaveValue('') |
| | | await sourceRow(page, 'special|说明').locator('input').nth(0).fill('报表独立修改') |
| | | await page.locator('.source-experiment-row input').nth(0).fill('报表实验甲') |
| | | await page.locator('.compare-table tbody tr').nth(0).getByRole('button', { name: '删除', exact: true }).click() |
| | | await expect(page.locator('.compare-table tbody tr')).toHaveCount(4) |
| | | await expect(sourceRow(page, 'special|说明').locator('input').nth(0)).toHaveValue('报表独立修改') |
| | | await add(page, '来源甲测试', 'BAT-A') |
| | | await api(page, 'test/9302/console-detail', 'PUT', { ...detailB, experimentName: '保存时实验乙', fieldValues: { ...detailB.fieldValues, 额定容量: { 'BAT-A': '保存时乙值' } } }) |
| | | await page.getByRole('button', { name: '打印 / 另存 PDF', exact: true }).click() |
| | | await expect(page.getByText('请先保存报表后再打印', { exact: true })).toBeVisible() |
| | | const saveResponse = page.waitForResponse(r => r.url().endsWith('/compare-report') && r.request().method() === 'POST') |
| | | await page.getByRole('button', { name: '保存报表', exact: true }).click() |
| | | const saved = (await (await saveResponse).json()).data |
| | | expect(saved.items).toHaveLength(5) |
| | | expect(saved.items[0].source.consoleDetail.fieldValues['额定容量']).toEqual({ 'BAT-A': '人工甲' }) |
| | | expect(saved.items[2].source.consoleDetail.experimentName).toBe('保存时实验乙') |
| | | expect(saved.settings.sourceDetailOverrides['9301|9301'].fieldValues['special|说明']).toBe('报表独立修改') |
| | | expect((await api(page, 'test/9301')).data.consoleDetail.fieldValues['说明']['BAT-A']).toBe('甲说明') |
| | | await page.goto(`/report-compare/${saved.id}/edit?mode=view`) |
| | | await expect(page.locator('.source-experiment-row input').nth(0)).toHaveValue('报表实验甲') |
| | | await expect(sourceRow(page, 'battery|额定容量').locator('input').nth(2)).toHaveValue('保存时乙值') |
| | | await page.reload() |
| | | await expect(sourceRow(page, 'special|说明').locator('input').nth(0)).toHaveValue('报表独立修改') |
| | | await expect(page.locator('.metric-chart canvas').first()).toBeVisible() |
| | | |
| | | await api(page, 'test/9301/console-detail', 'PUT', { ...detailA, experimentName: '来源随后改变', fieldValues: { ...detailA.fieldValues, 额定容量: { 'BAT-A': '最新甲值', 'BAT-B': '' } } }) |
| | | await page.getByRole('button', { name: '编辑报表', exact: true }).click() |
| | | await expect(sourceRow(page, 'battery|额定容量').locator('input').nth(0)).toHaveValue('人工甲') |
| | | await sourceRow(page, 'battery|额定容量').locator('input').nth(2).fill('') |
| | | await page.getByPlaceholder('请输入修改说明').fill('报表人工清空') |
| | | await page.getByRole('button', { name: '打印 / 另存 PDF', exact: true }).click() |
| | | await expect(page.getByText('有未保存修改,请先保存新版本后再打印', { exact: true })).toBeVisible() |
| | | await page.getByRole('button', { name: '保存新版本', exact: true }).click() |
| | | await expect(page).toHaveURL(/mode=view/) |
| | | const v2 = (await api(page, `compare-report/${saved.id}`)).data |
| | | expect(v2.items).toEqual(saved.items) |
| | | expect(v2.settings.sourceDetailOverrides['9302|9303'].fieldValues['battery|额定容量']).toBe('') |
| | | await api(page, `compare-report/${saved.id}/refresh-source`, 'POST', { baseVersionNo: 1, changeSummary: '陈旧基准拒绝' }, 500) |
| | | const v3 = (await api(page, `compare-report/${saved.id}/refresh-source`, 'POST', { baseVersionNo: 2, changeSummary: '主动更新来源' })).data |
| | | expect(v3.items[0].source.consoleDetail.fieldValues['额定容量']['BAT-A']).toBe('最新甲值') |
| | | expect(v3.settings.sourceDetailOverrides).toEqual(v2.settings.sourceDetailOverrides) |
| | | await api(page, 'test/9301', 'DELETE') |
| | | await api(page, `compare-report/${saved.id}/refresh-source`, 'POST', { baseVersionNo: 3, changeSummary: '删源更新失败' }, 500) |
| | | await page.context().addInitScript(() => { window.print = () => { document.body.dataset.printRequested = 'true' } }) |
| | | for (const version of [1, 3]) { |
| | | await page.goto(`/report-compare/${saved.id}/edit?mode=view&version=${version}`) |
| | | await expect(page.locator('.source-experiment-row input')).toHaveCount(4) |
| | | await expect(sourceRow(page, 'battery|额定容量').locator('input').nth(0)).toHaveValue(version === 1 ? '人工甲' : '最新甲值') |
| | | await page.setViewportSize({ width: 1440, height: 1000 }) |
| | | await page.screenshot({ path: `tests/e2e/.artifacts/compare-detail-v${version}-desktop.png`, fullPage: true }) |
| | | await page.setViewportSize({ width: 390, height: 844 }) |
| | | const menuToggle = page.getByRole('button', { name: '折叠菜单', exact: true }) |
| | | if (await menuToggle.count()) await menuToggle.click() |
| | | await page.getByRole('button', { name: '打印 / 另存 PDF', exact: true }).scrollIntoViewIfNeeded() |
| | | expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true) |
| | | await page.screenshot({ path: `tests/e2e/.artifacts/compare-detail-v${version}-narrow.png`, fullPage: true }) |
| | | const popupPromise = page.waitForEvent('popup') |
| | | await page.getByRole('button', { name: '打印 / 另存 PDF', exact: true }).click() |
| | | const popup = await popupPromise |
| | | await expect(popup.locator('h1')).toHaveText('来源详情独立报表') |
| | | await expect(popup.locator('.compare-table tbody tr')).toHaveCount(5) |
| | | await expect(popup.locator('.detail-table thead th')).toHaveCount(5) |
| | | await expect(popup.locator('.detail-table')).toContainText('报表实验甲') |
| | | await expect(popup.locator('.detail-table')).toContainText('保存时实验乙') |
| | | await expect(popup.locator('.detail-table')).toContainText('报表独立修改') |
| | | await expect(popup.locator('.detail-table')).toContainText(version === 1 ? '人工甲' : '最新甲值') |
| | | await expect(popup.locator('body')).toHaveAttribute('data-print-requested', 'true') |
| | | await popup.setViewportSize({ width: 1200, height: 900 }) |
| | | await popup.screenshot({ path: `tests/e2e/.artifacts/compare-detail-v${version}-print.png`, fullPage: true }) |
| | | await popup.pdf({ path: `tests/e2e/.artifacts/compare-detail-v${version}.pdf`, preferCSSPageSize: true, printBackground: true }) |
| | | await popup.close() |
| | | expect((await api(page, `compare-report/${saved.id}/versions/${version}/print`)).data.items).toEqual(version === 1 ? saved.items : v3.items) |
| | | } |
| | | expect(errors).toEqual([]) |
| | | }) |