接入对比报表已保存版本打印与修订导出
打印经后端导出权限读取指定保存版本,复用真实曲线与单位口径,补充草稿提示、弹窗失败反馈、多页表格和完整图例。修订记录导出使用正式版本摘要,增加隔离接口与浏览器验收。
| | |
| | | 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`, { |
| | |
| | | <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) => { |
| | |
| | | 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> |
| | |
| | | <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> |
| | | |
| New file |
| | |
| | | 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] || [] })), |
| | | } |
| | | } |
| | |
| | | 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 |
| | |
| | | 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]] }, |
| | |
| | | { 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> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' } |
| | | 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 |
| | | } |
| | | } |
| | |
| | | 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 () => { |
| | |
| | | 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: [], |
| | |
| | | <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> |
| | | |
| | |
| | | <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' |
| | |
| | | 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]) |
| | | |
| | |
| | | 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('') |
| | |
| | | } |
| | | } |
| | | |
| | | 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) |
| | | } |
| | |
| | | .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) { |
| | |
| | | canCreate.value = false |
| | | canEdit.value = false |
| | | canQuery.value = false |
| | | canExport.value = false |
| | | savedFormContent.value = '' |
| | | historyVisible.value = false |
| | | changeSummary.value = '' |
| | | reportLoading.value = true |
| | |
| | | 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('历史版本只读或无编辑权限') |
| | | // 编辑权限包含读取最新编辑基准,取消或保存后仍可预览;历史查看单独要求查看权限。 |
| | |
| | | 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() { |
| | |
| | | } |
| | | 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 { |
| | |
| | | <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> |
| | |
| | | <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"> |
| New file |
| | |
| | | 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([]) |
| | | }) |