| docs/pages/report-splice.md | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/utils/spliceSavedReport.ts | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/views/SpliceReportForm.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| tests/e2e/data-analysis-integrated-acceptance.spec.ts | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 |
docs/pages/report-splice.md
@@ -1,54 +1,30 @@ # 拼接报表页面 - 列表路由:`/report-splice` - 新增路由:`/report-splice/new` - 编辑路由:`/report-splice/:id/edit` - 列表实现:`src/views/SpliceReport.vue` - 新增/编辑实现:`src/views/SpliceReportForm.vue` - 数据:`src/stores/spliceReports.ts` - 状态:演示页面已开发;数据仍由前端种子数据和 `localStorage` 提供,后端报表事实未接入 - 列表:`/report-splice`;新增:`/report-splice/new`;编辑:`/report-splice/:id/edit`。 - 查看使用 `?mode=view`,指定历史版本使用 `?mode=view&version=N`。 - 正式数据来自 `/system/splice-report`;模板来自所属类型的 `/templates` 接口,不使用本地种子业务记录。 - 对应数据分析 Ticket11–13;本轮实现及自动验证完成后仍须用户重新验收,不能沿用已撤回的 Ticket11 关闭结论。 ## 页面目标 ## 来源与统计 针对同一电池组、同一组电池号的多个连续测试,将各测试曲线按选择顺序沿累计时间轴横向拼接,形成一份连续数据报表。 添加弹窗按电池组、测试名称或 ID 筛选检测完成测试;选择同组至少两个不同测试及其共同参与电池子集。默认按开始时间排序,可上下调整或删除任一未保存来源。后端再次验证来源身份、状态、电池组和共同集合。 ## 查询与列表 各测试全部参与电池共用循环偏移,首个有数据段保留原循环号,后段首循环接续前段末循环并保留段内跳号。不以日期空档补循环。来源表保留原号与拼接号;不同电池循环覆盖范围不同时,来源分界仍按该测试最早循环确定。 - 支持按拼接名称或拼接ID模糊查询。 - 支持创建人和创建时间范围筛选,创建人下拉包含“全部”。 - 列表展示拼接ID、项目拼接名称、创建时间和创建人。 - 支持分页、新建、编辑和删除确认,操作列固定在最右侧。 - “查看报表”复用完整拼接页进入只读模式,隐藏添加拼接、删除、字段模板、指标开关、选色和保存操作,仅保留内容浏览与返回。 统计图只展示最终电压1和能量效率。最终电压1取末实际工步终止电压,缺失不回退;能量效率为同循环放电能量除以充电能量乘100%,缺失或非正充电能量不计算,超过100%不截断。原五类演示实时曲线及未定义自动计算不再用于拼接。 ## 新增与编辑 ## 字段、模板与版本 - 拼接测试按列表顺序组成曲线时间段,第一条为基础测试并不可删除。 - 添加拼接弹窗仅选择测试;电池组和电池号由基础测试固定,只读展示。 - 添加或删除后续拼接测试不会修改测试详情,只会更新曲线的测试段、累计时间和拼接边界。 - 至少包含基础测试和一个追加测试后才允许保存。 - 保存后写入拼接报表本地数据并返回列表,编辑时恢复测试顺序、详情字段值、曲线显隐和颜色。 字段设置支持电池组共享字段、人工项、顺序、展示名称及单位。共享字段从后端来源预览读取;正式保存只冻结已选字段。拼接模板独立存储,只保存字段结构,不带来源或填写值,后续模板变化不修改报表。 ## 测试详情 表格指标可人工调整并显示计算原值;统计图保持冻结的原始计算结果。取消电池或移除来源时清理失效人工值,保留其他有效修改。报表修改不反写测试事实。 - 详情结构参考化成报表,固定展示实验、基础测试、电池组和电池号。 - 固定字段为 `Z/H/F/A/B/F/N/C`,其中原始值只读,`F/N` 和 `C` 显示自动计算状态。 - 支持应用电池组字段、选择特殊字段模板、添加特殊字段和另存特殊字段模板。 - 电池组字段、特殊字段及填写值独立保存,不随拼接测试数量变化。 首次保存重新读取所选来源;普通编辑基于已保存快照追加版本;只有明确执行“按最新来源生成新版本”才重读来源。来源已删除时仍可普通编辑、查看历史及打印,主动更新来源被拒绝。已有错误历史快照不被静默改写,应在来源尚存时主动生成新版本。 ## 拼接曲线 ## 权限、删除与打印 - 展示电压、电流、正电位、负电位、温度五类曲线,可分别显示或隐藏。 - 每个测试形成一个独立时间段,按选择顺序横向连接为累计 `h:mm` 时间轴。 - 测试段标尺按测试时长显示比例,并标记每段名称、起止时间和图表内分界线。 - 三个固定电池号作为曲线系列,顶部色块可统一修改系列颜色,每张图保留内部图例。 - 每张曲线同时显示左右纵坐标,图表使用全宽纵向排列以保证长时间轴可读性。 三类报表分别使用查看、新增、编辑、删除、导出权限;拼接页面按正式权限展示按钮,REST 始终校验权限。有对应权限的其他创建人可协作操作。 ## 数据结构 列表按名称、ID、创建人和本地日期筛选,时间戳按业务时间工具格式化。删除整份报表及所有版本,与最小删除日志在同一事务完成;失败保留报表,不显示成功,不删除来源测试。 - 拼接报表:`id`、`name`、`creator`、`createdAt`、`experimentName`、`batteryGroup`、`samples`、`tests`、`batteryFields`、`specialFields`、`detailFieldValues`、`metrics`、`seriesColors`。 - 拼接测试:`id`、`name`、`type`、`durationMinutes`。 - 演示数据存储键:`battery_detection_splice_reports_v1`。 ## 待开发 接入检测完成测试查询、允许拼接的测试兼容条件、真实采样时间轴、后端版本快照、修订审计和正式导出模板。 当前及历史指定版本共用查看和打印内容,打印不重新读取来源。未保存先提示,窗口打开失败有反馈,不把打印窗口打开或关闭记成已下载PDF。窄屏图表与宽表可横向滚动,多页打印保留来源及表头。 src/utils/spliceSavedReport.ts
@@ -56,7 +56,11 @@ for (let batteryStart = 0; batteryStart < batteries.length; batteryStart += 4) { const group = batteries.slice(batteryStart, batteryStart + 4) const segments = record.segments.filter(segment => group.includes(segment.source.batteryNo) && segment.originalCycleNos.some(no => range.includes(segment.offset + no))) const boundaries = [...new Map(segments.map(segment => [segment.source.selection.testId, { name: label(segment.source.selection.testId), xAxis: Math.max(range[0]!, segment.offset + segment.originalCycleNos[0]!) }])).values()] const boundaries = sources.flatMap(testId => { const starts = segments.filter(segment => segment.source.selection.testId === testId) .map(segment => segment.offset + segment.originalCycleNos[0]!) return starts.length ? [{ name: label(testId), xAxis: Math.max(range[0]!, Math.min(...starts)) }] : [] }) const legend = group.map(battery => `<span><i style="background:${color(battery)}"></i>${cell(battery)}</span>`).join('') const sourceLegend = [...new Set(segments.map(segment => `${label(segment.source.selection.testId)} · ${segment.source.name}`))].map(name => `<span>${cell(name)}</span>`).join('') for (const metric of visibleMetrics) { src/views/SpliceReportForm.vue
@@ -66,7 +66,10 @@ 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: detailFieldValues.value, statisticValues: statisticValues.value, detailFieldValues: Object.fromEntries(fields.value.map(field => [field.name, Object.fromEntries(samples.value .filter(battery => detailFieldValues.value[field.name]?.[battery] !== undefined) .map(battery => [battery, detailFieldValues.value[field.name]![battery]!]))])), statisticValues: statisticValues.value, metrics: statisticMetrics.filter(metric => statisticVisible[metric.key]).map(metric => metric.key), seriesColors: seriesColors.value, })) const statisticPreview = computed<SavedSpliceReport | null>(() => matrix.value ? { @@ -117,11 +120,19 @@ if (editingId.value) return const request = ++previewRequest draftMatrix.value = null; previewError.value = '' // 来源移除后同步移除其人工值,仍在范围内的修改继续保留。 statisticValues.value = Object.fromEntries(Object.entries(statisticValues.value).filter(([key]) => selected.some(selection => key.startsWith(`${selection.testId}|${selection.testBatteryId}|`)))) if (new Set(selected.map(item => item.testId)).size < 2 || !samples.value.length) { previewing.value = false; return } previewing.value = true try { const result = await previewSpliceReport(selected) if (request === previewRequest) draftMatrix.value = result if (request === previewRequest) { const allowed = new Set(result.segments.flatMap(segment => segment.originalCycleNos.flatMap(cycle => statisticMetrics.map(metric => spliceStatisticKey(segment, cycle, metric.key))))) statisticValues.value = Object.fromEntries(Object.entries(statisticValues.value).filter(([key]) => allowed.has(key))) draftMatrix.value = result } } catch (error) { if (request === previewRequest) previewError.value = error instanceof Error ? error.message : '统计预览失败' } finally { if (request === previewRequest) previewing.value = false } tests/e2e/data-analysis-integrated-acceptance.spec.ts
@@ -321,6 +321,12 @@ await expect(page.getByText('字段模板已保存', { exact: true })).toBeVisible() 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('-1.25') for (const width of [1440, 390]) { await page.setViewportSize({ width, height: 900 }) await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true) @@ -392,12 +398,12 @@ } await page.goto(`/report-${kind}`) await expect(page.getByRole('button', { name: /新建|新增/ })).toHaveCount(0) await expect(page.locator('tbody').getByRole('button', { name: '编辑', exact: true })).toHaveCount(0) await expect(page.locator('tbody').getByRole('button', { name: /^编辑(报表)?$/ })).toHaveCount(0) await expect(page.locator('tbody').getByRole('button', { name: '删除', exact: true })).toHaveCount(0) } for (const kind of kinds) { await login(page) const username = `ticket13_actor_${kind}` const username = `t13_${kind}_actor` const permissions = modules.find((module: any) => module.pageName === names[kind]).permissions await api(page, 'role', 'POST', { roleName: username, roleKey: 'common', roleSort: 1, status: '0', menuIds: permissions.map((item: any) => item.menuId) }) const role = (await api(page, `role/list?roleName=${username}`)).rows[0] @@ -425,7 +431,7 @@ await page.goto(`/report-${kind}`) await expect(page.getByRole('button', { name: /新建|新增/ })).toBeVisible() const row = page.locator('tbody tr').filter({ hasText: `跨创建人-${kind}` }) await expect(row.getByRole('button', { name: '编辑', exact: true })).toBeVisible() await expect(row.getByRole('button', { name: /^编辑(报表)?$/ })).toBeVisible() await expect(row.getByRole('button', { name: '删除', exact: true })).toBeVisible() await api(page, `${kind}-report/${first.id}`, 'DELETE') await api(page, `${kind}-report/${own.id}`, 'DELETE') @@ -442,7 +448,7 @@ await expect(page.getByText('测试详情已保存', { exact: true })).toBeVisible() await page.reload() await expect(page.locator('.detail-meta input')).toHaveValue('Ticket13 工步详情回归') await page.locator('.data-select-panel .step-select').first().getByRole('combobox').click() await page.locator('.data-select-panel .step-select').first().locator('.el-select').click() await page.getByRole('option', { name: '循环 3', exact: true }).click() await expect(page.locator('.battery-range-row .range-label').first()).toContainText('循环 3') await page.locator('.metric-switches label').filter({ hasText: '温度' }).click() @@ -461,3 +467,24 @@ await expect(page.locator('.project-chart canvas')).toHaveCount(3) expect((await api(page, 'test/9303/data/records')).data).toEqual(before) }) test('多电池循环覆盖不同,图上来源分界仍位于该测试最早拼接循环', async ({ page }) => { await login(page) for (const table of ['t_test_realtime_record', 't_test_step_record', 't_test_cycle_record']) { stack.executeIsolatedSql(`DELETE FROM ${table} WHERE (test_battery_id=9403 AND cycle_no=1) OR (test_battery_id=9402 AND cycle_no=5)`) } const saved = (await api(page, 'splice-report', 'POST', { ...payload('splice', '不同循环覆盖的来源分界'), selections: [selection(9303), selection(9303, 9403), selection(9302), selection(9302, 9402)], })).data expect(saved.segments.map((segment: any) => segment.originalCycleNos)).toEqual([[1, 3, 5], [3, 5], [5, 7], [7]]) expect(saved.segments.map((segment: any) => segment.offset)).toEqual([0, 0, 1, 1]) await page.goto(`/report-splice/${saved.id}/edit?mode=view`) const lines = page.locator('.splice-chart').first().locator('svg path[stroke="#82909b"]') await expect(lines).toHaveCount(2) const positions = await lines.evaluateAll(lines => lines.map(line => (line as SVGPathElement).getPointAtLength(0).x)) // 固定900像素图中循环1、6位于122和622附近;允许描边对齐的半像素偏移。 expect(Math.abs(positions[0]! - 122)).toBeLessThanOrEqual(1) expect(Math.abs(positions[1]! - 622)).toBeLessThanOrEqual(1) await page.screenshot({ path: 'tests/e2e/.artifacts/ticket11-asymmetric-cycles.png', fullPage: true }) })