yeg
2026-09-06 2d160f31150efe7792d80d920a5193aa6bd06d83
接入对比报表编辑与历史版本查看

沿用现有列表和表单,接入快照编辑、正式修订摘要和主动更新来源。补充独立版本、失败回滚、来源删除及权限的隔离验收测试。
2个文件已添加
4个文件已修改
418 ■■■■■ 已修改文件
docs/pages/report-compare.md 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/compareReports.ts 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/CompareReportHistoryDialog.vue 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ReportCompare.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ReportCompareForm.vue 96 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/e2e/compare-version-editing.spec.ts 220 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/pages/report-compare.md
@@ -1,5 +1,15 @@
# 对比报表页面
## 当前实现(数据分析 Ticket04)
- 正式数据来自 `src/api/compareReports.ts`,不使用本地报表 Store;首次保存与更新来源由后端重新读取所选持久化当前有效数据。
- 原列表筛选和分页保留;“编辑报表”读取最新快照,“修订记录”与详情“历史版本”共用正式版本摘要。
- `/report-compare/:id/edit?mode=edit` 普通编辑名称、说明、实验、人工表格字段及曲线设置,填写修改说明后保存新版本;来源范围锁定,测量曲线只读。
- 默认详情读取最新版本;`?mode=view&version=N` 查看指定历史版,刷新保持对应版本,不提供回滚或删除历史版本。
- “按最新来源生成版本”是独立动作,填写说明后重读当前来源并冻结;保留人工表格值,旧版和来源事实不被改写。来源测试已删除则拒绝更新,普通编辑及历史查看仍可使用保存快照。
- 查看与历史接口执行 `system:compare-report:query`,编辑基准、普通编辑和更新来源执行 `system:compare-report:edit`;不限制为创建人本人。
- 打印、模板持久化和整份报表删除尚未接入。本节为当前实施事实;下文为保留的旧原型说明,不作为新 Ticket 验收依据。
- 路由:`/report-compare`
- 新增路由:`/report-compare/new`
- 编辑路由:`/report-compare/:id/edit`
src/api/compareReports.ts
@@ -53,6 +53,9 @@
}
export interface SavedCompareReport extends CompareReportSummary {
  versionNo: number
  operatorName: string
  versionCreatedAt: number
  changeSummary: string
  description: string
  experimentName: string
  items: CompareItem[]
@@ -78,8 +81,35 @@
  })).data
}
/** 读取已保存快照,不依赖来源测试仍存在或保持原值。 */
export async function getCompareReport(id: number) {
  return (await request<ApiResponse & { data: SavedCompareReport }>(`/system/compare-report/${id}`)).data
export async function getCompareReport(id: number, versionNo?: number, editing = false) {
  const suffix = editing ? '/editing' : versionNo === undefined ? '' : `/versions/${versionNo}`
  return (await request<ApiResponse & { data: SavedCompareReport }>(`/system/compare-report/${id}${suffix}`)).data
}
export interface CompareVersionSummary {
  versionNo: number
  operatorName: string
  createdAt: number
  changeSummary: string
}
/** 历史与修订摘要共用正式版本列表。 */
export async function listCompareVersions(id: number) {
  return (await request<ApiResponse & { data: CompareVersionSummary[] }>(`/system/compare-report/${id}/versions`)).data
}
/** 普通编辑只提交展示值,来源范围与测量沿用后端基准快照。 */
export async function editCompareReport(id: number, data: Omit<CreateCompareReport, 'selections'> & { baseVersionNo: number; changeSummary: string }) {
  return (await request<ApiResponse & { data: SavedCompareReport }>(`/system/compare-report/${id}/versions`, {
    method: 'POST', body: JSON.stringify(data),
  })).data
}
/** 主动按最新来源生成独立版本,保留已有人工表格值。 */
export async function refreshCompareSource(id: number, baseVersionNo: number, changeSummary: string) {
  return (await request<ApiResponse & { data: SavedCompareReport }>(`/system/compare-report/${id}/refresh-source`, {
    method: 'POST', body: JSON.stringify({ baseVersionNo, changeSummary }),
  })).data
}
/** 按完整所选范围区分比对项,同一电池的不同循环或工步不合并。 */
src/components/CompareReportHistoryDialog.vue
New file
@@ -0,0 +1,50 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { listCompareVersions, type CompareVersionSummary } from '@/api/compareReports'
const props = defineProps<{ reportId: number | null }>()
const visible = defineModel<boolean>({ default: false })
const router = useRouter()
const versions = ref<CompareVersionSummary[]>([])
const loading = ref(false)
const error = ref('')
watch(() => [visible.value, props.reportId] as const, async ([open, id], _old, onCleanup) => {
  let cancelled = false
  onCleanup(() => { cancelled = true })
  versions.value = []
  error.value = ''
  if (!open || !id) return
  loading.value = true
  try {
    const result = await listCompareVersions(id)
    if (!cancelled) versions.value = result
  } catch (cause) {
    if (!cancelled) error.value = cause instanceof Error ? cause.message : '历史版本加载失败'
  } finally { if (!cancelled) loading.value = false }
})
function viewVersion(versionNo: number) {
  visible.value = false
  router.push({ path: `/report-compare/${props.reportId}/edit`, query: { mode: 'view', version: versionNo } })
}
</script>
<template>
  <el-dialog v-model="visible" title="历史版本" width="820px" align-center class="compare-history-dialog">
    <el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
    <el-table :data="versions" v-loading="loading" empty-text="暂无历史版本" max-height="460">
      <el-table-column prop="versionNo" label="版本号" width="80"><template #default="{ row }">V{{ row.versionNo }}</template></el-table-column>
      <el-table-column prop="operatorName" label="操作人" min-width="100" />
      <el-table-column label="时间" width="180"><template #default="{ row }">{{ new Date(row.createdAt).toLocaleString() }}</template></el-table-column>
      <el-table-column prop="changeSummary" label="修改说明" min-width="210" />
      <el-table-column label="操作" width="88" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="viewVersion(row.versionNo)">查看版本</el-button></template></el-table-column>
    </el-table>
  </el-dialog>
</template>
<style>
.compare-history-dialog { max-width: calc(100vw - 28px); border-radius: 4px; }
.compare-history-dialog .cell { overflow-wrap: anywhere; }
</style>
src/views/ReportCompare.vue
@@ -5,6 +5,7 @@
import { listCompareReports } from '@/api/compareReports'
import type { CompareReportRecord } from '@/stores/compareReports'
import { getUserInfo } from '@/api/auth'
import CompareReportHistoryDialog from '@/components/CompareReportHistoryDialog.vue'
interface FilterState {
  reportName: string
@@ -17,11 +18,15 @@
const loading = ref(false)
const loadError = ref('')
const canCreate = ref(false)
const canEdit = ref(false)
const historyVisible = ref(false)
const historyReportId = ref<number | null>(null)
onMounted(async () => {
  loading.value = true
  try {
    const info = await getUserInfo()
    canCreate.value = Boolean(info.permissions?.some(item => item === '*:*:*' || item === 'system:compare-report:add'))
    canEdit.value = Boolean(info.permissions?.some(item => item === '*:*:*' || item === 'system:compare-report:edit'))
    records.value = (await listCompareReports()).map(item => ({ ...item,
      createdAt: new Date(item.createdAt).toLocaleString('sv-SE'),
      baselineTest: '', comparisonTest: '', description: '', revisions: [],
@@ -103,7 +108,7 @@
          <tbody>
            <tr v-for="item in pagedRecords" :key="item.id">
              <td class="id-cell">#{{ item.id }}</td><td :title="item.name"><strong>{{ item.name }}</strong></td><td>{{ item.createdAt }}</td><td>{{ item.creator }}</td>
              <td class="action-column"><div class="row-actions"><button type="button" @click="openView(item)">查看报表</button></div></td>
              <td class="action-column"><div class="row-actions"><button type="button" @click="openView(item)">查看报表</button><button v-if="canEdit" type="button" @click="router.push({ path: `/report-compare/${item.id}/edit`, query: { mode: 'edit' } })">编辑报表</button><button type="button" @click="historyReportId = item.id; historyVisible = true">修订记录</button></div></td>
            </tr>
            <tr v-if="!pagedRecords.length"><td colspan="5" class="empty-cell">{{ loading ? '正在加载报表' : loadError ? '报表列表加载失败' : '暂无符合条件的对比报表' }}</td></tr>
          </tbody>
@@ -112,6 +117,7 @@
      <footer class="pagination-bar"><span>显示 {{ pagedRecords.length }} 条,共 {{ filteredRecords.length }} 条</span><el-pagination v-model:current-page="currentPage" v-model:page-size="pageSize" :page-sizes="[10, 20, 50]" :total="filteredRecords.length" layout="sizes, prev, pager, next" background /></footer>
    </section>
    <CompareReportHistoryDialog v-model="historyVisible" :report-id="historyReportId" />
  </div>
</template>
src/views/ReportCompareForm.vue
@@ -8,10 +8,11 @@
import { CanvasRenderer } from 'echarts/renderers'
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
import VChart from 'vue-echarts'
import CompareReportHistoryDialog from '@/components/CompareReportHistoryDialog.vue'
import { getUserInfo } from '@/api/auth'
import type { CompareMetricKey, CompareTestSelection } from '@/stores/compareReports'
import type { PersistedTestRecord } from '@/api/tests'
import { compareSelectionKey, createCompareReport, getCompareReport, getCompareSources, previewCompareReport,
import { compareSelectionKey, createCompareReport, editCompareReport, getCompareReport, getCompareSources, previewCompareReport, refreshCompareSource,
  type CompareCandidate, type CompareItem, type CompareSelection, type SavedCompareReport } from '@/api/compareReports'
import { compareMetricDefinitions as metricDefinitions, type CompareMetricDefinition } from '@/utils/compareReportPrint'
@@ -30,8 +31,12 @@
  return Number.isFinite(value) && value > 0 ? value : null
})
const editingRecord = ref<SavedCompareReport | null>(null)
const readonly = computed(() => Boolean(editingId.value))
const readonly = computed(() => Boolean(editingId.value) && (route.query.mode !== 'edit' || !canEdit.value || Boolean(loadError.value)))
const canCreate = ref(false)
const canEdit = ref(false)
const canQuery = ref(false)
const historyVisible = ref(false)
const changeSummary = ref('')
const loadError = ref('')
const previewLoading = ref(false)
const previewError = ref('')
@@ -179,7 +184,7 @@
detailFields.value.forEach(ensureDetailFieldValues)
// 搜索跳转和浏览器后退会复用详情组件,旧请求的结果不能覆盖新报表。
watch(editingId, async (id, _previous, onCleanup) => {
watch(() => [editingId.value, route.query.mode, route.query.version] as const, async ([id, mode, version], _previous, onCleanup) => {
  let cancelled = false
  onCleanup(() => { cancelled = true })
  editingRecord.value = null
@@ -197,16 +202,23 @@
  detailFields.value.forEach(ensureDetailFieldValues)
  previewError.value = ''
  canCreate.value = false
  canEdit.value = false
  canQuery.value = false
  historyVisible.value = false
  changeSummary.value = ''
  reportLoading.value = true
  try {
    const info = await getUserInfo()
    if (cancelled) return
    canEdit.value = Boolean(info.permissions?.some(item => item === '*:*:*' || item === 'system:compare-report:edit'))
    canQuery.value = Boolean(info.permissions?.some(item => item === '*:*:*' || item === 'system:compare-report:query'))
    if (id) {
      const record = await getCompareReport(id)
      if (mode === 'edit' && (!canEdit.value || version !== undefined)) throw new Error('历史版本只读或无编辑权限')
      const record = await getCompareReport(id, version === undefined ? undefined : Number(version), mode === 'edit')
      if (cancelled) return
      editingRecord.value = record
      loadEditingReport()
    } else {
      const info = await getUserInfo()
      if (cancelled) return
      canCreate.value = Boolean(info.permissions?.some(item => item === '*:*:*' || item === 'system:compare-report:add'))
      if (!canCreate.value) throw new Error('无新增对比报表权限')
      await loadCandidates()
@@ -222,7 +234,7 @@
  sourceItems.value = []
  try {
    const items = await previewCompareReport(selections)
    if (readonly.value) return
    if (editingId.value) return
    sourceItems.value = items
    detailFields.value.forEach(ensureDetailFieldValues)
  } catch (error) { previewError.value = error instanceof Error ? error.message : '曲线预览失败' }
@@ -333,7 +345,7 @@
  templateMode.value = mode
  if (mode === 'battery') {
    if (!batteryTemplateNames.value.includes(selectedBatteryTemplateName.value)) selectedBatteryTemplateName.value = '自定义'
    loadBatteryTemplate(selectedBatteryTemplateName.value)
    if (selectedBatteryTemplateName.value !== '自定义') loadBatteryTemplate(selectedBatteryTemplateName.value)
  } else {
    selectedTemplateKey.value = 'cycle'
  }
@@ -386,7 +398,7 @@
}
async function saveReport() {
  if (saving.value || readonly.value || !canCreate.value) return
  if (saving.value || reportLoading.value || loadError.value || readonly.value || !(editingId.value ? canEdit.value : canCreate.value)) return
  if (!reportName.value.trim()) {
    ElMessage.warning('请输入报告名称')
    return
@@ -395,11 +407,14 @@
    ElMessage.warning('请至少添加两条对比测试')
    return
  }
  if (editingId.value && !changeSummary.value.trim()) {
    ElMessage.warning('请输入修改说明')
    return
  }
  saving.value = true
  try {
    await createCompareReport({
    const content = {
      name: reportName.value.trim(), description: description.value, experimentName: experimentName.value,
      selections: selectedTests.value.map(item => item.selection),
      settings: {
        metrics: visibleMetrics.value.map(item => item.key), seriesColors: seriesColors.value.slice(0, selectedTests.value.length),
        batteryTemplateFields: [...batteryFields.value], specialFields: [...specialFields.value],
@@ -407,14 +422,46 @@
          Object.fromEntries(selectedTests.value.map(item => [detailKey(item), detailFieldValues[field]?.[detailKey(item)] || ''])),
        ])),
      },
    })
    }
    if (editingId.value && editingRecord.value) {
      await editCompareReport(editingId.value, { ...content, baseVersionNo: editingRecord.value.versionNo, changeSummary: changeSummary.value.trim() })
    } else {
      await createCompareReport({ ...content, selections: selectedTests.value.map(item => item.selection) })
    }
    ElMessage.success('对比报表已保存')
    await router.push('/report-compare')
    if (editingId.value && !canQuery.value) {
      editingRecord.value = await getCompareReport(editingId.value, undefined, true)
      loadEditingReport()
      changeSummary.value = ''
    } else {
      await router.push(editingId.value ? { path: `/report-compare/${editingId.value}/edit`, query: { mode: 'view' } } : '/report-compare')
    }
  } catch (error) {
    ElMessage.error(error instanceof Error ? error.message : '保存失败,请重试')
  } finally {
    saving.value = false
  }
}
async function generateFromLatestSource() {
  if (saving.value || !editingRecord.value || !editingId.value) return
  let reason: string
  try {
    const result = await ElMessageBox.prompt('将重新读取当前来源并生成新版本,保留已有人工表格值。', '按最新来源生成版本', {
      inputPlaceholder: '请输入更新来源说明', inputValidator: value => value?.trim() && value.trim().length <= 480 ? true : '请填写不超过480字的说明',
      confirmButtonText: '生成新版本', cancelButtonText: '取消',
    })
    reason = result.value.trim()
  } catch { return }
  saving.value = true
  try {
    const record = await refreshCompareSource(editingId.value, editingRecord.value.versionNo, reason)
    editingRecord.value = record
    loadEditingReport()
    ElMessage.success(`已生成版本 ${record.versionNo}`)
  } catch (error) {
    ElMessage.error(error instanceof Error ? error.message : '更新来源失败')
  } finally { saving.value = false }
}
</script>
@@ -428,21 +475,22 @@
    <section class="report-name-panel">
      <label><span>报表名称</span><el-input v-model="reportName" :readonly="readonly" maxlength="100" clearable placeholder="请输入报表名称" /></label>
      <label><span>说明</span><el-input v-model="description" :readonly="readonly" maxlength="2000" type="textarea" placeholder="请输入说明" /></label>
      <span v-if="editingRecord">版本 {{ editingRecord.versionNo }} · {{ editingRecord.creator }}</span>
      <label v-if="editingId && !readonly"><span>修改说明</span><el-input v-model="changeSummary" maxlength="500" placeholder="请输入修改说明" /></label>
      <span v-if="editingRecord">版本 {{ editingRecord.versionNo }} · {{ editingRecord.operatorName }} · {{ new Date(editingRecord.versionCreatedAt).toLocaleString() }} · {{ editingRecord.changeSummary }}</span>
    </section>
    <section class="compare-info-panel">
      <header class="panel-heading"><div><strong>对比信息</strong><el-icon><ArrowUp /></el-icon></div></header>
      <div class="compare-table-wrap">
        <table class="compare-table">
          <thead><tr><th>测试名称</th><th>测试类型</th><th>电池号</th><th>工步</th><th>循环</th><th v-if="!readonly">操作</th></tr></thead>
          <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.id}-${item.batteryGroup || batteryGroup}-${item.batteryNo}-${index}`"><td>{{ item.name }}</td><td>{{ item.type }}</td><td>{{ item.batteryNo }}</td><td>{{ item.process }}</td><td>{{ item.cycle }}</td><td v-if="!readonly"><button class="delete-link" type="button" @click="removeTest(item)">删除</button></td></tr>
            <tr v-for="(item, index) in selectedTests" :key="`${item.id}-${item.batteryGroup || batteryGroup}-${item.batteryNo}-${index}`"><td>{{ item.name }}</td><td>{{ item.type }}</td><td>{{ item.batteryNo }}</td><td>{{ item.process }}</td><td>{{ item.cycle }}</td><td v-if="!editingId"><button class="delete-link" type="button" @click="removeTest(item)">删除</button></td></tr>
            <tr v-if="!selectedTests.length"><td :colspan="readonly ? 5 : 6" class="empty-cell">暂无对比测试</td></tr>
          </tbody>
        </table>
      </div>
      <button v-if="!readonly && canCreate" class="primary-button compact-button" type="button" :disabled="previewLoading || saving" @click="openAddTest"><el-icon><Plus /></el-icon>添加比较</button>
      <button v-if="!editingId && canCreate" class="primary-button compact-button" type="button" :disabled="previewLoading || saving" @click="openAddTest"><el-icon><Plus /></el-icon>添加比较</button>
    </section>
    <section class="detail-panel">
@@ -472,7 +520,17 @@
      <div class="charts-grid"><article v-for="metric in visibleMetrics" :key="metric.key" class="chart-card"><header><strong>{{ metric.title }}</strong></header><VChart class="metric-chart" :option="chartOption(metric)" :update-options="{ notMerge: true }" autoresize /></article></div>
    </section>
    <footer class="form-actions"><button v-if="readonly" class="primary-button" type="button" @click="router.push('/report-compare')">返回列表</button><button v-else-if="canCreate" class="primary-button save-button" type="button" :disabled="saving || previewLoading || !!previewError" @click="saveReport"><el-icon><Check /></el-icon>{{ saving ? '保存中' : '保存报表' }}</button></footer>
    <footer class="form-actions">
      <button v-if="readonly && canQuery && editingRecord" class="secondary-button" type="button" :disabled="reportLoading || saving" @click="historyVisible = true"><el-icon><Clock /></el-icon>历史版本</button>
      <button v-if="readonly && editingRecord && route.query.version !== undefined" class="secondary-button" type="button" @click="router.push({ query: { mode: 'view' } })">查看最新版本</button>
      <button v-if="readonly && canEdit && editingRecord && route.query.version === undefined" class="secondary-button" type="button" :disabled="reportLoading || saving || !!loadError" @click="generateFromLatestSource"><el-icon><Refresh /></el-icon>按最新来源生成版本</button>
      <button v-if="readonly && canEdit && editingRecord && route.query.version === undefined" class="secondary-button" type="button" :disabled="reportLoading || saving || !!loadError" @click="router.push({ query: { mode: 'edit' } })"><el-icon><Edit /></el-icon>编辑报表</button>
      <button v-if="readonly" class="primary-button" type="button" @click="router.push('/report-compare')">返回列表</button>
      <button v-else-if="editingId" class="secondary-button" type="button" :disabled="saving" @click="router.push({ query: { mode: 'view' } })">取消编辑</button>
      <button v-if="!readonly && (canCreate || canEdit)" class="primary-button save-button" type="button" :disabled="saving || reportLoading || !!loadError || previewLoading || !!previewError" @click="saveReport"><el-icon><Check /></el-icon>{{ saving ? '保存中' : editingId ? '保存新版本' : '保存报表' }}</button>
    </footer>
    <CompareReportHistoryDialog v-model="historyVisible" :report-id="editingId" />
    <el-dialog v-model="testDialogVisible" class="test-dialog" title="添加对比" width="540px" align-center destroy-on-close>
      <div class="compare-select-form">
@@ -564,7 +622,7 @@
.chart-card header { height: 38px; display: flex; align-items: center; gap: 18px; padding: 0 14px; color: #52606d; font-size: 11px; }
.chart-card header strong { margin-right: auto; color: #26333f; font-size: 13px; }
.metric-chart { width: 100%; height: 210px; }
.form-actions { position: sticky; bottom: 0; z-index: 20; min-height: 62px; display: flex; align-items: center; justify-content: flex-end; gap: 10px; margin-top: 16px; padding: 10px 16px; background: rgba(234, 240, 246, .96); border-top: 1px solid #cbd9e5; }
.form-actions { position: sticky; bottom: 0; z-index: 20; min-height: 62px; display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 10px; margin-top: 16px; padding: 10px 16px; background: rgba(234, 240, 246, .96); border-top: 1px solid #cbd9e5; }
.form-actions .primary-button, .form-actions .secondary-button { min-width: 110px; }
.save-button { min-width: 110px; }
.compare-form-page :deep(.el-dialog) { max-width: calc(100vw - 28px); border-radius: 4px; }
tests/e2e/compare-version-editing.spec.ts
New file
@@ -0,0 +1,220 @@
import { expect, test, type Page } from '@playwright/test'
import { Ticket04Stack } from './support/ticket04-stack'
const stack = new Ticket04Stack()
test.beforeAll(async () => stack.start())
test.afterAll(async () => stack.stop())
async function login(page: Page, username = 'admin') {
  await page.goto('/login')
  await page.locator('input[autocomplete="username"]').fill(username)
  await page.locator('input[autocomplete="current-password"]').fill(username === 'admin' ? 'admin123' : 'Report123!')
  await page.getByRole('button', { name: '登录', exact: true }).click()
  await expect(page).toHaveURL(/\/dashboard$/)
}
async function api(page: Page, path: string, method = 'GET', data?: unknown, code = 200) {
  const token = await page.evaluate(() => localStorage.getItem('battery_detection_auth_token'))
  const response = await page.request.fetch(`${stack.backendUrl}/system/${path}`, {
    method, data, headers: { Authorization: `Bearer ${token}` },
  })
  const body = await response.json()
  expect(body.code, `${path}: ${body.msg}`).toBe(code)
  return body
}
test('快照编辑追加版本,主动更新才纳入修订,历史与来源删除独立', async ({ page }) => {
  const pageErrors: string[] = []
  page.on('pageerror', error => pageErrors.push(error.message))
  stack.executeIsolatedSql(`
    INSERT INTO t_battery_group(group_id,group_name,model) VALUES(8201,'版本验收组','型号');
    INSERT INTO t_battery_group_field(field_id,field_name,field_type) VALUES(8201,'质量','number');
    INSERT INTO t_battery_group_relation_field(group_id,battery_group_field_id,field_value) VALUES(8201,8201,'88');
    INSERT INTO t_test_type(test_type_id,test_type_name) VALUES(8201,'版本验收类型');
    INSERT INTO t_test(test_id,test_name,creator_id,principal_id,test_type_id,test_purpose,test_status,reception_status)
    VALUES(8201,'版本来源测试',1,1,8201,'隔离验收','COMPLETED','ENDED');
    INSERT INTO t_test_battery(test_battery_id,test_id,group_id,battery_no,collector_id) VALUES(8201,8201,8201,'BAT-V',8201);
    INSERT INTO t_detection_cabinet(cabinet_id,adapter_code,cabinet_code,cabinet_name,vendor_device_type,vendor_device_id)
    VALUES(8201,'version-fixture','version-fixture','验收检测柜',27,1);
    INSERT INTO t_detection_channel(channel_id,adapter_code,cabinet_id,devtype,devid,subdevid,chlid) VALUES(8201,'version-fixture',8201,27,1,1,1);
    INSERT INTO t_test_channel_binding(binding_id,test_id,test_battery_id,channel_id) VALUES(8201,8201,8201,8201);
    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,content_sha256)
    VALUES(8201,8201,8201,'version-fixture','version:27:1:1:1','1',27,1,1,1,1,'2026-09-01 00:00:10.123','2026-09-01 00:00:00.123','2026-09-01 00:00:11.123',1,1,'充电',3.7,-2,0,UNHEX(SHA2('v1',256))),
          (8201,8201,8201,'version-fixture','version:27:1:1:1','2',27,1,1,1,2,'2026-09-01 00:01:10.123','2026-09-01 00:01:00.123','2026-09-01 00:01:11.123',1,1,'充电',3.8,NULL,NULL,UNHEX(SHA2('v2',256))),
          (8201,8201,8201,'version-fixture','version:27:1:1:1','3',27,1,1,1,3,'2026-09-01 00:02:10.123','2026-09-01 00:02:00.123','2026-09-01 00:02:11.123',1,2,'放电',3.6,1,25,UNHEX(SHA2('v3',256)));
  `)
  await login(page)
  const selections = [1, 2].map(stepNo => ({ testId: 8201, testBatteryId: 8201, cycleNo: 1, stepNo, logicalNo: null }))
  const payload = {
    name: '版本验收V1', description: '原说明', experimentName: '版本实验', selections,
    settings: { metrics: ['voltage', 'current', 'temperature'], seriesColors: ['#123456', '#109854'],
      batteryTemplateFields: ['质量'], specialFields: ['人工指标'], detailFieldValues: { 人工指标: { '8201|8201|1|1|': '12' } } },
  }
  const v1 = (await api(page, 'compare-report', 'POST', payload)).data
  const firstIdentity = v1.items[0].records.find((row: any) => row.recordNo === '1').stableIdentity
  await api(page, 'test/8201/console-data/actions', 'POST', {
    dataType: 'REALTIME', operationType: 'EDIT', scopeSummary: '电流修订', reason: '验证版本隔离',
    identitySummary: JSON.stringify([firstIdentity]),
    overrides: [{ stableIdentity: firstIdentity, fieldValues: JSON.stringify({ current: 66 }) }],
  })
  const edit = { name: '版本验收V2', description: '新说明', experimentName: '版本实验',
    settings: payload.settings, baseVersionNo: 1, changeSummary: '普通编辑说明' }
  const v2 = (await api(page, `compare-report/${v1.id}/versions`, 'POST', edit)).data
  expect(v2.versionNo).toBe(2)
  expect(v2.items).toEqual(v1.items)
  expect((await api(page, `compare-report/${v1.id}/versions/1`)).data).toEqual(v1)
  expect((await api(page, `compare-report/${v1.id}`)).data).toEqual(v2)
  const history = (await api(page, `compare-report/${v1.id}/versions`)).data
  expect(history.map((version: any) => version.versionNo)).toEqual([2, 1])
  expect(history[0]).toMatchObject({ operatorName: 'admin', changeSummary: '普通编辑说明' })
  expect(history[0].createdAt).toBeGreaterThan(0)
  const v3 = (await api(page, `compare-report/${v1.id}/refresh-source`, 'POST', { baseVersionNo: 2, changeSummary: '纳入电流修订' })).data
  expect(v3.versionNo).toBe(3)
  expect(v3.items[0].records.find((row: any) => row.recordNo === '1').current).toBe(66)
  expect(v3.settings.detailFieldValues).toEqual(v2.settings.detailFieldValues)
  expect((await api(page, `compare-report/${v1.id}/versions/2`)).data).toEqual(v2)
  await page.goto(`/report-compare/${v1.id}/edit?mode=view`)
  await page.getByRole('button', { name: '编辑报表', exact: true }).click()
  await expect(page.getByPlaceholder('请输入报表名称')).toHaveValue('版本验收V2')
  await page.getByPlaceholder('请输入报表名称').fill('浏览器编辑V4')
  await page.getByPlaceholder('请输入修改说明').fill('调整人工表格与显隐')
  await page.locator('.field-table tbody tr').filter({ hasText: '人工指标' }).locator('input').first().fill('99')
  await page.locator('.metric-switches label').filter({ hasText: '电流' }).locator('.el-switch__core').click()
  await page.route('**/dev-api/system/compare-report/*/versions', async route => {
    await route.fulfill({ json: { code: 500, msg: '版本保存故障' } })
  }, { times: 1 })
  await page.getByRole('button', { name: '保存新版本', exact: true }).click()
  await expect(page.getByText('版本保存故障', { exact: true })).toBeVisible()
  await expect(page.getByPlaceholder('请输入报表名称')).toHaveValue('浏览器编辑V4')
  await page.getByRole('button', { name: '保存新版本', exact: true }).click()
  await expect(page).toHaveURL(/mode=view$/)
  const v4 = (await api(page, `compare-report/${v1.id}`)).data
  expect(v4.versionNo).toBe(4)
  expect(v4.items).toEqual(v3.items)
  expect(v4.settings.detailFieldValues['人工指标']['8201|8201|1|1|']).toBe('99')
  expect(v4.settings.metrics).not.toContain('current')
  const beforeHistory = (await api(page, `compare-report/${v1.id}/versions`)).data
  const nextEdit = { ...edit, baseVersionNo: 4 }
  for (const invalid of [
    { ...nextEdit, items: [{ records: [{ current: 999 }] }] },
    { ...nextEdit, selections },
    { ...nextEdit, settings: { ...payload.settings, records: [] } },
    { ...nextEdit, baseVersionNo: 1 },
    { ...nextEdit, changeSummary: '' },
  ]) await api(page, `compare-report/${v1.id}/versions`, 'POST', invalid, 500)
  await api(page, `compare-report/${v1.id}/refresh-source`, 'POST', { baseVersionNo: 4, changeSummary: '伪造', items: [] }, 500)
  // 在版本插入后的名称更新处拒绝写入,证明新版本也随事务回滚。
  stack.executeIsolatedSql("ALTER TABLE t_compare_report ADD CONSTRAINT version_failure CHECK (report_name <> 'failure')")
  await api(page, `compare-report/${v1.id}/versions`, 'POST', { ...nextEdit, name: 'failure' }, 500)
  stack.executeIsolatedSql('ALTER TABLE t_compare_report DROP CONSTRAINT version_failure')
  expect((await api(page, `compare-report/${v1.id}/versions`)).data).toEqual(beforeHistory)
  expect((await api(page, `compare-report/${v1.id}`)).data).toEqual(v4)
  expect((await api(page, 'test/8201/data/records')).data.find((row: any) => row.stableIdentity === firstIdentity).current).toBe(66)
  await page.getByRole('button', { name: '历史版本', exact: true }).click()
  const dialog = page.getByRole('dialog', { name: '历史版本' })
  await expect(dialog.locator('.el-table__body-wrapper tbody tr')).toHaveCount(4)
  await dialog.locator('.el-table__body-wrapper tbody tr').filter({ hasText: '首次保存对比报表' }).getByRole('button', { name: '查看版本' }).click()
  await expect(page).toHaveURL(/version=1/)
  await expect(page.getByPlaceholder('请输入报表名称')).toHaveValue('版本验收V1')
  await expect(page.getByRole('button', { name: '编辑报表', exact: true })).toHaveCount(0)
  await expect(page.locator('.metric-chart canvas')).toHaveCount(3)
  await page.reload()
  await expect(page.getByPlaceholder('请输入报表名称')).toHaveValue('版本验收V1')
  await page.getByRole('button', { name: '查看最新版本', exact: true }).click()
  await expect(page.getByPlaceholder('请输入报表名称')).toHaveValue('浏览器编辑V4')
  await page.getByRole('button', { name: '按最新来源生成版本', exact: true }).click()
  await page.getByPlaceholder('请输入更新来源说明').fill('浏览器主动更新')
  await page.getByRole('button', { name: '生成新版本', exact: true }).click()
  await expect(page.getByText('已生成版本 5', { exact: true })).toBeVisible()
  const v5 = (await api(page, `compare-report/${v1.id}`)).data
  expect(v5.versionNo).toBe(5)
  expect(v5.settings.detailFieldValues['人工指标']['8201|8201|1|1|']).toBe('99')
  // 删除单条来源只影响主动更新;已存历史保留完整测量。
  await api(page, 'test/8201/console-data/actions', 'POST', {
    dataType: 'REALTIME', operationType: 'DELETE', scopeSummary: '删除第一条测量', reason: '验证版本独立',
    identitySummary: JSON.stringify([firstIdentity]),
  })
  const v6 = (await api(page, `compare-report/${v1.id}/refresh-source`, 'POST', { baseVersionNo: 5, changeSummary: '排除已删除记录' })).data
  expect(v6.items[0].records).toHaveLength(1)
  expect(v6.items[0].records[0].current ?? null).toBeNull()
  expect(v6.items[0].zeroTime).toBe(Date.parse('2026-09-01T00:01:00.123Z'))
  expect((await api(page, `compare-report/${v1.id}/versions/1`)).data).toEqual(v1)
  await api(page, 'test/8201', 'DELETE')
  const v7 = (await api(page, `compare-report/${v1.id}/versions`, 'POST', { ...nextEdit, baseVersionNo: 6, name: '源删除后编辑' })).data
  expect(v7.items).toEqual(v6.items)
  await api(page, `compare-report/${v1.id}/refresh-source`, 'POST', { baseVersionNo: 7, changeSummary: '已删除来源' }, 500)
  expect((await api(page, `compare-report/${v1.id}`)).data).toEqual(v7)
  expect(stack.queryDatabase('SELECT COUNT(*) FROM t_compare_report_version')).toBe('7')
  expect(stack.queryDatabase('SELECT COUNT(*) FROM t_test WHERE test_id=8201')).toBe('0')
  await stack.restartBackend()
  await page.evaluate(() => { localStorage.removeItem('battery_detection_auth_token'); localStorage.removeItem('battery_detection_auth_user') })
  await login(page)
  await page.goto('/report-compare')
  await page.getByRole('button', { name: '修订记录', exact: true }).click()
  await expect(page.getByRole('dialog', { name: '历史版本' }).locator('.el-table__body-wrapper tbody tr')).toHaveCount(7)
  await page.getByRole('dialog', { name: '历史版本' }).locator('.el-table__body-wrapper tbody tr').first().getByRole('button', { name: '查看版本' }).click()
  await expect(page.getByPlaceholder('请输入报表名称')).toHaveValue('源删除后编辑')
  await page.getByRole('button', { name: '查看最新版本', exact: true }).click()
  for (const width of [1440, 390]) {
    await page.setViewportSize({ width, height: 900 })
    await expect.poll(async () => (await page.locator('.metric-chart canvas').first().boundingBox())!.width).toBeGreaterThan(200)
    await page.screenshot({ path: `tests/e2e/.artifacts/compare-version-${width}.png`, fullPage: true, animations: 'disabled' })
    expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
    const pixels = await page.locator('.metric-chart canvas').first().evaluate((canvas: HTMLCanvasElement) => {
      const data = canvas.getContext('2d')!.getImageData(0, 0, canvas.width, canvas.height).data
      let colored = 0
      for (let i = 0; i < data.length; i += 4) if (data[i + 3] > 100 && Math.abs(data[i] - data[i + 2]) > 50) colored++
      return colored
    })
    expect(pixels).toBeGreaterThan(10)
  }
  await page.setViewportSize({ width: 1440, height: 900 })
  await page.getByRole('button', { name: '历史版本', exact: true }).click()
  await expect(page.getByRole('dialog', { name: '历史版本' }).locator('.el-table__body-wrapper tbody tr')).toHaveCount(7)
  await page.screenshot({ path: 'tests/e2e/.artifacts/compare-version-history.png', animations: 'disabled' })
  const menus = (await api(page, 'menu/permissionList')).data
  const comparePage = menus.flatMap((module: any) => module.pages).find((item: any) => item.pageName === '对比报表')
  expect(comparePage.permissions.map((item: any) => item.menuName)).toContain('编辑')
  for (const [username, action] of [['version_reader', '查看'], ['version_editor', '编辑'], ['version_author', '新增'], ['version_denied', '']]) {
    const menuIds = comparePage.permissions.filter((item: any) => item.menuName === action).map((item: any) => item.menuId)
    await api(page, 'role', 'POST', { roleName: username, roleKey: 'common', roleSort: 1, status: '0', menuIds })
    const role = (await api(page, `role/list?roleName=${username}`)).rows[0]
    await api(page, 'user', 'POST', { userName: username, nickName: username, password: 'Report123!', status: '0', roleIds: [role.roleId] })
  }
  for (const username of ['version_reader', 'version_editor', 'version_author', 'version_denied']) {
    await page.evaluate(() => { localStorage.removeItem('battery_detection_auth_token'); localStorage.removeItem('battery_detection_auth_user') })
    await login(page, username)
    const canRead = username === 'version_reader'
    const canEdit = username === 'version_editor'
    await api(page, `compare-report/${v1.id}/versions`, 'GET', undefined, canRead ? 200 : 403)
    await api(page, `compare-report/${v1.id}/versions/1`, 'GET', undefined, canRead ? 200 : 403)
    await api(page, `compare-report/${v1.id}/editing`, 'GET', undefined, canEdit ? 200 : 403)
    if (!canEdit) {
      await api(page, `compare-report/${v1.id}/versions`, 'POST', { ...nextEdit, baseVersionNo: 7 }, 403)
      await api(page, `compare-report/${v1.id}/refresh-source`, 'POST', { baseVersionNo: 7, changeSummary: '无权限更新' }, 403)
    } else {
      // 只有编辑权限的另一用户也可直接编辑,保存后继续读取编辑基准。
      await page.goto(`/report-compare/${v1.id}/edit?mode=edit`)
      await expect(page.getByPlaceholder('请输入报表名称')).toHaveValue('源删除后编辑')
      await page.getByPlaceholder('请输入报表名称').fill('跨用户编辑V8')
      await page.getByPlaceholder('请输入修改说明').fill('编辑权限跨创建人')
      await page.getByRole('button', { name: '保存新版本', exact: true }).click()
      await expect(page.getByPlaceholder('请输入修改说明')).toHaveValue('')
      const v8 = (await api(page, `compare-report/${v1.id}/editing`)).data
      expect(v8.versionNo).toBe(8)
      expect(v8.operatorName).toBe('version_editor')
      expect(v8.items).toEqual(v7.items)
      await api(page, `compare-report/${v1.id}/refresh-source`, 'POST', { baseVersionNo: 8, changeSummary: '删除来源拒绝更新' }, 500)
    }
    if (canRead) {
      await page.goto(`/report-compare/${v1.id}/edit?mode=view`)
      await expect(page.getByRole('button', { name: '历史版本', exact: true })).toBeVisible()
      await expect(page.getByRole('button', { name: '编辑报表', exact: true })).toHaveCount(0)
      await expect(page.getByRole('button', { name: '按最新来源生成版本', exact: true })).toHaveCount(0)
    }
  }
  expect(pageErrors).toEqual([])
})