From 802057a42167d8bd99be4d0b08c25cff422d1535 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期一, 07 九月 2026 21:26:06 +0800
Subject: [PATCH] 修复拼接人工值清理和多电池来源分界
---
src/utils/spliceSavedReport.ts | 6 ++
src/views/SpliceReportForm.vue | 15 ++++++-
docs/pages/report-splice.md | 56 ++++++++--------------------
tests/e2e/data-analysis-integrated-acceptance.spec.ts | 35 +++++++++++++++--
4 files changed, 65 insertions(+), 47 deletions(-)
diff --git a/docs/pages/report-splice.md b/docs/pages/report-splice.md
index f851383..60e19ef 100644
--- a/docs/pages/report-splice.md
+++ b/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。窄屏图表与宽表可横向滚动,多页打印保留来源及表头。
diff --git a/src/utils/spliceSavedReport.ts b/src/utils/spliceSavedReport.ts
index 6e9a4b9..075cedd 100644
--- a/src/utils/spliceSavedReport.ts
+++ b/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) {
diff --git a/src/views/SpliceReportForm.vue b/src/views/SpliceReportForm.vue
index d550b9c..b9f1d04 100644
--- a/src/views/SpliceReportForm.vue
+++ b/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 }
diff --git a/tests/e2e/data-analysis-integrated-acceptance.spec.ts b/tests/e2e/data-analysis-integrated-acceptance.spec.ts
index a37f617..cf2e81f 100644
--- a/tests/e2e/data-analysis-integrated-acceptance.spec.ts
+++ b/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 })
+})
--
Gitblit v1.7.1