接入化成真实循环曲线与历史版本打印
保留现有化成矩阵,增加五类完整曲线、循环选择、颜色显隐保存和指定版本打印入口。
复用测量绘图与打印窗口流程,补充旧版缺失、窄屏、权限及多页打印验收,更新页面文档。
前端构建及化成、对比隔离验收通过;等待用户验收后整理注释并关闭任务。
| | |
| | | - 路由:`/report-formation`、`/report-formation/new`、`/report-formation/:id/edit` |
| | | - 页面:`src/views/FormationReport.vue`、`src/views/FormationReportForm.vue` |
| | | - 正式接口:`src/api/formationReports.ts` |
| | | - 实现范围:数据分析 Ticket08,2026-09-07 用户验收通过,验收后注释整理及复验完成,已关闭;真实充电终止电压属于 Ticket09,五类曲线与打印属于 Ticket10。 |
| | | - 实现范围:数据分析 Ticket08、Ticket09 已关闭;Ticket10 接入五类真实曲线与指定版本打印,待用户验收。 |
| | | |
| | | ## 来源与矩阵 |
| | | |
| | |
| | | |
| | | 化成模板由后端独立保存,只包含结构,不能带测试、电池、测量或人工值;与对比模板隔离。同名更新需要明确确认及编辑权限。应用仅影响当前草稿,既有报表不随模板更新;普通编辑新增未冻结共享字段时保持缺失。同名换共享身份时不会沿用原字段人工调整。 |
| | | |
| | | ## 曲线与打印 |
| | | |
| | | 曲线按所选循环展示全部参与测试、电池的持久化当前有效实时记录,不限制前三条,不从其他循环补点。每个测试、电池、循环以柜及采集器最早设备时间为零点,各指标保留自身设备时间和真实间隔;电流显示 mA,空值断开、零值和负值保留。图例包含测试名与电池号,显隐与颜色随版本保存且不裁剪快照范围。 |
| | | |
| | | 首次保存或主动更新来源冻结全部曲线及其零点,普通编辑只修改展示设置。旧版缺少曲线时显示“该版本未保存曲线数据”;重开和打印均不回填。当前版与历史版均可通过“打印 / 另存 PDF”打印该版本完整矩阵、实际充电工步选择和所有循环曲线。存在未保存内容先提示;窗口打开失败或导出权限撤销可见,不生成下载历史或下载成功事实。 |
| | | |
| | | 打印复用对比报表的窗口流程,矩阵横向每组最多四列并顺序输出所有组,长表重复列头;曲线和图例共同分页。实际打印内容来自后端 `/system/formation-report/{id}/versions/{versionNo}/print`,不读取页面草稿或来源测试。 |
| | | |
| | | ## 权限与删除 |
| | | |
| | | 后端分别校验查看、新增、编辑、删除权限,允许有对应权限者跨创建人操作。模板读取沿用查看/新增/编辑权限,创建和更新分别沿用新增与编辑权限。 |
| | | 后端分别校验查看、新增、编辑、删除、导出权限,允许有对应权限者跨创建人操作。导出使用 V16 新增的 `system:formation-report:export`,普通查看权限不能替代。模板读取沿用查看/新增/编辑权限,创建和更新分别沿用新增与编辑权限。 |
| | | |
| | | 整体删除报表时清理全部版本,与最小操作日志同一事务提交,不删除来源测试;日志仅保留报表身份、操作人和时间。来源测试删除不删除独立报表。 |
| | | |
| | | 当前正式列表和矩阵不读取本地种子报表,不展示旧假终止电压、演示曲线或打印入口。旧打印工具仍被未接入的拼接演示页面引用,不用于 Ticket08 正式版本。 |
| | | 当前正式化成页面与打印不读取本地种子报表或演示曲线。旧 `formationReportPrint.ts` 的演示定义仍被未接入的拼接页面引用;正式化成打印使用 `formationSavedReportPrint.ts`。 |
| | | |
| | | ## 验证 |
| | | |
| | | `tests/e2e/formation-matrix.spec.ts` 使用隔离正式后端、MySQL 8、Redis 和 Chrome 验证矩阵、保存、模板、权限、历史和两个删除方向。普通服务由用户手动启动,不使用共享开发库进行自动验收。 |
| | | |
| | | `tests/e2e/formation-curves-print.spec.ts` 覆盖循环曲线、设备时间、真实间隔、毫单位、快照独立性、显隐颜色保存、导出权限、窗口失败、窄屏及实际 PDF 输出。 |
| | |
| | | import { request, type ApiResponse } from './request' |
| | | import type { PersistedTestRecord } from './tests' |
| | | import type { ReportMetricKey } from '@/utils/reportMeasurementChart' |
| | | |
| | | export interface FormationSelection { testId: number; testBatteryId: number } |
| | | export interface FormationCandidate { selection: FormationSelection; name: string; type: string; groupId: number; batteryGroup: string; batteryNo: string } |
| | |
| | | metrics: Record<string, Record<string, number | null>> |
| | | businessFields: Array<{ fieldDefinitionId: number; fieldName: string; fieldValue: string | null }> |
| | | chargeSteps: Record<string, FormationChargeStep[]> |
| | | curves?: Record<string, { zeroTime: number | null; records: PersistedTestRecord[] }> |
| | | } |
| | | export interface FormationMatrix { items: FormationItem[]; cycleNos: number[] } |
| | | export interface FormationSettings { fields: FormationField[]; values: Record<string, string>; chargeStepSelections: Record<string, number> } |
| | | export interface FormationSettings { fields: FormationField[]; values: Record<string, string>; chargeStepSelections: Record<string, number>; metrics?: ReportMetricKey[]; seriesColors?: Record<string, string> } |
| | | export interface FormationSummary { id: number; name: string; creator: string; createdAt: number; testName?: string; batteryGroup?: string } |
| | | export interface FormationVersion { versionNo: number; operatorName: string; createdAt: number; changeSummary: string } |
| | | export interface SavedFormationReport extends FormationSummary, FormationMatrix { |
| | |
| | | } |
| | | export function listFormationVersions(id: number) { return data<FormationVersion[]>(`/${id}/versions`) } |
| | | export function deleteFormationReport(id: number) { return data<void>(`/${id}`, 'DELETE') } |
| | | /** 导出动作独立授权,只读取指定已保存版本。 */ |
| | | export function getFormationPrintVersion(id: number, versionNo: number) { return data<SavedFormationReport>(`/${id}/versions/${versionNo}/print`) } |
| | | export function listFormationTemplates() { return data<FormationTemplate[]>('/templates') } |
| | | export function createFormationTemplate(name: string, fields: FormationField[]) { return data<number>('/templates', 'POST', { name, fields }) } |
| | | export function updateFormationTemplate(id: number, name: string, fields: FormationField[]) { return data<void>(`/templates/${id}`, 'PUT', { name, fields }) } |
| | |
| | | import type { CompareItem } from '@/api/compareReports' |
| | | import type { PersistedTestRecord } from '@/api/tests' |
| | | import type { CompareMetricKey } from '@/stores/compareReports' |
| | | import { toTestDisplayValue } from './testDataUnits' |
| | | import { measurementChartOption, type ReportMetricKey } from './reportMeasurementChart' |
| | | |
| | | /** 完整所选范围参与图例身份,避免同名电池的不同循环或工步混淆。 */ |
| | | export function compareSeriesName(item: CompareItem) { |
| | |
| | | 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] || [] })), |
| | | } |
| | | /** 对比页面与打印共享冻结测量的绘图口径。 */ |
| | | export function compareChartOption(metric: { key: ReportMetricKey; unit: string }, items: CompareItem[], colors: string[]) { |
| | | return measurementChartOption(metric, items.map(item => ({ ...item, name: compareSeriesName(item) })), colors) |
| | | } |
| | |
| | | import type { CompareMetricKey } from '@/stores/compareReports' |
| | | import { compareChartOption, compareSeriesName } from './compareReportChart' |
| | | import { compareFields, compareFieldLabel, compareBusinessFieldValue } from './compareReportFields' |
| | | import { openSavedReportPrint } from './savedReportPrint' |
| | | |
| | | use([LineChart, GridComponent, SVGRenderer]) |
| | | |
| | |
| | | |
| | | /** 点击时先打开窗口,再经后端导出权限读取指定保存版本;打开或取消打印不代表 PDF 保存成功。 */ |
| | | export async function openCompareReportPrint(id: number, versionNo: number) { |
| | | const popup = window.open('', '_blank', 'width=1200,height=820') |
| | | 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() |
| | | } catch (error) { |
| | | popup.close() |
| | | throw error |
| | | } |
| | | return openSavedReportPrint(() => getComparePrintVersion(id, versionNo), printHtml) |
| | | } |
| New file |
| | |
| | | import type { FormationItem } from '@/api/formationReports' |
| | | import { measurementChartOption, type ReportMetricKey } from './reportMeasurementChart' |
| | | |
| | | export const formationCurveMetrics: Array<{ key: ReportMetricKey; title: string; unit: string }> = [ |
| | | { key: 'voltage', title: '电压', unit: 'V' }, { key: 'current', title: '电流', unit: 'mA' }, |
| | | { key: 'positiveVoltage', title: '正电位', unit: 'V' }, { key: 'negativeVoltage', title: '负电位', unit: 'V' }, |
| | | { key: 'temperature', title: '温度', unit: '℃' }, |
| | | ] |
| | | const palette = ['#3978f6', '#109854', '#e33d54', '#a56b12', '#7d42d8', '#159ba5'] |
| | | export function formationSeriesKey(item: FormationItem) { return `${item.source.selection.testId}|${item.source.selection.testBatteryId}` } |
| | | export function formationSeriesName(item: FormationItem) { return `${item.source.name} · ${item.source.batteryNo}` } |
| | | export function formationSeriesColor(item: FormationItem, index: number, colors: Record<string, string>) { |
| | | const color = colors[formationSeriesKey(item)] |
| | | return color && /^#[0-9a-f]{6}$/i.test(color) ? color : palette[index % palette.length]! |
| | | } |
| | | /** 按冻结循环取全量测量,空循环和旧版缺少曲线都不回填其他来源。 */ |
| | | export function formationChartOption(metric: typeof formationCurveMetrics[number], items: FormationItem[], cycle: number, colors: Record<string, string>) { |
| | | return measurementChartOption(metric, items.map(item => ({ name: formationSeriesName(item), |
| | | zeroTime: item.curves?.[cycle]?.zeroTime ?? null, records: item.curves?.[cycle]?.records ?? [], |
| | | })), items.map((item, index) => formationSeriesColor(item, index, colors))) |
| | | } |
| | |
| | | import type { FormationField, FormationItem } from '@/api/formationReports' |
| | | import { formatTestDisplayValue } from './testDataUnits' |
| | | |
| | | /** 页面和打印按同一冻结工步及选择展示终止值,缺失时不借用其他工步。 */ |
| | | export function formationChargeChoice(item: FormationItem, cycle: number, selections: Record<string, number>) { |
| | | const choices = item.chargeSteps?.[String(cycle)] || [] |
| | | const reliable = choices.filter(step => ['cc', 'charge', 'charging'].includes((step.stepType || '').trim().toLowerCase())) |
| | | return reliable.length === 1 ? reliable[0] : choices.find(step => step.stepNo === selections[`${item.source.selection.testId}|${item.source.selection.testBatteryId}|${cycle}`]) |
| | | } |
| | | |
| | | export const defaultFormationFields: FormationField[] = [ |
| | | ...['Z', 'H', 'F', 'A', 'B'].map(key => ({ key, scope: 'detail' as const, kind: 'battery' as const, displayName: key, unit: '' })), |
| | | ...[['FN', 'F/N'], ['C', 'C']].map(([key, displayName]) => ({ key: key!, scope: 'detail' as const, kind: 'manual' as const, displayName: displayName!, unit: '' })), |
| New file |
| | |
| | | import { init, use } from 'echarts/core' |
| | | import { LineChart } from 'echarts/charts' |
| | | import { GridComponent, LegendComponent } from 'echarts/components' |
| | | import { SVGRenderer } from 'echarts/renderers' |
| | | import { formationValueKey, getFormationPrintVersion, type FormationItem, type SavedFormationReport } from '@/api/formationReports' |
| | | import { formationChargeChoice, formationFieldLabel, formationSourceValue } from './formationReportFields' |
| | | import { formationChartOption, formationCurveMetrics, formationSeriesColor, formationSeriesName } from './formationCycleChart' |
| | | import { openSavedReportPrint } from './savedReportPrint' |
| | | |
| | | use([LineChart, GridComponent, LegendComponent, SVGRenderer]) |
| | | function escapeHtml(value: unknown) { |
| | | const entities: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' } |
| | | return String(value ?? '').replace(/[&<>"']/g, character => entities[character]!) |
| | | } |
| | | |
| | | function printHtml(record: SavedFormationReport) { |
| | | const colors = record.settings.seriesColors ?? {} |
| | | const items = [...new Set(record.items.map(item => item.source.selection.testId))].flatMap(id => record.items.filter(item => item.source.selection.testId === id)) |
| | | const cell = (value: unknown) => `<td>${escapeHtml(value == null || value === '' ? '缺失' : value)}</td>` |
| | | const tables = (cycle: number) => { |
| | | const result: string[] = [] |
| | | // 每组最多四列,按原测试分组顺序打印全部矩阵;长表重复列头并允许跨页。 |
| | | for (let start = 0; start < items.length; start += 4) { |
| | | const group = items.slice(start, start + 4) |
| | | const value = (item: FormationItem, read: () => unknown) => cycle && !item.cycleNos.includes(cycle) ? '无该循环' : read() |
| | | const rows: unknown[][] = cycle ? [ |
| | | ['充电工步号', ...group.map(item => value(item, () => formationChargeChoice(item, cycle, record.settings.chargeStepSelections ?? {})?.stepNo ?? '未识别'))], |
| | | ['充电工步名称', ...group.map(item => value(item, () => formationChargeChoice(item, cycle, record.settings.chargeStepSelections ?? {})?.name || '未选择'))], |
| | | ['终止电压 (V)', ...group.map(item => value(item, () => formationChargeChoice(item, cycle, record.settings.chargeStepSelections ?? {})?.endVoltage))], |
| | | ] : [] |
| | | for (const field of record.settings.fields.filter(field => field.scope === (cycle ? 'cycle' : 'detail'))) { |
| | | rows.push([formationFieldLabel(field), ...group.map(item => value(item, () => |
| | | record.settings.values[formationValueKey(item, cycle, field)] ?? formationSourceValue(item, cycle, field)))]) |
| | | } |
| | | result.push(`<table class="matrix-table"><thead><tr><th>${cycle ? `循环${cycle} / 测试` : '测试'}</th>${group.map(item => `<th>${escapeHtml(item.source.name)}</th>`).join('')}</tr><tr><th>电池组 / 电池号</th>${group.map(item => `<th>${escapeHtml(item.source.batteryGroup)} / ${escapeHtml(item.source.batteryNo)}</th>`).join('')}</tr></thead><tbody>${rows.map(row => `<tr>${row.map(cell).join('')}</tr>`).join('')}</tbody></table>`) |
| | | } |
| | | return result.join('') |
| | | } |
| | | const legend = items.map(item => `<span><i style="background:${formationSeriesColor(item, record.items.indexOf(item), colors)}"></i>${escapeHtml(formationSeriesName(item))}</span>`).join('') |
| | | const cycles = record.cycleNos.map(cycle => { |
| | | const available = record.items.some(item => item.curves?.[cycle]?.records.length) |
| | | const charts = available ? formationCurveMetrics.filter(metric => (record.settings.metrics ?? formationCurveMetrics.map(item => item.key)).includes(metric.key)).map(metric => { |
| | | const chart = init(null, undefined, { renderer: 'svg', ssr: true, width: 900, height: 300 }) |
| | | let svg: string |
| | | try { chart.setOption({ ...formationChartOption(metric, record.items, cycle, colors), tooltip: { show: false } }); svg = chart.renderToSVGString() } |
| | | finally { chart.dispose() } |
| | | return `<article class="chart-card"><h3>循环${cycle} · ${escapeHtml(metric.title)} (${escapeHtml(metric.unit)})</h3>${svg}<div class="legend">${legend}</div></article>` |
| | | }).join('') : '' |
| | | return `<section class="cycle-section" data-cycle="${cycle}"><h2>循环${cycle}</h2>${tables(cycle)}<div class="charts">${charts || `<p>${available ? '当前未启用曲线指标' : record.items.some(item => item.curves) ? '该循环暂无已保存实时数据' : '该版本未保存曲线数据'}</p>`}</div></section>` |
| | | }).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;font:11px "Microsoft YaHei",sans-serif;color:#26333f;background:#fff;letter-spacing:0;print-color-adjust:exact;-webkit-print-color-adjust:exact} |
| | | .report-header{display:flex;flex-wrap:wrap;gap:16px;justify-content:space-between;border-bottom:2px solid #2b78a3;padding-bottom:12px;margin-bottom:16px}.report-header>div{min-width:0;max-width:100%}h1{font-size:22px;margin:0;overflow-wrap:anywhere}.saved-meta{display:flex;flex-wrap:wrap;gap:8px 18px;max-width:50%;overflow-wrap:anywhere}h2{font-size:14px;background:#f4f8fb;padding:10px;border:1px solid #d8e1e9;break-after:avoid}h3{margin:0;padding:8px 12px;font-size:13px}table{width:100%;table-layout:fixed;border-collapse:collapse;margin:0 0 16px}thead{display:table-header-group}tr{break-inside:avoid}td,th{border:1px solid #cbd8e4;padding:7px;white-space:pre-wrap;overflow-wrap:anywhere;text-align:center}th{background:#3c6497;color:#fff}td:first-child,th:first-child{width:150px;text-align:left}td:first-child{background:#f4f8fb}.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 svg{display:block;width:100%;height:auto}.legend{display:flex;flex-wrap:wrap;gap:8px 16px;padding:10px}.legend span{display:flex;align-items:flex-start;gap:6px;overflow-wrap:anywhere;max-width:100%;break-inside:avoid}.legend i{flex:0 0 12px;height:12px;margin-top:2px}.note{white-space:pre-wrap;overflow-wrap:anywhere} |
| | | @media(max-width:680px){.charts{grid-template-columns:1fr}.saved-meta{max-width:100%}}@media print{body{padding:0}.charts{display:block}.chart-card{margin-bottom:12px}.report-header{break-inside:avoid}} |
| | | </style></head><body><header class="report-header"><div><h1>${escapeHtml(record.name)}</h1><p>检测室数据可视化系统 · 化成报表</p></div><div class="saved-meta"><span>报表ID #${record.id}</span><span>版本 V${record.versionNo}</span><span>创建人 ${escapeHtml(record.creator)}</span><span>创建时间 ${date(record.createdAt)}</span><span>版本操作人 ${escapeHtml(record.operatorName)}</span><span>版本时间 ${date(record.versionCreatedAt)}</span><span>修改说明 ${escapeHtml(record.changeSummary)}</span></div></header><section><h2>报表数据</h2>${tables(0)}</section>${cycles}<section><h2>报表说明</h2><div class="note">${escapeHtml(record.description || '-')}</div></section></body></html>` |
| | | } |
| | | |
| | | /** 指定当前或历史保存版本打印,不读取页面草稿和来源测试。 */ |
| | | export function openFormationSavedReportPrint(id: number, versionNo: number) { |
| | | return openSavedReportPrint(() => getFormationPrintVersion(id, versionNo), printHtml) |
| | | } |
| New file |
| | |
| | | import type { PersistedTestRecord } from '@/api/tests' |
| | | import { toTestDisplayValue } from './testDataUnits' |
| | | |
| | | export type ReportMetricKey = 'voltage' | 'current' | 'positiveVoltage' | 'negativeVoltage' | 'temperature' |
| | | export interface MeasurementSeries { name: string; zeroTime: number | null; records: PersistedTestRecord[] } |
| | | |
| | | /** 页面和打印共用测量、时间及单位口径;不连接缺失值,也不限制曲线条数。 */ |
| | | export function measurementChartOption(metric: { key: ReportMetricKey; unit: string }, items: MeasurementSeries[], colors: string[]) { |
| | | const field: Record<ReportMetricKey, 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 [dataMin, dataMax] = series.reduce<[number, number]>(([min, max], points) => points.reduce<[number, number]>(([low, high], [, value]) => |
| | | value === null ? [low, high] : [Math.min(low, value), Math.max(high, value)], [min, max]), [0, 0]) |
| | | 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, formatter: (value: number) => Number(value.toPrecision(6)).toString() }, |
| | | }) |
| | | return { |
| | | animation: false, color: colors, grid: { top: 40, right: 32, bottom: 42, left: 32, containLabel: true }, |
| | | 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: item.name, type: 'line', smooth: false, |
| | | connectNulls: false, showSymbol: true, symbolSize: 5, lineStyle: { width: 2 }, data: series[index] || [] })), |
| | | } |
| | | } |
| New file |
| | |
| | | /** 点击时先打开窗口,再授权读取指定快照;打开或取消打印不代表 PDF 保存成功。 */ |
| | | export async function openSavedReportPrint<T>(load: () => Promise<T>, html: (record: T) => string) { |
| | | const popup = window.open('', '_blank', 'width=1200,height=820') |
| | | if (!popup) throw new Error('打印窗口打开失败,请允许浏览器弹出窗口后重试') |
| | | try { |
| | | popup.document.body.textContent = '正在加载已保存版本' |
| | | const record = await load() |
| | | if (popup.closed) throw new Error('打印窗口已关闭,请重试') |
| | | popup.document.open() |
| | | popup.document.write(html(record)) |
| | | popup.document.close() |
| | | await popup.document.fonts.ready |
| | | if (popup.closed) throw new Error('打印窗口已关闭,请重试') |
| | | popup.focus() |
| | | popup.print() |
| | | } catch (error) { |
| | | popup.close() |
| | | throw error |
| | | } |
| | | } |
| | |
| | | import { computed, ref, watch } from 'vue' |
| | | import { ElMessage, ElMessageBox } from 'element-plus' |
| | | import { useRoute, useRouter } from 'vue-router' |
| | | import { use } from 'echarts/core' |
| | | import { LineChart } from 'echarts/charts' |
| | | import { CanvasRenderer } from 'echarts/renderers' |
| | | import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components' |
| | | import VChart from 'vue-echarts' |
| | | import { getUserInfo } from '@/api/auth' |
| | | import { getFormationSources, previewFormationReport, formationValueKey, type FormationCandidate, type FormationItem, type FormationField } from '@/api/formationReports' |
| | | import { defaultFormationFields, formationFieldLabel, formationSourceValue } from '@/utils/formationReportFields' |
| | | import { defaultFormationFields, formationFieldLabel, formationSourceValue, formationChargeChoice } from '@/utils/formationReportFields' |
| | | import { formationChartOption, formationCurveMetrics, formationSeriesKey, formationSeriesName, formationSeriesColor } from '@/utils/formationCycleChart' |
| | | import type { ReportMetricKey } from '@/utils/reportMeasurementChart' |
| | | import { openFormationSavedReportPrint } from '@/utils/formationSavedReportPrint' |
| | | import FormationFieldControls from '@/components/FormationFieldControls.vue' |
| | | import { createFormationReport, getFormationReport, editFormationReport, refreshFormationSource, listFormationVersions, |
| | | type SavedFormationReport, type FormationVersion } from '@/api/formationReports' |
| | | |
| | | use([CanvasRenderer, LineChart, GridComponent, LegendComponent, TooltipComponent]) |
| | | const route = useRoute() |
| | | const router = useRouter() |
| | | const reportName = ref('') |
| | |
| | | const canCreate = ref(false) |
| | | const canEdit = ref(false) |
| | | const canQuery = ref(false) |
| | | const canExport = ref(false) |
| | | const printing = ref(false) |
| | | const editingId = computed(() => Number(route.params.id) || null) |
| | | const readonly = computed(() => Boolean(editingId.value) && (route.query.mode !== 'edit' || !canEdit.value || Boolean(route.query.version))) |
| | | const saved = ref<SavedFormationReport | null>(null) |
| | |
| | | const fields = ref<FormationField[]>(structuredClone(defaultFormationFields)) |
| | | const values = ref<Record<string, string>>({}) |
| | | const chargeStepSelections = ref<Record<string, number>>({}) |
| | | const metrics = ref<ReportMetricKey[]>(formationCurveMetrics.map(metric => metric.key)) |
| | | const seriesColors = ref<Record<string, string>>({}) |
| | | const activeCycle = ref<number | null>(null) |
| | | const visibleMetrics = computed(() => formationCurveMetrics.filter(metric => metrics.value.includes(metric.key))) |
| | | const hasCurves = computed(() => items.value.some(item => item.curves?.[activeCycle.value ?? -1]?.records.length)) |
| | | const detailFields = computed(() => fields.value.filter(field => field.scope === 'detail')) |
| | | const cycleFields = computed(() => fields.value.filter(field => field.scope === 'cycle')) |
| | | const reportColumns = computed(() => [...new Set(items.value.map(item => item.source.selection.testId))].map(id => ({ |
| | |
| | | } |
| | | function chargeChoices(item: FormationItem, cycle: number) { return item.chargeSteps?.[String(cycle)] || [] } |
| | | function chargeChoice(item: FormationItem, cycle: number) { |
| | | const choices = chargeChoices(item, cycle) |
| | | const reliable = choices.filter(step => ['cc', 'charge', 'charging'].includes((step.stepType || '').toLowerCase())) |
| | | return reliable.length === 1 ? reliable[0] : choices.find(step => step.stepNo === chargeStepSelections.value[`${item.source.selection.testId}|${item.source.selection.testBatteryId}|${cycle}`]) |
| | | return formationChargeChoice(item, cycle, chargeStepSelections.value) |
| | | } |
| | | function chargeKey(item: FormationItem, cycle: number) { return `${item.source.selection.testId}|${item.source.selection.testBatteryId}|${cycle}` } |
| | | function formContent() { return JSON.stringify({ name: reportName.value, description: description.value, fields: fields.value, values: values.value, chargeStepSelections: chargeStepSelections.value }) } |
| | | function updateSeriesColor(item: FormationItem, color: string | null) { if (color) seriesColors.value[formationSeriesKey(item)] = color } |
| | | function formContent() { return JSON.stringify({ name: reportName.value, description: description.value, fields: fields.value, values: values.value, chargeStepSelections: chargeStepSelections.value, metrics: metrics.value, seriesColors: seriesColors.value }) } |
| | | function restore(record: SavedFormationReport) { |
| | | saved.value = record; reportName.value = record.name; description.value = record.description || '' |
| | | items.value = record.items; cycleNos.value = record.cycleNos |
| | | fields.value = record.settings.fields; values.value = record.settings.values; chargeStepSelections.value = record.settings.chargeStepSelections || {} |
| | | metrics.value = record.settings.metrics ?? formationCurveMetrics.map(metric => metric.key) |
| | | seriesColors.value = record.settings.seriesColors ?? {} |
| | | changeSummary.value = ''; savedContent.value = formContent() |
| | | } |
| | | function pruneValues() { |
| | | const allowed = new Set(items.value.flatMap(item => fields.value.flatMap(field => |
| | | (field.scope === 'detail' ? [0] : item.cycleNos).map(cycle => formationValueKey(item, cycle, field))))) |
| | | values.value = Object.fromEntries(Object.entries(values.value).filter(([key]) => allowed.has(key))) |
| | | const sourceKeys = new Set(items.value.map(formationSeriesKey)) |
| | | seriesColors.value = Object.fromEntries(Object.entries(seriesColors.value).filter(([key]) => sourceKeys.has(key))) |
| | | } |
| | | function applyFields(next: FormationField[]) { |
| | | // 模板仅改变草稿结构;同名换共享身份时不能继承原字段的人工调整。 |
| | |
| | | saving.value = true |
| | | try { |
| | | pruneValues() |
| | | const content = { name: reportName.value.trim(), description: description.value, settings: { fields: fields.value, values: values.value, chargeStepSelections: chargeStepSelections.value } } |
| | | const content = { name: reportName.value.trim(), description: description.value, settings: { fields: fields.value, values: values.value, chargeStepSelections: chargeStepSelections.value, metrics: metrics.value, seriesColors: seriesColors.value } } |
| | | const result = editingId.value && saved.value |
| | | ? await editFormationReport(editingId.value, { ...content, baseVersionNo: saved.value.versionNo, changeSummary: changeSummary.value.trim() }) |
| | | : await createFormationReport({ ...content, selections: items.value.map(item => item.source.selection) }) |
| | |
| | | async function openVersion(versionNo: number) { |
| | | historyVisible.value = false |
| | | await router.push({ path: `/report-formation/${editingId.value}/edit`, query: { mode: 'view', version: versionNo } }) |
| | | } |
| | | async function printReport(versionNo = saved.value?.versionNo) { |
| | | if (printing.value || saving.value || loading.value || loadError.value || !canExport.value) return |
| | | if (!saved.value || versionNo === undefined) { ElMessage.warning('请先保存报表后再打印'); return } |
| | | if (formContent() !== savedContent.value || changeSummary.value.trim()) { ElMessage.warning('有未保存修改,请先保存新版本后再打印'); return } |
| | | printing.value = true |
| | | try { await openFormationSavedReportPrint(saved.value.id, versionNo) } |
| | | catch (error) { ElMessage.error(error instanceof Error ? error.message : '打印失败,请重试') } |
| | | finally { printing.value = false } |
| | | } |
| | | // 快速切换来源时只接受最后一次预览,避免旧响应把矩阵归入错误的选择。 |
| | | let previewSequence = 0 |
| | |
| | | try { |
| | | const permissions = (await getUserInfo()).permissions || [] |
| | | const has = (action: string) => permissions.includes('*:*:*') || permissions.includes(`system:formation-report:${action}`) |
| | | canCreate.value = has('add'); canEdit.value = has('edit'); canQuery.value = has('query') |
| | | canCreate.value = has('add'); canEdit.value = has('edit'); canQuery.value = has('query'); canExport.value = has('export') |
| | | if (!editingId.value) { |
| | | if (!canCreate.value) throw new Error('无新增化成报表权限') |
| | | reportName.value = ''; description.value = ''; fields.value = structuredClone(defaultFormationFields); chargeStepSelections.value = {} |
| | | metrics.value = formationCurveMetrics.map(metric => metric.key); seriesColors.value = {} |
| | | candidates.value = await getFormationSources() |
| | | } else { |
| | | const editing = route.query.mode === 'edit' && !route.query.version |
| | |
| | | finally { loading.value = false } |
| | | } |
| | | watch(selectedKeys, () => { if (!editingId.value) void preview() }) |
| | | watch(cycleNos, cycles => { if (activeCycle.value === null || !cycles.includes(activeCycle.value)) activeCycle.value = cycles[0] ?? null }) |
| | | watch(() => route.fullPath, () => { void load() }, { immediate: true }) |
| | | </script> |
| | | |
| | |
| | | </article> |
| | | <el-empty v-if="reportItems.length && !cycleNos.length && !previewLoading" description="所选电池暂无循环记录" :image-size="60" /> |
| | | </div></section> |
| | | <section class="metrics-panel"> |
| | | <header class="panel-heading"><div><strong>循环曲线</strong></div><label><el-select v-model="activeCycle" class="curve-cycle-select" aria-label="曲线循环" :disabled="!cycleNos.length"><el-option v-for="cycle in cycleNos" :key="cycle" :label="`循环${cycle}`" :value="cycle" /></el-select></label></header> |
| | | <el-checkbox-group v-if="!readonly" v-model="metrics" class="metric-switches"><el-checkbox v-for="metric in formationCurveMetrics" :key="metric.key" :value="metric.key">{{ metric.title }}</el-checkbox></el-checkbox-group> |
| | | <div class="chart-legend-bar"><div v-for="(item, index) in items" :key="formationSeriesKey(item)" class="chart-legend-item"> |
| | | <el-color-picker v-if="!readonly" :model-value="formationSeriesColor(item, index, seriesColors)" :aria-label="`${formationSeriesName(item)}颜色`" @change="updateSeriesColor(item, $event)" /> |
| | | <i v-else class="series-swatch" :style="{ background: formationSeriesColor(item, index, seriesColors) }" /><span>{{ formationSeriesName(item) }}</span> |
| | | </div></div> |
| | | <div v-if="hasCurves && activeCycle !== null" class="charts-grid"><article v-for="metric in visibleMetrics" :key="metric.key" class="chart-card"><header><strong>{{ metric.title }} ({{ metric.unit }})</strong></header><VChart class="metric-chart" :option="formationChartOption(metric, items, activeCycle, seriesColors)" :update-options="{ notMerge: true }" autoresize /></article></div> |
| | | <el-empty v-if="!hasCurves" :description="saved && !items.some(item => item.curves) ? '该版本未保存曲线数据' : '该循环暂无已保存实时数据'" :image-size="60" /> |
| | | <el-empty v-else-if="!visibleMetrics.length" description="当前未启用曲线指标" :image-size="60" /> |
| | | </section> |
| | | </div> |
| | | <section class="note-panel"><label><span>报表说明</span><el-input v-model="description" :disabled="readonly" type="textarea" :rows="3" maxlength="2000" show-word-limit /></label></section> |
| | | <section v-if="editingId && !readonly" class="note-panel"><label><span>修改说明</span><el-input v-model="changeSummary" maxlength="500" placeholder="请输入修改说明" /></label></section> |
| | | <footer class="form-actions"><button class="secondary-button" type="button" @click="router.push('/report-formation')">返回列表</button><button v-if="!readonly && (editingId ? canEdit : canCreate)" class="primary-button" type="button" :disabled="saving || loading || previewLoading || !items.length" @click="saveReport"><el-icon><Check /></el-icon>{{ saving ? '保存中' : editingId ? '保存新版本' : '保存报表' }}</button></footer> |
| | | <footer class="form-actions"><button v-if="canExport" class="secondary-button" type="button" :disabled="printing || saving || loading || previewLoading" @click="printReport()"><el-icon><Printer /></el-icon>{{ printing ? '正在打开打印' : '打印 / 另存 PDF' }}</button><button class="secondary-button" type="button" @click="router.push('/report-formation')">返回列表</button><button v-if="!readonly && (editingId ? canEdit : canCreate)" class="primary-button" type="button" :disabled="saving || loading || previewLoading || !items.length" @click="saveReport"><el-icon><Check /></el-icon>{{ saving ? '保存中' : editingId ? '保存新版本' : '保存报表' }}</button></footer> |
| | | </template> |
| | | <el-dialog v-model="historyVisible" title="化成报表历史版本" width="min(760px, calc(100vw - 28px))"><el-alert v-if="historyError" :title="historyError" type="error" :closable="false" /><el-table v-loading="historyLoading" :data="history"><el-table-column prop="versionNo" label="版本" width="70" /><el-table-column prop="operatorName" label="操作人" width="110" /><el-table-column label="时间" width="180"><template #default="{ row }">{{ new Date(row.createdAt).toLocaleString() }}</template></el-table-column><el-table-column prop="changeSummary" label="修改说明" /><el-table-column label="操作" width="70"><template #default="{ row }"><el-button link type="primary" @click="openVersion(row.versionNo)">查看</el-button></template></el-table-column></el-table></el-dialog> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .curve-cycle-select{width:130px;flex:0 0 130px;margin:8px 0}.metrics-panel .chart-legend-item span{max-width:100%;overflow:visible;white-space:normal;overflow-wrap:anywhere}.series-swatch{flex:0 0 14px;height:14px}.metric-switches .el-checkbox{margin-right:0}.metrics-panel .chart-legend-item{max-width:100%;align-items:flex-start}.metrics-panel .chart-legend-item .el-color-picker{flex-shrink:0}.metrics-panel .metric-chart{height:260px}.metrics-panel .chart-legend-bar{padding-top:12px;padding-bottom:12px} |
| | | .version-bar{display:flex;align-items:center;flex-wrap:wrap;gap:10px;padding:8px 0 16px;color:#52606d;font-size:12px}.version-bar span{overflow-wrap:anywhere} |
| | | .source-panel{padding:16px;background:#fff;border:1px solid #d8e1e9;margin-bottom:16px}.source-panel label{display:flex;align-items:center;gap:16px}.source-panel .el-select{flex:1;min-width:0}.formation-form-page{min-width:0}.missing-cycle{color:#7b8794}.cycle-card h3{margin:0;padding:12px 16px;font-size:14px}.cycle-data-table,.report-data-table{table-layout:fixed}.cycle-data-table th,.report-data-table th{white-space:normal;overflow-wrap:anywhere}.cycle-data-table th:first-child,.report-data-table th:first-child{width:180px}.cycle-data-table td,.report-data-table td{overflow-wrap:anywhere}.formation-form-page :deep(.el-alert){margin-bottom:12px}@media(max-width:680px){.source-panel label{align-items:stretch;flex-direction:column;gap:8px}} |
| | | .formation-form-page{width:100%;padding-bottom:78px}.page-tools{height:34px;display:flex;align-items:center;justify-content:space-between}.page-tools>span{color:#7f8d99;font-size:10px;font-weight:600}.back-button{display:inline-flex;align-items:center;gap:6px;border:0;color:#52606d;background:transparent;font-size:13px;cursor:pointer}.primary-button,.secondary-button{height:34px;display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:0 13px;border:1px solid transparent;border-radius:3px;font-size:13px;cursor:pointer;white-space:nowrap}.primary-button{color:#fff;background:var(--blue);border-color:var(--blue)}.secondary-button{color:#52606d;background:#fff;border-color:var(--line-strong)}.report-name-panel,.setup-panel,.cycle-panel,.metrics-panel,.note-panel{background:#fff;border:1px solid #d8e1e9;margin-bottom:16px}.report-name-panel{padding:18px 22px}.report-name-panel label,.note-panel label{display:grid;grid-template-columns:72px minmax(260px,520px);align-items:center;gap:12px}.report-name-panel label>span,.note-panel label>span,.setup-grid label>span{color:#52606d;font-size:12px;font-weight:500}.report-name-panel :deep(.el-input__wrapper),.setup-grid :deep(.el-input__wrapper),.setup-grid :deep(.el-select__wrapper),.note-panel :deep(.el-textarea__inner),.cycle-table :deep(.el-input__wrapper){border-radius:3px;box-shadow:0 0 0 1px #d8e1e9 inset;background:#f8fafc}.panel-heading{min-height:48px;display:flex;align-items:center;justify-content:space-between;padding:0 24px;border-bottom:1px solid var(--line)}.panel-heading>div{display:flex;align-items:center;gap:9px}.panel-heading>strong,.panel-heading>div>strong{color:#26333f;font-size:13px}.panel-heading span{color:#8795a3;font-size:11px;margin-left:10px}.setup-grid{display:grid;grid-template-columns:repeat(2,minmax(240px,1fr));gap:18px;padding:20px 24px 12px}.setup-grid label{display:grid;grid-template-columns:72px minmax(0,1fr);align-items:center;gap:10px}.sample-toolbar{display:flex;align-items:center;justify-content:space-between;padding:4px 24px 10px;color:#52606d;font-size:12px}.sample-list{display:flex;flex-wrap:wrap;gap:10px;padding:0 24px 20px}.sample-chip{display:flex;align-items:center;gap:6px}.sample-chip :deep(.el-select){width:150px}.delete-link{border:0;color:#c45454;background:transparent;font-size:12px;cursor:pointer}.cycle-tabs{display:flex;gap:0;padding:16px 24px 0}.cycle-tabs button{min-width:84px;height:30px;color:#1677dd;background:#fff;border:1px solid #78b2f9;font-size:12px;cursor:pointer}.cycle-tabs button:first-child{border-radius:3px 0 0 3px}.cycle-tabs button:last-child{border-radius:0 3px 3px 0}.cycle-tabs button+button{border-left:0}.cycle-tabs button.active{color:#fff;background:#2188f5}.cycle-table-wrap{overflow-x:auto;padding:14px 24px 22px}.cycle-table{width:100%;min-width:900px;border-collapse:collapse;color:#52606d;font-size:12px}.cycle-table th,.cycle-table td{height:42px;padding:0 10px;border:1px solid #cbd8e4;text-align:center;white-space:nowrap}.cycle-table th{color:#fff;background:#3c6497;font-weight:600}.cycle-table th:nth-child(1){width:90px}.cycle-table th:nth-child(2){width:240px}.cycle-table th:nth-child(3){width:150px}.cycle-table th:nth-child(4),.cycle-table th:nth-child(5){width:130px}.charts-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;padding:18px 24px 24px}.chart-legend-bar{display:flex;align-items:center;gap:20px;padding:12px 24px;border-bottom:1px solid var(--line);color:#52606d;font-size:12px}.legend-item{display:inline-flex;align-items:center;gap:7px}.legend-color{width:14px;height:14px;border-radius:2px}.chart-card{border:1px solid #d8e1e9;background:#fff}.chart-card header{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;color:#26333f;font-size:12px}.chart-card header span{color:#8795a3;font-size:10px}.metric-chart{width:100%;height:230px}.note-panel{padding:18px 24px}.note-panel :deep(.el-textarea){min-width:260px}.form-actions{position:sticky;bottom:0;z-index:4;display:flex;justify-content:flex-end;gap:10px;padding:14px 0;background:rgba(244,248,251,.96);border-top:1px solid #d8e1e9}@media(max-width:900px){.setup-grid,.charts-grid{grid-template-columns:1fr}.report-name-panel label,.note-panel label{grid-template-columns:1fr}.report-name-panel label>span,.note-panel label>span{margin-bottom:6px}.form-actions{position:static}} |
| New file |
| | |
| | | import { expect, test, type Page } from '@playwright/test' |
| | | import { Ticket04Stack } from './support/ticket04-stack' |
| | | |
| | | const stack = new Ticket04Stack() |
| | | test.use({ actionTimeout: 20_000 }) |
| | | test.beforeAll(async () => { await stack.start(); seed() }) |
| | | 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 body = await (await page.request.fetch(`${stack.backendUrl}/system/${path}`, { method, data, headers: { Authorization: `Bearer ${token}` } })).json() |
| | | expect(body.code, `${path}: ${body.msg}`).toBe(code) |
| | | return body |
| | | } |
| | | |
| | | const ids = [8901, 8902, 8903, 8904, 8905] |
| | | const selections = ids.map(id => ({ testId: id, testBatteryId: id })) |
| | | function seed() { |
| | | stack.executeIsolatedSql(` |
| | | INSERT INTO t_battery_group(group_id,group_name,model) VALUES(8901,'曲线验收组','型号'); |
| | | INSERT INTO t_test_type(test_type_id,test_type_name) VALUES(8901,'化成'); |
| | | INSERT INTO t_test(test_id,test_name,creator_id,principal_id,test_type_id,test_purpose,test_status,reception_status) |
| | | VALUES ${ids.map((id, i) => `(${id},'化成测试${i + 1}',1,1,8901,'隔离验收','COMPLETED','ENDED')`).join(',')}; |
| | | INSERT INTO t_test_battery(test_battery_id,test_id,group_id,battery_no,collector_id) |
| | | VALUES ${ids.map(id => `(${id},${id},8901,'同名电池',${id})`).join(',')}; |
| | | 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 ${ids.flatMap((id, i) => [1, 3].flatMap(cycle => [0, 1, 2].map(point => { |
| | | const seq = cycle * 10 + point |
| | | return `(${id},${id},${id},'formation-curve','formation:${id}','${seq}',27,1,1,${i + 1},${seq},'2026-09-0${i + 1} 0${cycle}:0${point === 2 ? 4 : point}:10.123','2026-09-0${i + 1} 0${cycle}:0${point === 2 ? 4 : point}:00.123','2026-09-0${i + 1} 0${cycle}:0${point === 2 ? 4 : point}:11.123',${cycle},${point + 1},'实际工步',${cycle + i / 10},${point === 1 ? 'NULL' : point === 0 ? '0' : '-8.1651'},${point === 1 ? 'NULL' : '0'},0.1,-0.2,UNHEX(SHA2('${id}:${seq}',256)))` |
| | | }))).join(',')}; |
| | | INSERT INTO t_test_step_record(test_id,test_battery_id,binding_id,adapter_code,source_identity,source_record_id,dedupe_key,cycle_no,step_no,step_name,step_type,device_time_utc,server_received_at_utc,adapter_timezone_id,device_time_raw,voltage_v,content_sha256) |
| | | VALUES ${ids.flatMap(id => [1, 3].map(cycle => `(${id},${id},${id},'formation-curve','formation:${id}','${cycle}','${cycle}',${cycle},2,'实际充电','cc','2026-09-01 00:00:00','2026-09-01 00:00:01','Asia/Hong_Kong','2026-09-01 08:00:00',4.123,UNHEX(SHA2('${id}:${cycle}',256)))`)).join(',')}; |
| | | `) |
| | | } |
| | | |
| | | test('化成每测试电池循环冻结全部实时记录与独立设备时间零点', async ({ page }) => { |
| | | await login(page) |
| | | const matrix = (await api(page, 'formation-report/preview', 'POST', selections)).data |
| | | expect(matrix.cycleNos).toEqual([1, 3]) |
| | | expect(matrix.items).toHaveLength(5) |
| | | for (const [index, item] of matrix.items.entries()) { |
| | | for (const cycle of [1, 3]) { |
| | | const curve = item.curves[String(cycle)] |
| | | expect(curve.records).toHaveLength(3) |
| | | expect(curve.zeroTime).toBe(Date.parse(`2026-09-0${index + 1}T0${cycle}:00:00.123Z`)) |
| | | expect(curve.records.every((row: any) => row.cycleNo === cycle)).toBe(true) |
| | | expect(curve.records.find((row: any) => row.stepNo === 2).current ?? null).toBeNull() |
| | | expect(curve.records.find((row: any) => row.stepNo === 1).current).toBe(0) |
| | | expect(curve.records.find((row: any) => row.stepNo === 3).current).toBe(-8.1651) |
| | | } |
| | | } |
| | | }) |
| | | |
| | | const settings = { |
| | | fields: [{ key: 'note', scope: 'detail', kind: 'manual', displayName: '人工值', unit: '' }], |
| | | values: { '8901|8901|0|note': '0' }, chargeStepSelections: {}, |
| | | metrics: ['voltage', 'current', 'positiveVoltage', 'negativeVoltage', 'temperature'], |
| | | seriesColors: Object.fromEntries(ids.map((id, i) => [`${id}|${id}`, ['#123456', '#3978f6', '#f08a3e', '#109854', '#e33d54'][i]])), |
| | | } |
| | | |
| | | test('化成曲线版本保存、普通编辑、主动更新与指定版本打印权限', async ({ page }) => { |
| | | await login(page) |
| | | const payload = { name: '化成曲线V1', description: '说明 <script>不可执行</script>', selections, settings } |
| | | const v1 = (await api(page, 'formation-report', 'POST', payload)).data |
| | | expect(v1.settings.metrics).toEqual(settings.metrics) |
| | | expect(v1.settings.seriesColors).toEqual(settings.seriesColors) |
| | | expect(v1.items[0].curves['1'].records).toHaveLength(3) |
| | | const bad = { ...payload, settings: { ...settings, seriesColors: { '8901|8901': 'red' } } } |
| | | await api(page, 'formation-report', 'POST', bad, 500) |
| | | stack.executeIsolatedSql('UPDATE t_test_realtime_record SET current_a=99 WHERE test_id=8901 AND cycle_no=1; UPDATE t_test_step_record SET voltage_v=5 WHERE test_id=8901') |
| | | const v2 = (await api(page, `formation-report/${v1.id}/versions`, 'POST', { name: '化成曲线V2', description: '', baseVersionNo: 1, changeSummary: '隐藏温度', settings: { ...settings, metrics: settings.metrics.filter(key => key !== 'temperature') } })).data |
| | | expect(v2.items).toEqual(v1.items) |
| | | const v3 = (await api(page, `formation-report/${v1.id}/refresh-source`, 'POST', { baseVersionNo: 2, changeSummary: '重读当前来源' })).data |
| | | expect(v3.items[0].curves['1'].records.every((row: any) => row.current === 99)).toBe(true) |
| | | expect(v3.items[0].chargeSteps['1'][0].endVoltage).toBe(5) |
| | | await api(page, 'test/8901', 'DELETE') |
| | | for (const saved of [v1, v2, v3]) { |
| | | expect((await api(page, `formation-report/${v1.id}/versions/${saved.versionNo}/print`)).data).toEqual(saved) |
| | | } |
| | | const permissions = (await api(page, 'menu/permissionList')).data.flatMap((module: any) => module.pages).find((item: any) => item.pageName === '化成报表').permissions |
| | | for (const [username, action] of [['fc_reader', '查看'], ['fc_exporter', '导出']]) { |
| | | const menuIds = permissions.filter((item: any) => item.menuName === action).map((item: any) => item.menuId) |
| | | expect(menuIds).toHaveLength(1) |
| | | 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, 'fc_reader') |
| | | await api(page, `formation-report/${v1.id}/versions/1/print`, 'GET', undefined, 403) |
| | | await login(page, 'fc_exporter') |
| | | expect((await api(page, `formation-report/${v1.id}/versions/1/print`)).data).toEqual(v1) |
| | | await api(page, `formation-report/${v1.id}`, 'GET', undefined, 403) |
| | | }) |
| | | |
| | | test('化成页面循环切换、五类四条曲线、版本设置和多页打印', async ({ page }) => { |
| | | await login(page) |
| | | const pageErrors: string[] = [] |
| | | page.on('pageerror', error => pageErrors.push(error.message)) |
| | | await page.goto('/report-formation/new') |
| | | await page.getByRole('button', { name: '打印 / 另存 PDF', exact: true }).click() |
| | | await expect(page.getByText('请先保存报表后再打印', { exact: true })).toBeVisible() |
| | | await page.locator('.source-panel .el-select').click() |
| | | for (const number of [2, 3, 4, 5]) await page.getByRole('option', { name: `化成测试${number} / 曲线验收组 / 同名电池`, exact: true }).click() |
| | | await page.getByRole('combobox', { name: '化成来源', exact: true }).press('Escape') |
| | | await expect(page.locator('.chart-card')).toHaveCount(5) |
| | | await expect(page.locator('.chart-legend-item')).toHaveCount(4) |
| | | await expect(page.locator('.chart-legend-bar')).toContainText('化成测试5 · 同名电池') |
| | | await page.locator('.curve-cycle-select').click() |
| | | await page.getByRole('option', { name: '循环3', exact: true }).click() |
| | | await page.getByPlaceholder('请输入报表名称').fill('页面化成打印') |
| | | const colorPicker = page.locator('.chart-legend-item').filter({ hasText: '化成测试2' }).locator('.el-color-picker') |
| | | await colorPicker.click() |
| | | const colorPanel = page.locator('.el-color-picker__panel:visible') |
| | | await colorPanel.locator('input').fill('#123456') |
| | | await colorPanel.getByRole('button', { name: /确定|确认|OK/ }).click() |
| | | await page.getByRole('button', { name: '保存报表', exact: true }).click() |
| | | await expect(page).toHaveURL(/mode=view/) |
| | | const id = Number(page.url().match(/formation\/(\d+)/)![1]) |
| | | const v1 = (await api(page, `formation-report/${id}`)).data |
| | | expect(v1.settings.seriesColors['8902|8902']).toBe('#123456') |
| | | expect(v1.items).toHaveLength(4) |
| | | const plotted = await page.evaluate(async (record) => { |
| | | const path = '/src/utils/formationCycleChart.ts' |
| | | const { formationChartOption, formationCurveMetrics } = await import(path) |
| | | return [1, 3].map(cycle => formationCurveMetrics.map((metric: any) => formationChartOption(metric, record.items, cycle, record.settings.seriesColors))) |
| | | }, v1) |
| | | for (const cycle of plotted) { |
| | | expect(cycle[1].series).toHaveLength(4) |
| | | expect(cycle[1].series[0].data).toEqual([[10, 0], [70, null], [250, -8165.1]]) |
| | | expect(cycle[2].series[0].data).toEqual([[0, 0.1], [60, 0.1], [240, 0.1]]) |
| | | expect(cycle[3].series[0].data[0][1]).toBe(-0.2) |
| | | } |
| | | expect(plotted[0][0].series.find((series: any) => series.name === '化成测试2 · 同名电池').data[0][1]).toBe(1.1) |
| | | expect(plotted[1][0].series.find((series: any) => series.name === '化成测试2 · 同名电池').data[0][1]).toBe(3.1) |
| | | for (const width of [1440, 390]) { |
| | | await page.setViewportSize({ width, height: 900 }) |
| | | await page.locator('.metrics-panel').scrollIntoViewIfNeeded() |
| | | await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true) |
| | | const coloredPixels = await page.locator('.metric-chart canvas').first().evaluate((canvas: HTMLCanvasElement) => { |
| | | const pixels = canvas.getContext('2d')!.getImageData(0, 0, canvas.width, canvas.height).data |
| | | let count = 0 |
| | | for (let i = 0; i < pixels.length; i += 4) if (pixels[i + 3]! > 0 && Math.max(pixels[i]!, pixels[i + 1]!, pixels[i + 2]!) - Math.min(pixels[i]!, pixels[i + 1]!, pixels[i + 2]!) > 50) count++ |
| | | return count |
| | | }) |
| | | expect(coloredPixels).toBeGreaterThan(100) |
| | | await page.locator('.metrics-panel').evaluate(element => window.scrollTo(0, element.getBoundingClientRect().top + window.scrollY - 76)) |
| | | await page.screenshot({ path: `tests/e2e/.artifacts/formation-curves-${width}.png` }) |
| | | } |
| | | await page.getByRole('button', { name: '编辑', exact: true }).click() |
| | | await page.locator('.metric-switches .el-checkbox').filter({ hasText: '温度' }).click() |
| | | await page.getByRole('button', { name: '打印 / 另存 PDF', exact: true }).click() |
| | | await expect(page.getByText('有未保存修改,请先保存新版本后再打印', { exact: true })).toBeVisible() |
| | | await page.getByPlaceholder('请输入修改说明').fill('保存显隐设置') |
| | | await page.getByRole('button', { name: '保存新版本', exact: true }).click() |
| | | await expect(page).toHaveURL(/mode=view/) |
| | | await page.reload() |
| | | await expect(page.locator('.chart-card')).toHaveCount(4) |
| | | expect((await api(page, `formation-report/${id}`)).data.items).toEqual(v1.items) |
| | | await page.evaluate(() => { window.open = () => null }) |
| | | await page.getByRole('button', { name: '打印 / 另存 PDF', exact: true }).click() |
| | | await expect(page.getByText('打印窗口打开失败,请允许浏览器弹出窗口后重试', { exact: true })).toBeVisible() |
| | | for (const source of ids.slice(1)) await api(page, `test/${source}`, 'DELETE') |
| | | await page.context().addInitScript(() => { window.print = () => { document.body.dataset.printRequested = 'true' } }) |
| | | for (const version of [2, 1]) { |
| | | await page.goto(`/report-formation/${id}/edit?mode=view&version=${version}`) |
| | | await expect(page.locator('.chart-card')).toHaveCount(version === 1 ? 5 : 4) |
| | | 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') |
| | | await expect(popup.locator('.saved-meta')).toContainText(`V${version}`) |
| | | await expect(popup.locator('.chart-card')).toHaveCount(version === 1 ? 10 : 8) |
| | | await expect(popup.locator('.legend').first()).toContainText('化成测试5 · 同名电池') |
| | | await expect(popup.locator('[data-cycle="3"] .matrix-table')).toContainText('4.123') |
| | | await expect(popup.locator('.chart-card').first().locator('svg path[stroke="#123456"]').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/formation-print-v${version}.png`, fullPage: true }) |
| | | await popup.pdf({ path: `tests/e2e/.artifacts/formation-print-v${version}.pdf`, preferCSSPageSize: true, printBackground: true }) |
| | | await popup.close() |
| | | } |
| | | expect((await api(page, `formation-report/${id}/versions`)).data).toHaveLength(2) |
| | | await expect(page.getByText(/下载成功|成功下载/)).toHaveCount(0) |
| | | expect(pageErrors).toEqual([]) |
| | | }) |
| | | |
| | | test('旧版缺少曲线仍可重开打印,普通编辑不补造且主动更新不回填旧版', async ({ page }) => { |
| | | await login(page) |
| | | stack.executeIsolatedSql(` |
| | | INSERT INTO t_test(test_id,test_name,creator_id,principal_id,test_type_id,test_purpose,test_status,reception_status) VALUES(8990,'旧版来源',1,1,8901,'隔离验收','COMPLETED','ENDED'); |
| | | INSERT INTO t_test_battery(test_battery_id,test_id,group_id,battery_no,collector_id) VALUES(8990,8990,8901,'旧版电池',8990); |
| | | INSERT INTO t_test_realtime_record(test_id,test_battery_id,binding_id,adapter_code,source_identity,source_record_id,cabinet_devtype,cabinet_devid,cabinet_subdevid,cabinet_chlid,cabinet_sequence_id,cabinet_device_time_utc,collector_device_time_utc,server_received_at_utc,cycle_no,step_no,step_name,voltage_v,current_a,content_sha256) |
| | | VALUES(8990,8990,8990,'legacy','legacy:8990','1',27,1,1,1,1,'2026-09-01 00:00:10','2026-09-01 00:00:00','2026-09-01 00:00:11',1,1,'旧版工步',3.5,0,UNHEX(SHA2('legacy',256))); |
| | | `) |
| | | const created = (await api(page, 'formation-report', 'POST', { name: '旧版化成', description: '', selections: [{ testId: 8990, testBatteryId: 8990 }], settings: { ...settings, values: {}, seriesColors: {} } })).data |
| | | // 仅在隔离库构造早期快照,模拟当时尚未保存曲线与工步选择的报表。 |
| | | stack.executeIsolatedSql(`UPDATE t_formation_report_version SET snapshot_json=JSON_REMOVE(snapshot_json,'$.items[0].curves','$.items[0].chargeSteps','$.settings.metrics','$.settings.seriesColors') WHERE report_id=${created.id}`) |
| | | const old = (await api(page, `formation-report/${created.id}`)).data |
| | | await page.context().addInitScript(() => { window.print = () => { document.body.dataset.printRequested = 'true' } }) |
| | | await page.goto(`/report-formation/${created.id}/edit?mode=view`) |
| | | await expect(page.getByText('该版本未保存曲线数据', { exact: true })).toBeVisible() |
| | | const popupPromise = page.waitForEvent('popup') |
| | | await page.getByRole('button', { name: '打印 / 另存 PDF', exact: true }).click() |
| | | const popup = await popupPromise |
| | | await expect(popup.getByText('该版本未保存曲线数据', { exact: true })).toBeVisible() |
| | | await popup.close() |
| | | const edited = (await api(page, `formation-report/${created.id}/versions`, 'POST', { name: '旧版普通编辑', description: '', baseVersionNo: 1, changeSummary: '沿用快照', settings: old.settings })).data |
| | | expect(edited.items).toEqual(old.items) |
| | | const updated = (await api(page, `formation-report/${created.id}/refresh-source`, 'POST', { baseVersionNo: 2, changeSummary: '主动读取曲线' })).data |
| | | expect(updated.items[0].curves['1'].records).toHaveLength(1) |
| | | expect((await api(page, `formation-report/${created.id}/versions/1/print`)).data).toEqual(old) |
| | | }) |
| | |
| | | await expect(page.getByRole('textbox', { name: '化成测试甲 同名电池 循环1 充电容量', exact: true })).toHaveValue('8512.2000') |
| | | await expect(page.getByRole('textbox', { name: '化成测试甲 同名电池 循环2 充电容量', exact: true })).toHaveAttribute('placeholder', '指标缺失') |
| | | await expect(page.getByRole('textbox', { name: '化成测试甲 同名电池 循环1 放电容量', exact: true })).toHaveValue('0.0000') |
| | | await expect(page.locator('[data-cycle="4"] .missing-cycle')).toHaveCount(14) |
| | | await expect(page.locator('[data-cycle="4"] .missing-cycle')).toHaveCount(17) |
| | | await expect(page.getByRole('textbox', { name: '化成测试甲 同名电池 F/N', exact: true })).toHaveValue('') |
| | | await expect(page.getByRole('textbox', { name: '化成测试甲 同名电池 F', exact: true })).toHaveValue('12.5') |
| | | await expect(page.getByText('自动计算', { exact: true })).toHaveCount(0) |
| | |
| | | expect((await api(page, `compare-report/templates/${template.data}`, 'PUT', { name: '越界', fields: [] })).code).not.toBe(200) |
| | | const created = await api(page, 'formation-report', 'POST', { |
| | | name: '化成独立版本', description: '人工项与测量分开', selections: [8801, 8802].map(id => ({ testId: id, testBatteryId: id })), |
| | | settings: { fields, values: { '8801|8801|0|FN': '人工甲', '8802|8802|5|capacity1v': '9.2' } }, |
| | | settings: { fields, values: { '8801|8801|0|FN': '人工甲', '8802|8802|5|capacity1v': '9.2' }, chargeStepSelections: {} }, |
| | | }) |
| | | expect(created.code).toBe(200) |
| | | expect(created.code, created.msg).toBe(200) |
| | | const first = created.data |
| | | expect(first.items[0].metrics['1'].energyEfficiency).toBeCloseTo(124.467303453, 6) |
| | | expect(first.items[0].cycles[0].chargeCapacity).toBe(8.5122) |
| | | expect(first.items[0].cycles[0].dischargeCapacity).toBeUndefined() |
| | | stack.executeIsolatedSql("UPDATE t_test_cycle_record SET charge_capacity_ah=99 WHERE test_id=8801 AND cycle_no=1; UPDATE t_battery_group_relation_field SET field_value='25' WHERE group_id=8801") |
| | | const edit = { baseVersionNo: 1, name: '化成独立版本二', description: '', changeSummary: '仅表格调整', settings: { fields, values: { ...first.settings.values, '8801|8801|1|chargeCapacity': '123' } } } |
| | | const edit = { baseVersionNo: 1, name: '化成独立版本二', description: '', changeSummary: '仅表格调整', settings: { fields, values: { ...first.settings.values, '8801|8801|1|chargeCapacity': '123' }, chargeStepSelections: {} } } |
| | | expect((await api(page, `formation-report/${first.id}/versions`, 'POST', { ...edit, items: [{ cycles: [{ chargeCapacity: 999 }] }] })).code).not.toBe(200) |
| | | const second = await api(page, `formation-report/${first.id}/versions`, 'POST', edit) |
| | | expect(second.code).toBe(200) |
| | |
| | | |
| | | test('化成动作权限独立且允许跨创建人操作,失败事务与两种删除方向正确', async ({ page }) => { |
| | | await login(page) |
| | | const payload = { name: '权限化成', description: '', selections: [{ testId: 8802, testBatteryId: 8802 }], settings: { fields: reportFields, values: {} } } |
| | | const payload = { name: '权限化成', description: '', selections: [{ testId: 8802, testBatteryId: 8802 }], settings: { fields: reportFields, values: {}, chargeStepSelections: {} } } |
| | | const before = (await api(page, 'formation-report/list')).rows |
| | | stack.executeIsolatedSql("CREATE TRIGGER reject_formation_version BEFORE INSERT ON t_formation_report_version FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='版本写入失败'") |
| | | try { expect((await api(page, 'formation-report', 'POST', payload)).code).not.toBe(200) } |