接入对比报表编辑与历史版本查看
沿用现有列表和表单,接入快照编辑、正式修订摘要和主动更新来源。补充独立版本、失败回滚、来源删除及权限的隔离验收测试。
| | |
| | | # 对比报表页面 |
| | | |
| | | ## 当前实现(数据分析 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` |
| | |
| | | } |
| | | export interface SavedCompareReport extends CompareReportSummary { |
| | | versionNo: number |
| | | operatorName: string |
| | | versionCreatedAt: number |
| | | changeSummary: string |
| | | description: string |
| | | experimentName: string |
| | | items: CompareItem[] |
| | |
| | | })).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 |
| | | } |
| | | |
| | | /** 按完整所选范围区分比对项,同一电池的不同循环或工步不合并。 */ |
| New file |
| | |
| | | <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> |
| | |
| | | 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 |
| | |
| | | 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: [], |
| | |
| | | <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> |
| | |
| | | <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> |
| | | |
| | |
| | | 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' |
| | | |
| | |
| | | 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('') |
| | |
| | | |
| | | 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 |
| | |
| | | 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() |
| | |
| | | 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 : '曲线预览失败' } |
| | |
| | | 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' |
| | | } |
| | |
| | | } |
| | | |
| | | 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 |
| | |
| | | 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], |
| | |
| | | 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> |
| | | |
| | |
| | | <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"> |
| | |
| | | <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"> |
| | |
| | | .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; } |
| New file |
| | |
| | | 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([]) |
| | | }) |