yeg
2026-09-06 6163832178a3f36df323374c283a694fea334691
接入对比报表已保存版本打印与修订导出

打印经后端导出权限读取指定保存版本,复用真实曲线与单位口径,补充草稿提示、弹窗失败反馈、多页表格和完整图例。修订记录导出使用正式版本摘要,增加隔离接口与浏览器验收。
2个文件已添加
5个文件已修改
594 ■■■■■ 已修改文件
src/api/compareReports.ts 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/CompareReportHistoryDialog.vue 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/compareReportChart.ts 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/compareReportPrint.ts 231 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ReportCompare.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ReportCompareForm.vue 92 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/e2e/compare-saved-version-print.spec.ts 185 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/compareReports.ts
@@ -98,6 +98,16 @@
  return (await request<ApiResponse & { data: CompareVersionSummary[] }>(`/system/compare-report/${id}/versions`)).data
}
/** 通过独立导出权限读取指定版本,打印不能使用页面草稿或重新读取来源。 */
export async function getComparePrintVersion(id: number, versionNo: number) {
  return (await request<ApiResponse & { data: SavedCompareReport }>(`/system/compare-report/${id}/versions/${versionNo}/print`)).data
}
/** 修订 CSV 使用正式版本摘要,后端校验导出权限。 */
export async function exportCompareVersions(id: number) {
  return (await request<ApiResponse & { data: CompareVersionSummary[] }>(`/system/compare-report/${id}/versions/export`)).data
}
/** 普通编辑只提交展示值,来源范围与测量沿用快照;后端拒绝陈旧基准。 */
export async function editCompareReport(id: number, data: Omit<CreateCompareReport, 'selections'> & { baseVersionNo: number; changeSummary: string }) {
  return (await request<ApiResponse & { data: SavedCompareReport }>(`/system/compare-report/${id}/versions`, {
src/components/CompareReportHistoryDialog.vue
@@ -1,14 +1,16 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { listCompareVersions, type CompareVersionSummary } from '@/api/compareReports'
import { ElMessage } from 'element-plus'
import { exportCompareVersions, listCompareVersions, type CompareVersionSummary } from '@/api/compareReports'
const props = defineProps<{ reportId: number | null }>()
const props = defineProps<{ reportId: number | null; canExport: boolean }>()
const visible = defineModel<boolean>({ default: false })
const router = useRouter()
const versions = ref<CompareVersionSummary[]>([])
const loading = ref(false)
const error = ref('')
const exporting = ref(false)
// 切换报表或关闭弹窗后,旧请求不能覆盖下次打开的历史列表。
watch(() => [visible.value, props.reportId] as const, async ([open, id], _old, onCleanup) => {
@@ -30,6 +32,26 @@
  visible.value = false
  router.push({ path: `/report-compare/${props.reportId}/edit`, query: { mode: 'view', version: versionNo } })
}
async function exportRevisions() {
  if (!props.reportId || exporting.value) return
  const id = props.reportId
  exporting.value = true
  try {
    const history = await exportCompareVersions(id)
    // 文本单元格避免被表格软件解释为公式,CSV 只表达版本修订事实。
    const cell = (value: string) => `"${(/^[=+@\-\t\r\n]/.test(value) ? `'${value}` : value).replace(/"/g, '""')}"`
    const rows = history.map(version => [String(version.versionNo), version.operatorName,
      new Date(version.createdAt).toLocaleString(), version.changeSummary].map(cell).join(','))
    const url = URL.createObjectURL(new Blob(['\uFEFF版本号,操作人,时间,修改说明\r\n', rows.join('\r\n')], { type: 'text/csv;charset=utf-8' }))
    const anchor = document.createElement('a')
    anchor.href = url
    anchor.download = `对比报表-${id}-修订记录.csv`
    anchor.click()
    window.setTimeout(() => URL.revokeObjectURL(url), 1000)
  } catch (cause) { ElMessage.error(cause instanceof Error ? cause.message : '修订记录导出失败') }
  finally { exporting.value = false }
}
</script>
<template>
@@ -42,6 +64,7 @@
      <el-table-column prop="changeSummary" label="修改说明" min-width="210" />
      <el-table-column label="操作" width="88" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="viewVersion(row.versionNo)">查看版本</el-button></template></el-table-column>
    </el-table>
    <template #footer><el-button v-if="canExport" :loading="exporting" :disabled="loading || !!error" @click="exportRevisions"><el-icon><Download /></el-icon>导出修订 CSV</el-button></template>
  </el-dialog>
</template>
src/utils/compareReportChart.ts
New file
@@ -0,0 +1,45 @@
import type { CompareItem } from '@/api/compareReports'
import type { PersistedTestRecord } from '@/api/tests'
import type { CompareMetricKey } from '@/stores/compareReports'
import { toTestDisplayValue } from './testDataUnits'
/** 完整所选范围参与图例身份,避免同名电池的不同循环或工步混淆。 */
export function compareSeriesName(item: CompareItem) {
  const source = item.source
  return `${source.name} · ${source.batteryNo} · 循环${source.selection.cycleNo} · ${source.process}`
}
/** 页面和打印共用测量、时间及单位口径;不连接缺失值,也不限制曲线条数。 */
export function compareChartOption(metric: { key: CompareMetricKey; unit: string }, items: CompareItem[], colors: string[]) {
  const field: Record<CompareMetricKey, keyof PersistedTestRecord> = { voltage: 'voltage', current: 'current', positiveVoltage: 'positivePotential', negativeVoltage: 'negativePotential', temperature: 'temperature' }
  const series = items.map(item => item.records.flatMap(row => {
    // 柜与采集器分别使用自己的设备时间,共享已冻结的比对项零点。
    const time = metric.key === 'voltage' || metric.key === 'current' ? row.deviceTime : row.collectorDeviceTime
    if (time == null || item.zeroTime == null) return []
    const value = row[field[metric.key]]
    return [[(Number(time) - item.zeroTime) / 1000, value == null ? null : toTestDisplayValue(metric.key, Number(value))] as [number, number | null]]
  }).sort((left, right) => left[0] - right[0]))
  const values = series.flat().map(point => point[1]).filter((value): value is number => value !== null)
  const dataMin = Math.min(0, ...values)
  const dataMax = Math.max(0, ...values)
  const range = Math.max(dataMax - dataMin, 1)
  const axisMax = Math.max(metric.unit === '℃' ? Math.ceil(dataMax / 10) * 10 : Math.ceil(dataMax * 10) / 10, 1)
  const yAxis = (position: 'left' | 'right') => ({
    type: 'value', name: metric.unit, position, min: dataMin - range * 0.05, max: axisMax,
    nameTextStyle: { color: '#8795a3', fontSize: 10 },
    axisLine: { show: true, lineStyle: { color: '#cbd8e4' } },
    axisTick: { show: true, lineStyle: { color: '#cbd8e4' } },
    splitLine: { show: position === 'left', lineStyle: { color: '#e8eef4' } },
    axisLabel: { color: '#8795a3', fontSize: 10 },
  })
  return {
    animation: false, color: colors, grid: { top: 40, right: 46, bottom: 42, left: 46 },
    tooltip: { trigger: 'axis' }, legend: { show: false },
    xAxis: { type: 'value', min: 0, name: '相对时间 / s', nameLocation: 'middle', nameGap: 28,
      axisLine: { lineStyle: { color: '#cbd8e4' } }, axisLabel: { color: '#8795a3', fontSize: 10 },
      nameTextStyle: { color: '#26333f', fontSize: 11, fontWeight: 600 } },
    yAxis: [yAxis('left'), yAxis('right')],
    series: items.map((item, index) => ({ name: compareSeriesName(item), type: 'line', smooth: false,
      connectNulls: false, showSymbol: true, symbolSize: 5, lineStyle: { width: 2 }, data: series[index] || [] })),
  }
}
src/utils/compareReportPrint.ts
@@ -1,5 +1,12 @@
import { toTestDisplayValue } from './testDataUnits'
import type { CompareMetricKey, CompareReportRecord, CompareTestSelection } from '@/stores/compareReports'
import { init, use } from 'echarts/core'
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 type { CompareMetricKey } from '@/stores/compareReports'
import { compareChartOption, compareSeriesName } from './compareReportChart'
use([LineChart, GridComponent, SVGRenderer])
export interface CompareMetricDefinition {
  key: CompareMetricKey
@@ -8,22 +15,7 @@
  values: number[][]
}
export interface CompareReportPrintData {
  name: string
  reportId?: number
  creator?: string
  createdAt?: string
  experimentName: string
  batteryGroup: string
  tests: CompareTestSelection[]
  detailTests: CompareTestSelection[]
  fields: string[]
  detailFieldValues: Record<string, Record<string, string>>
  metrics: CompareMetricKey[]
  seriesColors: string[]
}
// 演示数组仍为内部单位,在页面和打印输出时统一换算。
// 未交付的其他页面仍复用演示数组;正式对比页面与打印只读取后端测量。
export const compareMetricDefinitions: CompareMetricDefinition[] = [
  { key: 'voltage', title: '电压', unit: 'V', values: [[1.25, 1.34, 1.46, 1.42, 1.48, 1.55, 1.58], [1.12, 1.20, 1.31, 1.28, 1.36, 1.31, 1.28], [1.38, 1.43, 1.49, 1.47, 1.51, 1.56, 1.62]] },
  { key: 'current', title: '电流', unit: 'mA', values: [[1.10, 1.15, 1.23, 1.20, 1.26, 1.30, 1.38], [0.94, 1.02, 1.11, 1.06, 1.10, 1.08, 1.04], [1.18, 1.24, 1.29, 1.27, 1.34, 1.39, 1.48]] },
@@ -32,155 +24,88 @@
  { key: 'temperature', title: '温度', unit: '℃', values: [[25, 27, 30, 29, 32, 35, 38], [25, 26, 29, 31, 34, 37, 41], [25, 28, 31, 30, 33, 36, 39]] },
]
const defaultBatteryFields = ['额定容量', '电流', '内阻']
const defaultSpecialFields = ['温升']
const defaultSeriesColors = ['#3978f6', '#7d42d8', '#f08a3e']
export function compareDetailKey(item: CompareTestSelection, batteryGroup: string) {
  return `${item.id}|${item.batteryGroup || batteryGroup}|${item.batteryNo}`
}
export function uniqueCompareDetailTests(tests: CompareTestSelection[], batteryGroup: string) {
  const seen = new Set<string>()
  return tests.filter(item => {
    const key = compareDetailKey(item, batteryGroup)
    if (seen.has(key)) return false
    seen.add(key)
    return true
  })
}
export function createCompareReportPrintData(record: CompareReportRecord): CompareReportPrintData {
  const batteryGroup = record.batteryGroup || '电池组1'
  const fallbackTests: CompareTestSelection[] = [
    { id: 100000 + record.id * 2, name: record.baselineTest, type: '化成', batteryGroup, batteryNo: record.batterySamples?.[0] || 'SP26-3', process: '恒流充电', cycle: 3 },
    { id: 100001 + record.id * 2, name: record.comparisonTest, type: '化成', batteryGroup, batteryNo: record.batterySamples?.[1] || 'SP26-4', process: '恒流放电', cycle: 3 },
  ]
  const tests = (record.compareTests?.length ? record.compareTests : fallbackTests).map(item => ({ ...item }))
  const fields = [
    ...(record.batteryTemplateFields ? record.batteryTemplateFields : defaultBatteryFields),
    ...(record.specialFields ? record.specialFields : defaultSpecialFields),
  ]
  return {
    name: record.name,
    reportId: record.id,
    creator: record.creator,
    createdAt: record.createdAt,
    experimentName: record.description.split(' · ')[0] || 'XXX实验',
    batteryGroup,
    tests,
    detailTests: uniqueCompareDetailTests(tests, batteryGroup),
    fields,
    detailFieldValues: record.detailFieldValues || {},
    metrics: record.metrics ? [...record.metrics] : compareMetricDefinitions.map(metric => metric.key),
    seriesColors: record.seriesColors?.length ? [...record.seriesColors] : [...defaultSeriesColors],
  }
}
function escapeHtml(value: unknown) {
  const entities: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
  return String(value ?? '').replace(/[&<>"']/g, character => entities[character])
}
function safeColor(value: string | undefined, index: number) {
  return value && /^#[0-9a-f]{6}$/i.test(value) ? value : defaultSeriesColors[index % defaultSeriesColors.length]
function chartSvg(metric: CompareMetricDefinition, record: SavedCompareReport) {
  const chart = init(null, undefined, { renderer: 'svg', ssr: true, width: 900, height: 300 })
  try {
    chart.setOption({ ...compareChartOption(metric, record.items, record.settings.seriesColors), tooltip: { show: false } })
    return chart.renderToSVGString()
  } finally { chart.dispose() }
}
function timeLabel(index: number) {
  const hours = Math.floor(index / 60)
  const minutes = String(index % 60).padStart(2, '0')
  return `${hours}:${minutes}`
}
function createChartSvg(metric: CompareMetricDefinition, data: CompareReportPrintData) {
  const chartTests = data.detailTests.slice(0, 3)
  const series = chartTests.map((item, index) => ({
    name: `${item.name} · ${item.batteryNo}`,
    color: safeColor(data.seriesColors[index], index),
    values: (metric.values[index] || metric.values[0]).map(value => toTestDisplayValue(metric.key, value)),
  }))
  const values = series.flatMap(item => item.values)
  const dataMax = Math.max(0, ...values)
  const axisMax = Math.max(metric.unit === '℃' ? Math.ceil(dataMax / 10) * 10 : Math.ceil(dataMax * 10) / 10, 1)
  const width = 680
  const height = 260
  const left = 54
  const right = 626
  const top = 32
  const bottom = 214
  const plotWidth = right - left
  const plotHeight = bottom - top
  const pointCount = Math.max(1, ...series.map(item => item.values.length))
  const yTicks = Array.from({ length: 6 }, (_, index) => axisMax * index / 5)
  const xTicks = Array.from({ length: pointCount }, (_, index) => index)
  const yCoordinate = (value: number) => bottom - (value / axisMax) * plotHeight
  const xCoordinate = (index: number) => pointCount === 1 ? left : left + index * plotWidth / (pointCount - 1)
  const gridLines = yTicks.map(value => {
    const y = yCoordinate(value)
    const label = metric.unit === '℃' ? Math.round(value) : Number(value.toFixed(2))
    return `<line x1="${left}" y1="${y}" x2="${right}" y2="${y}" stroke="#e8eef4"/><text x="${left - 8}" y="${y + 4}" text-anchor="end">${label}</text><text x="${right + 8}" y="${y + 4}" text-anchor="start">${label}</text>`
function printHtml(record: SavedCompareReport) {
  const cell = (value: unknown) => `<td>${escapeHtml(value === '' || value == null ? '-' : value)}</td>`
  const fields = [...record.settings.batteryTemplateFields, ...record.settings.specialFields]
  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 xLabels = xTicks.map(index => `<text x="${xCoordinate(index)}" y="${bottom + 20}" text-anchor="middle">${timeLabel(index)}</text>`).join('')
  const lines = series.map(item => {
    const points = item.values.map((value, index) => `${xCoordinate(index)},${yCoordinate(value)}`).join(' ')
    return `<polyline points="${points}" fill="none" stroke="${item.color}" stroke-width="2.2" stroke-linejoin="round" stroke-linecap="round"/>`
  const fieldValue = (field: string, item: CompareItem) => {
    const value = record.settings.detailFieldValues[field]?.[compareSelectionKey(item.source.selection)]
    // 与重开表单相同:未填写才使用冻结业务字段,显式清空仍为空。
    return value === undefined ? item.businessFields.find(value => value.fieldName === field)?.fieldValue : value
  }
  // 横向每组最多四项,所有组顺序输出,长表可跨页并重复列头。
  const detailTables: string[] = []
  for (let start = 0; start < record.items.length; start += 4) {
    const items = record.items.slice(start, start + 4)
    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}`)],
      ...fields.map(field => [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>`)
  }
  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'
    return `<span><i style="background:${safeColor}"></i>${escapeHtml(compareSeriesName(item))}</span>`
  }).join('')
  return `<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="${escapeHtml(metric.title)}曲线">
    <g fill="#7b8794" font-family="Microsoft YaHei, sans-serif" font-size="10">
      ${gridLines}
      ${xLabels}
      <text x="${left}" y="18">${escapeHtml(metric.unit)}</text>
      <text x="${right}" y="18" text-anchor="end">${escapeHtml(metric.unit)}</text>
      <text x="${(left + right) / 2}" y="252" text-anchor="middle" fill="#405261">时间/h:mm</text>
    </g>
    <line x1="${left}" y1="${top}" x2="${left}" y2="${bottom}" stroke="#cbd8e4"/>
    <line x1="${right}" y1="${top}" x2="${right}" y2="${bottom}" stroke="#cbd8e4"/>
    <line x1="${left}" y1="${bottom}" x2="${right}" y2="${bottom}" stroke="#cbd8e4"/>
    ${lines}
  </svg>`
}
function createPrintHtml(data: CompareReportPrintData) {
  const enabledMetrics = compareMetricDefinitions.filter(metric => data.metrics.includes(metric.key))
  const chartTests = data.detailTests.slice(0, 3)
  const compareRows = data.tests.length
    ? data.tests.map(item => `<tr><td>${escapeHtml(item.name)}</td><td>${escapeHtml(item.type)}</td><td>${escapeHtml(item.batteryNo)}</td><td>${escapeHtml(item.process)}</td><td>${escapeHtml(item.cycle)}</td></tr>`).join('')
    : '<tr><td colspan="5" class="empty">暂无对比测试</td></tr>'
  const detailHeader = data.detailTests.map((_, index) => `<th>对比项 ${index + 1}</th>`).join('')
  const detailCell = (item: CompareTestSelection, value: unknown) => `<td data-key="${escapeHtml(compareDetailKey(item, data.batteryGroup))}">${escapeHtml(value || '-')}</td>`
  const fixedDetailRows = [
    `<tr><td>测试</td>${data.detailTests.map(item => detailCell(item, item.name)).join('')}</tr>`,
    `<tr><td>电池组</td>${data.detailTests.map(item => detailCell(item, item.batteryGroup || data.batteryGroup)).join('')}</tr>`,
    `<tr><td>电池号</td>${data.detailTests.map(item => detailCell(item, item.batteryNo)).join('')}</tr>`,
  ].join('')
  const fieldRows = data.fields.map(field => `<tr><td>${escapeHtml(field)}</td>${data.detailTests.map(item => detailCell(item, data.detailFieldValues[field]?.[compareDetailKey(item, data.batteryGroup)] || '-')).join('')}</tr>`).join('')
  const detailRows = data.detailTests.length ? `${fixedDetailRows}${fieldRows}` : '<tr><td colspan="2" class="empty">暂无测试详情</td></tr>'
  const legend = chartTests.map((item, index) => `<span><i style="background:${safeColor(data.seriesColors[index], index)}"></i>${escapeHtml(item.name)} · ${escapeHtml(item.batteryNo)}</span>`).join('')
  const charts = enabledMetrics.map(metric => `<article class="chart-card"><header><strong>${escapeHtml(metric.title)}</strong></header>${createChartSvg(metric, data)}<div class="chart-inner-legend">${legend}</div></article>`).join('')
  const savedMeta = data.reportId ? `<span>报告ID <strong>#${data.reportId}</strong></span><span>创建人 <strong>${escapeHtml(data.creator || '-')}</strong></span><span>创建时间 <strong>${escapeHtml(data.createdAt || '-')}</strong></span>` : ''
  return `<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><title>${escapeHtml(data.name)}</title><style>
    @page{size:A4 landscape;margin:10mm}*{box-sizing:border-box}body{margin:0;color:#26333f;background:#fff;font-family:"Microsoft YaHei",sans-serif;font-size:11px;-webkit-print-color-adjust:exact;print-color-adjust:exact}.report-header{display:flex;align-items:flex-end;justify-content:space-between;gap:20px;margin-bottom:16px;padding-bottom:12px;border-bottom:2px solid #2b78a3}.report-header h1{margin:0;font-size:22px}.report-header p{margin:5px 0 0;color:#71808d}.saved-meta{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px 18px;color:#7b8794}.saved-meta strong{margin-left:5px;color:#405261}.section{margin:0 0 16px;break-inside:auto}.section-title{display:flex;align-items:center;gap:8px;height:34px;margin:0;padding:0 10px;border:1px solid #d8e1e9;border-bottom:0;background:#f4f8fb;font-size:13px}.section-title:before{width:3px;height:15px;background:#2b78a3;content:""}.experiment{padding:10px 12px;border:1px solid #d8e1e9;border-bottom:0;color:#52606d}.experiment strong{margin-left:12px;color:#26333f}table{width:100%;border-collapse:collapse;table-layout:fixed}th,td{height:32px;padding:6px 9px;border:1px solid #cbd8e4;text-align:center;overflow-wrap:anywhere}th{color:#fff;background:#3c6497;font-weight:600}.detail-table th:first-child,.detail-table td:first-child{width:130px;text-align:left}.detail-table td:first-child{background:#f7fafc;font-weight:600}.empty{height:64px;color:#8795a3}.curve-header{display:flex;align-items:center;gap:18px;min-height:38px;padding:8px 10px;border:1px solid #d8e1e9;border-bottom:0}.curve-header strong{font-size:13px}.legend{display:flex;flex-wrap:wrap;gap:8px 18px}.legend span,.chart-inner-legend span{display:inline-flex;align-items:center;gap:6px;color:#52606d}.legend i,.chart-inner-legend i{width:12px;height:12px;border:1px solid rgba(38,51,63,.18);display:inline-block}.charts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;padding:10px;border:1px solid #d8e1e9}.chart-card{position:relative;min-width:0;border:1px solid #d8e1e9;break-inside:avoid}.chart-card header{height:32px;display:flex;align-items:center;padding:0 10px;border-bottom:1px solid #edf1f5}.chart-card svg{display:block;width:100%;height:auto}.chart-inner-legend{display:flex;flex-wrap:wrap;gap:5px 12px;padding:0 10px 9px;font-size:9px}.no-chart{grid-column:1/-1;padding:30px;text-align:center;color:#8795a3}@media print{.section-title,.curve-header,th,.detail-table td:first-child{print-color-adjust:exact;-webkit-print-color-adjust:exact}}
  const charts = compareMetricDefinitions.filter(metric => record.settings.metrics.includes(metric.key)).map(metric =>
    `<article class="chart-card"><header><strong>${metric.title} (${escapeHtml(record.displayUnits[metric.key] || metric.unit)})</strong></header>${chartSvg(metric, record)}<div class="chart-inner-legend">${legend}</div></article>`).join('')
  const date = (time: number) => escapeHtml(new Date(time).toLocaleString())
  return `<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><title>${escapeHtml(record.name)} - V${record.versionNo}</title><style>
    @page{size:A4 landscape;margin:10mm}
    *{box-sizing:border-box}body{margin:0;padding:16px;color:#26333f;background:#fff;font-family:"Microsoft YaHei",sans-serif;font-size:11px;letter-spacing:0;-webkit-print-color-adjust:exact;print-color-adjust:exact}
    .report-header{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;margin-bottom:16px;padding-bottom:12px;border-bottom:2px solid #2b78a3}.report-header>div{min-width:0}.report-header h1{margin:0;font-size:22px;overflow-wrap:anywhere}.report-header p{margin:5px 0;color:#71808d}.saved-meta{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px 18px;max-width:50%;overflow-wrap:anywhere}.saved-meta strong{margin-left:5px}
    .section{margin:0 0 16px}.section-title{margin:0;padding:9px 10px;border:1px solid #d8e1e9;background:#f4f8fb;font-size:13px;break-after:avoid}.experiment,.note{padding:10px 12px;white-space:pre-wrap;overflow-wrap:anywhere}.experiment strong{margin-left:12px}
    table{width:100%;border-collapse:collapse;table-layout:fixed;margin-bottom:16px}thead{display:table-header-group}tr{break-inside:avoid}th,td{padding:6px 9px;border:1px solid #cbd8e4;text-align:center;overflow-wrap:anywhere;white-space:pre-wrap}th{color:#fff;background:#3c6497;font-weight:600}.detail-table th:first-child,.detail-table td:first-child{width:130px;text-align:left}.detail-table td:first-child{background:#f7fafc;font-weight:600}
    .legend,.chart-inner-legend{display:flex;flex-wrap:wrap;gap:10px 18px;padding:12px 0}.legend span,.chart-inner-legend span{display:flex;align-items:flex-start;gap:6px;max-width:100%;overflow-wrap:anywhere;break-inside:avoid}.legend i,.chart-inner-legend i{flex:0 0 12px;height:12px;margin-top:2px}.chart-inner-legend{padding:10px;font-size:10px}
    .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 header{padding:9px 10px;border-bottom:1px solid #edf1f5}.chart-card svg{display:block;width:100%;height:auto}.empty{padding:30px;color:#8795a3}
    @media(max-width:680px){.report-header{flex-direction:column}.saved-meta{max-width:100%;justify-content:flex-start}.charts{grid-template-columns:1fr}}
    @media print{body{padding:0}.charts{display:block}.chart-card{margin:0 0 12px}.report-header{break-inside:avoid}}
  </style></head><body>
    <header class="report-header"><div><h1>${escapeHtml(data.name)}</h1><p>检测室数据可视化系统 · 对比报表</p></div><div class="saved-meta">${savedMeta}</div></header>
    <section class="section"><h2 class="section-title">对比信息</h2><table><thead><tr><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(data.experimentName || '-')}</strong></div><table class="detail-table"><thead><tr><th>字段</th>${detailHeader}</tr></thead><tbody>${detailRows}</tbody></table></section>
    <section class="section"><div class="curve-header"><strong>对比曲线</strong><div class="legend">${legend}</div></div><div class="charts">${charts || '<div class="no-chart">当前未启用曲线指标</div>'}</div></section>
    <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><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>`
}
export function openCompareReportPrint(data: CompareReportPrintData) {
/** 点击时先打开窗口,再经后端导出权限读取指定保存版本;打开或取消打印不代表 PDF 保存成功。 */
export async function openCompareReportPrint(id: number, versionNo: number) {
  const popup = window.open('', '_blank', 'width=1200,height=820')
  if (!popup) return false
  popup.document.open()
  popup.document.write(createPrintHtml(data))
  popup.document.close()
  window.setTimeout(() => {
  if (!popup) throw new Error('打印窗口打开失败,请允许浏览器弹出窗口后重试')
  try {
    popup.document.body.textContent = '正在加载已保存版本'
    const record = await getComparePrintVersion(id, versionNo)
    if (popup.closed) throw new Error('打印窗口已关闭,请重试')
    popup.document.open()
    popup.document.write(printHtml(record))
    popup.document.close()
    await popup.document.fonts.ready
    if (popup.closed) throw new Error('打印窗口已关闭,请重试')
    popup.focus()
    popup.print()
  }, 250)
  return true
  } catch (error) {
    popup.close()
    throw error
  }
}
src/views/ReportCompare.vue
@@ -19,6 +19,7 @@
const loadError = ref('')
const canCreate = ref(false)
const canEdit = ref(false)
const canExport = ref(false)
const historyVisible = ref(false)
const historyReportId = ref<number | null>(null)
onMounted(async () => {
@@ -27,6 +28,7 @@
    const info = await getUserInfo()
    canCreate.value = Boolean(info.permissions?.some(item => item === '*:*:*' || item === 'system:compare-report:add'))
    canEdit.value = Boolean(info.permissions?.some(item => item === '*:*:*' || item === 'system:compare-report:edit'))
    canExport.value = Boolean(info.permissions?.some(item => item === '*:*:*' || item === 'system:compare-report:export'))
    records.value = (await listCompareReports()).map(item => ({ ...item,
      createdAt: new Date(item.createdAt).toLocaleString('sv-SE'),
      baselineTest: '', comparisonTest: '', description: '', revisions: [],
@@ -117,7 +119,7 @@
      <footer class="pagination-bar"><span>显示 {{ pagedRecords.length }} 条,共 {{ filteredRecords.length }} 条</span><el-pagination v-model:current-page="currentPage" v-model:page-size="pageSize" :page-sizes="[10, 20, 50]" :total="filteredRecords.length" layout="sizes, prev, pager, next" background /></footer>
    </section>
    <CompareReportHistoryDialog v-model="historyVisible" :report-id="historyReportId" />
    <CompareReportHistoryDialog v-model="historyVisible" :report-id="historyReportId" :can-export="canExport" />
  </div>
</template>
src/views/ReportCompareForm.vue
@@ -1,5 +1,4 @@
<script setup lang="ts">
import { toTestDisplayValue } from '@/utils/testDataUnits'
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
@@ -11,10 +10,10 @@
import CompareReportHistoryDialog from '@/components/CompareReportHistoryDialog.vue'
import { getUserInfo } from '@/api/auth'
import type { CompareMetricKey, CompareTestSelection } from '@/stores/compareReports'
import type { PersistedTestRecord } from '@/api/tests'
import { compareSelectionKey, createCompareReport, editCompareReport, getCompareReport, getCompareSources, previewCompareReport, refreshCompareSource,
  type CompareCandidate, type CompareItem, type CompareSelection, type SavedCompareReport } from '@/api/compareReports'
import { compareMetricDefinitions as metricDefinitions, type CompareMetricDefinition } from '@/utils/compareReportPrint'
import { compareMetricDefinitions as metricDefinitions, openCompareReportPrint } from '@/utils/compareReportPrint'
import { compareChartOption } from '@/utils/compareReportChart'
use([CanvasRenderer, LineChart, GridComponent, LegendComponent, TooltipComponent])
@@ -35,6 +34,9 @@
const canCreate = ref(false)
const canEdit = ref(false)
const canQuery = ref(false)
const canExport = ref(false)
const printing = ref(false)
const savedFormContent = ref('')
const historyVisible = ref(false)
const changeSummary = ref('')
const loadError = ref('')
@@ -127,17 +129,6 @@
  }
}
function metricValues(key: CompareMetricKey): Array<Array<[number, number | null]>> {
  const field: Record<CompareMetricKey, keyof PersistedTestRecord> = { voltage: 'voltage', current: 'current', positiveVoltage: 'positivePotential', negativeVoltage: 'negativePotential', temperature: 'temperature' }
  // 各来源设备时间按比对项零点转为相对秒,测量仅在展示时换算毫单位,保留缺失值。
  return sourceItems.value.map(item => item.records.flatMap(row => {
    const time = key === 'voltage' || key === 'current' ? row.deviceTime : row.collectorDeviceTime
    if (time == null || item.zeroTime == null) return []
    const value = row[field[key]]
    return [[(Number(time) - item.zeroTime) / 1000, value == null ? null : toTestDisplayValue(key, Number(value))] as [number, number | null]]
  }).sort((left, right) => left[0] - right[0]))
}
function detailKey(item: SelectedTest) {
  return compareSelectionKey(item.selection)
}
@@ -176,6 +167,7 @@
    .filter(field => batteryFields.value.includes(field.name))
    .map(field => field.id)
  detailFields.value.forEach(ensureDetailFieldValues)
  savedFormContent.value = JSON.stringify(formContent())
}
function updateSeriesColor(index: number, color: string | null) {
@@ -204,6 +196,8 @@
  canCreate.value = false
  canEdit.value = false
  canQuery.value = false
  canExport.value = false
  savedFormContent.value = ''
  historyVisible.value = false
  changeSummary.value = ''
  reportLoading.value = true
@@ -212,6 +206,7 @@
    if (cancelled) return
    canEdit.value = Boolean(info.permissions?.some(item => item === '*:*:*' || item === 'system:compare-report:edit'))
    canQuery.value = Boolean(info.permissions?.some(item => item === '*:*:*' || item === 'system:compare-report:query'))
    canExport.value = Boolean(info.permissions?.some(item => item === '*:*:*' || item === 'system:compare-report:export'))
    if (id) {
      if (mode === 'edit' && (!canEdit.value || version !== undefined)) throw new Error('历史版本只读或无编辑权限')
      // 编辑权限包含读取最新编辑基准,取消或保存后仍可预览;历史查看单独要求查看权限。
@@ -366,37 +361,32 @@
  ElMessage.success(`${templateMode.value === 'battery' ? '电池组字段' : '特殊字段'}模板已应用`)
}
function chartOption(metric: CompareMetricDefinition) {
    const names = chartSeries.value.map(item => `${item.name} · ${item.batteryNo} · 循环${item.cycle} · ${item.process}`)
    const colors = seriesColors.value
    const visibleSeries = metricValues(metric.key)
    const visibleValues = visibleSeries.flat().map(point => point[1]).filter((value): value is number => value !== null)
    const dataMin = Math.min(0, ...visibleValues)
    const dataMax = Math.max(0, ...visibleValues)
    const range = Math.max(dataMax - dataMin, 1)
  const axisMax = Math.max(metric.unit === '℃' ? Math.ceil(dataMax / 10) * 10 : Math.ceil(dataMax * 10) / 10, 1)
  const createYAxis = (position: 'left' | 'right') => ({
    type: 'value',
    name: metric.unit,
    position,
    min: dataMin - range * 0.05,
    max: axisMax,
    nameTextStyle: { color: '#8795a3', fontSize: 10 },
    axisLine: { show: true, lineStyle: { color: '#cbd8e4' } },
    axisTick: { show: true, lineStyle: { color: '#cbd8e4' } },
    splitLine: { show: position === 'left', lineStyle: { color: '#e8eef4' } },
    axisLabel: { color: '#8795a3', fontSize: 10 },
  })
function formContent() {
  return {
    animation: false,
    color: colors,
      grid: { top: 40, right: 46, bottom: 42, left: 46 },
    tooltip: { trigger: 'axis' },
    legend: { show: false },
      xAxis: { type: 'value', min: 0, name: '相对时间 / s', nameLocation: 'middle', nameGap: 28, axisLine: { lineStyle: { color: '#cbd8e4' } }, axisLabel: { color: '#8795a3', fontSize: 10 }, nameTextStyle: { color: '#26333f', fontSize: 11, fontWeight: 600 } },
    yAxis: [createYAxis('left'), createYAxis('right')],
    series: names.map((name, index) => ({ name, type: 'line', smooth: false, connectNulls: false, showSymbol: true, symbolSize: 5, lineStyle: { width: 2 }, data: visibleSeries[index] || [] })),
    name: reportName.value, description: description.value, experimentName: experimentName.value,
    settings: {
      metrics: visibleMetrics.value.map(item => item.key), seriesColors: seriesColors.value.slice(0, selectedTests.value.length),
      batteryTemplateFields: [...batteryFields.value], specialFields: [...specialFields.value],
      detailFieldValues: Object.fromEntries(detailFields.value.map(field => [field,
        Object.fromEntries(selectedTests.value.map(item => [detailKey(item), detailFieldValues[field]?.[detailKey(item)] || ''])),
      ])),
    },
  }
}
async function printReport() {
  if (printing.value || saving.value || reportLoading.value || loadError.value || !canExport.value) return
  const record = editingRecord.value
  if (!record) { ElMessage.warning('请先保存报表后再打印'); return }
  if (JSON.stringify(formContent()) !== savedFormContent.value || changeSummary.value.trim()) {
    ElMessage.warning('有未保存修改,请先保存新版本后再打印')
    return
  }
  printing.value = true
  try {
    await openCompareReportPrint(record.id, record.versionNo)
  } catch (error) { ElMessage.error(error instanceof Error ? error.message : '打印失败,请重试') }
  finally { printing.value = false }
}
async function saveReport() {
@@ -415,16 +405,7 @@
  }
  saving.value = true
  try {
    const content = {
      name: reportName.value.trim(), description: description.value, experimentName: experimentName.value,
      settings: {
        metrics: visibleMetrics.value.map(item => item.key), seriesColors: seriesColors.value.slice(0, selectedTests.value.length),
        batteryTemplateFields: [...batteryFields.value], specialFields: [...specialFields.value],
        detailFieldValues: Object.fromEntries(detailFields.value.map(field => [field,
          Object.fromEntries(selectedTests.value.map(item => [detailKey(item), detailFieldValues[field]?.[detailKey(item)] || ''])),
        ])),
      },
    }
    const content = { ...formContent(), name: reportName.value.trim() }
    if (editingId.value && editingRecord.value) {
      await editCompareReport(editingId.value, { ...content, baseVersionNo: editingRecord.value.versionNo, changeSummary: changeSummary.value.trim() })
    } else {
@@ -517,10 +498,11 @@
      <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 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="chartOption(metric)" :update-options="{ notMerge: true }" autoresize /></article></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>
    <footer class="form-actions">
      <button v-if="canExport" class="secondary-button" type="button" :disabled="printing || saving || reportLoading || !!loadError" @click="printReport"><el-icon><Printer /></el-icon>{{ printing ? '正在打开打印' : '打印 / 另存 PDF' }}</button>
      <button v-if="readonly && canQuery && editingRecord" class="secondary-button" type="button" :disabled="reportLoading || saving" @click="historyVisible = true"><el-icon><Clock /></el-icon>历史版本</button>
      <button v-if="readonly && editingRecord && route.query.version !== undefined" class="secondary-button" type="button" @click="router.push({ query: { mode: 'view' } })">查看最新版本</button>
      <button v-if="readonly && canEdit && editingRecord && route.query.version === undefined" class="secondary-button" type="button" :disabled="reportLoading || saving || !!loadError" @click="generateFromLatestSource"><el-icon><Refresh /></el-icon>按最新来源生成版本</button>
@@ -530,7 +512,7 @@
      <button v-if="!readonly && (canCreate || canEdit)" class="primary-button save-button" type="button" :disabled="saving || reportLoading || !!loadError || previewLoading || !!previewError" @click="saveReport"><el-icon><Check /></el-icon>{{ saving ? '保存中' : editingId ? '保存新版本' : '保存报表' }}</button>
    </footer>
    <CompareReportHistoryDialog v-model="historyVisible" :report-id="editingId" />
    <CompareReportHistoryDialog v-model="historyVisible" :report-id="editingId" :can-export="canExport" />
    <el-dialog v-model="testDialogVisible" class="test-dialog" title="添加对比" width="540px" align-center destroy-on-close>
      <div class="compare-select-form">
tests/e2e/compare-saved-version-print.spec.ts
New file
@@ -0,0 +1,185 @@
import { expect, test, type Page } from '@playwright/test'
import { readFile } from 'node:fs/promises'
import { Ticket04Stack } from './support/ticket04-stack'
const stack = new Ticket04Stack()
test.use({ actionTimeout: 20_000 })
test.beforeAll(async () => stack.start())
test.afterAll(async () => stack.stop())
async function login(page: Page, username = 'admin') {
  if (page.url().startsWith('http')) await page.evaluate(() => {
    localStorage.removeItem('battery_detection_auth_token')
    localStorage.removeItem('battery_detection_auth_user')
  })
  await page.goto('/login')
  await page.locator('input[autocomplete="username"]').fill(username)
  await page.locator('input[autocomplete="current-password"]').fill(username === 'admin' ? 'admin123' : 'Report123!')
  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
}
test('指定版本打印独立于来源,导出权限由正式 API 执行', async ({ page }) => {
  stack.executeIsolatedSql(`
    INSERT INTO t_battery_group(group_id,group_name,model) VALUES(8301,'打印验收组','型号');
    INSERT INTO t_battery_group_field(field_id,field_name,field_type) VALUES(8301,'质量','number');
    INSERT INTO t_battery_group_relation_field(group_id,battery_group_field_id,field_value) VALUES(8301,8301,'88');
    INSERT INTO t_test_type(test_type_id,test_type_name) VALUES(8301,'打印验收类型');
    INSERT INTO t_test(test_id,test_name,creator_id,principal_id,test_type_id,test_purpose,test_status,reception_status)
    VALUES(8301,'打印来源同名电池长测试名称用于检查图例完整换行',1,1,8301,'隔离验收','COMPLETED','ENDED');
    INSERT INTO t_test_battery(test_battery_id,test_id,group_id,battery_no,collector_id) VALUES(8301,8301,8301,'BAT-P',8301);
    INSERT INTO t_detection_cabinet(cabinet_id,adapter_code,cabinet_code,cabinet_name,vendor_device_type,vendor_device_id)
    VALUES(8301,'print-fixture','print-fixture','验收检测柜',27,1);
    INSERT INTO t_detection_channel(channel_id,adapter_code,cabinet_id,devtype,devid,subdevid,chlid) VALUES(8301,'print-fixture',8301,27,1,1,1);
    INSERT INTO t_test_channel_binding(binding_id,test_id,test_battery_id,channel_id) VALUES(8301,8301,8301,8301);
    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,temperature,positive_potential,negative_potential,content_sha256)
    VALUES ${[1, 2, 3, 4, 5].flatMap(cycle => [0, 1, 2].map(point => {
      const seq = cycle * 10 + point
      return `(8301,8301,8301,'print-fixture','print:27:1:1:1','${seq}',27,1,1,1,${seq},'2026-09-01 0${cycle}:0${point}:10.123','2026-09-01 0${cycle}:0${point}:00.123','2026-09-01 0${cycle}:0${point}:11.123',${cycle},1,'充电',${3.5 + cycle / 10},${point === 1 ? 'NULL' : '-8.1651'},${point === 0 ? '0' : '25'},0.1,-0.2,UNHEX(SHA2('${seq}',256)))`
    })).join(',')};
  `)
  await login(page)
  const selections = [1, 2, 3, 4, 5].map(cycleNo => ({ testId: 8301, testBatteryId: 8301, cycleNo, stepNo: 1, logicalNo: null }))
  const payload = { name: '指定打印V1', description: '第一版说明 <script>不可执行</script>', experimentName: '打印实验', selections,
    settings: { metrics: ['voltage', 'current', 'temperature', 'positiveVoltage', 'negativeVoltage'],
      seriesColors: ['#123456', '#3978f6', '#f08a3e', '#109854', '#e33d54'], batteryTemplateFields: ['质量'],
      specialFields: Array.from({ length: 36 }, (_, index) => `人工指标${index + 1}`),
      detailFieldValues: { 人工指标1: { '8301|8301|1|1|': '0', '8301|8301|2|1|': '-12' } } },
  }
  const v1 = (await api(page, 'compare-report', 'POST', payload)).data
  const v2 = (await api(page, `compare-report/${v1.id}/versions`, 'POST', {
    name: '指定打印V2', description: '当前版说明', experimentName: payload.experimentName,
    settings: payload.settings, baseVersionNo: 1, changeSummary: '第二版真实修订说明',
  })).data
  await api(page, 'test/8301', 'DELETE')
  for (const saved of [v1, v2]) {
    expect((await api(page, `compare-report/${v1.id}/versions/${saved.versionNo}/print`)).data).toEqual(saved)
  }
  const menus = (await api(page, 'menu/permissionList')).data
  const comparePage = menus.flatMap((module: any) => module.pages).find((item: any) => item.pageName === '对比报表')
  expect(comparePage.permissions.map((item: any) => item.menuName)).toContain('导出')
  for (const [username, actions] of [['print_reader', ['查看']], ['print_exporter', ['导出']]] as const) {
    const menuIds = comparePage.permissions.filter((item: any) => (actions as readonly string[]).includes(item.menuName)).map((item: any) => item.menuId)
    await api(page, 'role', 'POST', { roleName: username, roleKey: 'common', roleSort: 1, status: '0', menuIds })
    const role = (await api(page, `role/list?roleName=${username}`)).rows[0]
    await api(page, 'user', 'POST', { userName: username, nickName: username, password: 'Report123!', status: '0', roleIds: [role.roleId] })
  }
  await login(page, 'print_reader')
  await api(page, `compare-report/${v1.id}/versions/1/print`, 'GET', undefined, 403)
  await api(page, `compare-report/${v1.id}/versions/export`, 'GET', undefined, 403)
  await page.goto(`/report-compare/${v1.id}/edit?mode=view`)
  await expect(page.getByPlaceholder('请输入报表名称')).toHaveValue('指定打印V2')
  await expect(page.getByRole('button', { name: '打印 / 另存 PDF', exact: true })).toHaveCount(0)
  await login(page, 'print_exporter')
  expect((await api(page, `compare-report/${v1.id}/versions/1/print`)).data).toEqual(v1)
  const history = (await api(page, `compare-report/${v1.id}/versions/export`)).data
  expect(history.map((version: any) => version.versionNo)).toEqual([2, 1])
  expect(history[0].changeSummary).toBe('第二版真实修订说明')
  await login(page)
  const pageErrors: string[] = []
  page.on('pageerror', error => pageErrors.push(error.message))
  await page.goto('/report-compare/new')
  await page.getByRole('button', { name: '打印 / 另存 PDF', exact: true }).click()
  await expect(page.getByText('请先保存报表后再打印', { exact: true })).toBeVisible()
  await page.goto(`/report-compare/${v1.id}/edit?mode=edit`)
  await expect(page.getByPlaceholder('请输入报表名称')).toHaveValue('指定打印V2')
  await page.getByPlaceholder('请输入报表名称').fill('未保存草稿')
  let printRequests = 0
  page.on('request', request => { if (request.url().endsWith('/print')) printRequests++ })
  await page.getByRole('button', { name: '打印 / 另存 PDF', exact: true }).click()
  await expect(page.getByText('有未保存修改,请先保存新版本后再打印', { exact: true })).toBeVisible()
  expect(printRequests).toBe(0)
  await page.getByRole('button', { name: '取消编辑', exact: true }).click()
  await expect(page.getByPlaceholder('请输入报表名称')).toHaveValue('指定打印V2')
  await page.evaluate(() => { window.open = () => null })
  await page.getByRole('button', { name: '打印 / 另存 PDF', exact: true }).click()
  await expect(page.getByText('打印窗口打开失败,请允许浏览器弹出窗口后重试', { exact: true })).toBeVisible()
  expect(printRequests).toBe(0)
  await page.reload()
  await expect(page.getByPlaceholder('请输入报表名称')).toHaveValue('指定打印V2')
  // 模拟打印对话框返回(包括取消),不得据此追加版本或下载成功事实。
  await page.context().addInitScript(() => { window.print = () => { document.body.dataset.printRequested = 'true' } })
  await page.route('**/dev-api/system/compare-report/*/versions/*/print', async route => {
    await route.fulfill({ json: { code: 403, msg: '打印导出权限已撤销' } })
  }, { times: 1 })
  const deniedPopupPromise = page.waitForEvent('popup')
  await page.getByRole('button', { name: '打印 / 另存 PDF', exact: true }).click()
  const deniedPopup = await deniedPopupPromise
  await expect(page.getByText('打印导出权限已撤销', { exact: true })).toBeVisible()
  await expect.poll(() => deniedPopup.isClosed()).toBe(true)
  let blockedSourceRequests = 0
  await page.route('**/dev-api/system/test/**', async route => { blockedSourceRequests++; await route.abort() })
  for (const version of [2, 1]) {
    await page.goto(`/report-compare/${v1.id}/edit?mode=view${version === 1 ? '&version=1' : ''}`)
    await expect(page.getByPlaceholder('请输入报表名称')).toHaveValue(`指定打印V${version}`)
    for (const width of [1440, 390]) {
      await page.setViewportSize({ width, height: 900 })
      const button = page.getByRole('button', { name: '打印 / 另存 PDF', exact: true })
      await button.scrollIntoViewIfNeeded()
      await expect(button).toBeInViewport()
      expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
      await page.screenshot({ path: `tests/e2e/.artifacts/compare-print-entry-v${version}-${width}.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(`指定打印V${version}`)
    await expect(popup.locator('.saved-meta')).toContainText(`V${version}`)
    await expect(popup.locator('.compare-table tbody tr')).toHaveCount(5)
    await expect(popup.locator('.chart-card')).toHaveCount(5)
    await expect(popup.locator('.legend')).toContainText('循环5')
    await expect(popup.locator('.chart-inner-legend')).toHaveCount(5)
    await expect(popup.locator('body')).toHaveAttribute('data-print-requested', 'true')
    await expect(popup.locator('.detail-table tbody tr').filter({ hasText: '质量' }).first()).toContainText('88')
    const manualRow = popup.locator('.detail-table tbody tr').filter({ has: popup.locator('td:first-child', { hasText: /^人工指标1$/ }) }).first()
    await expect(manualRow.locator('td').nth(1)).toHaveText('0')
    await expect(manualRow.locator('td').nth(2)).toHaveText('-12')
    await expect(popup.locator('.chart-card').filter({ hasText: '电流' })).toContainText('mA')
    for (const color of payload.settings.seriesColors) {
      await expect(popup.locator('.chart-card').first().locator(`svg path[stroke="${color}"]`).first()).toBeAttached()
    }
    await popup.setViewportSize({ width: 1200, height: 900 })
    await popup.emulateMedia({ media: 'print' })
    expect(await popup.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
    await popup.screenshot({ path: `tests/e2e/.artifacts/compare-print-v${version}.png`, fullPage: true })
    await popup.pdf({ path: `tests/e2e/.artifacts/compare-print-v${version}.pdf`, preferCSSPageSize: true, printBackground: true })
    await popup.close()
  }
  expect(blockedSourceRequests).toBe(0)
  expect((await api(page, `compare-report/${v1.id}/versions`)).data).toEqual(history)
  await expect(page.getByText(/下载成功|成功下载/)).toHaveCount(0)
  await page.getByRole('button', { name: '历史版本', exact: true }).click()
  const downloadPromise = page.waitForEvent('download')
  await page.getByRole('button', { name: '导出修订 CSV', exact: true }).click()
  const download = await downloadPromise
  expect(download.suggestedFilename()).toBe(`对比报表-${v1.id}-修订记录.csv`)
  const csv = await readFile((await download.path())!, 'utf8')
  expect(csv).toContain('版本号,操作人,时间,修改说明')
  expect(csv).toContain('第二版真实修订说明')
  expect(csv).toContain('admin')
  expect(csv).not.toContain('PDF')
  const v3 = (await api(page, `compare-report/${v1.id}/versions`, 'POST', {
    name: '隐藏全部曲线V3', description: '', experimentName: '', settings: { ...payload.settings, metrics: [] },
    baseVersionNo: 2, changeSummary: '关闭所有指标',
  })).data
  await page.goto(`/report-compare/${v1.id}/edit?mode=view`)
  await expect(page.getByPlaceholder('请输入报表名称')).toHaveValue(v3.name)
  const emptyPopupPromise = page.waitForEvent('popup')
  await page.getByRole('button', { name: '打印 / 另存 PDF', exact: true }).click()
  const emptyPopup = await emptyPopupPromise
  await expect(emptyPopup.getByText('当前未启用曲线指标', { exact: true })).toBeVisible()
  await expect(emptyPopup.locator('.chart-card')).toHaveCount(0)
  await emptyPopup.close()
  expect(pageErrors).toEqual([])
})