| | |
| | | |
| | | ## 来源与统计 |
| | | |
| | | 添加弹窗按电池组、测试名称或 ID 筛选检测完成测试;选择同组至少两个不同测试及其共同参与电池子集。默认按开始时间排序,可上下调整或删除任一未保存来源。后端再次验证来源身份、状态、电池组和共同集合。 |
| | | 添加弹窗按电池组、测试名称或 ID 筛选检测完成测试中的电池;至少选择两个不同测试电池段,允许同测试及跨测试、跨组、跨电池号。默认按开始时间排序,同测试按选入顺序,可上下调整或删除任一未保存来源。后端再次验证来源身份、状态和电池归属,拒绝重复段。 |
| | | |
| | | 各测试全部参与电池共用循环偏移,首个有数据段保留原循环号,后段首循环接续前段末循环并保留段内跳号。不以日期空档补循环。来源表保留原号与拼接号;不同电池循环覆盖范围不同时,来源分界仍按该测试最早循环确定。 |
| | | 每个测试电池独立计算循环偏移,首个有数据段保留原循环号,后段首循环接续前段末循环并保留段内跳号。空段不占用虚构循环。每指标一条完整接续线,各指标共用段顺序和循环定位,页面不再按40点或4个来源拆图。 |
| | | |
| | | 统计图只展示最终电压1和能量效率。最终电压1取末实际工步终止电压,缺失不回退;能量效率为同循环放电能量除以充电能量乘100%,缺失或非正充电能量不计算,超过100%不截断。原五类演示实时曲线及未定义自动计算不再用于拼接。 |
| | | |
| | | ## 字段、模板与版本 |
| | | |
| | | 字段设置支持电池组共享字段、人工项、顺序、展示名称及单位。共享字段从后端来源预览读取;正式保存只冻结已选字段。拼接模板独立存储,只保存字段结构,不带来源或填写值,后续模板变化不修改报表。 |
| | | 测试详情按来源列带入控制台保存的实验、两类字段列表和逐电池字符串,包含空字符串。来源人工字段不凭名称推断共享身份或单位。报表人工调整以测试和测试电池身份保存,重排后不串列。拼接模板独立存储,只保存字段结构、顺序、展示名称与单位,不带来源或填写值。 |
| | | |
| | | 表格指标可人工调整并显示计算原值;统计图保持冻结的原始计算结果。取消电池或移除来源时清理失效人工值,保留其他有效修改。报表修改不反写测试事实。 |
| | | |
| | |
| | | export interface SpliceCandidate { selection: SpliceSelection; name: string; type: string; groupId: number; group: string; batteryNo: string; startedAt: number } |
| | | export interface SpliceField { name: string; category: 'battery' | 'special'; displayName: string; unit: string; fieldDefinitionId?: number | null } |
| | | export interface SpliceBusinessField { fieldDefinitionId: number; fieldName: string; fieldValue: string | null } |
| | | export interface SpliceSegment { source: SpliceCandidate; offset: number; originalCycleNos: number[]; statistics: Record<string, Record<string, number | null>>; businessFields?: SpliceBusinessField[] } |
| | | export interface SpliceSegment { source: SpliceCandidate; offset: number; originalCycleNos: number[]; statistics: Record<string, Record<string, number | null>>; businessFields?: SpliceBusinessField[]; consoleDetail?: { experimentName?: string; batteryFields: string[]; specialFields: string[]; fieldValues: Record<string, string> } } |
| | | export interface SpliceMatrix { segments: SpliceSegment[]; cycleNos: number[] } |
| | | export interface SpliceSettings { fieldStructure?: SpliceField[]; statisticValues?: Record<string, string>; batteryFields?: string[]; specialFields?: string[]; detailFieldValues?: Record<string, Record<string, string>>; metrics?: string[]; seriesColors?: string[] } |
| | | export interface SpliceSettings { sourceExperimentNames?: Record<string,string>; hiddenSeries?: boolean; fieldStructure?: SpliceField[]; statisticValues?: Record<string, string>; batteryFields?: string[]; specialFields?: string[]; detailFieldValues?: Record<string, Record<string, string>>; metrics?: string[]; seriesColors?: string[] } |
| | | export interface SpliceSummary { id: number; name: string; creator: string; createdAt: number } |
| | | export interface SavedSpliceReport extends SpliceSummary { description?: string; selections: SpliceSelection[]; segments: SpliceSegment[]; cycleNos: number[]; settings: SpliceSettings; versionNo: number; operatorName: string; versionCreatedAt: number; changeSummary: string } |
| | | export interface SavedSpliceReport extends SpliceSummary { spliceMode?: 'battery-segments'; description?: string; selections: SpliceSelection[]; segments: SpliceSegment[]; cycleNos: number[]; settings: SpliceSettings; versionNo: number; operatorName: string; versionCreatedAt: number; changeSummary: string } |
| | | const root='/system/splice-report' |
| | | async function data<T>(path:string, method='GET', body?:unknown){return (await request<ApiResponse & {data:T}>(`${root}${path}`,{method,...(body===undefined?{}:{body:JSON.stringify(body)})})).data} |
| | | export function listSpliceReports(){return request<ApiResponse & {rows:SpliceSummary[]}>(`${root}/list`).then(r=>r.rows)} |
| | |
| | | field.fieldDefinitionId ? field.fieldDefinitionId === value.fieldDefinitionId : field.name === value.fieldName) |
| | | return field.category === 'battery' ? business?.fieldValue ?? '' : '' |
| | | } |
| | | /** 新版列身份包含测试与测试电池;空字符串不回退为组字段或其他列。 */ |
| | | export function spliceSourceKey(source: SpliceCandidate) { return `${source.selection.testId}|${source.selection.testBatteryId}` } |
| | | export function spliceSourceValue(segment: SpliceSegment, settings: SpliceSettings, field: SpliceField) { |
| | | const manual = settings.detailFieldValues?.[field.name]?.[spliceSourceKey(segment.source)] |
| | | if (manual !== undefined) return manual |
| | | const detail = segment.consoleDetail?.fieldValues[field.name] |
| | | if (detail !== undefined) return detail |
| | | if (segment.consoleDetail?.batteryFields.includes(field.name) || segment.consoleDetail?.specialFields.includes(field.name)) return '' |
| | | // 用户明确选择的共享字段仍按其身份提供组值,不冒充缺失的控制台人工字段。 |
| | | return field.fieldDefinitionId ? segment.businessFields?.find(value => value.fieldDefinitionId === field.fieldDefinitionId)?.fieldValue ?? '' : '' |
| | | } |
| | | export function spliceExperiment(segment: SpliceSegment, settings: SpliceSettings) { |
| | | return settings.sourceExperimentNames?.[spliceSourceKey(segment.source)] ?? segment.consoleDetail?.experimentName ?? '' |
| | | } |
| | | /** 人工统计绑定测试电池和原循环身份,来源重排不改变归属。 */ |
| | | export function spliceStatisticKey(segment: SpliceSegment, cycle: number, metric: string) { |
| | | return `${segment.source.selection.testId}|${segment.source.selection.testBatteryId}|${cycle}|${metric}` |
| | |
| | | import type { SavedSpliceReport } from '@/api/spliceReports' |
| | | import { spliceSavedReportBody } from '@/utils/spliceSavedReport' |
| | | import '@/utils/spliceSavedReport.css' |
| | | const props = defineProps<{ report: SavedSpliceReport }>() |
| | | const content = computed(() => spliceSavedReportBody(props.report)) |
| | | const props = defineProps<{ report: SavedSpliceReport; chartsOnly?: boolean }>() |
| | | const content = computed(() => spliceSavedReportBody(props.report, props.chartsOnly)) |
| | | </script> |
| | | |
| | | <template> |
| | |
| | | import { LineChart } from 'echarts/charts' |
| | | import { GridComponent, MarkLineComponent } from 'echarts/components' |
| | | import { SVGRenderer } from 'echarts/renderers' |
| | | import { getSplicePrintVersion, spliceFields, spliceFieldValue, spliceStatisticKey, type SavedSpliceReport } from '@/api/spliceReports' |
| | | import { getSplicePrintVersion, spliceFields, spliceFieldValue, spliceStatisticKey, spliceSourceValue, spliceExperiment, type SavedSpliceReport } from '@/api/spliceReports' |
| | | import { openSavedReportPrint } from './savedReportPrint' |
| | | import css from './spliceSavedReport.css?inline' |
| | | |
| | |
| | | const display = (value: unknown) => { const number = numeric(value); return number === null ? '缺失' : String(Number(number.toFixed(4))) } |
| | | |
| | | /** 重开与打印共用冻结统计及人工字段,绝不重算或读取来源测试。 */ |
| | | export function spliceSavedReportBody(record: SavedSpliceReport) { |
| | | export function spliceSavedReportBody(record: SavedSpliceReport, chartsOnly = false) { |
| | | if (record.spliceMode === 'battery-segments') return segmentReportBody(record, chartsOnly) |
| | | const sources = [...new Set(record.segments.map(segment => segment.source.selection.testId))] |
| | | const batteries = [...new Set(record.segments.map(segment => segment.source.batteryNo))] |
| | | const color = (battery: string) => { |
| | |
| | | return `<div class="splice-snapshot"><header><h1>${cell(record.name)}</h1>${heading}</header><section><h2>来源测试顺序与循环对应</h2><table><thead><tr><th>顺序</th><th>来源测试</th><th>电池组</th><th>参与电池号</th><th>原循环号 → 拼接循环号</th></tr></thead><tbody>${sourceRows}</tbody></table></section><section><h2>报表字段</h2>${fieldRows ? `<table><thead><tr><th>电池号</th><th>字段</th><th>保存值</th></tr></thead><tbody>${fieldRows}</tbody></table>` : '<p>无已保存字段</p>'}</section><section><h2>统计图表</h2>${charts.join('') || `<p>${cycles.length ? '当前未启用统计图指标' : '无已保存循环统计'}</p>`}</section>${statistics}<section><h2>报表说明</h2><div class="splice-note">${cell(record.description)}</div></section></div>` |
| | | } |
| | | |
| | | /** 新版每指标一条完整接续线;旧版仍走原快照呈现,不静默重算历史偏移。 */ |
| | | function segmentReportBody(record: SavedSpliceReport, chartsOnly: boolean) { |
| | | const segments = record.segments |
| | | const cycles = record.cycleNos |
| | | const savedColor = record.settings.seriesColors?.[0] |
| | | const color = savedColor && /^#[\da-f]{6}$/i.test(savedColor) ? savedColor : '#3978f6' |
| | | const sourceName = (index: number) => `来源${index + 1} · ${segments[index]!.source.name} · ${segments[index]!.source.group} · ${segments[index]!.source.batteryNo}` |
| | | const charts = metrics.filter(metric => !record.settings.metrics || record.settings.metrics.includes(metric.key)).map(metric => { |
| | | if (!cycles.length) return `<article class="splice-chart"><h3>${metric.title}</h3><p>无已保存循环统计</p></article>` |
| | | const chart = init(null, undefined, { renderer: 'svg', ssr: true, width: 900, height: 320 }) |
| | | let svg: string |
| | | try { |
| | | chart.setOption({ animation: false, grid: { top: 42, right: 35, bottom: 48, left: 70 }, |
| | | xAxis: { type: 'value', min: cycles[0]! - 0.5, max: cycles.at(-1)! + 0.5, minInterval: 1, name: '拼接循环号', nameLocation: 'middle', nameGap: 30, axisLabel: { hideOverlap: true, formatter: (value: number) => Number.isInteger(value) ? String(value) : '' } }, |
| | | yAxis: { type: 'value', scale: true, name: metric.unit }, |
| | | series: [{ name: '接续曲线', type: 'line', connectNulls: false, showSymbol: true, symbolSize: 5, itemStyle: { color }, |
| | | // 人工表格值不进入计算原值曲线;缺值保留断点,全部指标共用循环偏移。 |
| | | data: record.settings.hiddenSeries ? [] : segments.flatMap((segment, index) => segment.originalCycleNos.map(no => ({ name: `${sourceName(index)} · 原循环${no} · 拼接循环${no + segment.offset}`, value: [no + segment.offset, numeric(segment.statistics[String(no)]?.[metric.key])] }))), |
| | | markLine: { silent: true, symbol: 'none', lineStyle: { color: '#82909b', type: 'dashed' }, label: { show: false }, data: segments.flatMap((segment, index) => segment.originalCycleNos.length ? [{ name: `来源${index + 1}`, xAxis: segment.originalCycleNos[0]! + segment.offset }] : []) }, |
| | | }], |
| | | }) |
| | | svg = chart.renderToSVGString() |
| | | } finally { chart.dispose() } |
| | | return `<article class="splice-chart" data-series-count="1"><h3>${metric.title} (${metric.unit})</h3><div class="splice-chart-plot">${svg}</div><div class="splice-legend"><span><i style="background:${color}"></i>接续曲线${record.settings.hiddenSeries ? '(已隐藏)' : ''}</span></div><div class="splice-legend">${segments.map((segment, index) => `<span>${cell(sourceName(index))} · ${segment.originalCycleNos.length ? `循环${segment.originalCycleNos[0]! + segment.offset}~${segment.originalCycleNos.at(-1)! + segment.offset}` : '无已保存循环'}</span>`).join('')}</div></article>` |
| | | }).join('') |
| | | if (chartsOnly) return `<div class="splice-snapshot">${charts}</div>` |
| | | const fields = spliceFields(record.settings) |
| | | const headers = segments.map((_, index) => `<th>${cell(sourceName(index))}</th>`).join('') |
| | | const details = `<section><h2>测试详情</h2><table><thead><tr><th>字段</th>${headers}</tr></thead><tbody><tr><td>实验</td>${segments.map(segment => `<td>${escapeHtml(spliceExperiment(segment, record.settings))}</td>`).join('')}</tr>${fields.map(field => `<tr><td>${cell(field.displayName)}${field.unit ? ` (${cell(field.unit)})` : ''}</td>${segments.map(segment => `<td>${escapeHtml(spliceSourceValue(segment, record.settings, field))}</td>`).join('')}</tr>`).join('')}</tbody></table></section>` |
| | | const sources = `<section><h2>来源顺序与循环对应</h2><table><thead><tr><th>来源</th><th>原循环号 → 拼接循环号</th></tr></thead><tbody>${segments.map((segment, index) => `<tr><td>${cell(sourceName(index))}</td><td>${segment.originalCycleNos.map(no => `${no} → ${no + segment.offset}`).join('、') || '无已保存循环'}</td></tr>`).join('')}</tbody></table></section>` |
| | | const statistics = segments.map((segment, index) => `<section class="splice-statistics"><h2>${cell(sourceName(index))}</h2><table><thead><tr><th>原循环号</th><th>拼接循环号</th>${metrics.map(metric => `<th>${metric.title} (${metric.unit})</th>`).join('')}</tr></thead><tbody>${segment.originalCycleNos.map(no => `<tr><td>${no}</td><td>${no + segment.offset}</td>${metrics.map(metric => { |
| | | const original = segment.statistics[String(no)]?.[metric.key] |
| | | const override = record.settings.statisticValues?.[spliceStatisticKey(segment, no, metric.key)] |
| | | return `<td>${override === undefined ? display(original) : `${escapeHtml(override)}<br><small>计算原值:${display(original)}</small>`}</td>` |
| | | }).join('')}</tr>`).join('') || '<tr><td colspan="4">无已保存循环</td></tr>'}</tbody></table></section>`).join('') |
| | | return `<div class="splice-snapshot"><header><h1>${cell(record.name)}</h1><p>拼接报表 #${record.id} · 版本 V${record.versionNo}</p><p>创建人 ${cell(record.creator)} · 版本操作人 ${cell(record.operatorName)}</p><p>修改说明 ${cell(record.changeSummary)}</p></header>${sources}${details}<section><h2>统计图表</h2>${charts || '<p>当前未启用统计图指标</p>'}</section>${statistics}<section><h2>报表说明</h2><div class="splice-note">${cell(record.description)}</div></section></div>` |
| | | } |
| | | |
| | | /** 先打开窗口,再使用独立导出权限读取指定版本;页面草稿不进入打印内容。 */ |
| | | export function openSpliceSavedReportPrint(id: number, versionNo: number) { |
| | | return openSavedReportPrint(() => getSplicePrintVersion(id, versionNo), record => `<!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}body{margin:0;padding:16px;background:white}@media print{body{padding:0}}${css}</style></head><body>${spliceSavedReportBody(record)}</body></html>`) |
| | |
| | | import { useRoute, useRouter } from 'vue-router' |
| | | import { getUserInfo } from '@/api/auth' |
| | | import { getSpliceSources, previewSpliceReport, createSpliceReport, editSpliceReport, refreshSpliceSource, |
| | | getSpliceReport, getSpliceVersion, listSpliceVersions, spliceFields, spliceFieldValue, spliceStatisticKey, |
| | | getSpliceReport, getSpliceVersion, listSpliceVersions, spliceFields, spliceFieldValue, spliceStatisticKey, spliceSourceKey, spliceSourceValue, spliceExperiment, |
| | | type SpliceCandidate, type SpliceMatrix, type SpliceField, type SpliceSettings, type SavedSpliceReport } from '@/api/spliceReports' |
| | | import { openSpliceSavedReportPrint } from '@/utils/spliceSavedReport' |
| | | import SpliceSavedReport from '@/components/SpliceSavedReport.vue' |
| | |
| | | const fields = ref<SpliceField[]>([]) |
| | | const detailFieldValues = ref<Record<string, Record<string, string>>>({}) |
| | | const statisticValues = ref<Record<string, string>>({}) |
| | | const sourceExperimentNames = ref<Record<string,string>>({}) |
| | | const hiddenSeries = ref(false) |
| | | const statisticMetrics = [{ key: 'finalVoltage1', title: '最终电压1', unit: 'V' }, { key: 'energyEfficiency', title: '能量效率', unit: '%' }] as const |
| | | const statisticVisible = reactive({ finalVoltage1: true, energyEfficiency: true }) |
| | | const seriesColors = ref(['#3978f6', '#109854', '#d25b45', '#7754a8']) |
| | |
| | | const selectedCandidateId = ref<string | null>(null) |
| | | const candidateGroupId = ref<number | null>(null) |
| | | const candidateQuery = ref('') |
| | | const batteryGroup = computed(() => selectedTests.value[0]?.group || '') |
| | | const batteryGroup = computed(() => [...new Set(selectedTests.value.map(item => item.group))].join('、')) |
| | | const candidateTests = computed(() => sources.value.filter(source => |
| | | (!candidateGroupId.value || source.groupId === candidateGroupId.value) && |
| | | (source.name.includes(candidateQuery.value) || String(source.selection.testId).includes(candidateQuery.value) || source.batteryNo.includes(candidateQuery.value)))) |
| | | const candidateGroups = computed(() => [...new Map(sources.value.map(source => [source.groupId, source.group])).entries()]) |
| | | const commonBatteries = computed(() => selectedTests.value.length ? [...new Set(sources.value |
| | | .filter(source => source.selection.testId === selectedTests.value[0]!.selection.testId).map(source => source.batteryNo))] |
| | | .filter(battery => selectedTests.value.every(test => sources.value.some(source => |
| | | source.selection.testId === test.selection.testId && source.batteryNo === battery))) : []) |
| | | const selections = computed(() => selectedTests.value.map(item => item.selection)) |
| | | const matrix = computed(() => savedReport.value || draftMatrix.value) |
| | | const segmentMode = computed(() => !savedReport.value || savedReport.value.spliceMode === 'battery-segments') |
| | | const detailColumns = computed(() => matrix.value?.segments || []) |
| | | const statisticSettings = computed<SpliceSettings>(() => ({ |
| | | sourceExperimentNames: sourceExperimentNames.value, |
| | | hiddenSeries: hiddenSeries.value, |
| | | fieldStructure: fields.value, |
| | | batteryFields: fields.value.filter(field => field.category === 'battery').map(field => field.name), |
| | | specialFields: fields.value.filter(field => field.category === 'special').map(field => field.name), |
| | | detailFieldValues: Object.fromEntries(fields.value.map(field => [field.name, Object.fromEntries(samples.value |
| | | detailFieldValues: Object.fromEntries(fields.value.map(field => [field.name, Object.fromEntries((segmentMode.value ? selectedTests.value.map(spliceSourceKey) : samples.value) |
| | | .filter(battery => detailFieldValues.value[field.name]?.[battery] !== undefined) |
| | | .map(battery => [battery, detailFieldValues.value[field.name]![battery]!]))])), |
| | | statisticValues: statisticValues.value, |
| | |
| | | })) |
| | | const statisticPreview = computed<SavedSpliceReport | null>(() => matrix.value ? { |
| | | id: 0, createdAt: 0, creator: '', versionNo: 0, operatorName: '', versionCreatedAt: 0, changeSummary: '', |
| | | spliceMode: 'battery-segments', |
| | | selections: selections.value, ...savedReport.value, ...matrix.value, |
| | | name: reportName.value, description: experimentName.value, settings: statisticSettings.value, |
| | | } : null) |
| | |
| | | fields.value = spliceFields(saved.settings).map(field => ({ ...field })) |
| | | detailFieldValues.value = structuredClone(saved.settings.detailFieldValues || {}) |
| | | statisticValues.value = { ...saved.settings.statisticValues } |
| | | sourceExperimentNames.value = { ...saved.settings.sourceExperimentNames } |
| | | hiddenSeries.value = saved.settings.hiddenSeries === true |
| | | seriesColors.value = [...(saved.settings.seriesColors || ['#3978f6', '#109854', '#d25b45', '#7754a8'])] |
| | | selectedTests.value = [...new Map(saved.segments.map(segment => [segment.source.selection.testId, segment.source])).values()] |
| | | selectedTests.value = saved.segments.map(segment => segment.source) |
| | | samples.value = [...new Set(saved.segments.map(segment => segment.source.batteryNo))] |
| | | const keys = saved.settings.metrics |
| | | statisticMetrics.forEach(metric => { statisticVisible[metric.key] = !keys || (keys.length > 0 && !keys.some(key => statisticMetrics.some(item => item.key === key))) || keys.includes(metric.key) }) |
| | |
| | | if (!canCreate.value) throw new Error('无新增拼接报表权限') |
| | | selectedTests.value = []; samples.value = []; reportName.value = ''; experimentName.value = '' |
| | | fields.value = []; detailFieldValues.value = {}; statisticValues.value = {}; reordered = false |
| | | sourceExperimentNames.value = {}; hiddenSeries.value = false |
| | | sources.value = await getSpliceSources() |
| | | savedContent.value = formContent() |
| | | } |
| | |
| | | statisticMetrics.map(metric => spliceStatisticKey(segment, cycle, metric.key))))) |
| | | statisticValues.value = Object.fromEntries(Object.entries(statisticValues.value).filter(([key]) => allowed.has(key))) |
| | | draftMatrix.value = result |
| | | // 控制台只提供字段名,带入时不猜单位;已有人工结构与值保持不变。 |
| | | for (const segment of result.segments) for (const category of ['battery', 'special'] as const) { |
| | | for (const name of segment.consoleDetail?.[category === 'battery' ? 'batteryFields' : 'specialFields'] || []) { |
| | | if (!fields.value.some(field => field.name === name)) fields.value.push({ name, category, displayName: name, unit: '' }) |
| | | } |
| | | } |
| | | } |
| | | } catch (error) { |
| | | if (request === previewRequest) previewError.value = error instanceof Error ? error.message : '统计预览失败' |
| | |
| | | } |
| | | async function saveReport() { |
| | | if (!reportName.value.trim()) { ElMessage.warning('请输入拼接报表名称'); return } |
| | | if (!editingId.value && (!draftMatrix.value || previewing.value)) { ElMessage.warning('请选择至少两个测试及共同电池,并等待统计预览'); return } |
| | | if (!editingId.value && (!draftMatrix.value || previewing.value)) { ElMessage.warning('请选择至少两个测试电池段,并等待统计预览'); return } |
| | | saving.value = true |
| | | try { |
| | | const payload = { name: reportName.value.trim(), description: experimentName.value, settings: statisticSettings.value } |
| | |
| | | <section class="report-name-panel"><label><span>拼接报表名称</span><el-input v-model="reportName" clearable placeholder="请输入拼接报表名称" /></label></section> |
| | | |
| | | <section class="test-panel"> |
| | | <header class="panel-heading"><div><strong>拼接测试</strong></div><small>{{ selectedTests.length }} 个测试</small></header> |
| | | <div class="test-table-wrap"><table class="test-table"><thead><tr><th>顺序</th><th>测试名称</th><th>测试类型</th><th>电池组</th><th>开始时间</th><th v-if="!editingId">操作</th></tr></thead><tbody> |
| | | <tr v-for="(item, index) in selectedTests" :key="item.selection.testId"><td><span class="order-index">{{ index + 1 }}</span></td><td>{{ item.name }}</td><td>{{ item.type }}</td><td>{{ item.group }}</td><td>{{ item.startedAt ? new Date(item.startedAt).toLocaleString() : '缺失' }}</td> |
| | | <header class="panel-heading"><div><strong>拼接测试</strong></div><small>{{ selectedTests.length }} 个来源段</small></header> |
| | | <div class="test-table-wrap"><table class="test-table"><thead><tr><th>顺序</th><th>测试名称</th><th>测试类型</th><th>电池组</th><th>电池号</th><th v-if="!editingId">操作</th></tr></thead><tbody> |
| | | <tr v-for="(item, index) in selectedTests" :key="spliceSourceKey(item)"><td><span class="order-index">{{ index + 1 }}</span></td><td>{{ item.name }}</td><td>{{ item.type }}</td><td>{{ item.group }}</td><td>{{ item.batteryNo }}</td> |
| | | <td v-if="!editingId"><el-tooltip content="上移"><el-button circle aria-label="上移测试" :disabled="index === 0" @click="moveTest(index, -1)"><el-icon><ArrowUp /></el-icon></el-button></el-tooltip><el-tooltip content="下移"><el-button circle aria-label="下移测试" :disabled="index === selectedTests.length - 1" @click="moveTest(index, 1)"><el-icon><ArrowDown /></el-icon></el-button></el-tooltip><button class="delete-link" @click="removeSpliceTest(index)">删除</button></td></tr> |
| | | <tr v-if="!selectedTests.length"><td colspan="6">暂无已选测试</td></tr> |
| | | </tbody></table></div> |
| | | <div v-if="!editingId" class="panel-actions"><button class="primary-button compact-button" type="button" @click="openAddTest"><el-icon><Plus /></el-icon>添加拼接</button> |
| | | <el-checkbox-group v-model="samples" aria-label="共同参与电池"><el-checkbox v-for="battery in commonBatteries" :key="battery" :value="battery">{{ battery }}</el-checkbox></el-checkbox-group> |
| | | <span v-if="selectedTests.length && !commonBatteries.length">无共同参与电池</span> |
| | | </div> |
| | | </section> |
| | | |
| | | <section class="detail-panel"> |
| | | <header class="panel-heading"><div><strong>测试详情</strong></div></header> |
| | | <div class="detail-meta"><label><span>报表说明</span><el-input v-model="experimentName" /></label><div><span>电池组</span><strong>{{ batteryGroup }}</strong></div></div> |
| | | <div class="detail-table-wrap"><table class="detail-table" :style="{ minWidth: `${Math.max(760, samples.length * 190 + 170)}px` }"><thead><tr><th>字段</th><th v-for="sample in samples" :key="sample">{{ sample }}</th></tr></thead><tbody> |
| | | <div v-if="segmentMode" class="detail-table-wrap"><table class="detail-table" :style="{ minWidth: `${Math.max(760, detailColumns.length * 190 + 170)}px` }"><thead><tr><th>字段</th><th v-for="segment in detailColumns" :key="spliceSourceKey(segment.source)">{{ segment.source.name }}<br>{{ segment.source.group }} · {{ segment.source.batteryNo }}</th></tr></thead><tbody> |
| | | <tr><td>实验</td><td v-for="segment in detailColumns" :key="spliceSourceKey(segment.source)"><el-input :model-value="spliceExperiment(segment, statisticSettings)" placeholder="缺失" @update:model-value="sourceExperimentNames[spliceSourceKey(segment.source)] = $event" /></td></tr> |
| | | <tr v-for="field in fields" :key="field.name"><td>{{ field.displayName }}{{ field.unit ? ` (${field.unit})` : '' }}</td><td v-for="segment in detailColumns" :key="spliceSourceKey(segment.source)"><el-input :model-value="spliceSourceValue(segment, statisticSettings, field)" size="small" placeholder="缺失" @update:model-value="setFieldValue(field, spliceSourceKey(segment.source), $event)" /></td></tr> |
| | | </tbody></table></div> |
| | | <div v-else class="detail-table-wrap"><table class="detail-table" :style="{ minWidth: `${Math.max(760, samples.length * 190 + 170)}px` }"><thead><tr><th>字段</th><th v-for="sample in samples" :key="sample">{{ sample }}</th></tr></thead><tbody> |
| | | <tr v-for="field in fields" :key="field.name"><td>{{ field.displayName }}{{ field.unit ? ` (${field.unit})` : '' }}</td><td v-for="sample in samples" :key="sample"><el-input :model-value="fieldValue(field, sample)" size="small" placeholder="缺失" @update:model-value="setFieldValue(field, sample, $event)" /></td></tr> |
| | | </tbody></table></div> |
| | | <div class="detail-actions"><button class="primary-button" type="button" @click="addSpecialField"><el-icon><Plus /></el-icon>添加项</button></div> |
| | |
| | | |
| | | <section v-if="statisticPreview" class="chart-panel"> |
| | | <div class="metric-switches"><label v-for="metric in statisticMetrics" :key="metric.key"><span>{{ metric.title }}</span><el-switch v-model="statisticVisible[metric.key]" /></label></div> |
| | | <div class="chart-legend-bar"><div v-for="(sample, index) in samples" :key="sample" class="chart-legend-item"><el-color-picker :model-value="seriesColors[index]" :aria-label="`修改${sample}颜色`" @change="updateSeriesColor(index, $event)" /><span>{{ sample }}</span></div></div> |
| | | <SpliceSavedReport :report="statisticPreview" /> |
| | | <div v-if="segmentMode" class="chart-legend-bar"><div class="chart-legend-item"><el-color-picker :model-value="seriesColors[0]" aria-label="修改接续曲线颜色" @change="updateSeriesColor(0, $event)" /><button type="button" :aria-pressed="!hiddenSeries" @click="hiddenSeries = !hiddenSeries">接续曲线{{ hiddenSeries ? '(已隐藏)' : '' }}</button></div></div> |
| | | <div v-else class="chart-legend-bar"><div v-for="(sample, index) in samples" :key="sample" class="chart-legend-item"><el-color-picker :model-value="seriesColors[index]" :aria-label="`修改${sample}颜色`" @change="updateSeriesColor(index, $event)" /><span>{{ sample }}</span></div></div> |
| | | <SpliceSavedReport :report="statisticPreview" :charts-only="segmentMode" /> |
| | | </section> |
| | | <el-alert v-if="previewError" :title="previewError" type="error" :closable="false" /> |
| | | <p v-if="previewing">正在加载循环统计</p> |
| | |
| | | |
| | | <el-dialog v-model="addDialogVisible" class="add-dialog" title="添加拼接" width="min(560px, 94vw)" align-center destroy-on-close> |
| | | <div class="dialog-form"> |
| | | <label><span>电池组</span><el-select v-model="candidateGroupId" clearable :disabled="selectedTests.length > 0" placeholder="全部"><el-option v-for="[id, name] in candidateGroups" :key="id" :label="name" :value="id" /></el-select></label> |
| | | <label><span>电池组</span><el-select v-model="candidateGroupId" clearable placeholder="全部"><el-option v-for="[id, name] in candidateGroups" :key="id" :label="name" :value="id" /></el-select></label> |
| | | <label><span>测试名称</span><el-input v-model="candidateQuery" placeholder="输入测试名称或ID搜索" /></label> |
| | | <label><span>选择测试电池</span><el-select v-model="selectedCandidateId" filterable clearable placeholder="选择检测完成测试电池"><el-option v-for="item in candidateTests" :key="`${item.selection.testId}:${item.selection.testBatteryId}`" :label="`${item.name}(${item.group},${item.batteryNo})`" :value="`${item.selection.testId}:${item.selection.testBatteryId}`" :disabled="selectedTests.some(test => test.selection.testId === item.selection.testId && test.selection.testBatteryId === item.selection.testBatteryId)" /></el-select></label> |
| | | </div> |
| | |
| | | expect(errors).toEqual([]) |
| | | }) |
| | | |
| | | test('拼接前置行为:共同多电池、跳号和末工步缺失', async ({ page }) => { |
| | | test('拼接逐电池来源、跳号和末工步缺失', async ({ page }) => { |
| | | await login(page) |
| | | // 后段从循环5开始,必须平移到前段末循环5之后;不额外假设首段的归零规则。 |
| | | const single = [selection(9303), selection(9302)] |
| | |
| | | expect.soft(nextSegment.originalCycleNos.map((no: number) => no + nextSegment.offset), '后段应紧接前段且保留段内跳号').toEqual([6, 8]) |
| | | expect.soft(preview.segments[0].statistics['3'].finalVoltage1 ?? null, '最后实际工步缺失,不能回退到 4.2 V').toBeNull() |
| | | const multiple = await request(page, 'splice-report/preview', 'POST', [selection(9302), selection(9302, 9402), selection(9303), selection(9303, 9403)]) |
| | | expect.soft(multiple.code, `共同多电池:${multiple.msg}`).toBe(200) |
| | | expect.soft(multiple.code, `逐电池段:${multiple.msg}`).toBe(200) |
| | | const mismatch = await request(page, 'splice-report/preview', 'POST', [selection(9302), selection(9304)]) |
| | | expect(mismatch.code).not.toBe(200) |
| | | expect(mismatch.code).toBe(200) |
| | | for (const valid of [[selection(9302), selection(9302, 9402)], [selection(9302), selection(9303), selection(9303, 9403)]]) |
| | | expect((await request(page, 'splice-report/preview', 'POST', valid)).code).toBe(200) |
| | | for (const invalid of [ |
| | | [selection(9302), selection(9302, 9402)], |
| | | [selection(9302), selection(9303), selection(9303, 9403)], |
| | | [selection(9302), selection(9302)], |
| | | [selection(9302), selection(9305)], |
| | | [selection(9302, 9403), selection(9303)], |
| | | ]) expect((await request(page, 'splice-report/preview', 'POST', invalid)).code).not.toBe(200) |
| | |
| | | |
| | | test('拼接模板仅保存所属类型字段结构,业务字段和人工指标随版本独立保存', async ({ page }) => { |
| | | await login(page) |
| | | const fields = [{ name: 'F', category: 'battery', displayName: '业务F', unit: '' }, { name: '说明项', category: 'special', displayName: '人工说明', unit: '' }] |
| | | const fieldDefinitionId = Number(stack.queryDatabase("SELECT field_id FROM t_battery_group_field WHERE field_name='F'")) |
| | | const fields = [{ name: 'F', category: 'battery', displayName: '业务F', unit: '', fieldDefinitionId }, { name: '说明项', category: 'special', displayName: '人工说明', unit: '' }] |
| | | const template = (await api(page, 'splice-report/templates', 'POST', { name: '拼接专用字段', fields })).data |
| | | const templates = (await api(page, 'splice-report/templates')).data |
| | | const stored = templates.find((item: any) => item.id === template) |
| | |
| | | const draft = { ...payload('splice', '模板与人工统计'), selections, settings: { ...payload('splice', '').settings, fieldStructure: stored.fields, statisticValues: {} } } |
| | | const first = (await api(page, 'splice-report', 'POST', draft)).data |
| | | expect(first.segments[0].businessFields.map((field: any) => field.fieldName)).toEqual(['F']) |
| | | expect(first.cycleNos).toEqual([1, 3, 5, 6, 8]) |
| | | expect(first.segments.map((segment: any) => segment.offset)).toEqual([0, 0, 1, 1]) |
| | | expect(first.cycleNos).toEqual([1, 3, 5, 6, 8, 10, 11, 13, 14, 16]) |
| | | expect(first.segments.map((segment: any) => segment.offset)).toEqual([0, 5, 6, 9]) |
| | | const original = (await api(page, 'test/9303/data/steps')).data |
| | | const second = (await api(page, `splice-report/${first.id}/versions`, 'POST', { name: first.name, description: '', baseVersionNo: 1, changeSummary: '人工调整', settings: { ...first.settings, statisticValues: { '9303|9303|3|finalVoltage1': '-1.25' } } })).data |
| | | expect(second.segments).toEqual(first.segments) |
| | |
| | | page.on('pageerror', error => errors.push(error.message)) |
| | | await page.goto('/report-splice/new') |
| | | await page.getByPlaceholder('请输入拼接报表名称').fill('浏览器多电池拼接') |
| | | for (const name of ['连续来源3', '连续来源2']) { |
| | | for (const [name, battery] of [['连续来源3', '同名电池'], ['连续来源3', '第二电池'], ['连续来源2', '同名电池'], ['连续来源2', '第二电池']]) { |
| | | await page.locator('.panel-actions').getByRole('button', { name: '添加拼接', exact: true }).click() |
| | | const dialog = page.getByRole('dialog', { name: '添加拼接', exact: true }) |
| | | await dialog.getByPlaceholder('输入测试名称或ID搜索').fill(name) |
| | | await dialog.getByRole('combobox').last().click() |
| | | await page.getByRole('option', { name: new RegExp(name) }).click() |
| | | await page.getByRole('option', { name: new RegExp(`${name}.*${battery}`) }).click() |
| | | await dialog.getByRole('button', { name: '添加拼接', exact: true }).click() |
| | | await expect(dialog).not.toBeVisible() |
| | | } |
| | | await expect(page.locator('.test-table tbody tr').first()).toContainText('连续来源2') |
| | | await page.getByRole('button', { name: '下移测试', exact: true }).first().click() |
| | | await page.getByRole('button', { name: '上移测试', exact: true }).nth(2).click() |
| | | await page.getByRole('button', { name: '上移测试', exact: true }).nth(1).click() |
| | | await page.getByRole('button', { name: '上移测试', exact: true }).nth(3).click() |
| | | await page.getByRole('button', { name: '上移测试', exact: true }).nth(2).click() |
| | | await expect(page.locator('.test-table tbody tr').first()).toContainText('连续来源3') |
| | | await expect(page.locator('.statistics-editor tbody tr')).toHaveCount(10) |
| | | await page.locator('.panel-actions .el-checkbox').filter({ hasText: '第二电池' }).click() |
| | | await expect(page.locator('.statistics-editor tbody tr')).toHaveCount(5) |
| | | await page.locator('.panel-actions .el-checkbox').filter({ hasText: '第二电池' }).click() |
| | | await expect(page.locator('.statistics-editor tbody tr')).toHaveCount(10) |
| | | await expect(page.locator('.splice-snapshot')).not.toContainText('SP2') |
| | | await expect(page.locator('.splice-chart')).toHaveCount(2) |
| | |
| | | await fRow.locator('.el-checkbox').click() |
| | | await fRow.getByRole('textbox', { name: '展示名称', exact: true }).fill('冻结业务F') |
| | | await fieldDialog.getByRole('button', { name: '确认', exact: true }).click() |
| | | await expect(page.locator('.detail-table tbody tr').first().locator('input').first()).toHaveValue(/^(12.5|25)$/) |
| | | await expect(page.locator('.detail-table tbody tr').filter({ hasText: '冻结业务F' }).locator('input').first()).toHaveValue(/^(12.5|25)$/) |
| | | await page.getByRole('button', { name: '选择字段模板', exact: true }).click() |
| | | await page.getByRole('button', { name: '保存为模板', exact: true }).click() |
| | | const saveTemplate = page.getByRole('dialog', { name: '保存拼接字段模板', exact: true }) |
| | |
| | | await page.getByRole('dialog', { name: '选择拼接字段模板', exact: true }).getByRole('button', { name: '应用模板', exact: true }).click() |
| | | await page.getByRole('textbox', { name: '连续来源3 同名电池 循环3 最终电压1', exact: true }).fill('-1.25') |
| | | await page.getByRole('textbox', { name: '连续来源3 第二电池 循环3 最终电压1', exact: true }).fill('9') |
| | | await page.locator('.panel-actions .el-checkbox').filter({ hasText: '第二电池' }).click() |
| | | await expect(page.locator('.statistics-editor tbody tr')).toHaveCount(5) |
| | | await page.locator('.panel-actions .el-checkbox').filter({ hasText: '第二电池' }).click() |
| | | await expect(page.getByRole('textbox', { name: '连续来源3 第二电池 循环3 最终电压1', exact: true })).toHaveValue('') |
| | | await expect(page.getByRole('textbox', { name: '连续来源3 第二电池 循环3 最终电压1', exact: true })).toHaveValue('9') |
| | | await expect(page.getByRole('textbox', { name: '连续来源3 同名电池 循环3 最终电压1', exact: true })).toHaveValue('-1.25') |
| | | for (const width of [1440, 390]) { |
| | | await page.setViewportSize({ width, height: 900 }) |
| New file |
| | |
| | | import { expect, test, type Page } from '@playwright/test' |
| | | import { Ticket04Stack } from './support/ticket04-stack' |
| | | |
| | | const stack = new Ticket04Stack() |
| | | const selection = (testBatteryId: number) => ({ testId: testBatteryId < 9703 ? 9701 : 9702, testBatteryId }) |
| | | const fields = [{ name: '备注', category: 'special', displayName: '备注', unit: '' }] |
| | | test.use({ actionTimeout: 20_000 }) |
| | | test.beforeAll(async () => { |
| | | await stack.start() |
| | | stack.executeIsolatedSql(` |
| | | INSERT INTO t_battery_group(group_id,group_name,model) VALUES(9701,'甲组','型号'),(9702,'乙组','型号'); |
| | | INSERT INTO t_test_type(test_type_id,test_type_name) VALUES(9701,'容量'); |
| | | INSERT INTO t_test(test_id,test_name,creator_id,principal_id,test_type_id,test_purpose,test_status,reception_status,started_at_utc) |
| | | VALUES(9701,'测试甲',1,1,9701,'隔离验收','COMPLETED','ENDED','2026-09-01 00:00:00'),(9702,'测试乙',1,1,9701,'隔离验收','COMPLETED','ENDED','2026-09-02 00:00:00'); |
| | | INSERT INTO t_test_battery(test_battery_id,test_id,group_id,battery_no,collector_id) |
| | | VALUES(9701,9701,9701,'1号',9701),(9702,9701,9701,'2号',9702),(9703,9702,9702,'1号',9703),(9704,9702,9702,'空段',9704),(9705,9702,9702,'长段',9705); |
| | | `) |
| | | for (const [id, cycles] of [[9701, [1,3]], [9702,[2,4]], [9703,[1]], [9705, Array.from({length:45},(_,index) => index+1)]] as const) { |
| | | for (const no of cycles) stack.executeIsolatedSql(` |
| | | INSERT INTO t_test_cycle_record(test_id,test_battery_id,binding_id,adapter_code,source_identity,source_record_id,dedupe_key,adapter_timezone_id,device_time_utc,device_time_raw,server_received_at_utc,cycle_no,charge_energy_wh,discharge_energy_wh,content_sha256) |
| | | VALUES(${selection(id).testId},${id},${id},'splice','splice:${id}','${no}','${no}','Asia/Hong_Kong','2026-09-01 00:00:00','2026-09-01 08:00:00','2026-09-01 00:00:01',${no},${no === 3 ? 0 : 1},1.2,UNHEX(SHA2('${id}:${no}',256))); |
| | | INSERT INTO t_test_step_record(test_id,test_battery_id,binding_id,adapter_code,source_identity,source_record_id,dedupe_key,adapter_timezone_id,device_time_utc,device_time_raw,server_received_at_utc,cycle_no,step_no,voltage_v,content_sha256) |
| | | VALUES(${selection(id).testId},${id},${id},'splice','splice:${id}','${no}','${no}','Asia/Hong_Kong','2026-09-01 00:00:00','2026-09-01 08:00:00','2026-09-01 00:00:01',${no},1,${no === 3 ? 'NULL' : no === 1 ? 0 : -0.2},UNHEX(SHA2('${id}:${no}',256))); |
| | | `) |
| | | } |
| | | }) |
| | | test.afterAll(async () => stack.stop()) |
| | | async function login(page: Page) { |
| | | await page.goto('/login') |
| | | await page.locator('input[autocomplete="username"]').fill('admin') |
| | | await page.locator('input[autocomplete="current-password"]').fill('admin123') |
| | | await page.getByRole('button',{name:'登录',exact:true}).click() |
| | | await expect(page).toHaveURL(/dashboard$/) |
| | | } |
| | | async function api(page: Page, path: string, method = 'GET', data?: unknown, code = 200) { |
| | | const token = await page.evaluate(() => localStorage.getItem('battery_detection_auth_token')) |
| | | const result = await (await page.request.fetch(`${stack.backendUrl}/system/${path}`,{method,data,headers:{Authorization:`Bearer ${token}`}})).json() |
| | | expect(result.code, `${path}: ${result.msg}`).toBe(code) |
| | | return result.data |
| | | } |
| | | test('逐电池身份、接续、来源详情、界面重排及保存打印闭环',async ({page}) => { |
| | | await login(page) |
| | | await api(page,'test/9701/console-detail','PUT',{experimentName:'实验甲',batteryFields:['人工字段无共享身份'],specialFields:['备注'],fieldValues:{备注:{'1号':'甲一','2号':''},人工字段无共享身份:{'1号':'原样单位未知','2号':'另一值'}}}) |
| | | await api(page,'test/9702/console-detail','PUT',{experimentName:'实验乙',batteryFields:[],specialFields:['备注'],fieldValues:{备注:{'1号':'乙一','空段':'空来源','长段':'长来源'}}}) |
| | | const selections = [9701,9702,9703].map(selection) |
| | | const preview = await api(page,'splice-report/preview','POST',selections) |
| | | expect(preview.segments.map((s:any) => s.originalCycleNos.map((no:number) => no+s.offset))).toEqual([[1,3],[4,6],[7]]) |
| | | expect(preview.segments.map((s:any) => s.consoleDetail.fieldValues.备注)).toEqual(['甲一','','乙一']) |
| | | expect(preview.segments[0].statistics['1']).toMatchObject({finalVoltage1:0,energyEfficiency:120}) |
| | | expect(preview.segments[0].statistics['3'].finalVoltage1 ?? null).toBeNull() |
| | | expect(preview.segments[0].statistics['3'].energyEfficiency ?? null).toBeNull() |
| | | for (const charge of ['NULL','0','-1']) { |
| | | stack.executeIsolatedSql(`UPDATE t_test_cycle_record SET charge_energy_wh=${charge} WHERE test_battery_id=9703`) |
| | | expect((await api(page,'splice-report/preview','POST',selections)).segments[2].statistics['1'].energyEfficiency ?? null).toBeNull() |
| | | } |
| | | stack.executeIsolatedSql('UPDATE t_test_cycle_record SET charge_energy_wh=1 WHERE test_battery_id=9703') |
| | | stack.executeIsolatedSql("UPDATE t_test SET test_status='WAITING' WHERE test_id=9702") |
| | | await api(page,'splice-report/preview','POST',selections,500) |
| | | stack.executeIsolatedSql("UPDATE t_test SET test_status='COMPLETED' WHERE test_id=9702") |
| | | await api(page,'splice-report/preview','POST',[selection(9701),selection(9701)],500) |
| | | await api(page,'splice-report/preview','POST',[selection(9701),{testId:9701,testBatteryId:9703}],500) |
| | | const sameTest = await api(page,'splice-report','POST',{name:'同测试两段',selections:selections.slice(0,2),settings:{fieldStructure:fields}}) |
| | | expect(sameTest.segments).toHaveLength(2) |
| | | const reordered = await api(page,'splice-report/preview','POST',[selection(9703),selection(9704),selection(9701)]) |
| | | expect(reordered.segments.map((s:any) => s.originalCycleNos.map((no:number) => no+s.offset))).toEqual([[1],[],[2,4]]) |
| | | await page.goto('/report-splice/new') |
| | | await page.getByPlaceholder('请输入拼接报表名称').fill('逐电池拼接界面') |
| | | for (const label of ['测试甲(甲组,1号)','测试甲(甲组,2号)','测试乙(乙组,1号)']) { |
| | | await page.locator('.panel-actions').getByRole('button',{name:'添加拼接',exact:true}).click() |
| | | await page.getByRole('dialog').locator('.el-select').last().click() |
| | | await page.getByRole('option',{name:label,exact:true}).click() |
| | | await page.getByRole('dialog').getByRole('button',{name:'添加拼接',exact:true}).click() |
| | | } |
| | | await expect(page.locator('.splice-chart')).toHaveCount(2) |
| | | await expect(page.locator('.splice-snapshot h1')).toHaveCount(0) |
| | | const detailRow = page.locator('.detail-table tr').filter({has:page.locator('td').filter({hasText:/^备注$/})}) |
| | | await expect(detailRow.locator('input').nth(0)).toHaveValue('甲一') |
| | | await expect(detailRow.locator('input').nth(1)).toHaveValue('') |
| | | await detailRow.locator('input').nth(2).fill('乙一报表') |
| | | await page.getByLabel('测试乙 1号 循环1 最终电压1').fill('8') |
| | | await page.getByRole('button',{name:'上移测试',exact:true}).nth(2).click() |
| | | await expect(detailRow.locator('input').nth(1)).toHaveValue('乙一报表') |
| | | await expect(page.getByLabel('测试乙 1号 循环1 最终电压1')).toHaveValue('8') |
| | | await page.locator('.test-table .delete-link').last().click() |
| | | await expect(detailRow.locator('input')).toHaveCount(2) |
| | | await expect(detailRow.locator('input').nth(1)).toHaveValue('乙一报表') |
| | | await page.locator('.panel-actions').getByRole('button',{name:'添加拼接',exact:true}).click() |
| | | await page.getByRole('dialog').locator('.el-select').last().click() |
| | | await page.getByRole('option',{name:'测试甲(甲组,2号)',exact:true}).click() |
| | | await page.getByRole('dialog').getByRole('button',{name:'添加拼接',exact:true}).click() |
| | | await expect(page.getByLabel('测试乙 1号 循环1 最终电压1')).toHaveValue('8') |
| | | await page.getByRole('button',{name:'接续曲线',exact:true}).click() |
| | | await page.getByRole('button',{name:'保存报表',exact:true}).click() |
| | | await expect(page).toHaveURL(/report-splice$/) |
| | | const token = await page.evaluate(() => localStorage.getItem('battery_detection_auth_token')) |
| | | const list = await (await page.request.get(`${stack.backendUrl}/system/splice-report/list`,{headers:{Authorization:`Bearer ${token}`}})).json() |
| | | const saved = await api(page,`splice-report/${list.rows.find((r:any) => r.name === '逐电池拼接界面').id}`) |
| | | expect(saved.selections).toEqual([selection(9701),selection(9703),selection(9702)]) |
| | | expect(saved.settings.hiddenSeries).toBe(true) |
| | | expect(saved.settings.detailFieldValues.备注['9702|9703']).toBe('乙一报表') |
| | | expect(saved.settings.statisticValues['9702|9703|1|finalVoltage1']).toBe('8') |
| | | expect(saved.segments[1].statistics['1'].finalVoltage1).toBe(0) |
| | | expect((await api(page,'test/9702')).consoleDetail.fieldValues.备注['1号']).toBe('乙一') |
| | | await page.goto(`/report-splice/${saved.id}/edit?mode=view`) |
| | | await page.reload() |
| | | await expect(page.locator('.splice-snapshot')).toContainText('乙一报表') |
| | | await page.context().addInitScript(() => {window.print=()=>{document.body.dataset.printRequested='true'}}) |
| | | const popupPromise = page.waitForEvent('popup') |
| | | await page.getByRole('button',{name:'打印 / 另存 PDF',exact:true}).click() |
| | | const popup = await popupPromise |
| | | await expect(popup.locator('body')).toHaveAttribute('data-print-requested','true') |
| | | expect(await popup.locator('.splice-snapshot').innerText()).toEqual(await page.locator('.splice-snapshot').innerText()) |
| | | await popup.close() |
| | | const long = await api(page,'splice-report','POST',{name:'完整长线',selections:[9701,9702,9703,9704,9705].map(selection),settings:{fieldStructure:fields,seriesColors:['#123456']}}) |
| | | await page.goto(`/report-splice/${long.id}/edit?mode=view`) |
| | | await expect(page.locator('.splice-chart')).toHaveCount(2) |
| | | await expect(page.locator('.splice-chart svg path[stroke="#123456"]').first()).toBeAttached() |
| | | await expect(page.locator('.splice-statistics')).toHaveCount(5) |
| | | for (const width of [1440,390]) { |
| | | await page.setViewportSize({width,height:900}) |
| | | await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true) |
| | | await page.screenshot({path:`tests/e2e/.artifacts/splice-segments-${width}.png`,fullPage:true}) |
| | | } |
| | | const longPopup = page.waitForEvent('popup') |
| | | await page.getByRole('button',{name:'打印 / 另存 PDF',exact:true}).click() |
| | | const printed = await longPopup |
| | | await expect(printed.locator('.splice-statistics')).toHaveCount(5) |
| | | await printed.pdf({path:'tests/e2e/.artifacts/splice-segments-long.pdf',preferCSSPageSize:true,printBackground:true}) |
| | | await printed.close() |
| | | await api(page,'test/9701','DELETE') |
| | | await api(page,'test/9702','DELETE') |
| | | expect(await api(page,`splice-report/${long.id}/versions/1/print`)).toEqual(long) |
| | | await api(page,`splice-report/${long.id}/refresh-source`,'POST',{baseVersionNo:1,changeSummary:'来源已删'},500) |
| | | }) |
| | |
| | | await page.getByRole('button', { name: '打印 / 另存 PDF', exact: true }).click() |
| | | await expect(page.getByText('有未保存修改,请先保存新版本后再打印', { exact: true })).toBeVisible() |
| | | await page.getByPlaceholder('请输入拼接报表名称').fill('拼接打印V3') |
| | | await page.locator('.detail-table input').fill('人工新版') |
| | | await page.locator('.detail-table tr').filter({ hasText: '人工备注' }).locator('input').first().fill('人工新版') |
| | | await page.locator('.metric-switches label').filter({ hasText: '能量效率' }).click() |
| | | await page.getByRole('button', { name: '保存修改', exact: true }).click() |
| | | await expect(page).toHaveURL(/\/report-splice$/) |
| | | const v3 = (await api(page, `splice-report/${v1.id}`)).data |
| | | expect(v3.versionNo).toBe(3) |
| | | expect(v3.segments).toEqual(v1.segments) |
| | | expect(v3.settings.detailFieldValues['人工备注']['电池01']).toBe('人工新版') |
| | | expect(v3.settings.detailFieldValues['人工备注']['9201|9201']).toBe('人工新版') |
| | | expect(v3.settings.metrics).toEqual(['finalVoltage1']) |
| | | await page.goto(`/report-splice/${v1.id}/edit?mode=view&version=999`) |
| | | await expect(page.locator('.el-alert')).toContainText('版本不存在') |
| | |
| | | expect(saved.segments[0].statistics['1'].energyEfficiency).toBeCloseTo(124.46730345) |
| | | expect(saved.segments[0].statistics['3'].finalVoltage1 ?? null).toBeNull() |
| | | await page.goto(`/report-splice/${saved.id}/edit?mode=view`) |
| | | await expect(page.locator('.splice-chart')).toHaveCount(6) |
| | | await expect(page.locator('.splice-chart')).toHaveCount(2) |
| | | await expect(page.locator('.splice-statistics').first()).toContainText('124.4673') |
| | | await expect(page.locator('.splice-statistics').first()).toContainText('缺失') |
| | | for (const width of [1440, 390]) { |
| | |
| | | await page.getByRole('button', { name: '打印 / 另存 PDF', exact: true }).click() |
| | | const popup = await popupPromise |
| | | await expect(popup.locator('body')).toHaveAttribute('data-print-requested', 'true') |
| | | await expect(popup.locator('.splice-chart')).toHaveCount(6) |
| | | await expect(popup.locator('.splice-chart')).toHaveCount(2) |
| | | await expect(popup.locator('.splice-chart svg path[stroke="#123456"]').first()).toBeAttached() |
| | | expect(await popup.locator('.splice-snapshot').innerText()).toEqual(await page.locator('.splice-snapshot').innerText()) |
| | | await popup.emulateMedia({ media: 'print' }) |