From e6a282cb844d7f5f7ec6c0fb18b643fd6f3d557e Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期日, 06 九月 2026 16:57:08 +0800
Subject: [PATCH] 接入对比报表首次保存和列表重开
---
src/api/auth.ts | 1
tests/e2e/compare-save-and-reopen.spec.ts | 210 ++++++++++++++
src/views/ReportCompare.vue | 98 +----
src/components/GlobalSearch.vue | 21 +
src/views/ReportCompareForm.vue | 372 +++++++++---------------
src/api/compareReports.ts | 82 +++++
src/stores/compareReports.ts | 80 -----
tests/e2e/support/ticket04-stack.ts | 6
8 files changed, 480 insertions(+), 390 deletions(-)
diff --git a/src/api/auth.ts b/src/api/auth.ts
index 1d8fe22..7315eef 100644
--- a/src/api/auth.ts
+++ b/src/api/auth.ts
@@ -15,6 +15,7 @@
nickName?: string
}
roles?: string[]
+ permissions?: string[]
}
export function loginApi(data: LoginParams) {
diff --git a/src/api/compareReports.ts b/src/api/compareReports.ts
new file mode 100644
index 0000000..708f4db
--- /dev/null
+++ b/src/api/compareReports.ts
@@ -0,0 +1,82 @@
+import { request, type ApiResponse } from './request'
+import type { PersistedTestRecord, TestLogicStepMapping } from './tests'
+import type { CompareMetricKey } from '@/stores/compareReports'
+
+export interface CompareReportSummary {
+ id: number
+ name: string
+ creator: string
+ createdAt: number
+}
+
+export async function listCompareReports() {
+ return (await request<ApiResponse & { rows: CompareReportSummary[] }>('/system/compare-report/list')).rows
+}
+
+export interface CompareSelection {
+ testId: number
+ testBatteryId: number
+ cycleNo: number
+ stepNo: number | null
+ logicalNo: number | null
+}
+export interface CompareCandidate {
+ selection: CompareSelection
+ name: string
+ type: string
+ groupId: number
+ batteryGroup: string
+ batteryNo: string
+ process: string
+}
+export interface CompareItem {
+ source: CompareCandidate
+ zeroTime: number | null
+ records: PersistedTestRecord[]
+ logicalMapping: TestLogicStepMapping | null
+ businessFields: Array<{ fieldDefinitionId: number; fieldName: string; fieldType: string; fieldValue: string | null }>
+}
+export interface CompareSettings {
+ metrics: CompareMetricKey[]
+ seriesColors: string[]
+ batteryTemplateFields: string[]
+ specialFields: string[]
+ detailFieldValues: Record<string, Record<string, string>>
+}
+export interface CreateCompareReport {
+ name: string
+ description: string
+ experimentName: string
+ selections: CompareSelection[]
+ settings: CompareSettings
+}
+export interface SavedCompareReport extends CompareReportSummary {
+ versionNo: number
+ description: string
+ experimentName: string
+ items: CompareItem[]
+ settings: CompareSettings
+ units: Record<string, string>
+ displayUnits: Record<string, string>
+}
+
+export async function getCompareSources() {
+ return (await request<ApiResponse & { data: CompareCandidate[] }>('/system/compare-report/sources')).data
+}
+export async function previewCompareReport(selections: CompareSelection[]) {
+ return (await request<ApiResponse & { data: CompareItem[] }>('/system/compare-report/preview', {
+ method: 'POST', body: JSON.stringify(selections),
+ })).data
+}
+export async function createCompareReport(data: CreateCompareReport) {
+ return (await request<ApiResponse & { data: SavedCompareReport }>('/system/compare-report', {
+ method: 'POST', body: JSON.stringify(data),
+ })).data
+}
+export async function getCompareReport(id: number) {
+ return (await request<ApiResponse & { data: SavedCompareReport }>(`/system/compare-report/${id}`)).data
+}
+
+export function compareSelectionKey(item: CompareSelection) {
+ return `${item.testId}|${item.testBatteryId}|${item.cycleNo}|${item.stepNo ?? ''}|${item.logicalNo ?? ''}`
+}
diff --git a/src/components/GlobalSearch.vue b/src/components/GlobalSearch.vue
index e76ba87..d7c6f60 100644
--- a/src/components/GlobalSearch.vue
+++ b/src/components/GlobalSearch.vue
@@ -1,8 +1,9 @@
<script setup lang="ts">
-import { computed, ref } from 'vue'
+import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useTests } from '@/stores/tests'
-import { useCompareReports } from '@/stores/compareReports'
+import { listCompareReports, type CompareReportSummary } from '@/api/compareReports'
+import { getUserInfo } from '@/api/auth'
import { useFormationReports } from '@/stores/formationReports'
import { useSpliceReports } from '@/stores/spliceReports'
@@ -20,7 +21,18 @@
const visible = ref(false)
const keyword = ref('')
const { records: tests } = useTests()
-const { records: compareReports } = useCompareReports()
+const compareReports = ref<CompareReportSummary[]>([])
+const compareError = ref('')
+watch(visible, async open => {
+ compareReports.value = []
+ compareError.value = ''
+ if (!open) return
+ try {
+ const info = await getUserInfo()
+ if (info.permissions?.some(item => item === '*:*:*' || item === 'system:compare-report:query'))
+ compareReports.value = await listCompareReports()
+ } catch (error) { compareError.value = error instanceof Error ? error.message : '对比报表加载失败' }
+})
const { records: formationReports } = useFormationReports()
const { records: spliceReports } = useSpliceReports()
@@ -50,7 +62,7 @@
category: '测试任务', path: `/test-list/${item.id}/console`, icon: 'Monitor', keywords: `${item.id} ${item.batteryNo} ${item.owner}`,
})),
...compareReports.value.map(item => ({
- key: `compare-${item.id}`, title: item.name, description: `对比报表 · ${item.creator} · ${item.createdAt}`,
+ key: `compare-${item.id}`, title: item.name, description: `对比报表 · ${item.creator} · ${new Date(item.createdAt).toLocaleString()}`,
category: '报表', path: `/report-compare/${item.id}/edit?mode=view`, icon: 'TrendCharts', keywords: `${item.id} 对比 报告`,
})),
...formationReports.value.map(item => ({
@@ -90,6 +102,7 @@
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<div class="search-summary"><span>{{ keyword.trim() ? '搜索结果' : '常用页面' }}</span><small>{{ searchResults.length }} 项</small></div>
+ <el-alert v-if="compareError" :title="compareError" type="error" :closable="false" />
<div v-if="searchResults.length" class="search-results">
<button v-for="item in searchResults" :key="item.key" type="button" class="search-result" @click="openResult(item)">
<span class="result-icon"><el-icon><component :is="item.icon" /></el-icon></span>
diff --git a/src/stores/compareReports.ts b/src/stores/compareReports.ts
index a08403f..919bdf1 100644
--- a/src/stores/compareReports.ts
+++ b/src/stores/compareReports.ts
@@ -1,5 +1,3 @@
-import { ref } from 'vue'
-
export interface CompareReportRevision {
id: string
modifiedAt: string
@@ -37,82 +35,4 @@
seriesColors?: string[]
detailFieldValues?: Record<string, Record<string, string>>
revisions: CompareReportRevision[]
-}
-
-export type CompareReportPayload = Pick<CompareReportRecord, 'name' | 'creator' | 'baselineTest' | 'comparisonTest' | 'description' | 'compareTests' | 'metrics' | 'batteryGroup' | 'batterySamples' | 'customFields' | 'batteryTemplateFields' | 'specialFields' | 'seriesColors' | 'detailFieldValues'>
-
-const STORAGE_KEY = 'battery_detection_compare_reports_v1'
-
-const seedRecords: CompareReportRecord[] = [
- { id: 6058, name: 'SP2 循环寿命对比报告', creator: 'Admin', createdAt: '2026-08-05 10:24', baselineTest: 'SP2电池寿命循环', comparisonTest: '高温循环性能测试', description: '对比常温与高温循环条件下的容量保持率和内阻变化。', revisions: [{ id: 'CR-6058-02', modifiedAt: '2026-08-05 11:10', modifier: 'Admin', summary: '补充容量保持率对比说明' }, { id: 'CR-6058-01', modifiedAt: '2026-08-05 10:24', modifier: 'Admin', summary: '创建对比报表' }] },
- { id: 6057, name: '低温充放电性能对比', creator: '李工', createdAt: '2026-08-04 17:36', baselineTest: '低温充放电测试', comparisonTest: '常温倍率性能测试', description: '分析低温环境对放电平台和可用容量的影响。', revisions: [{ id: 'CR-6057-01', modifiedAt: '2026-08-04 17:36', modifier: '李工', summary: '创建对比报表' }] },
- { id: 6056, name: '不同倍率放电曲线对比', creator: '王工', createdAt: '2026-08-04 14:18', baselineTest: '1C倍率放电测试', comparisonTest: '2C倍率放电测试', description: '对比不同倍率下的电压平台、温升和放电容量。', revisions: [{ id: 'CR-6056-01', modifiedAt: '2026-08-04 14:18', modifier: '王工', summary: '创建对比报表' }] },
- { id: 6055, name: '高温循环批次差异分析', creator: 'Admin', createdAt: '2026-08-03 16:42', baselineTest: '高温循环性能测试-A批', comparisonTest: '高温循环性能测试-B批', description: '比较两个生产批次在高温循环后的衰减差异。', revisions: [{ id: 'CR-6055-01', modifiedAt: '2026-08-03 16:42', modifier: 'Admin', summary: '创建对比报表' }] },
- { id: 6054, name: '静置前后内阻对比', creator: '赵工', createdAt: '2026-08-03 11:05', baselineTest: '化成后内阻测试', comparisonTest: '静置72小时内阻测试', description: '分析静置过程对电芯交流内阻的影响。', revisions: [{ id: 'CR-6054-01', modifiedAt: '2026-08-03 11:05', modifier: '赵工', summary: '创建对比报表' }] },
- { id: 6053, name: 'SOC 区间容量对比', creator: '李工', createdAt: '2026-08-02 15:28', baselineTest: 'SOC 20%-80%循环', comparisonTest: 'SOC 10%-90%循环', description: '比较不同SOC窗口下的循环容量衰减。', revisions: [{ id: 'CR-6053-01', modifiedAt: '2026-08-02 15:28', modifier: '李工', summary: '创建对比报表' }] },
- { id: 6052, name: '快充策略温升对比', creator: '王工', createdAt: '2026-08-02 10:12', baselineTest: '标准充电策略', comparisonTest: '快充策略V2', description: '对比两种充电策略的峰值温升和充电时长。', revisions: [{ id: 'CR-6052-01', modifiedAt: '2026-08-02 10:12', modifier: '王工', summary: '创建对比报表' }] },
- { id: 6051, name: '不同环境温度容量对比', creator: 'Admin', createdAt: '2026-08-01 18:06', baselineTest: '25℃容量测试', comparisonTest: '45℃容量测试', description: '分析环境温度变化对可用容量的影响。', revisions: [{ id: 'CR-6051-01', modifiedAt: '2026-08-01 18:06', modifier: 'Admin', summary: '创建对比报表' }] },
- { id: 6050, name: '电池组一致性对比', creator: '赵工', createdAt: '2026-08-01 13:40', baselineTest: '电池组A一致性测试', comparisonTest: '电池组B一致性测试', description: '对比单体压差、温差和容量离散程度。', revisions: [{ id: 'CR-6050-01', modifiedAt: '2026-08-01 13:40', modifier: '赵工', summary: '创建对比报表' }] },
- { id: 6049, name: '循环前后容量保持率', creator: '李工', createdAt: '2026-07-31 16:22', baselineTest: '循环前容量标定', comparisonTest: '500次循环后容量标定', description: '统计循环前后的容量保持率和能量效率。', revisions: [{ id: 'CR-6049-01', modifiedAt: '2026-07-31 16:22', modifier: '李工', summary: '创建对比报表' }] },
- { id: 6048, name: '过充保护策略对比', creator: '王工', createdAt: '2026-07-30 14:52', baselineTest: '保护策略V1测试', comparisonTest: '保护策略V2测试', description: '比较两版保护策略的触发时间和峰值电压。', revisions: [{ id: 'CR-6048-01', modifiedAt: '2026-07-30 14:52', modifier: '王工', summary: '创建对比报表' }] },
- { id: 6047, name: '工步优化前后效率对比', creator: 'Admin', createdAt: '2026-07-29 09:18', baselineTest: '原工步流程测试', comparisonTest: '优化工步流程测试', description: '比较流程优化前后的测试时长和能量效率。', revisions: [{ id: 'CR-6047-01', modifiedAt: '2026-07-29 09:18', modifier: 'Admin', summary: '创建对比报表' }] },
-]
-
-function loadRecords() {
- try {
- const stored = localStorage.getItem(STORAGE_KEY)
- if (stored) return JSON.parse(stored) as CompareReportRecord[]
- } catch {
- localStorage.removeItem(STORAGE_KEY)
- }
- return seedRecords
-}
-
-const records = ref<CompareReportRecord[]>(loadRecords())
-
-function persist() {
- localStorage.setItem(STORAGE_KEY, JSON.stringify(records.value))
-}
-
-function nowString() {
- const date = new Date()
- const pad = (value: number) => String(value).padStart(2, '0')
- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
-}
-
-export function useCompareReports() {
- function createReport(data: CompareReportPayload) {
- const id = Math.max(6000, ...records.value.map(item => item.id)) + 1
- const createdAt = nowString()
- records.value.unshift({
- ...data,
- id,
- createdAt,
- revisions: [{ id: `CR-${id}-01`, modifiedAt: createdAt, modifier: data.creator, summary: '创建对比报表' }],
- })
- persist()
- }
-
- function updateReport(id: number, data: CompareReportPayload) {
- const target = records.value.find(item => item.id === id)
- if (!target) return false
- const modifiedAt = nowString()
- const revisionNumber = String(target.revisions.length + 1).padStart(2, '0')
- Object.assign(target, data)
- target.revisions.unshift({
- id: `CR-${id}-${revisionNumber}`,
- modifiedAt,
- modifier: data.creator,
- summary: '更新报表名称、对比对象或说明',
- })
- persist()
- return true
- }
-
- function removeReport(id: number) {
- records.value = records.value.filter(item => item.id !== id)
- persist()
- }
-
- return { records, createReport, updateReport, removeReport }
}
diff --git a/src/views/ReportCompare.vue b/src/views/ReportCompare.vue
index bf46124..39e2b43 100644
--- a/src/views/ReportCompare.vue
+++ b/src/views/ReportCompare.vue
@@ -1,9 +1,10 @@
<script setup lang="ts">
-import { computed, reactive, ref } from 'vue'
-import { ElMessage, ElMessageBox } from 'element-plus'
+import { computed, onMounted, reactive, ref } from 'vue'
+import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
-import { useCompareReports, type CompareReportRecord } from '@/stores/compareReports'
-import { createCompareReportPrintData, openCompareReportPrint } from '@/utils/compareReportPrint'
+import { listCompareReports } from '@/api/compareReports'
+import type { CompareReportRecord } from '@/stores/compareReports'
+import { getUserInfo } from '@/api/auth'
interface FilterState {
reportName: string
@@ -12,13 +13,27 @@
}
const router = useRouter()
-const { records, removeReport } = useCompareReports()
+const records = ref<CompareReportRecord[]>([])
+const loading = ref(false)
+const loadError = ref('')
+const canCreate = ref(false)
+onMounted(async () => {
+ loading.value = true
+ try {
+ const info = await getUserInfo()
+ canCreate.value = Boolean(info.permissions?.some(item => item === '*:*:*' || item === 'system:compare-report:add'))
+ records.value = (await listCompareReports()).map(item => ({ ...item,
+ createdAt: new Date(item.createdAt).toLocaleString('sv-SE'),
+ baselineTest: '', comparisonTest: '', description: '', revisions: [],
+ }))
+ } catch (error) {
+ loadError.value = error instanceof Error ? error.message : '报表列表加载失败'
+ } finally { loading.value = false }
+})
const filters = reactive<FilterState>({ reportName: '', creator: '', dateRange: null })
const appliedFilters = ref<FilterState>({ reportName: '', creator: '', dateRange: null })
const currentPage = ref(1)
const pageSize = ref(10)
-const revisionVisible = ref(false)
-const activeReport = ref<CompareReportRecord | null>(null)
const creatorOptions = computed(() => [...new Set(records.value.map(item => item.creator))].sort((a, b) => a.localeCompare(b, 'zh-CN')))
@@ -61,67 +76,15 @@
router.push('/report-compare/new')
}
-function openEdit(item: CompareReportRecord) {
- router.push(`/report-compare/${item.id}/edit`)
-}
-
function openView(item: CompareReportRecord) {
router.push({ path: `/report-compare/${item.id}/edit`, query: { mode: 'view' } })
}
-function openRevisions(item: CompareReportRecord) {
- activeReport.value = item
- revisionVisible.value = true
-}
-
-function escapeCsv(value: string | number) {
- return `"${String(value).replaceAll('"', '""')}"`
-}
-
-function downloadRevisions(item: CompareReportRecord) {
- const headers = ['修订记录ID', '报告ID', '报告名称', '修订时间', '修订人', '修订内容']
- const rows = item.revisions.map(revision => [revision.id, item.id, item.name, revision.modifiedAt, revision.modifier, revision.summary])
- const csv = `\uFEFF${[headers, ...rows].map(row => row.map(escapeCsv).join(',')).join('\n')}`
- const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }))
- const anchor = document.createElement('a')
- anchor.href = url
- anchor.download = `${item.name}_修订记录.csv`
- anchor.style.display = 'none'
- document.body.appendChild(anchor)
- anchor.click()
- anchor.remove()
- window.setTimeout(() => URL.revokeObjectURL(url), 1000)
- ElMessage.success(`已导出 ${item.revisions.length} 条修订记录`)
-}
-
-function printPdf(item: CompareReportRecord) {
- const opened = openCompareReportPrint(createCompareReportPrintData(item))
- if (!opened) {
- ElMessage.warning('浏览器阻止了报表窗口,请允许弹出窗口后重试')
- return
- }
- ElMessage.info('已打开打印版报表,请在打印窗口选择“另存为 PDF”')
-}
-
-async function deleteReport(item: CompareReportRecord) {
- try {
- await ElMessageBox.confirm(`删除“${item.name}”及其修订记录后无法恢复,是否继续?`, '确认删除对比报表', {
- confirmButtonText: '删除',
- cancelButtonText: '取消',
- type: 'warning',
- confirmButtonClass: 'danger-confirm',
- })
- removeReport(item.id)
- currentPage.value = Math.min(currentPage.value, Math.max(1, Math.ceil(filteredRecords.value.length / pageSize.value)))
- ElMessage.success('对比报表已删除')
- } catch {
- // 用户取消删除。
- }
-}
</script>
<template>
<div class="compare-report-page">
+ <el-alert v-if="loadError" :title="loadError" type="error" :closable="false" show-icon />
<section class="filter-panel" aria-label="对比报表查询条件">
<header class="section-heading"><div><i></i><strong>查询报表</strong><span>REPORT QUERY</span></div><small>共 {{ records.length }} 份报表</small></header>
<div class="filter-grid">
@@ -133,31 +96,22 @@
</section>
<section class="result-panel">
- <div class="result-toolbar"><div><i></i><strong>对比报表列表</strong><span>{{ filteredRecords.length }} RESULTS · 按创建时间倒序</span></div><button class="primary-button" type="button" @click="openCreate"><el-icon><Plus /></el-icon>新建报表</button></div>
+ <div class="result-toolbar"><div><i></i><strong>对比报表列表</strong><span>{{ filteredRecords.length }} RESULTS · 按创建时间倒序</span></div><button v-if="canCreate" class="primary-button" type="button" @click="openCreate"><el-icon><Plus /></el-icon>新建报表</button></div>
<div class="table-wrap">
<table>
<thead><tr><th>报告ID</th><th>报告名称</th><th>创建时间</th><th>创建人</th><th class="action-column">操作</th></tr></thead>
<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><button type="button" @click="openEdit(item)">编辑</button><button type="button" @click="printPdf(item)">下载PDF</button><button type="button" @click="openRevisions(item)">修订记录</button><button type="button" @click="downloadRevisions(item)">下载记录</button><button class="delete-action" type="button" @click="deleteReport(item)">删除</button></div></td>
+ <td class="action-column"><div class="row-actions"><button type="button" @click="openView(item)">查看报表</button></div></td>
</tr>
- <tr v-if="!pagedRecords.length"><td colspan="5" class="empty-cell">暂无符合条件的对比报表</td></tr>
+ <tr v-if="!pagedRecords.length"><td colspan="5" class="empty-cell">{{ loading ? '正在加载报表' : loadError ? '报表列表加载失败' : '暂无符合条件的对比报表' }}</td></tr>
</tbody>
</table>
</div>
<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>
- <el-dialog v-model="revisionVisible" class="revision-dialog" title="报表修订记录" width="760px" align-center>
- <div v-if="activeReport" class="revision-summary"><span>报告ID <strong>#{{ activeReport.id }}</strong></span><span>报告名称 <strong>{{ activeReport.name }}</strong></span><button class="secondary-button" type="button" @click="downloadRevisions(activeReport)"><el-icon><Download /></el-icon>下载记录</button></div>
- <div class="revision-table-wrap">
- <table class="revision-table">
- <thead><tr><th>记录ID</th><th>修订时间</th><th>修订人</th><th>修订内容</th></tr></thead>
- <tbody><tr v-for="item in activeReport?.revisions || []" :key="item.id"><td class="id-cell">{{ item.id }}</td><td>{{ item.modifiedAt }}</td><td>{{ item.modifier }}</td><td :title="item.summary">{{ item.summary }}</td></tr></tbody>
- </table>
- </div>
- </el-dialog>
</div>
</template>
diff --git a/src/views/ReportCompareForm.vue b/src/views/ReportCompareForm.vue
index b9e425f..3e7bb3a 100644
--- a/src/views/ReportCompareForm.vue
+++ b/src/views/ReportCompareForm.vue
@@ -8,11 +8,12 @@
import { CanvasRenderer } from 'echarts/renderers'
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
import VChart from 'vue-echarts'
-import { getCurrentUser } from '@/utils/auth'
-import { useCompareReports, type CompareMetricKey, type CompareReportPayload, type CompareTestSelection } from '@/stores/compareReports'
-import { getPersistedTestRecords, getTestDetail, getTestList, type PersistedTestRecord } from '@/api/tests'
-import { useBatteryGroupFields } from '@/stores/batteryGroupFields'
-import { compareMetricDefinitions as metricDefinitions, openCompareReportPrint, type CompareMetricDefinition } from '@/utils/compareReportPrint'
+import { getUserInfo } from '@/api/auth'
+import type { CompareMetricKey, CompareTestSelection } from '@/stores/compareReports'
+import type { PersistedTestRecord } from '@/api/tests'
+import { compareSelectionKey, createCompareReport, getCompareReport, getCompareSources, previewCompareReport,
+ type CompareCandidate, type CompareItem, type CompareSelection, type SavedCompareReport } from '@/api/compareReports'
+import { compareMetricDefinitions as metricDefinitions, type CompareMetricDefinition } from '@/utils/compareReportPrint'
use([CanvasRenderer, LineChart, GridComponent, LegendComponent, TooltipComponent])
@@ -24,22 +25,27 @@
const route = useRoute()
const router = useRouter()
-const currentUser = getCurrentUser()
-const { records, createReport, updateReport } = useCompareReports()
const editingId = computed(() => {
const value = Number(route.params.id)
return Number.isFinite(value) && value > 0 ? value : null
})
-const editingRecord = computed(() => editingId.value ? records.value.find(item => item.id === editingId.value) || null : null)
-const readonly = computed(() => route.query.mode === 'view')
-const { fields: managedGroupFields } = useBatteryGroupFields()
+const editingRecord = ref<SavedCompareReport | null>(null)
+const readonly = computed(() => Boolean(editingId.value))
+const canCreate = ref(false)
+const loadError = ref('')
+const previewLoading = ref(false)
+const previewError = ref('')
+const reportLoading = ref(false)
const saving = ref(false)
const reportName = ref('')
-const experimentName = ref('XXX实验')
-const batteryGroup = ref('电池组1')
-const selectedTests = ref<CompareTestSelection[]>([])
-const candidateTests = ref<CompareTestSelection[]>([])
-const sourceRecords = reactive<Record<number, PersistedTestRecord[]>>({})
+const experimentName = ref('')
+const description = ref('')
+const batteryGroup = ref('')
+type SelectedTest = CompareTestSelection & { selection: CompareSelection }
+const selectedTests = ref<SelectedTest[]>([])
+const candidates = ref<CompareCandidate[]>([])
+const candidateTests = computed(() => [...new Map(candidates.value.map(item => [item.selection.testId, toSelectedTest(item)])).values()])
+const sourceItems = ref<CompareItem[]>([])
const candidatesLoading = ref(false)
const candidatesError = ref('')
const testDialogVisible = ref(false)
@@ -53,37 +59,16 @@
const batteryFields = ref(['额定容量', '电流', '内阻'])
const specialFields = ref(['温升'])
const detailFieldValues = reactive<Record<string, Record<string, string>>>({})
-const batteryTemplateStorageKey = 'battery_detection_group_field_templates_v1'
const presetBatteryTemplateLabels: Record<string, string[]> = {
'标准容量样品': ['额定容量', '电流', '内阻'],
'标准循环样品': ['额定容量', '循环温度', '内阻'],
'低温性能样品': ['目标温度', '静置时间', '环境备注'],
}
-const batteryTemplateSelections = reactive<Record<string, number[]>>((() => {
- try {
- const stored = localStorage.getItem(batteryTemplateStorageKey)
- return stored ? JSON.parse(stored) as Record<string, number[]> : {}
- } catch {
- localStorage.removeItem(batteryTemplateStorageKey)
- return {}
- }
-})())
+const batteryTemplateSelections = reactive<Record<string, number[]>>({})
const selectedBatteryTemplateName = ref('标准容量样品')
const selectedBatteryTemplateFieldIds = ref<number[]>([])
-const specialTemplateStorageKey = 'battery_detection_compare_special_templates_v2'
-const specialTemplateSelections = reactive<Record<string, string[]>>((() => {
- try {
- const stored = localStorage.getItem(specialTemplateStorageKey)
- if (stored) return JSON.parse(stored) as Record<string, string[]>
- const legacyStored = localStorage.getItem('battery_detection_compare_special_template_v1')
- const legacyFields = legacyStored ? JSON.parse(legacyStored) as string[] : []
- return legacyFields.length ? { 已保存特殊字段模板: legacyFields } : {}
- } catch {
- localStorage.removeItem(specialTemplateStorageKey)
- return {}
- }
-})())
+const specialTemplateSelections = reactive<Record<string, string[]>>({})
const fieldTemplates = computed<FieldTemplate[]>(() => [
{ key: 'cycle', name: '循环寿命模板', fields: ['容量保持率', '能量效率', '循环次数', '内阻变化'] },
@@ -109,37 +94,27 @@
'低温性能样品',
...Object.keys(batteryTemplateSelections).filter(name => !['自定义', '标准容量样品', '标准循环样品', '低温性能样品'].includes(name)),
])
-const batteryTemplateFieldOptions = computed(() => [...managedGroupFields.value].sort((a, b) => b.createdAt.localeCompare(a.createdAt)))
+const batteryTemplateFieldOptions = computed(() => [...new Map(sourceItems.value.flatMap(item => item.businessFields)
+ .map(field => [field.fieldDefinitionId, { id: field.fieldDefinitionId, name: field.fieldName, type: field.fieldType, status: '启用' }])).values()])
const enabledBatteryTemplateFields = computed(() => batteryTemplateFieldOptions.value.filter(field => field.status === '启用'))
const detailFields = computed(() => [...batteryFields.value, ...specialFields.value])
const selectedCandidate = computed(() => candidateTests.value.find(item => item.id === selectedCandidateId.value) || null)
-const detailTests = computed(() => {
- const seen = new Set<string>()
- return selectedTests.value.filter(item => {
- const key = detailKey(item)
- if (seen.has(key)) return false
- seen.add(key)
- return true
- })
-})
+const detailTests = computed(() => selectedTests.value)
const chartSeries = computed(() => detailTests.value)
-const batteryChoices = computed(() => [...new Set((sourceRecords[selectedCandidateId.value || 0] || []).map(item => item.batteryNo).filter(Boolean))])
-const cycleChoices = computed(() => [...new Set((sourceRecords[selectedCandidateId.value || 0] || []).map(item => Number(item.cycleNo)).filter(Number.isFinite))].sort((a, b) => a - b))
-const processChoices = computed(() => [...new Map((sourceRecords[selectedCandidateId.value || 0] || []).map(item => [`${item.stepNo}-${item.stepName}`, `${item.stepNo}-${item.stepName}`])).values()])
+const testCandidates = computed(() => candidates.value.filter(item => item.selection.testId === selectedCandidateId.value))
+const batteryChoices = computed(() => [...new Set(testCandidates.value.map(item => item.batteryNo))])
+const cycleChoices = computed(() => [...new Set(testCandidates.value.filter(item => item.batteryNo === selectedBatteryNo.value).map(item => item.selection.cycleNo))].sort((a, b) => a - b))
+const processChoices = computed(() => testCandidates.value.filter(item => item.batteryNo === selectedBatteryNo.value && item.selection.cycleNo === selectedCycle.value).map(item => item.process))
+
+function toSelectedTest(item: CompareCandidate): SelectedTest {
+ return { ...item, id: item.selection.testId, cycle: item.selection.cycleNo }
+}
async function loadCandidates() {
candidatesLoading.value = true
candidatesError.value = ''
try {
- const list = await getTestList({ pageNum: 1, pageSize: 200, status: 'COMPLETED' })
- const completed = list.rows.filter(item => item.status === '检测完成')
- const loaded = await Promise.all(completed.map(async item => {
- const [detail, rows] = await Promise.all([getTestDetail(item.id), getPersistedTestRecords(item.id)])
- sourceRecords[item.id] = rows
- const first = rows[0]
- return first ? { id: item.id, name: item.name, type: item.type, batteryGroup: detail.batteries[0]?.groupName || item.batteryGroup, batteryNo: first.batteryNo, process: `${first.stepNo}-${first.stepName}`, cycle: first.cycleNo } : null
- }))
- candidateTests.value = loaded.filter(Boolean) as CompareTestSelection[]
+ candidates.value = await getCompareSources()
} catch (error) {
candidatesError.value = error instanceof Error ? error.message : '真实测试来源加载失败'
} finally {
@@ -147,17 +122,18 @@
}
}
-function recordsFor(item: CompareTestSelection) {
- return (sourceRecords[item.id] || []).filter(row => row.batteryNo === item.batteryNo && Number(row.cycleNo) === Number(item.cycle) && `${row.stepNo}-${row.stepName}` === item.process)
-}
-
-function metricValues(key: CompareMetricKey) {
+function metricValues(key: CompareMetricKey): Array<Array<[number, number | null]>> {
const field: Record<CompareMetricKey, keyof PersistedTestRecord> = { voltage: 'voltage', current: 'current', positiveVoltage: 'positivePotential', negativeVoltage: 'negativePotential', temperature: 'temperature' }
- return chartSeries.value.map(item => recordsFor(item).map(row => row[field[key]]).filter((value): value is number => value !== null && value !== undefined && value !== '' && Number.isFinite(Number(value))).map(Number))
+ return sourceItems.value.map(item => item.records.flatMap(row => {
+ const time = key === 'voltage' || key === 'current' ? row.deviceTime : row.collectorDeviceTime
+ if (time == null || item.zeroTime == null) return []
+ const value = row[field[key]]
+ return [[(Number(time) - item.zeroTime) / 1000, value == null ? null : toTestDisplayValue(key, Number(value))] as [number, number | null]]
+ }).sort((left, right) => left[0] - right[0]))
}
-function detailKey(item: CompareTestSelection) {
- return `${item.id}|${item.batteryGroup || batteryGroup.value}|${item.batteryNo}`
+function detailKey(item: SelectedTest) {
+ return compareSelectionKey(item.selection)
}
function ensureDetailFieldValues(field: string) {
@@ -173,24 +149,16 @@
if (!record) return
reportName.value = record.name
- batteryGroup.value = record.batteryGroup || '电池组1'
- const descriptionParts = record.description.split(' · ')
- experimentName.value = descriptionParts[0] || 'XXX实验'
-
- const fallbackTests: CompareTestSelection[] = [
- { id: 100000 + record.id * 2, name: record.baselineTest, type: '化成', batteryGroup: batteryGroup.value, batteryNo: record.batterySamples?.[0] || 'SP26-3', process: '恒流充电', cycle: 3 },
- { id: 100001 + record.id * 2, name: record.comparisonTest, type: '化成', batteryGroup: batteryGroup.value, batteryNo: record.batterySamples?.[1] || 'SP26-4', process: '恒流放电', cycle: 3 },
- ]
- selectedTests.value = (record.compareTests?.length ? record.compareTests : fallbackTests).map(item => ({ ...item }))
-
- if (record.metrics) {
- metricDefinitions.forEach(metric => { metricVisible[metric.key] = record.metrics!.includes(metric.key) })
- }
- if (record.seriesColors?.length) seriesColors.value = [...record.seriesColors]
- if (record.batteryTemplateFields) batteryFields.value = [...record.batteryTemplateFields]
- if (record.specialFields) specialFields.value = [...record.specialFields]
+ experimentName.value = record.experimentName || ''
+ description.value = record.description || ''
+ sourceItems.value = record.items
+ selectedTests.value = record.items.map(item => toSelectedTest(item.source))
+ metricDefinitions.forEach(metric => { metricVisible[metric.key] = record.settings.metrics.includes(metric.key) })
+ seriesColors.value = [...record.settings.seriesColors]
+ batteryFields.value = [...record.settings.batteryTemplateFields]
+ specialFields.value = [...record.settings.specialFields]
Object.keys(detailFieldValues).forEach(field => delete detailFieldValues[field])
- Object.entries(record.detailFieldValues || {}).forEach(([field, values]) => {
+ Object.entries(record.settings.detailFieldValues).forEach(([field, values]) => {
detailFieldValues[field] = { ...values }
})
selectedBatteryTemplateName.value = '自定义'
@@ -205,8 +173,37 @@
}
detailFields.value.forEach(ensureDetailFieldValues)
-loadEditingReport()
-onMounted(() => { void loadCandidates() })
+onMounted(async () => {
+ reportLoading.value = true
+ try {
+ if (editingId.value) {
+ editingRecord.value = await getCompareReport(editingId.value)
+ loadEditingReport()
+ } else {
+ const info = await getUserInfo()
+ canCreate.value = Boolean(info.permissions?.some(item => item === '*:*:*' || item === 'system:compare-report:add'))
+ if (!canCreate.value) throw new Error('无新增对比报表权限')
+ await loadCandidates()
+ }
+ } catch (error) { loadError.value = error instanceof Error ? error.message : '报表加载失败' }
+ finally { reportLoading.value = false }
+})
+
+async function refreshPreview() {
+ previewLoading.value = true
+ previewError.value = ''
+ sourceItems.value = []
+ try {
+ sourceItems.value = await previewCompareReport(selectedTests.value.map(item => item.selection))
+ detailFields.value.forEach(ensureDetailFieldValues)
+ sourceItems.value.forEach(item => item.businessFields.forEach(field => {
+ const key = compareSelectionKey(item.source.selection)
+ if (batteryFields.value.includes(field.fieldName) && !detailFieldValues[field.fieldName]?.[key])
+ detailFieldValues[field.fieldName][key] = field.fieldValue ?? ''
+ }))
+ } catch (error) { previewError.value = error instanceof Error ? error.message : '曲线预览失败' }
+ finally { previewLoading.value = false }
+}
function openAddTest() {
selectedCandidateId.value = null
@@ -223,8 +220,9 @@
selectedProcess.value = selectedCandidate.value.process
}
-function addSelectedTest() {
- const candidate = selectedCandidate.value
+async function addSelectedTest() {
+ if (previewLoading.value || saving.value) return
+ const candidate = testCandidates.value.find(item => item.batteryNo === selectedBatteryNo.value && item.selection.cycleNo === selectedCycle.value && item.process === selectedProcess.value)
if (!candidate) {
ElMessage.warning('请选择要加入对比的测试')
return
@@ -233,23 +231,26 @@
ElMessage.warning('请完整选择电池、循环和工步')
return
}
- if (selectedTests.value.some(item => item.id === candidate.id && (item.batteryGroup || batteryGroup.value) === (candidate.batteryGroup || batteryGroup.value) && item.batteryNo === selectedBatteryNo.value && item.process === selectedProcess.value && item.cycle === selectedCycle.value)) {
+ if (selectedTests.value.some(item => detailKey(item) === compareSelectionKey(candidate.selection))) {
ElMessage.warning('相同测试、电池和工步循环已加入对比信息')
return
}
- selectedTests.value.push({ ...candidate, batteryNo: selectedBatteryNo.value, cycle: selectedCycle.value, process: selectedProcess.value })
+ selectedTests.value.push(toSelectedTest(candidate))
+ if (!seriesColors.value[selectedTests.value.length - 1]) seriesColors.value.push('#109854')
detailFields.value.forEach(ensureDetailFieldValues)
testDialogVisible.value = false
- ElMessage.success('已加入对比测试')
+ await refreshPreview()
}
-function removeTest(itemToRemove: CompareTestSelection) {
+async function removeTest(itemToRemove: SelectedTest) {
+ if (previewLoading.value || saving.value) return
if (selectedTests.value.length === 1) {
ElMessage.warning('至少保留一条对比测试')
return
}
const index = selectedTests.value.indexOf(itemToRemove)
- if (index >= 0) selectedTests.value.splice(index, 1)
+ if (index >= 0) { selectedTests.value.splice(index, 1); seriesColors.value.splice(index, 1) }
+ await refreshPreview()
}
function addCustomField() {
@@ -281,10 +282,6 @@
delete detailFieldValues[field]
}
-function persistBatteryTemplates() {
- localStorage.setItem(batteryTemplateStorageKey, JSON.stringify(batteryTemplateSelections))
-}
-
function resolveBatteryTemplateFieldIds(name: string) {
const savedIds = batteryTemplateSelections[name]
if (savedIds) return savedIds.filter(id => enabledBatteryTemplateFields.value.some(field => field.id === id))
@@ -307,34 +304,6 @@
.filter((name): name is string => Boolean(name))
specialFields.value = specialFields.value.filter(field => !batteryFields.value.includes(field))
detailFields.value.forEach(ensureDetailFieldValues)
-}
-
-function saveBatteryTemplate() {
- applyBatteryTemplateFields()
- batteryTemplateSelections[selectedBatteryTemplateName.value] = [...selectedBatteryTemplateFieldIds.value]
- persistBatteryTemplates()
- ElMessage.success(`模板“${selectedBatteryTemplateName.value}”已保存`)
-}
-
-async function saveBatteryTemplateAs() {
- try {
- const result = await ElMessageBox.prompt('请输入新模板名称,当前字段勾选会保存为新模板。', '另存电池组字段模板', {
- confirmButtonText: '保存', cancelButtonText: '取消', inputPlaceholder: '输入模板名称',
- inputValidator: value => value.trim() ? true : '请输入模板名称',
- })
- const name = result.value.trim()
- if (batteryTemplateNames.value.includes(name)) {
- ElMessage.warning('模板名称已存在,请使用其他名称')
- return
- }
- selectedBatteryTemplateName.value = name
- applyBatteryTemplateFields()
- batteryTemplateSelections[name] = [...selectedBatteryTemplateFieldIds.value]
- persistBatteryTemplates()
- ElMessage.success(`已另存为“${name}”`)
- } catch {
- // 用户取消另存模板。
- }
}
function openFieldTemplate(mode: 'battery' | 'special') {
@@ -360,41 +329,11 @@
ElMessage.success(`${templateMode.value === 'battery' ? '电池组字段' : '特殊字段'}模板已应用`)
}
-async function saveSpecialTemplate() {
- if (!specialFields.value.length) {
- ElMessage.warning('请先添加或选择特殊字段')
- return
- }
- try {
- const result = await ElMessageBox.prompt('请输入特殊字段模板名称,当前特殊字段会保存为新模板。', '另存特殊字段模板', {
- confirmButtonText: '保存', cancelButtonText: '取消', inputPlaceholder: '输入模板名称',
- inputValidator: value => value.trim() ? true : '请输入模板名称',
- })
- const name = result.value.trim()
- if (fieldTemplates.value.some(item => item.name === name)) {
- ElMessage.warning('特殊字段模板名称已存在,请使用其他名称')
- return
- }
- specialTemplateSelections[name] = [...specialFields.value]
- localStorage.setItem(specialTemplateStorageKey, JSON.stringify(specialTemplateSelections))
- selectedTemplateKey.value = `saved:${name}`
- ElMessage.success(`已另存为“${name}”`)
- } catch {
- // 用户取消另存模板。
- }
-}
-
function chartOption(metric: CompareMetricDefinition) {
- const names = chartSeries.value.map(item => `${item.name} · ${item.batteryNo}`)
+ const names = chartSeries.value.map(item => `${item.name} · ${item.batteryNo} · 循环${item.cycle} · ${item.process}`)
const colors = seriesColors.value
- const visibleSeries = metricValues(metric.key).map(series => series.map(value => toTestDisplayValue(metric.key, value)))
- const visibleValues = visibleSeries.flat()
- const maxDataLength = Math.max(1, ...visibleSeries.map(series => series.length))
- const timeLabels = Array.from({ length: maxDataLength }, (_, index) => {
- const hours = Math.floor(index / 60)
- const minutes = String(index % 60).padStart(2, '0')
- return `${hours}:${minutes}`
- })
+ const visibleSeries = metricValues(metric.key)
+ const visibleValues = visibleSeries.flat().map(point => point[1]).filter((value): value is number => value !== null)
const dataMin = Math.min(0, ...visibleValues)
const dataMax = Math.max(0, ...visibleValues)
const range = Math.max(dataMax - dataMin, 1)
@@ -416,94 +355,57 @@
color: colors,
grid: { top: 40, right: 46, bottom: 42, left: 46 },
tooltip: { trigger: 'axis' },
- legend: { top: 4, right: 6, icon: 'roundRect', itemWidth: 14, itemHeight: 8, textStyle: { fontSize: 11, color: '#52606d' } },
- xAxis: { type: 'category', boundaryGap: false, name: '时间/h:mm', nameLocation: 'middle', nameGap: 28, data: timeLabels, axisLine: { lineStyle: { color: '#cbd8e4' } }, axisLabel: { color: '#8795a3', fontSize: 10, interval: 'auto' }, nameTextStyle: { color: '#26333f', fontSize: 11, fontWeight: 600 } },
+ legend: { show: false },
+ xAxis: { type: 'value', min: 0, name: '相对时间 / s', nameLocation: 'middle', nameGap: 28, axisLine: { lineStyle: { color: '#cbd8e4' } }, axisLabel: { color: '#8795a3', fontSize: 10 }, nameTextStyle: { color: '#26333f', fontSize: 11, fontWeight: 600 } },
yAxis: [createYAxis('left'), createYAxis('right')],
- series: names.map((name, index) => ({ name, type: 'line', smooth: false, showSymbol: false, lineStyle: { width: 2 }, data: visibleSeries[index] || [] })),
+ series: names.map((name, index) => ({ name, type: 'line', smooth: false, connectNulls: false, showSymbol: true, symbolSize: 5, lineStyle: { width: 2 }, data: visibleSeries[index] || [] })),
}
-}
-
-function exportPdf() {
- if (!reportName.value.trim()) {
- ElMessage.warning('请先输入报告名称')
- return
- }
- const opened = openCompareReportPrint({
- name: reportName.value.trim(),
- reportId: editingRecord.value?.id,
- creator: editingRecord.value?.creator || currentUser?.displayName || 'Admin',
- createdAt: editingRecord.value?.createdAt,
- experimentName: experimentName.value,
- batteryGroup: batteryGroup.value,
- tests: selectedTests.value.map(item => ({ ...item })),
- detailTests: detailTests.value.map(item => ({ ...item })),
- fields: [...detailFields.value],
- detailFieldValues: Object.fromEntries(Object.entries(detailFieldValues).map(([field, values]) => [field, { ...values }])),
- metrics: visibleMetrics.value.map(item => item.key),
- seriesColors: [...seriesColors.value],
- })
- if (!opened) {
- ElMessage.warning('浏览器阻止了报表窗口,请允许弹出窗口后重试')
- return
- }
- ElMessage.info('已打开打印版报表,请在打印窗口选择“另存为 PDF”')
}
async function saveReport() {
+ if (saving.value || readonly.value || !canCreate.value) return
if (!reportName.value.trim()) {
ElMessage.warning('请输入报告名称')
- return
- }
- if (records.value.some(item => item.id !== editingId.value && item.name.trim().toLowerCase() === reportName.value.trim().toLowerCase())) {
- ElMessage.warning('报告名称已存在')
return
}
if (selectedTests.value.length < 2) {
ElMessage.warning('请至少添加两条对比测试')
return
}
- if (!batteryGroup.value.trim()) {
- ElMessage.warning('请选择电池组')
- return
- }
saving.value = true
- await new Promise(resolve => window.setTimeout(resolve, 250))
- const payload: CompareReportPayload = {
- name: reportName.value.trim(),
- creator: currentUser?.displayName || 'Admin',
- baselineTest: selectedTests.value[0].name,
- comparisonTest: selectedTests.value[1].name,
- description: `${experimentName.value.trim()} · ${batteryGroup.value.trim()}`,
- compareTests: selectedTests.value.map(item => ({ ...item })),
- metrics: visibleMetrics.value.map(item => item.key),
- batteryGroup: batteryGroup.value,
- batterySamples: detailTests.value.map(item => item.batteryNo),
- customFields: detailFields.value,
- batteryTemplateFields: [...batteryFields.value],
- specialFields: [...specialFields.value],
- seriesColors: [...seriesColors.value],
- detailFieldValues: Object.fromEntries(
- Object.entries(detailFieldValues).map(([field, values]) => [field, { ...values }]),
- ),
- }
- if (editingId.value) {
- updateReport(editingId.value, payload)
- ElMessage.success('对比报表已更新')
- } else {
- createReport(payload)
+ try {
+ await createCompareReport({
+ 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],
+ detailFieldValues: Object.fromEntries(detailFields.value.map(field => [field,
+ Object.fromEntries(selectedTests.value.map(item => [detailKey(item), detailFieldValues[field]?.[detailKey(item)] || ''])),
+ ])),
+ },
+ })
ElMessage.success('对比报表已保存')
+ await router.push('/report-compare')
+ } catch (error) {
+ ElMessage.error(error instanceof Error ? error.message : '保存失败,请重试')
+ } finally {
+ saving.value = false
}
- saving.value = false
- await router.push('/report-compare')
}
</script>
<template>
<div class="compare-form-page" :class="{ 'is-readonly': readonly }">
+ <el-alert v-if="loadError" :title="loadError" type="error" :closable="false" show-icon />
+ <div v-if="reportLoading">正在加载报表</div>
+ <el-alert v-if="previewError" :title="previewError" type="error" :closable="false" show-icon />
<div class="page-tools"><button class="back-button" type="button" @click="router.push('/report-compare')"><el-icon><ArrowLeft /></el-icon>返回对比报表</button><span>{{ readonly ? 'VIEW COMPARISON REPORT' : editingId ? 'EDIT COMPARISON REPORT' : 'NEW COMPARISON REPORT' }}</span></div>
<section class="report-name-panel">
- <label><span>报表名称</span><el-input v-model="reportName" clearable placeholder="请输入报表名称" /></label>
+ <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>
</section>
<section class="compare-info-panel">
@@ -517,13 +419,13 @@
</tbody>
</table>
</div>
- <button v-if="!readonly" class="primary-button compact-button" type="button" @click="openAddTest"><el-icon><Plus /></el-icon>添加比较</button>
+ <button v-if="!readonly && canCreate" class="primary-button compact-button" type="button" :disabled="previewLoading || saving" @click="openAddTest"><el-icon><Plus /></el-icon>添加比较</button>
</section>
<section class="detail-panel">
<header class="panel-heading"><div><strong>测试详情</strong><el-icon><ArrowUp /></el-icon></div></header>
<div class="detail-meta">
- <label><span>实验</span><el-input v-model="experimentName" /></label>
+ <label><span>实验</span><el-input v-model="experimentName" :readonly="readonly" maxlength="500" /></label>
</div>
<div class="field-table-wrap">
<table class="field-table" :style="{ minWidth: `${Math.max(900, detailTests.length * 220 + 214)}px` }">
@@ -532,28 +434,28 @@
<tr class="detail-meta-row"><td>测试</td><td v-for="item in detailTests" :key="detailKey(item)">{{ item.name }}</td><td v-if="!readonly">-</td></tr>
<tr class="detail-meta-row"><td>电池组</td><td v-for="item in detailTests" :key="detailKey(item)">{{ item.batteryGroup || batteryGroup }}</td><td v-if="!readonly">-</td></tr>
<tr class="sample-row"><td>电池号</td><td v-for="item in detailTests" :key="detailKey(item)">{{ item.batteryNo }}</td><td v-if="!readonly">-</td></tr>
- <tr v-for="field in detailFields" :key="field"><td>{{ field }}</td><td v-for="item in detailTests" :key="detailKey(item)"><el-input v-model="detailFieldValues[field][detailKey(item)]" size="small" placeholder="请输入" /></td><td v-if="!readonly"><button v-if="specialFields.includes(field)" class="delete-link" type="button" @click="removeCustomField(field)">删除</button><span v-else class="field-lock">-</span></td></tr>
+ <tr v-for="field in detailFields" :key="field"><td>{{ field }}</td><td v-for="item in detailTests" :key="detailKey(item)"><el-input v-model="detailFieldValues[field][detailKey(item)]" :readonly="readonly" size="small" placeholder="请输入" /></td><td v-if="!readonly"><button v-if="specialFields.includes(field)" class="delete-link" type="button" @click="removeCustomField(field)">删除</button><span v-else class="field-lock">-</span></td></tr>
<tr v-if="!detailFields.length"><td :colspan="detailTests.length + (readonly ? 1 : 2)" class="empty-cell">暂无字段</td></tr>
</tbody>
</table>
</div>
- <div v-if="!readonly" class="detail-actions"><button class="primary-button" type="button" @click="addCustomField"><el-icon><Plus /></el-icon>添加项</button><button class="secondary-button" type="button" @click="openFieldTemplate('battery')">电池组字段模板</button><button class="secondary-button" type="button" @click="openFieldTemplate('special')">选择特殊字段模板</button><button class="secondary-button" type="button" @click="saveSpecialTemplate">另存特殊字段模板</button></div>
+ <div v-if="!readonly" class="detail-actions"><button class="primary-button" type="button" @click="addCustomField"><el-icon><Plus /></el-icon>添加项</button><button class="secondary-button" type="button" @click="openFieldTemplate('battery')">电池组字段</button><button class="secondary-button" type="button" @click="openFieldTemplate('special')">选择特殊字段</button></div>
</section>
<section class="metrics-panel">
<div v-if="!readonly" class="metric-switches"><label v-for="metric in metricDefinitions" :key="metric.key"><span>{{ metric.title }}</span><el-switch v-model="metricVisible[metric.key]" inline-prompt active-text="显" inactive-text="隐" /></label></div>
- <div v-if="chartSeries.length" class="chart-legend-bar"><strong>对比图例</strong><div v-for="(item, index) in chartSeries" :key="detailKey(item)" class="chart-legend-item"><el-color-picker v-if="!readonly" class="legend-color-picker" :model-value="seriesColors[index]" :show-alpha="false" popper-class="legend-color-popper" :aria-label="`修改${item.name}颜色`" @change="updateSeriesColor(index, $event)" /><span>{{ item.name }} · {{ item.batteryNo }}</span></div></div>
+ <div v-if="chartSeries.length" class="chart-legend-bar"><strong>对比图例</strong><div v-for="(item, index) in chartSeries" :key="detailKey(item)" class="chart-legend-item"><el-color-picker v-if="!readonly" class="legend-color-picker" :model-value="seriesColors[index]" :show-alpha="false" popper-class="legend-color-popper" :aria-label="`修改${item.name}颜色`" @change="updateSeriesColor(index, $event)" /><i v-else class="series-swatch" :style="{ background: seriesColors[index] }" /><span>{{ item.name }} · {{ item.batteryNo }} · 循环{{ item.cycle }} · {{ item.process }}</span></div></div>
<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)" autoresize /></article></div>
</section>
- <footer class="form-actions"><button class="secondary-button" type="button" @click="exportPdf"><el-icon><Download /></el-icon>导出PDF</button><button v-if="readonly" class="primary-button" type="button" @click="router.push('/report-compare')">返回列表</button><button v-else class="primary-button save-button" type="button" :disabled="saving" @click="saveReport"><el-icon><Check /></el-icon>{{ saving ? '保存中' : editingId ? '保存修改' : '保存报表' }}</button></footer>
+ <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>
<el-dialog v-model="testDialogVisible" class="test-dialog" title="添加对比" width="540px" align-center destroy-on-close>
<div class="compare-select-form">
<label><span>选择测试</span><el-select v-model="selectedCandidateId" filterable clearable :loading="candidatesLoading" placeholder="输入测试名称或ID搜索" @change="syncDialogDefaults"><el-option v-for="item in candidateTests" :key="item.id" :label="`${item.name}(${item.id})`" :value="item.id" /></el-select></label>
- <label><span>选择电池</span><el-select v-model="selectedBatteryNo" placeholder="请选择" :disabled="!selectedCandidateId"><el-option v-for="item in batteryChoices" :key="item" :label="item" :value="item" /></el-select></label>
- <label><span>选择循环</span><el-select v-model="selectedCycle" placeholder="请选择" :disabled="!selectedCandidateId"><el-option v-for="item in cycleChoices" :key="item" :label="`${item}`" :value="item" /></el-select></label>
- <label><span>选择工步</span><div class="control-stack"><el-select v-model="selectedProcess" placeholder="请选择" :disabled="!selectedCandidateId"><el-option v-for="item in processChoices" :key="item" :label="item" :value="item" /></el-select><small class="select-hint">仅显示所选检测完成测试中已保存的实际工步</small></div></label>
+ <label><span>选择电池</span><el-select v-model="selectedBatteryNo" placeholder="请选择" :disabled="!selectedCandidateId" @change="selectedCycle = cycleChoices[0] ?? null; selectedProcess = processChoices[0] || ''"><el-option v-for="item in batteryChoices" :key="item" :label="item" :value="item" /></el-select></label>
+ <label><span>选择循环</span><el-select v-model="selectedCycle" placeholder="请选择" :disabled="!selectedCandidateId" @change="selectedProcess = processChoices[0] || ''"><el-option v-for="item in cycleChoices" :key="item" :label="`${item}`" :value="item" /></el-select></label>
+ <label><span>选择工步</span><div class="control-stack"><el-select v-model="selectedProcess" placeholder="请选择" :disabled="!selectedCandidateId"><el-option v-for="item in processChoices" :key="item" :label="item" :value="item" /></el-select></div></label>
<p v-if="candidatesError" class="select-error">{{ candidatesError }}</p><p v-else-if="!candidatesLoading && !candidateTests.length" class="select-empty">暂无检测完成且有已保存数据的测试</p>
</div>
<template #footer><div class="dialog-footer-left"><button class="primary-button" type="button" @click="addSelectedTest"><el-icon><Check /></el-icon>添加</button></div></template>
@@ -568,7 +470,7 @@
<div v-else class="template-empty">暂无字段,请先到电池组字段管理中添加字段</div>
</div>
<el-radio-group v-else v-model="selectedTemplateKey" class="template-options"><el-radio v-for="item in fieldTemplates" :key="item.key" :value="item.key" border>{{ item.name }}({{ item.fields.join('、') }})</el-radio></el-radio-group>
- <template #footer><div v-if="templateMode === 'battery'" class="battery-template-actions"><button class="primary-button" type="button" @click="applyFieldTemplate"><el-icon><Check /></el-icon>确认</button><button class="primary-button" type="button" @click="saveBatteryTemplate"><el-icon><Check /></el-icon>保存</button><button class="secondary-button" type="button" @click="saveBatteryTemplateAs"><el-icon><DocumentCopy /></el-icon>另存模板</button></div><div v-else><button class="secondary-button" type="button" @click="templateDialogVisible = false">取消</button><button class="primary-button" type="button" @click="applyFieldTemplate">应用模板</button></div></template>
+ <template #footer><div v-if="templateMode === 'battery'" class="battery-template-actions"><button class="primary-button" type="button" @click="applyFieldTemplate"><el-icon><Check /></el-icon>确认</button></div><div v-else><button class="secondary-button" type="button" @click="templateDialogVisible = false">取消</button><button class="primary-button" type="button" @click="applyFieldTemplate">应用字段</button></div></template>
</el-dialog>
</div>
</template>
@@ -589,6 +491,8 @@
.secondary-button:hover { color: var(--blue); border-color: #9dc8f5; }
.report-name-panel, .compare-info-panel, .detail-panel, .metrics-panel { background: #fff; border: 1px solid #d8e1e9; }
.report-name-panel { padding: 18px 22px; margin-bottom: 16px; }
+.report-name-panel label + label { margin-top: 12px; }
+.series-swatch { flex: 0 0 12px; width: 12px; height: 12px; display: inline-block; }
.report-name-panel label { display: grid; grid-template-columns: 72px minmax(260px, 420px); align-items: center; gap: 12px; }
.report-name-panel label > span, .detail-meta label > span { color: #52606d; font-size: 12px; font-weight: 500; }
.report-name-panel :deep(.el-input__wrapper), .detail-meta :deep(.el-input__wrapper), .detail-meta :deep(.el-select__wrapper), .field-table :deep(.el-input__wrapper), .field-table :deep(.el-select__wrapper) { min-height: 34px; border-radius: 3px; box-shadow: 0 0 0 1px #d8e1e9 inset; background: #f8fafc; }
@@ -622,7 +526,7 @@
.chart-legend-bar { min-height: 52px; display: flex; flex-wrap: wrap; align-items: center; gap: 12px 24px; border-bottom: 1px solid var(--line); }
.chart-legend-bar > strong { color: #26333f; font-size: 12px; font-weight: 600; }
.chart-legend-item { display: inline-flex; align-items: center; gap: 7px; min-width: 0; color: #52606d; font-size: 12px; }
-.chart-legend-item span { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.chart-legend-item span { max-width: 360px; overflow-wrap: anywhere; }
.legend-color-picker { width: 18px; height: 18px; display: inline-flex; align-items: center; justify-content: center; }
.legend-color-picker :deep(.el-color-picker__trigger) { width: 14px !important; height: 14px !important; padding: 0 !important; border: 0 !important; border-radius: 2px; background: transparent !important; box-shadow: none !important; overflow: hidden; }
.legend-color-picker :deep(.el-color-picker__color) { width: 14px; height: 14px; border: 0; border-radius: 2px; box-shadow: 0 0 0 1px rgba(36, 54, 71, .18); }
diff --git a/tests/e2e/compare-save-and-reopen.spec.ts b/tests/e2e/compare-save-and-reopen.spec.ts
new file mode 100644
index 0000000..8ff2be5
--- /dev/null
+++ b/tests/e2e/compare-save-and-reopen.spec.ts
@@ -0,0 +1,210 @@
+import { expect, test, type Page } from '@playwright/test'
+import { Ticket04Stack } from './support/ticket04-stack'
+
+const stack = new Ticket04Stack()
+test.use({ actionTimeout: 20_000 })
+test.beforeAll(async () => stack.start())
+test.afterAll(async () => stack.stop())
+
+async function login(page: Page, username = 'admin', password = 'admin123') {
+ if (page.url().startsWith('http')) await page.evaluate(() => {
+ localStorage.removeItem('battery_detection_auth_token')
+ localStorage.removeItem('battery_detection_auth_user')
+ })
+ await page.goto('/login')
+ await page.locator('input[autocomplete="username"]').fill(username)
+ await page.locator('input[autocomplete="current-password"]').fill(password)
+ await page.getByRole('button', { name: '登录', exact: true }).click()
+ await expect(page).toHaveURL(/\/dashboard$/)
+}
+
+async function api(page: Page, path: string, method = 'GET', data?: unknown) {
+ const token = await page.evaluate(() => localStorage.getItem('battery_detection_auth_token'))
+ const response = await page.request.fetch(`${stack.backendUrl}${path}`, {
+ method, data, headers: { Authorization: `Bearer ${token}` },
+ })
+ const body = await response.json()
+ expect(body.code, `${path}: ${body.msg}`).toBe(200)
+ return body
+}
+
+test('对比列表来自正式后端且不导入本地种子报表', async ({ page }) => {
+ await login(page)
+ const result = await api(page, '/system/compare-report/list')
+ expect(result.rows).toEqual([])
+ await page.evaluate(() => localStorage.setItem('battery_detection_compare_reports_v1', JSON.stringify([
+ { id: 9000, name: '不得导入的本地报表', creator: 'admin', createdAt: '2026-09-06' },
+ ])))
+ await page.goto('/report-compare')
+ await expect(page.locator('.empty-cell')).toContainText('暂无符合条件')
+ await expect(page.locator('body')).not.toContainText('不得导入的本地报表')
+})
+
+function seedSources() {
+ stack.executeIsolatedSql(`
+ INSERT INTO t_battery_group(group_id,group_name,model) VALUES(8101,'对比验收电池组','验收型号');
+ INSERT INTO t_battery_group_field(field_id,field_name,field_type) VALUES(8101,'额定容量','number');
+ INSERT INTO t_battery_group_relation_field(group_id,battery_group_field_id,field_value) VALUES(8101,8101,'12.5');
+ INSERT INTO t_test_type(test_type_id,test_type_name) VALUES(8101,'对比验收类型');
+ INSERT INTO t_test(test_id,test_name,creator_id,principal_id,test_type_id,test_purpose,test_status,reception_status,logic_step_mappings_json)
+ VALUES(8101,'对比来源测试',1,1,8101,'隔离受控数据','COMPLETED','ENDED','[{"logicalNo":1,"logicalName":"合并充放电","startStepNo":1,"endStepNo":2}]'),
+ (8102,'未完成来源',1,1,8101,'隔离受控数据','WAITING','NOT_STARTED',NULL);
+ INSERT INTO t_test_battery(test_battery_id,test_id,group_id,battery_no,collector_id) VALUES(8101,8101,8101,'BAT-A',8101);
+ INSERT INTO t_detection_cabinet(cabinet_id,adapter_code,cabinet_code,cabinet_name,vendor_device_type,vendor_device_id)
+ VALUES(8101,'compare-fixture','compare-fixture','验收检测柜',27,1);
+ INSERT INTO t_detection_channel(channel_id,adapter_code,cabinet_id,devtype,devid,subdevid,chlid) VALUES(8101,'compare-fixture',8101,27,1,1,1);
+ INSERT INTO t_test_channel_binding(binding_id,test_id,test_battery_id,channel_id) VALUES(8101,8101,8101,8101);
+ INSERT INTO t_test_realtime_record(test_id,test_battery_id,binding_id,adapter_code,source_identity,source_record_id,cabinet_devtype,cabinet_devid,cabinet_subdevid,cabinet_chlid,cabinet_sequence_id,cabinet_device_time_utc,collector_device_time_utc,server_received_at_utc,cycle_no,step_no,step_name,voltage_v,current_a,temperature,positive_potential,negative_potential,content_sha256)
+ VALUES(8101,8101,8101,'compare-fixture','compare-fixture: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,-8.1651,0,0.1,-0.2,UNHEX(SHA2('1',256))),
+ (8101,8101,8101,'compare-fixture','compare-fixture:27:1:1:1','2',27,1,1,1,2,'2026-09-01 00:01:40.123','2026-09-01 00:01:30.123','2026-09-01 00:01:41.123',1,1,'充电',3.8,NULL,NULL,0.2,-0.3,UNHEX(SHA2('2',256))),
+ (8101,8101,8101,'compare-fixture','compare-fixture: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.2,25,0.3,-0.4,UNHEX(SHA2('3',256))),
+ (8101,8101,8101,'compare-fixture','compare-fixture:27:1:1:1','4',27,1,1,1,4,'2026-09-01 01:00:10.123','2026-09-01 01:00:00.123','2026-09-01 01:00:11.123',2,1,'充电',3.9,-2,26,0.4,-0.5,UNHEX(SHA2('4',256)));
+ `)
+}
+
+test('首次保存冻结真实来源,失败回滚且重启与删除来源后仍可重开', async ({ page }) => {
+ seedSources()
+ await login(page)
+ const sources = (await api(page, '/system/compare-report/sources')).data
+ expect(sources.some((item: any) => item.selection.testId === 8102)).toBe(false)
+ const selections = [
+ { testId: 8101, testBatteryId: 8101, cycleNo: 1, stepNo: 1, logicalNo: null },
+ { testId: 8101, testBatteryId: 8101, cycleNo: 1, stepNo: null, logicalNo: 1 },
+ { testId: 8101, testBatteryId: 8101, cycleNo: 1, stepNo: 2, logicalNo: null },
+ { testId: 8101, testBatteryId: 8101, cycleNo: 2, stepNo: 1, logicalNo: null },
+ ]
+ const payload = {
+ name: '首版独立快照', description: '保存后保留说明', experimentName: '独立版本实验', selections,
+ settings: { metrics: ['voltage', 'current', 'temperature'], seriesColors: ['#123456', '#3978f6', '#f08a3e', '#109854'],
+ batteryTemplateFields: ['额定容量'], specialFields: ['人工备注'], detailFieldValues: { 人工备注: { '8101|8101|1|1|': '人工值' } } },
+ items: [{ records: [{ voltage: 9999 }] }],
+ }
+ const saved = (await api(page, '/system/compare-report', 'POST', payload)).data
+ expect(saved.versionNo).toBe(1)
+ expect(saved.items).toHaveLength(4)
+ expect(saved.items[0].zeroTime).toBe(Date.parse('2026-09-01T00:00:00.123Z'))
+ expect(saved.items[1].records).toHaveLength(3)
+ expect(saved.items[0].records.find((row: any) => row.recordNo === '1').current).toBe(-8.1651)
+ expect(saved.items[0].businessFields[0].fieldValue).toBe('12.5')
+ expect(saved.settings).toEqual(payload.settings)
+ expect((await api(page, `/system/compare-report/${saved.id}`)).data).toEqual(saved)
+ expect(stack.queryDatabase('SELECT COUNT(*) FROM t_compare_report_version')).toBe('1')
+
+ const token = await page.evaluate(() => localStorage.getItem('battery_detection_auth_token'))
+ async function rejectSave(data: unknown) {
+ const response = await page.request.post(`${stack.backendUrl}/system/compare-report`, { data, headers: { Authorization: `Bearer ${token}` } })
+ expect((await response.json()).code).not.toBe(200)
+ expect((await api(page, '/system/compare-report/list')).rows).toHaveLength(1)
+ }
+ await rejectSave({ ...payload, selections: [selections[0], { ...selections[1], testId: 8102 }] })
+ await rejectSave({ ...payload, selections: [selections[0], { ...selections[1], testBatteryId: 999 }] })
+ // 在版本插入时拒绝,证明此前报表身份插入也随业务事务回滚。
+ stack.executeIsolatedSql('ALTER TABLE t_compare_report_version ADD CONSTRAINT compare_failure CHECK (report_id = 1)')
+ await rejectSave({ ...payload, name: '数据库写入失败' })
+ stack.executeIsolatedSql('ALTER TABLE t_compare_report_version DROP CONSTRAINT compare_failure')
+ expect(stack.queryDatabase('SELECT COUNT(*) FROM t_compare_report')).toBe('1')
+
+ await page.goto('/report-compare/new')
+ await page.getByPlaceholder('请输入报表名称').fill('浏览器保存报表')
+ await page.getByPlaceholder('请输入说明').fill('浏览器说明')
+ for (const process of ['1-充电', '逻辑工步 1-合并充放电', '2-放电']) {
+ await page.getByRole('button', { name: '添加比较', exact: true }).click()
+ const dialog = page.getByRole('dialog', { name: '添加对比' })
+ async function select(label: string, value: string) {
+ const control = dialog.locator('label').filter({ hasText: label }).locator('.el-select')
+ await control.locator('.el-select__wrapper').click()
+ const listId = await control.getByRole('combobox').getAttribute('aria-controls')
+ await page.locator(`#${listId}`).getByRole('option', { name: value, exact: true }).click()
+ }
+ await select('选择测试', '对比来源测试(8101)')
+ await select('选择循环', '1')
+ await select('选择工步', process)
+ await dialog.getByRole('button', { name: '添加', exact: true }).click()
+ await expect(page.getByRole('button', { name: '添加比较', exact: true })).toBeEnabled()
+ }
+ await page.locator('.metric-switches label').filter({ hasText: '正电位' }).locator('.el-switch__core').click()
+ await page.route('**/dev-api/system/compare-report', 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('浏览器保存报表')
+ expect((await api(page, '/system/compare-report/list')).rows).toHaveLength(1)
+ const createResponse = page.waitForResponse(response => response.url().endsWith('/system/compare-report') && response.request().method() === 'POST')
+ await page.getByRole('button', { name: '保存报表', exact: true }).click()
+ const browserSaved = (await (await createResponse).json()).data
+ await expect(page).toHaveURL(/\/report-compare$/)
+ expect(browserSaved.items).toHaveLength(3)
+ expect(browserSaved.settings.metrics).not.toContain('positiveVoltage')
+ expect(browserSaved.description).toBe('浏览器说明')
+
+ await api(page, '/system/test/8101/console-data/actions', 'POST', {
+ dataType: 'REALTIME', operationType: 'EDIT', scopeSummary: '首行电流修订', reason: '验证报表独立',
+ identitySummary: JSON.stringify([saved.items[0].records[0].stableIdentity]),
+ overrides: [{ stableIdentity: saved.items[0].records[0].stableIdentity, fieldValues: JSON.stringify({ current: 66 }) }],
+ })
+ await api(page, '/system/test/8101/logic-step-mappings', 'PUT', [])
+ stack.executeIsolatedSql("UPDATE t_battery_group_relation_field SET field_value='99' WHERE group_id=8101")
+ expect((await api(page, `/system/compare-report/${saved.id}`)).data).toEqual(saved)
+ await api(page, '/system/test/8101', 'DELETE')
+ expect(stack.queryDatabase('SELECT COUNT(*) FROM t_test WHERE test_id=8101')).toBe('0')
+ expect(stack.queryDatabase('SELECT COUNT(*) FROM t_test_data_revision_action WHERE test_id=8101')).toBe('0')
+ expect((await api(page, `/system/compare-report/${saved.id}`)).data).toEqual(saved)
+ await stack.restartBackend()
+ await login(page)
+ expect((await api(page, `/system/compare-report/${saved.id}`)).data).toEqual(saved)
+ await page.goto('/report-compare')
+ await page.getByPlaceholder('输入报告名称或ID').fill('首版独立快照')
+ await page.getByRole('button', { name: '查询', exact: true }).click()
+ await page.getByRole('button', { name: '查看报表', exact: true }).click()
+ await expect(page.getByPlaceholder('请输入报表名称')).toHaveValue('首版独立快照')
+ await expect(page.locator('.compare-table tbody tr')).toHaveCount(4)
+ await expect(page.locator('.metric-chart canvas')).toHaveCount(3)
+ await page.reload()
+ await expect(page.getByPlaceholder('请输入报表名称')).toHaveValue('首版独立快照')
+ await expect(page.getByPlaceholder('请输入说明')).toHaveValue('保存后保留说明')
+ await expect(page.getByPlaceholder('请输入报表名称')).toHaveAttribute('readonly')
+ for (const width of [1440, 390]) {
+ await page.setViewportSize({ width, height: 900 })
+ await page.screenshot({ path: `tests/e2e/.artifacts/compare-reopen-${width}.png`, fullPage: true })
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
+ const coloredPixels = await page.locator('.metric-chart canvas').first().evaluate((canvas: HTMLCanvasElement) => {
+ const pixels = canvas.getContext('2d')!.getImageData(0, 0, canvas.width, canvas.height).data
+ let count = 0
+ for (let index = 0; index < pixels.length; index += 4)
+ if (pixels[index + 3] > 100 && Math.abs(pixels[index] - pixels[index + 2]) > 50) count++
+ return count
+ })
+ expect(coloredPixels).toBeGreaterThan(20)
+ }
+
+ const menus = (await api(page, '/system/menu/permissionList')).data
+ const comparePage = menus.flatMap((module: any) => module.pages).find((item: any) => item.pageName === '对比报表')
+ expect(comparePage).toBeTruthy()
+ for (const [name, action] of [['compare_reader', '查看'], ['compare_author', '新增'], ['compare_denied', '']]) {
+ const menuIds = comparePage.permissions.filter((item: any) => item.menuName === action).map((item: any) => item.menuId)
+ await api(page, '/system/role', 'POST', { roleName: name, roleKey: 'common', roleSort: 1, status: '0', menuIds })
+ const role = (await api(page, `/system/role/list?roleName=${name}`)).rows[0]
+ await api(page, '/system/user', 'POST', { userName: name, nickName: name, password: 'Report123!', phonenumber: '', status: '0', roleIds: [role.roleId] })
+ }
+ await login(page, 'compare_reader', 'Report123!')
+ expect((await api(page, `/system/compare-report/${saved.id}`)).data).toEqual(saved)
+ await page.goto(`/report-compare/${saved.id}/edit?mode=view`)
+ await expect(page.getByPlaceholder('请输入报表名称')).toHaveValue('首版独立快照')
+ await page.goto('/report-compare')
+ await expect(page.getByRole('button', { name: '新建报表' })).toHaveCount(0)
+ for (const username of ['compare_reader', 'compare_author', 'compare_denied']) {
+ await login(page, username, 'Report123!')
+ const userToken = await page.evaluate(() => localStorage.getItem('battery_detection_auth_token'))
+ const checks = username === 'compare_reader' ? [['POST', '', payload], ['GET', '/sources', undefined]]
+ : username === 'compare_author' ? [['GET', '/list', undefined], ['GET', `/${saved.id}`, undefined]]
+ : [['GET', '/list', undefined], ['GET', `/${saved.id}`, undefined], ['POST', '', payload]]
+ for (const [method, suffix, data] of checks) {
+ const response = await page.request.fetch(`${stack.backendUrl}/system/compare-report${suffix}`, {
+ method: method as string, data, headers: { Authorization: `Bearer ${userToken}` },
+ })
+ expect((await response.json()).code).toBe(403)
+ }
+ if (username === 'compare_author') expect((await api(page, '/system/compare-report/sources')).data).toEqual([])
+ }
+})
diff --git a/tests/e2e/support/ticket04-stack.ts b/tests/e2e/support/ticket04-stack.ts
index 88d685c..d5ec5bf 100644
--- a/tests/e2e/support/ticket04-stack.ts
+++ b/tests/e2e/support/ticket04-stack.ts
@@ -126,6 +126,12 @@
'--database=battery_detection', '--batch', '--skip-column-names', '-e', sql]).stdout.trim()
}
+ // 仅操作本次启动的临时容器,用于受控验收数据及事务故障注入。
+ executeIsolatedSql(sql: string) {
+ return this.dockerRun(['exec', this.mysqlContainer, 'mysql', '-uroot', `-p${mysqlPassword}`,
+ '--database=battery_detection', '--default-character-set=utf8mb4', '-e', sql]).stdout
+ }
+
redisCommand(...args: string[]) {
const result = this.dockerRun([
'exec', this.redisContainer, 'redis-cli', '-a', redisPassword, '--no-auth-warning', '--raw', ...args,
--
Gitblit v1.7.1