yeg
2026-09-08 45f82842224b64e15308a858778b6fdb62bcdf2d
按来源冻结对比详情并完善打印
2个文件已添加
3个文件已修改
329 ■■■■■ 已修改文件
src/api/compareReports.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/compareReportPrint.ts 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/compareSourceDetails.ts 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ReportCompareForm.vue 81 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/e2e/compare-source-details.spec.ts 181 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/compareReports.ts
@@ -51,6 +51,7 @@
  specialFields: string[]
  detailFieldValues: Record<string, Record<string, string>>
  fieldStructure?: CompareField[]
  sourceDetailOverrides?: Record<string, { experimentName?: string; fieldValues?: Record<string, string> }>
}
export interface CreateCompareReport {
  name: string
src/utils/compareReportPrint.ts
@@ -7,6 +7,7 @@
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])
@@ -42,27 +43,43 @@
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]
@@ -85,7 +102,7 @@
  </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>`
src/utils/compareSourceDetails.ts
New file
@@ -0,0 +1,37 @@
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] ?? '' : ''
}
src/views/ReportCompareForm.vue
@@ -9,13 +9,15 @@
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])
@@ -42,9 +44,11 @@
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()])
@@ -59,8 +63,8 @@
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[]>([])
@@ -95,8 +99,10 @@
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))
@@ -127,7 +133,14 @@
}
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) {
@@ -136,12 +149,8 @@
    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)
    }
  })
}
@@ -152,6 +161,8 @@
  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))
@@ -185,8 +196,10 @@
  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
@@ -344,7 +357,7 @@
    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
  }
@@ -457,8 +470,10 @@
      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)] ?? ''])),
      ])),
    },
  }
@@ -566,19 +581,31 @@
    <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>
@@ -587,7 +614,7 @@
    <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>
@@ -677,7 +704,9 @@
.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; }
tests/e2e/compare-source-details.spec.ts
New file
@@ -0,0 +1,181 @@
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([])
})