| | |
| | | import { request, type ApiResponse } from './request' |
| | | export interface SpliceSelection { testId: number; testBatteryId: number } |
| | | export interface SpliceCandidate { selection: SpliceSelection; name: string; type: string; groupId: number; group: string; batteryNo: string; startedAt: number } |
| | | export interface SpliceMatrix { segments: Array<Record<string, unknown>>; cycleNos: number[] } |
| | | export interface SpliceSegment { source: SpliceCandidate; offset: number; originalCycleNos: number[]; statistics: Record<string, Record<string, number | null>> } |
| | | export interface SpliceMatrix { segments: SpliceSegment[]; cycleNos: number[] } |
| | | export interface SpliceSettings { batteryFields?: string[]; specialFields?: string[]; detailFieldValues?: Record<string, Record<string, string>>; metrics?: string[]; seriesColors?: string[] } |
| | | export interface SpliceSummary { id: number; name: string; creator: string; createdAt: string } |
| | | export interface SavedSpliceReport extends SpliceSummary { description?: string; selections: SpliceSelection[]; segments: Array<Record<string, unknown>>; cycleNos: number[]; settings: Record<string, unknown>; versionNo: number; operatorName: string; versionCreatedAt: number; changeSummary: string } |
| | | export interface SavedSpliceReport extends SpliceSummary { description?: string; selections: SpliceSelection[]; segments: SpliceSegment[]; cycleNos: number[]; settings: SpliceSettings; versionNo: number; operatorName: string; versionCreatedAt: number; changeSummary: string } |
| | | const root='/system/splice-report' |
| | | async function data<T>(path:string, method='GET', body?:unknown){return (await request<ApiResponse & {data:T}>(`${root}${path}`,{method,...(body===undefined?{}:{body:JSON.stringify(body)})})).data} |
| | | export function listSpliceReports(){return request<ApiResponse & {rows:SpliceSummary[]}>(`${root}/list`).then(r=>r.rows)} |
| | |
| | | export function previewSpliceReport(s:SpliceSelection[]){return data<SpliceMatrix>('/preview','POST',s)} |
| | | export function createSpliceReport(body:Record<string, unknown>){return data<SavedSpliceReport>('','POST',body)} |
| | | export function getSpliceReport(id:number, editing=false){return data<SavedSpliceReport>(`/${id}${editing?'/editing':''}`)} |
| | | export function getSpliceVersion(id:number,versionNo:number){return data<SavedSpliceReport>(`/${id}/versions/${versionNo}`)} |
| | | export function getSplicePrintVersion(id:number,versionNo:number){return data<SavedSpliceReport>(`/${id}/versions/${versionNo}/print`)} |
| | | export function editSpliceReport(id:number,body:Record<string, unknown>){return data<SavedSpliceReport>(`/${id}/versions`,'POST',body)} |
| | | export function deleteSpliceReport(id:number){return data<void>(`/${id}`,'DELETE')} |
| | | export function listSpliceVersions(id:number){return data<Array<{versionNo:number;operatorName:string;createdAt:number;changeSummary:string}>>(`/${id}/versions`)} |
| New file |
| | |
| | | <script setup lang="ts"> |
| | | import { computed } from 'vue' |
| | | import type { SavedSpliceReport } from '@/api/spliceReports' |
| | | import { spliceSavedReportBody } from '@/utils/spliceSavedReport' |
| | | import '@/utils/spliceSavedReport.css' |
| | | const props = defineProps<{ report: SavedSpliceReport }>() |
| | | const content = computed(() => spliceSavedReportBody(props.report)) |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- 动态文本统一转义,图表仅由冻结数值和校验后的颜色生成。 --> |
| | | <div v-html="content"></div> |
| | | </template> |
| New file |
| | |
| | | .splice-snapshot{width:100%;min-width:0;background:#fff;padding:16px;color:#26333f;font:12px "Microsoft YaHei",sans-serif;letter-spacing:0;print-color-adjust:exact;-webkit-print-color-adjust:exact} |
| | | .splice-snapshot *{box-sizing:border-box;letter-spacing:0} |
| | | .splice-snapshot header{border-bottom:2px solid #2b78a3;margin-bottom:16px;break-inside:avoid} |
| | | .splice-snapshot h1{font-size:22px;margin:0 0 10px;overflow-wrap:anywhere} |
| | | .splice-snapshot h2{font-size:14px;padding:10px;background:#edf3f7;margin:16px 0 8px;break-after:avoid} |
| | | .splice-snapshot h3{font-size:13px;margin:0;padding:10px;overflow-wrap:anywhere} |
| | | .splice-snapshot p,.splice-snapshot .splice-note{white-space:pre-wrap;overflow-wrap:anywhere} |
| | | .splice-snapshot table{width:100%;border-collapse:collapse;table-layout:fixed;margin:0 0 16px;font-size:11px} |
| | | .splice-snapshot th,.splice-snapshot td{border:1px solid #cbd8e4;padding:7px;text-align:center;overflow-wrap:anywhere;white-space:pre-wrap} |
| | | .splice-snapshot th{background:#eaf2f8;color:#26333f} |
| | | .splice-snapshot thead{display:table-header-group} |
| | | .splice-snapshot tr{break-inside:avoid} |
| | | .splice-snapshot .splice-chart{border:1px solid #d8e1e9;margin-bottom:12px;break-inside:avoid} |
| | | .splice-snapshot svg{display:block;width:100%;height:auto} |
| | | .splice-snapshot .splice-legend{display:flex;flex-wrap:wrap;gap:8px 18px;padding:8px 12px} |
| | | .splice-snapshot .splice-legend span{display:flex;gap:6px;align-items:flex-start;overflow-wrap:anywhere;max-width:100%;break-inside:avoid} |
| | | .splice-snapshot .splice-legend i{flex:0 0 12px;height:12px;margin-top:2px} |
| | | @media(max-width:680px){.splice-snapshot{padding:8px}.splice-snapshot th,.splice-snapshot td{padding:5px 3px}} |
| | | @media print{.splice-snapshot{padding:0;font-size:11px}} |
| New file |
| | |
| | | import { init, use } from 'echarts/core' |
| | | import { LineChart } from 'echarts/charts' |
| | | import { GridComponent, MarkLineComponent } from 'echarts/components' |
| | | import { SVGRenderer } from 'echarts/renderers' |
| | | import { getSplicePrintVersion, type SavedSpliceReport } from '@/api/spliceReports' |
| | | import { openSavedReportPrint } from './savedReportPrint' |
| | | import css from './spliceSavedReport.css?inline' |
| | | |
| | | use([LineChart, GridComponent, MarkLineComponent, SVGRenderer]) |
| | | const metrics = [ |
| | | { key: 'finalVoltage1', title: '最终电压1', unit: 'V' }, |
| | | { key: 'energyEfficiency', title: '能量效率', unit: '%' }, |
| | | ] |
| | | function escapeHtml(value: unknown) { |
| | | const entities: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' } |
| | | return String(value ?? '').replace(/[&<>"']/g, character => entities[character]!) |
| | | } |
| | | const cell = (value: unknown) => escapeHtml(value == null || value === '' ? '缺失' : value) |
| | | const numeric = (value: unknown) => typeof value === 'number' && Number.isFinite(value) ? value : null |
| | | const display = (value: unknown) => { const number = numeric(value); return number === null ? '缺失' : String(Number(number.toFixed(4))) } |
| | | |
| | | /** 重开与打印共用冻结统计及人工字段,绝不重算或读取来源测试。 */ |
| | | export function spliceSavedReportBody(record: SavedSpliceReport) { |
| | | const sources = [...new Set(record.segments.map(segment => segment.source.selection.testId))] |
| | | const batteries = [...new Set(record.segments.map(segment => segment.source.batteryNo))] |
| | | const color = (battery: string) => { |
| | | const index = batteries.indexOf(battery) |
| | | const saved = record.settings.seriesColors?.[index] |
| | | return saved && /^#[\da-f]{6}$/i.test(saved) ? saved : ['#3978f6', '#109854', '#d25b45', '#7754a8'][index % 4]! |
| | | } |
| | | const label = (testId: number) => `来源${sources.indexOf(testId) + 1}` |
| | | const fields = [...(record.settings.batteryFields ?? []), ...(record.settings.specialFields ?? [])] |
| | | // 早期版本的五类实时显隐键不适用于循环统计,不能据此补造测量曲线。 |
| | | const savedMetrics = record.settings.metrics |
| | | const visibleMetrics = savedMetrics?.some(key => metrics.some(metric => metric.key === key)) || savedMetrics?.length === 0 |
| | | ? metrics.filter(metric => savedMetrics!.includes(metric.key)) : metrics |
| | | const sourceRows = record.segments.map(segment => `<tr><td>${label(segment.source.selection.testId)}</td><td>${cell(segment.source.name)}</td><td>${cell(segment.source.group)}</td><td>${cell(segment.source.batteryNo)}</td><td>${segment.originalCycleNos.map(no => `${no} → ${segment.offset + no}`).join('、') || '无已保存循环'}</td></tr>`).join('') |
| | | const fieldRows = batteries.flatMap(battery => fields.map(field => `<tr><td>${cell(battery)}</td><td>${cell(field)}</td><td>${cell(record.settings.detailFieldValues?.[field]?.[battery])}</td></tr>`)).join('') |
| | | const statistics = sources.map(testId => { |
| | | const segments = record.segments.filter(segment => segment.source.selection.testId === testId) |
| | | const rows = segments.flatMap(segment => segment.originalCycleNos.map(no => { |
| | | const stat = segment.statistics[String(no)] ?? {} |
| | | return `<tr><td>${cell(segment.source.batteryNo)}</td><td>${no}</td><td>${segment.offset + no}</td>${metrics.map(metric => `<td>${display(stat[metric.key])}</td>`).join('')}</tr>` |
| | | })).join('') |
| | | const name = `${label(testId)} · ${segments[0]!.source.name}` |
| | | return `<section class="splice-statistics"><h2>${cell(name)}</h2><table><thead><tr><th colspan="5">${cell(name)}</th></tr><tr><th>电池号</th><th>原循环号</th><th>拼接循环号</th><th>最终电压1 (V)</th><th>能量效率 (%)</th></tr></thead><tbody>${rows || '<tr><td colspan="5">无已保存循环</td></tr>'}</tbody></table></section>` |
| | | }).join('') |
| | | const cycles = [...new Set(record.segments.flatMap(segment => segment.originalCycleNos.map(no => segment.offset + no)))].sort((a, b) => a - b) |
| | | const charts: string[] = [] |
| | | // 长分段按实际统计点分图,多电池按四条分组;不补缺失循环,所有分组均保留来源标识。 |
| | | for (let start = 0; start < cycles.length; start += 40) { |
| | | const range = cycles.slice(start, start + 40) |
| | | for (let batteryStart = 0; batteryStart < batteries.length; batteryStart += 4) { |
| | | const group = batteries.slice(batteryStart, batteryStart + 4) |
| | | const segments = record.segments.filter(segment => group.includes(segment.source.batteryNo) && segment.originalCycleNos.some(no => range.includes(segment.offset + no))) |
| | | const boundaries = [...new Map(segments.map(segment => [segment.source.selection.testId, { name: label(segment.source.selection.testId), xAxis: Math.max(range[0]!, segment.offset + segment.originalCycleNos[0]!) }])).values()] |
| | | const legend = group.map(battery => `<span><i style="background:${color(battery)}"></i>${cell(battery)}</span>`).join('') |
| | | const sourceLegend = [...new Set(segments.map(segment => `${label(segment.source.selection.testId)} · ${segment.source.name}`))].map(name => `<span>${cell(name)}</span>`).join('') |
| | | for (const metric of visibleMetrics) { |
| | | const chart = init(null, undefined, { renderer: 'svg', ssr: true, width: 900, height: 300 }) |
| | | let svg: string |
| | | try { |
| | | chart.setOption({ animation: false, grid: { top: 42, right: 28, bottom: 45, left: 72 }, |
| | | xAxis: { type: 'value', min: range[0]! - 0.5, max: range.at(-1)! + 0.5, minInterval: 1, name: '拼接循环号', nameLocation: 'middle', nameGap: 28, axisLabel: { hideOverlap: true, formatter: (value: number) => Number.isInteger(value) ? String(value) : '' } }, |
| | | yAxis: { type: 'value', scale: true, name: metric.unit }, |
| | | series: group.map((battery, index) => ({ name: battery, type: 'line', connectNulls: false, showSymbol: true, symbolSize: 5, itemStyle: { color: color(battery) }, |
| | | data: segments.filter(segment => segment.source.batteryNo === battery).flatMap(segment => segment.originalCycleNos.filter(no => range.includes(segment.offset + no)).map(no => [segment.offset + no, numeric(segment.statistics[String(no)]?.[metric.key])])), |
| | | markLine: index === 0 ? { silent: true, symbol: 'none', lineStyle: { color: '#82909b', type: 'dashed' }, label: { formatter: '{b}', position: 'end' }, data: boundaries } : undefined, |
| | | })), |
| | | }) |
| | | svg = chart.renderToSVGString() |
| | | } finally { chart.dispose() } |
| | | charts.push(`<article class="splice-chart"><h3>${metric.title} (${metric.unit}) · 拼接循环 ${range[0]}–${range.at(-1)}</h3>${svg}<div class="splice-legend">${legend}</div><div class="splice-legend">${sourceLegend}</div></article>`) |
| | | } |
| | | } |
| | | } |
| | | const date = (value: unknown) => escapeHtml(new Date(Number(value)).toLocaleString()) |
| | | return `<div class="splice-snapshot"><header><h1>${cell(record.name)}</h1><p>拼接报表 #${record.id} · 版本 V${record.versionNo}</p><p>创建人 ${cell(record.creator)} · 创建时间 ${date(record.createdAt)}</p><p>版本操作人 ${cell(record.operatorName)} · 版本时间 ${date(record.versionCreatedAt)}</p><p>修改说明 ${cell(record.changeSummary)}</p></header><section><h2>来源测试顺序与循环对应</h2><table><thead><tr><th>顺序</th><th>来源测试</th><th>电池组</th><th>参与电池号</th><th>原循环号 → 拼接循环号</th></tr></thead><tbody>${sourceRows}</tbody></table></section><section><h2>报表字段</h2>${fieldRows ? `<table><thead><tr><th>电池号</th><th>字段</th><th>保存值</th></tr></thead><tbody>${fieldRows}</tbody></table>` : '<p>无已保存字段</p>'}</section><section><h2>统计图表</h2>${charts.join('') || `<p>${cycles.length ? '当前未启用统计图指标' : '无已保存循环统计'}</p>`}</section>${statistics}<section><h2>报表说明</h2><div class="splice-note">${cell(record.description)}</div></section></div>` |
| | | } |
| | | |
| | | /** 先打开窗口,再使用独立导出权限读取指定版本;页面草稿不进入打印内容。 */ |
| | | export function openSpliceSavedReportPrint(id: number, versionNo: number) { |
| | | return openSavedReportPrint(() => getSplicePrintVersion(id, versionNo), record => `<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><title>${escapeHtml(record.name)} - V${record.versionNo}</title><style>@page{size:A4 landscape;margin:10mm}body{margin:0;padding:16px;background:white}@media print{body{padding:0}}${css}</style></head><body>${spliceSavedReportBody(record)}</body></html>`) |
| | | } |
| | |
| | | import { GridComponent, LegendComponent, MarkLineComponent, TooltipComponent } from 'echarts/components' |
| | | import VChart from 'vue-echarts' |
| | | import { getCurrentUser } from '@/utils/auth' |
| | | import { useBatteryGroupFields } from '@/stores/batteryGroupFields' |
| | | import { getBatteryGroupFields, type BatteryGroupField } from '@/api/batteryGroupFields' |
| | | import { useSpliceReports, type SpliceReportPayload, type SpliceTestSelection } from '@/stores/spliceReports' |
| | | import { getSpliceSources } from '@/api/spliceReports' |
| | | import { getSpliceSources, getSpliceReport, getSpliceVersion, listSpliceVersions, type SavedSpliceReport } from '@/api/spliceReports' |
| | | import { getUserInfo } from '@/api/auth' |
| | | import { openSpliceSavedReportPrint } from '@/utils/spliceSavedReport' |
| | | import SpliceSavedReport from '@/components/SpliceSavedReport.vue' |
| | | import { formationFixedReportFields, formationMetricDefinitions, type FormationMetricDefinition, type FormationMetricKey } from '@/utils/formationReportPrint' |
| | | |
| | | use([CanvasRenderer, LineChart, GridComponent, LegendComponent, MarkLineComponent, TooltipComponent]) |
| | |
| | | const route = useRoute() |
| | | const router = useRouter() |
| | | const currentUser = getCurrentUser() |
| | | const { records, load: loadReports, getReport, createReport, updateReport } = useSpliceReports() |
| | | const { fields: managedGroupFields } = useBatteryGroupFields() |
| | | const { createReport, updateReport } = useSpliceReports() |
| | | const managedGroupFields = ref<BatteryGroupField[]>([]) |
| | | 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 readonly = computed(() => route.query.mode === 'view' || Boolean(route.query.version)) |
| | | const savedReport = ref<SavedSpliceReport | null>(null) |
| | | const savedContent = ref('') |
| | | const loading = ref(false) |
| | | const loadError = ref('') |
| | | const printing = ref(false) |
| | | const canExport = ref(false) |
| | | const history = ref<Awaited<ReturnType<typeof listSpliceVersions>>>([]) |
| | | const historyVisible = ref(false) |
| | | const statisticMetrics = [{ key: 'finalVoltage1', title: '最终电压1' }, { key: 'energyEfficiency', title: '能量效率' }] as const |
| | | const statisticVisible = reactive({ finalVoltage1: true, energyEfficiency: true }) |
| | | const statisticSettings = computed(() => ({ |
| | | ...savedReport.value?.settings, |
| | | batteryFields: batteryFields.value, specialFields: specialFields.value, detailFieldValues, |
| | | metrics: statisticMetrics.filter(metric => statisticVisible[metric.key]).map(metric => metric.key), |
| | | seriesColors: seriesColors.value, |
| | | })) |
| | | const statisticPreview = computed(() => savedReport.value ? { ...savedReport.value, name: reportName.value, description: experimentName.value, settings: statisticSettings.value } : null) |
| | | |
| | | function formContent() { |
| | | return JSON.stringify([reportName.value, experimentName.value, selectedTests.value, batteryFields.value, specialFields.value, detailFieldValues, metricVisible, statisticVisible, seriesColors.value]) |
| | | } |
| | | async function printReport() { |
| | | if (!savedReport.value) { ElMessage.warning('请先保存报表后再打印'); return } |
| | | if (formContent() !== savedContent.value) { ElMessage.warning('有未保存修改,请先保存新版本后再打印'); return } |
| | | printing.value = true |
| | | try { await openSpliceSavedReportPrint(savedReport.value.id, savedReport.value.versionNo) } |
| | | catch (error) { ElMessage.error(error instanceof Error ? error.message : '打印失败,请重试') } |
| | | finally { printing.value = false } |
| | | } |
| | | async function openHistory() { |
| | | if (!editingId.value) return |
| | | if (formContent() !== savedContent.value) { ElMessage.warning('有未保存修改,请先保存新版本后再查看历史'); return } |
| | | try { history.value = await listSpliceVersions(editingId.value); historyVisible.value = true } |
| | | catch (error) { ElMessage.error(error instanceof Error ? error.message : '历史版本加载失败') } |
| | | } |
| | | async function openVersion(version: number) { |
| | | historyVisible.value = false |
| | | await router.push({ path: route.path, query: { mode: 'view', version } }) |
| | | } |
| | | |
| | | const reportName = ref('') |
| | | const experimentName = ref('SP2连续工况实验') |
| | |
| | | { id: 105, name: '恢复容量测试', type: '容量', durationMinutes: 70 }, |
| | | { id: 106, name: '循环后静置测试', type: '静置', durationMinutes: 45 }, |
| | | ]) |
| | | onMounted(async () => { |
| | | const [sources] = await Promise.all([getSpliceSources(), loadReports()]) |
| | | candidateTests.value = sources.map(source => ({ id: source.selection.testId, testBatteryId: source.selection.testBatteryId, name: source.name, type: source.type, durationMinutes: 0 })) as SpliceTestSelection[] |
| | | async function loadPage() { |
| | | loading.value = true |
| | | loadError.value = '' |
| | | savedReport.value = null |
| | | try { |
| | | const info = await getUserInfo() |
| | | canExport.value = Boolean(info.permissions?.some(permission => permission === '*:*:*' || permission === 'system:splice-report:export')) |
| | | if (editingId.value) { |
| | | const saved = await getReport(editingId.value) |
| | | const version = route.query.version === undefined ? undefined : Number(route.query.version) |
| | | if (version !== undefined && (!Number.isInteger(version) || version < 1)) throw new Error('报表版本号无效') |
| | | const saved = version === undefined ? await getSpliceReport(editingId.value, !readonly.value) : await getSpliceVersion(editingId.value, version) |
| | | savedReport.value = saved |
| | | reportName.value = saved.name |
| | | experimentName.value = saved.description || '' |
| | | batteryGroup.value = saved.segments[0]?.source.group || '' |
| | | samples.value = [...new Set(saved.segments.map(segment => segment.source.batteryNo))] |
| | | const settings = saved.settings || {} |
| | | statisticMetrics.forEach(metric => { |
| | | statisticVisible[metric.key] = settings.metrics?.length === 0 || settings.metrics?.some(key => statisticMetrics.some(item => item.key === key)) |
| | | ? settings.metrics.includes(metric.key) : true |
| | | }) |
| | | selectedTests.value = saved.selections.map(selection => { |
| | | const candidate = candidateTests.value.find(item => item.id === selection.testId) |
| | | return { id: selection.testId, testBatteryId: selection.testBatteryId, name: candidate?.name || `测试${selection.testId}`, type: candidate?.type || '未知', durationMinutes: candidate?.durationMinutes || 0 } |
| | | const source = saved.segments.find(item => item.source.selection.testBatteryId === selection.testBatteryId)?.source |
| | | return { id: selection.testId, testBatteryId: selection.testBatteryId, name: source?.name || `测试${selection.testId}`, type: source?.type || '未知', durationMinutes: 0 } |
| | | }) |
| | | const savedSettings = settings as Record<string, unknown> |
| | | batteryFields.value = Array.isArray(savedSettings.batteryFields) ? [...savedSettings.batteryFields as string[]] : batteryFields.value |
| | |
| | | formationMetricDefinitions.forEach(metric => { metricVisible[metric.key] = Array.isArray(savedSettings.metrics) ? (savedSettings.metrics as string[]).includes(metric.key) : true }) |
| | | if (Array.isArray(savedSettings.seriesColors)) seriesColors.value = [...savedSettings.seriesColors as string[]] |
| | | dynamicDetailFields.value.forEach(ensureDetailField) |
| | | } else if (candidateTests.value.length) selectedTests.value = [candidateTests.value[0]] |
| | | }) |
| | | } else { |
| | | const sources = await getSpliceSources() |
| | | candidateTests.value = sources.map(source => ({ id: source.selection.testId, testBatteryId: source.selection.testBatteryId, name: source.name, type: source.type, durationMinutes: 0 })) |
| | | selectedTests.value = candidateTests.value.length ? [candidateTests.value[0]!] : [] |
| | | } |
| | | savedContent.value = formContent() |
| | | } catch (error) { loadError.value = error instanceof Error ? error.message : '拼接报表加载失败' } |
| | | finally { loading.value = false } |
| | | } |
| | | onMounted(loadPage) |
| | | watch(() => route.fullPath, loadPage) |
| | | |
| | | const batteryFields = ref(['额定容量', '内阻']) |
| | | const specialFields = ref(['环境温度']) |
| | |
| | | dynamicDetailFields.value.forEach(ensureDetailField) |
| | | watch(dynamicDetailFields, fields => fields.forEach(ensureDetailField)) |
| | | |
| | | function loadEditingRecord() { |
| | | const record = editingRecord.value |
| | | if (!record) return |
| | | reportName.value = record.name |
| | | experimentName.value = record.experimentName |
| | | batteryGroup.value = record.batteryGroup |
| | | samples.value = [...record.samples] |
| | | selectedTests.value = record.tests.map(item => ({ ...item })) |
| | | batteryFields.value = [...record.batteryFields] |
| | | specialFields.value = [...record.specialFields] |
| | | Object.keys(detailFieldValues).forEach(field => delete detailFieldValues[field]) |
| | | Object.entries(record.detailFieldValues).forEach(([field, values]) => { detailFieldValues[field] = { ...values } }) |
| | | formationMetricDefinitions.forEach(metric => { metricVisible[metric.key] = record.metrics.includes(metric.key) }) |
| | | seriesColors.value = [...record.seriesColors] |
| | | dynamicDetailFields.value.forEach(ensureDetailField) |
| | | } |
| | | loadEditingRecord() |
| | | |
| | | function openAddTest() { |
| | | selectedCandidateId.value = null |
| | | addDialogVisible.value = true |
| | |
| | | delete detailFieldValues[field] |
| | | } |
| | | |
| | | function openFieldTemplate(mode: 'battery' | 'special') { |
| | | async function openFieldTemplate(mode: 'battery' | 'special') { |
| | | templateMode.value = mode |
| | | if (mode === 'battery') { |
| | | try { managedGroupFields.value = await getBatteryGroupFields() } |
| | | catch (error) { ElMessage.error(error instanceof Error ? error.message : '字段加载失败'); return } |
| | | selectedBatteryFieldIds.value = batteryFieldOptions.value.filter(field => batteryFields.value.includes(field.name)).map(field => field.id) |
| | | } else { |
| | | selectedSpecialTemplateKey.value = 'cycle' |
| | |
| | | if (!reportName.value.trim()) { ElMessage.warning('请输入拼接报表名称'); return } |
| | | if (selectedTests.value.length < 2) { ElMessage.warning('请至少添加一个拼接测试'); return } |
| | | saving.value = true |
| | | await new Promise(resolve => window.setTimeout(resolve, 220)) |
| | | try { |
| | | const payload: SpliceReportPayload = { |
| | | name: reportName.value.trim(), |
| | | creator: currentUser?.displayName || 'Admin', |
| | |
| | | batteryFields: [...batteryFields.value], |
| | | specialFields: [...specialFields.value], |
| | | detailFieldValues: Object.fromEntries(Object.entries(detailFieldValues).map(([field, values]) => [field, { ...values }])), |
| | | metrics: enabledMetrics.value.map(metric => metric.key), |
| | | metrics: savedReport.value ? statisticSettings.value.metrics : enabledMetrics.value.map(metric => metric.key), |
| | | seriesColors: [...seriesColors.value], |
| | | } |
| | | if (editingId.value) { |
| | | const saved = await getReport(editingId.value) |
| | | await updateReport(editingId.value, saved.versionNo, payload) |
| | | if (!savedReport.value) throw new Error('请重新加载报表后编辑') |
| | | await updateReport(editingId.value, savedReport.value.versionNo, payload) |
| | | } else await createReport(payload) |
| | | saving.value = false |
| | | ElMessage.success(editingId.value ? '拼接报表已更新' : '拼接报表已保存') |
| | | await router.push('/report-splice') |
| | | } catch (error) { ElMessage.error(error instanceof Error ? error.message : '保存失败,请重试') } |
| | | finally { saving.value = false } |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="splice-form-page" :class="{ 'is-readonly': readonly }"> |
| | | <div class="page-tools"><button class="back-button" type="button" @click="router.push('/report-splice')"><el-icon><ArrowLeft /></el-icon>返回拼接报表</button><span>{{ readonly ? 'VIEW SPLICE REPORT' : editingId ? 'EDIT SPLICE REPORT' : 'NEW SPLICE REPORT' }}</span></div> |
| | | |
| | | <el-alert v-if="loadError" :title="loadError" type="error" :closable="false" /> |
| | | <p v-if="loading">正在加载拼接报表</p> |
| | | <SpliceSavedReport v-if="readonly && savedReport && !loading && !loadError" :report="savedReport" /> |
| | | <template v-if="!readonly && !loading && !loadError"> |
| | | |
| | | <section class="report-name-panel"><label><span>拼接报表名称</span><el-input v-model="reportName" clearable placeholder="请输入拼接报表名称" /></label></section> |
| | | |
| | |
| | | <section class="detail-panel"> |
| | | <header class="panel-heading"><div><strong>测试详情</strong><span>固定于基础测试,添加拼接不会改变</span></div></header> |
| | | <div class="detail-meta"><label><span>实验</span><el-input v-model="experimentName" /></label><div><span>基础测试</span><strong>{{ selectedTests[0]?.name }}</strong></div></div> |
| | | <div class="detail-table-wrap"><table class="detail-table" :style="{ minWidth: `${Math.max(760, samples.length * 190 + 170)}px` }"><thead><tr><th>电池组</th><th :colspan="samples.length">{{ batteryGroup }}</th></tr><tr><th>电池号</th><th v-for="sample in samples" :key="sample">{{ sample }}</th></tr></thead><tbody><tr v-for="(field, fieldIndex) in formationFixedReportFields" :key="field.key"><td>{{ field.label }}</td><td v-for="(sample, sampleIndex) in samples" :key="sample"><span v-if="field.mode === 'raw'" class="raw-value">{{ rawDetailValue(fieldIndex, sampleIndex) }}</span><span v-else class="auto-value">自动计算</span></td></tr><tr v-for="field in dynamicDetailFields" :key="field"><td>{{ field }}</td><td v-for="sample in samples" :key="sample"><el-input v-model="detailFieldValues[field][sample]" size="small" placeholder="请输入" /></td></tr></tbody></table></div> |
| | | <div class="detail-table-wrap"><table class="detail-table" :style="{ minWidth: `${Math.max(760, samples.length * 190 + 170)}px` }"><thead><tr><th>电池组</th><th :colspan="samples.length">{{ batteryGroup }}</th></tr><tr><th>电池号</th><th v-for="sample in samples" :key="sample">{{ sample }}</th></tr></thead><tbody><tr v-for="(field, fieldIndex) in (savedReport ? [] : formationFixedReportFields)" :key="field.key"><td>{{ field.label }}</td><td v-for="(sample, sampleIndex) in samples" :key="sample"><span v-if="field.mode === 'raw'" class="raw-value">{{ rawDetailValue(fieldIndex, sampleIndex) }}</span><span v-else class="auto-value">自动计算</span></td></tr><tr v-for="field in dynamicDetailFields" :key="field"><td>{{ field }}</td><td v-for="sample in samples" :key="sample"><el-input v-model="detailFieldValues[field][sample]" size="small" placeholder="请输入" /></td></tr></tbody></table></div> |
| | | <div v-if="!readonly" class="detail-actions"><button class="primary-button" type="button" @click="addSpecialField"><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 v-if="specialFields.length" class="special-field-list"><span v-for="field in specialFields" :key="field">{{ field }}<button type="button" :aria-label="`删除${field}`" @click="removeSpecialField(field)">×</button></span></div></div> |
| | | </section> |
| | | |
| | | <section class="chart-panel"> |
| | | <section v-if="statisticPreview" class="chart-panel"> |
| | | <div class="metric-switches"><label v-for="metric in statisticMetrics" :key="metric.key"><span>{{ metric.title }}</span><el-switch v-model="statisticVisible[metric.key]" /></label></div> |
| | | <div class="chart-legend-bar"><div v-for="(sample, index) in samples" :key="sample" class="chart-legend-item"><el-color-picker :model-value="seriesColors[index]" :aria-label="`修改${sample}颜色`" @change="updateSeriesColor(index, $event)" /><span>{{ sample }}</span></div></div> |
| | | <SpliceSavedReport :report="statisticPreview" /> |
| | | </section> |
| | | <section v-else class="chart-panel"> |
| | | <header class="panel-heading"><div><strong>拼接曲线</strong><span>不同测试沿累计时间轴横向连接</span></div></header> |
| | | <div v-if="!readonly" class="metric-switches"><label v-for="metric in formationMetricDefinitions" :key="metric.key"><span>{{ metric.title }}</span><el-switch v-model="metricVisible[metric.key]" inline-prompt active-text="显" inactive-text="隐" /></label></div> |
| | | <div class="segment-ruler" aria-label="拼接测试时间段"><div v-for="(segment, index) in timeline.segments" :key="segment.id" class="segment" :class="`segment-${index % 4}`" :style="{ flexBasis: `${segment.duration / Math.max(totalDuration, 1) * 100}%` }"><strong>{{ index + 1 }}. {{ segment.name }}</strong><span>{{ formatMinutes(segment.start) }} - {{ formatMinutes(segment.end) }}</span></div></div> |
| | |
| | | <div class="charts-list"><article v-for="metric in enabledMetrics" :key="metric.key" class="chart-card"><header><strong>{{ metric.title }}</strong><span>{{ metric.unit }}</span></header><VChart class="metric-chart" :option="chartOption(metric)" autoresize /></article></div> |
| | | </section> |
| | | |
| | | <footer class="form-actions"><button class="secondary-button" type="button" @click="router.push('/report-splice')">{{ readonly ? '返回列表' : '取消' }}</button><button v-if="!readonly" class="primary-button" type="button" :disabled="saving" @click="saveReport"><el-icon><Check /></el-icon>{{ saving ? '保存中' : editingId ? '保存修改' : '保存报表' }}</button></footer> |
| | | </template> |
| | | <footer class="form-actions"> |
| | | <button v-if="canExport" class="secondary-button" type="button" :disabled="printing || saving || loading || !!loadError" @click="printReport"><el-icon><Printer /></el-icon>{{ printing ? '正在打开打印' : '打印 / 另存 PDF' }}</button> |
| | | <button v-if="savedReport && !loading && !loadError" class="secondary-button" type="button" @click="openHistory">历史版本</button> |
| | | <button class="secondary-button" type="button" @click="router.push('/report-splice')">{{ readonly ? '返回列表' : '取消' }}</button> |
| | | <button v-if="!readonly" class="primary-button" type="button" :disabled="saving || loading || !!loadError" @click="saveReport"><el-icon><Check /></el-icon>{{ saving ? '保存中' : editingId ? '保存修改' : '保存报表' }}</button> |
| | | </footer> |
| | | <el-dialog v-model="historyVisible" title="历史版本" width="min(720px, 94vw)"> |
| | | <el-table :data="history"><el-table-column prop="versionNo" label="版本" width="70" /><el-table-column prop="operatorName" label="操作人" /><el-table-column label="时间"><template #default="{ row }">{{ new Date(row.createdAt).toLocaleString() }}</template></el-table-column><el-table-column prop="changeSummary" label="修改说明" /><el-table-column label="操作" width="80"><template #default="{ row }"><el-button link type="primary" @click="openVersion(row.versionNo)">查看</el-button></template></el-table-column></el-table> |
| | | </el-dialog> |
| | | |
| | | <el-dialog v-model="addDialogVisible" class="add-dialog" title="添加拼接" width="560px" align-center destroy-on-close> |
| | | <div class="dialog-form"><label><span>选择测试</span><el-select v-model="selectedCandidateId" filterable clearable placeholder="输入测试名称或ID搜索"><el-option v-for="item in candidateTests" :key="item.id" :label="`${item.name}(${item.type},${formatMinutes(item.durationMinutes)})`" :value="item.id" :disabled="selectedTests.some(test => test.id === item.id)" /></el-select></label><label><span>电池组</span><div class="fixed-dialog-value">{{ batteryGroup }}</div></label><label><span>电池号</span><div class="fixed-dialog-value">{{ samples.join('、') }}</div></label><small>电池组和电池号由基础测试固定,添加拼接不会修改测试详情。</small></div> |
| | |
| | | .splice-form-page{width:100%;padding-bottom:78px}.page-tools{height:34px;display:flex;align-items:center;justify-content:space-between}.page-tools>span{color:#7f8d99;font-size:10px;font-weight:600}.back-button{display:inline-flex;align-items:center;gap:6px;border:0;color:#52606d;background:transparent;font-size:13px;cursor:pointer}.back-button:hover{color:var(--blue)}.primary-button,.secondary-button{height:34px;display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:0 13px;border:1px solid transparent;border-radius:3px;font-size:13px;cursor:pointer;white-space:nowrap}.primary-button{color:#fff;background:var(--blue);border-color:var(--blue)}.secondary-button{color:#52606d;background:#fff;border-color:var(--line-strong)}.primary-button:disabled{opacity:.55;cursor:wait}.report-name-panel,.test-panel,.detail-panel,.chart-panel{margin-bottom:16px;border:1px solid #d8e1e9;background:#fff}.report-name-panel{padding:18px 22px}.report-name-panel label{display:grid;grid-template-columns:100px minmax(280px,560px);align-items:center;gap:12px}.report-name-panel label>span,.detail-meta label>span{color:#52606d;font-size:12px}.report-name-panel :deep(.el-input__wrapper),.detail-meta :deep(.el-input__wrapper){border-radius:3px;box-shadow:0 0 0 1px #d8e1e9 inset;background:#f8fafc}.panel-heading{min-height:48px;display:flex;align-items:center;justify-content:space-between;padding:0 24px;border-bottom:1px solid var(--line);background:#fbfdff}.panel-heading>div{display:flex;align-items:center;gap:9px}.panel-heading strong{color:#26333f;font-size:13px}.panel-heading span,.panel-heading small{color:#8795a3;font-size:11px}.test-table-wrap,.detail-table-wrap{overflow-x:auto;padding:18px 24px 0}table{width:100%;border-collapse:collapse;color:#52606d;font-size:12px}th,td{height:40px;padding:0 10px;border:1px solid #cbd8e4;text-align:center;white-space:nowrap}th{color:#fff;background:#3c6497;font-weight:600}.test-table{min-width:900px}.test-table th:nth-child(1){width:70px}.test-table th:nth-child(3){width:110px}.test-table th:nth-child(4){width:260px}.test-table th:nth-child(5){width:110px}.test-table th:last-child{width:100px}.order-index{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border:1px solid #aac0d4;color:#405261;background:#f7fafc}.base-lock{color:#647380}.delete-link{border:0;color:#b84e4e;background:transparent;font-size:12px;cursor:pointer}.panel-actions,.detail-actions{display:flex;align-items:center;gap:10px;padding:16px 24px 20px}.compact-button{height:30px;font-size:12px}.detail-meta{display:grid;grid-template-columns:minmax(280px,520px) minmax(260px,1fr);gap:20px;padding:18px 24px 0}.detail-meta label,.detail-meta>div{display:grid;grid-template-columns:72px minmax(0,1fr);align-items:center;gap:10px}.detail-meta>div{min-height:34px;color:#52606d;font-size:12px}.detail-meta>div strong{color:#26333f}.detail-table th:first-child,.detail-table td:first-child{width:150px;text-align:left}.detail-table td:first-child{color:#31404d;background:#f2f7fb;font-weight:600}.detail-table :deep(.el-input__wrapper){min-height:28px;border-radius:3px;box-shadow:0 0 0 1px #d8e1e9 inset;background:#fff}.raw-value{color:#405261;font-family:'Cascadia Mono',Consolas,monospace}.auto-value{color:#2188f5;font-weight:500}.special-field-list{display:flex;flex-wrap:wrap;gap:6px;margin-left:auto}.special-field-list>span{display:inline-flex;align-items:center;gap:5px;padding:4px 7px;border:1px solid #d8e1e9;color:#52606d;background:#f8fafc;font-size:11px}.special-field-list button{border:0;color:#ad4a4a;background:transparent;cursor:pointer}.metric-switches{display:flex;flex-wrap:wrap;align-items:center;gap:20px 28px;min-height:56px;padding:0 24px;border-bottom:1px solid var(--line)}.metric-switches label{display:inline-flex;align-items:center;gap:7px;color:#52606d;font-size:12px}.metric-switches :deep(.el-switch){--el-switch-on-color:#3978f6}.segment-ruler{display:flex;min-width:0;margin:16px 24px 0;border:1px solid #cbd8e4;background:#f8fafc}.segment{min-width:120px;display:flex;justify-content:center;flex-direction:column;gap:3px;height:52px;padding:0 12px;border-right:1px solid #cbd8e4;overflow:hidden}.segment:last-child{border-right:0}.segment strong,.segment span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.segment strong{color:#31404d;font-size:11px}.segment span{color:#71808d;font-size:10px}.segment-0{border-top:3px solid #3978f6}.segment-1{border-top:3px solid #6aa07a}.segment-2{border-top:3px solid #d58b3d}.segment-3{border-top:3px solid #8b69c7}.chart-legend-bar{min-height:50px;display:flex;flex-wrap:wrap;align-items:center;gap:12px 24px;margin-top:14px;padding:0 24px;border-top:1px solid var(--line);border-bottom:1px solid var(--line);color:#52606d;font-size:12px}.chart-legend-bar>strong{color:#26333f}.chart-legend-item{display:inline-flex;align-items:center;gap:7px}.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)}.legend-color-picker :deep(.el-color-picker__icon),.legend-color-picker :deep(.el-color-picker__empty),.legend-color-picker :deep(.el-color-picker__trigger svg){display:none!important}:global(.legend-color-popper.el-popper){padding:0!important;border:1px solid #b9c9d8!important;border-radius:4px!important;box-shadow:0 10px 24px rgba(25,48,69,.18)!important;overflow:hidden}.charts-list{display:grid;grid-template-columns:minmax(0,1fr);gap:14px;padding:18px 24px 24px}.chart-card{min-width:0;border:1px solid #d8e1e9;background:#fff}.chart-card header{height:38px;display:flex;align-items:center;justify-content:space-between;padding:0 14px;border-bottom:1px solid #edf1f5}.chart-card header strong{color:#26333f;font-size:13px}.chart-card header span{color:#8795a3;font-size:10px}.metric-chart{width:100%;height:250px}.form-actions{position:sticky;bottom:0;z-index:4;display:flex;justify-content:flex-end;gap:10px;padding:14px 0;border-top:1px solid #d8e1e9;background:rgba(244,248,251,.96)}.dialog-form{display:grid;gap:16px}.dialog-form label{display:grid;grid-template-columns:78px minmax(0,1fr);align-items:center;gap:12px;color:#52606d;font-size:12px}.dialog-form :deep(.el-select){width:100%}.fixed-dialog-value{min-height:34px;display:flex;align-items:center;padding:0 11px;border:1px solid #d8e1e9;background:#f8fafc;color:#405261}.dialog-form small{margin-left:90px;color:#8795a3;font-size:11px}.battery-field-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px 18px;max-height:300px;overflow:auto;padding:14px;border:1px solid #d8e1e9;background:#f8fafc}.template-options{display:flex;flex-direction:column;gap:10px}.template-options :deep(.el-radio){height:auto;min-height:42px;margin-right:0;white-space:normal}@media(max-width:900px){.detail-meta{grid-template-columns:1fr}.segment-ruler{overflow-x:auto}.segment{flex:0 0 180px!important}}@media(max-width:680px){.report-name-panel label{grid-template-columns:1fr}.panel-heading{align-items:flex-start;flex-direction:column;justify-content:center;gap:4px}.detail-actions{align-items:stretch;flex-direction:column}.special-field-list{margin-left:0}.metric-switches{gap:14px;padding:12px}.chart-legend-bar{align-items:flex-start;flex-direction:column;gap:9px;padding:12px}.charts-list{padding:12px}.battery-field-grid{grid-template-columns:repeat(2,minmax(0,1fr))}} |
| | | .splice-form-page.is-readonly :deep(.el-input), .splice-form-page.is-readonly :deep(.el-textarea) { pointer-events: none; } |
| | | .splice-form-page.is-readonly :deep(.el-input__wrapper), .splice-form-page.is-readonly :deep(.el-textarea__inner) { background: #f3f6f8; box-shadow: 0 0 0 1px #d8e1e9 inset; } |
| | | .form-actions{flex-wrap:wrap}.splice-form-page{min-width:0} |
| | | </style> |
| New file |
| | |
| | | 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 () => { |
| | | await stack.start() |
| | | stack.executeIsolatedSql(` |
| | | INSERT INTO t_battery_group(group_id,group_name,model) VALUES(9201,'拼接打印组','型号'); |
| | | INSERT INTO t_test_type(test_type_id,test_type_name) VALUES(9201,'容量'); |
| | | INSERT INTO t_test(test_id,test_name,creator_id,principal_id,test_type_id,test_purpose,test_status,reception_status) |
| | | VALUES(9201,'来源甲',1,1,9201,'隔离验收','COMPLETED','ENDED'),(9202,'来源乙',1,1,9201,'隔离验收','COMPLETED','ENDED'); |
| | | INSERT INTO t_test_battery(test_battery_id,test_id,group_id,battery_no,collector_id) |
| | | VALUES(9201,9201,9201,'电池01',9201),(9202,9202,9201,'电池01',9202); |
| | | `) |
| | | }) |
| | | test.afterAll(async () => stack.stop()) |
| | | |
| | | async function login(page: Page, username = 'admin') { |
| | | if (page.url().startsWith('http')) await page.evaluate(() => localStorage.clear()) |
| | | 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 body = await (await page.request.fetch(`${stack.backendUrl}/system/${path}`, { method, data, headers: { Authorization: `Bearer ${token}` } })).json() |
| | | expect(body.code, `${path}: ${body.msg}`).toBe(code) |
| | | return body |
| | | } |
| | | |
| | | test('指定保存版本独立重开、删源打印、未保存及窗口失败反馈', async ({ page }) => { |
| | | await login(page) |
| | | const payload = { name: '拼接打印V1', description: '说明 <script>不可执行</script>', selections: [{ testId: 9201, testBatteryId: 9201 }, { testId: 9202, testBatteryId: 9202 }], settings: { batteryFields: [], specialFields: ['人工备注'], detailFieldValues: { 人工备注: { 电池01: '0' } }, metrics: ['finalVoltage1', 'energyEfficiency'], seriesColors: ['#123456'] } } |
| | | const v1 = (await api(page, 'splice-report', 'POST', payload)).data |
| | | const v2 = (await api(page, `splice-report/${v1.id}/versions`, 'POST', { ...payload, name: '拼接打印V2', baseVersionNo: 1, changeSummary: '更新说明' })).data |
| | | await page.goto(`/report-splice/${v1.id}/edit?mode=view&version=1`) |
| | | await expect(page.locator('.splice-snapshot h1')).toHaveText('拼接打印V1') |
| | | await expect(page.locator('.splice-snapshot')).toContainText('来源甲') |
| | | await page.evaluate(() => { window.open = () => null }) |
| | | await page.getByRole('button', { name: '打印 / 另存 PDF', exact: true }).click() |
| | | await expect(page.getByText('打印窗口打开失败,请允许浏览器弹出窗口后重试', { exact: true })).toBeVisible() |
| | | await api(page, 'test/9201', 'DELETE') |
| | | await api(page, 'test/9202', 'DELETE') |
| | | await page.context().addInitScript(() => { window.print = () => { document.body.dataset.printRequested = 'true' } }) |
| | | for (const saved of [v1, v2]) { |
| | | expect((await api(page, `splice-report/${v1.id}/versions/${saved.versionNo}/print`)).data).toEqual(saved) |
| | | await page.goto(`/report-splice/${v1.id}/edit?mode=view&version=${saved.versionNo}`) |
| | | await expect(page.locator('.splice-snapshot h1')).toHaveText(saved.name) |
| | | const popupPromise = page.waitForEvent('popup') |
| | | await page.getByRole('button', { name: '打印 / 另存 PDF', exact: true }).click() |
| | | const popup = await popupPromise |
| | | await expect(popup.locator('body')).toHaveAttribute('data-print-requested', 'true') |
| | | await expect(popup.locator('.splice-snapshot')).toContainText('来源甲') |
| | | await expect(popup.locator('.splice-note')).toHaveText(saved.description) |
| | | expect(await popup.locator('.splice-snapshot').innerText()).toEqual(await page.locator('.splice-snapshot').innerText()) |
| | | await popup.close() |
| | | } |
| | | await page.goto(`/report-splice/${v1.id}/edit`) |
| | | await page.getByPlaceholder('请输入拼接报表名称').fill('未保存名称') |
| | | await page.getByRole('button', { name: '打印 / 另存 PDF', exact: true }).click() |
| | | await expect(page.getByText('有未保存修改,请先保存新版本后再打印', { exact: true })).toBeVisible() |
| | | await page.goto('/report-splice/new') |
| | | await page.getByRole('button', { name: '打印 / 另存 PDF', exact: true }).click() |
| | | await expect(page.getByText('请先保存报表后再打印', { exact: true })).toBeVisible() |
| | | }) |
| | | |
| | | test('打印权限独立校验,查看用户不能直接获取打印版本', async ({ page }) => { |
| | | await login(page) |
| | | const saved = (await api(page, 'splice-report/list')).rows[0] |
| | | const permissions = (await api(page, 'menu/permissionList')).data.flatMap((module: any) => module.pages).find((item: any) => item.pageName === '拼接报表').permissions |
| | | for (const [username, action] of [['splice_reader', '查看'], ['splice_exporter', '导出']]) { |
| | | const menuIds = permissions.filter((item: any) => item.menuName === action).map((item: any) => item.menuId) |
| | | expect(menuIds).toHaveLength(1) |
| | | 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] }) |
| | | } |
| | | await login(page, 'splice_reader') |
| | | await api(page, `splice-report/${saved.id}/versions/1/print`, 'GET', undefined, 403) |
| | | await page.goto(`/report-splice/${saved.id}/edit?mode=view`) |
| | | await expect(page.locator('.splice-snapshot h1')).toHaveText('拼接打印V2') |
| | | await expect(page.getByRole('button', { name: '打印 / 另存 PDF', exact: true })).toHaveCount(0) |
| | | await page.getByRole('button', { name: '历史版本', exact: true }).click() |
| | | await page.locator('.el-dialog tr').filter({ hasText: '首次保存' }).getByRole('button', { name: '查看', exact: true }).click() |
| | | await expect(page.locator('.splice-snapshot h1')).toHaveText('拼接打印V1') |
| | | await login(page, 'splice_exporter') |
| | | await api(page, `splice-report/${saved.id}/versions/1/print`) |
| | | await api(page, `splice-report/${saved.id}`, 'GET', undefined, 403) |
| | | await api(page, `splice-report/${saved.id}/versions/999/print`, 'GET', undefined, 500) |
| | | }) |
| | | |
| | | test('长分段真实统计、单位缺失和桌面窄屏多页打印', async ({ page }) => { |
| | | await login(page) |
| | | const cycleNos = [1, 3, ...Array.from({ length: 48 }, (_, index) => index + 5)] |
| | | stack.executeIsolatedSql(` |
| | | INSERT INTO t_test(test_id,test_name,creator_id,principal_id,test_type_id,test_purpose,test_status,reception_status) |
| | | VALUES(9211,'长分段甲',1,1,9201,'隔离验收','COMPLETED','ENDED'),(9212,'长分段乙',1,1,9201,'隔离验收','COMPLETED','ENDED'); |
| | | INSERT INTO t_test_battery(test_battery_id,test_id,group_id,battery_no,collector_id) |
| | | VALUES(9211,9211,9201,'电池长名称01',9211),(9212,9212,9201,'电池长名称01',9212); |
| | | `) |
| | | for (let start = 0; start < cycleNos.length; start += 10) { |
| | | const batch = cycleNos.slice(start, start + 10) |
| | | stack.executeIsolatedSql(` |
| | | INSERT INTO t_test_cycle_record(test_id,test_battery_id,binding_id,adapter_code,source_identity,source_record_id,dedupe_key,adapter_timezone_id,device_time_utc,device_time_raw,server_received_at_utc,cycle_no,charge_energy_wh,discharge_energy_wh,content_sha256) |
| | | VALUES ${[9211, 9212].flatMap(id => batch.map(no => `(${id},${id},${id},'splice','splice:${id}','${no}','${no}','Asia/Hong_Kong','2026-09-01 00:00:00','2026-09-01 08:00:00','2026-09-01 00:00:01',${no},${no === 3 ? '0' : '0.1361'},0.1694,UNHEX(SHA2('${id}:${no}',256)))`)).join(',')}; |
| | | INSERT INTO t_test_step_record(test_id,test_battery_id,binding_id,adapter_code,source_identity,source_record_id,dedupe_key,adapter_timezone_id,device_time_utc,device_time_raw,server_received_at_utc,cycle_no,step_no,voltage_v,content_sha256) |
| | | VALUES ${[9211, 9212].flatMap(id => batch.map(no => `(${id},${id},${id},'splice','splice:${id}','${no}','${no}','Asia/Hong_Kong','2026-09-01 00:00:00','2026-09-01 08:00:00','2026-09-01 00:00:01',${no},1,${no === 3 ? 'NULL' : no === 1 ? '0' : '-0.2'},UNHEX(SHA2('${id}:${no}',256)))`)).join(',')}; |
| | | `) |
| | | } |
| | | const saved = (await api(page, 'splice-report', 'POST', { name: '长分段拼接打印', description: '长说明'.repeat(150), selections: [{ testId: 9211, testBatteryId: 9211 }, { testId: 9212, testBatteryId: 9212 }], settings: { batteryFields: ['字段 (mAh)'], specialFields: ['备注'], detailFieldValues: { '字段 (mAh)': { 电池长名称01: '8512.2' }, 备注: { 电池长名称01: '' } }, metrics: ['finalVoltage1', 'energyEfficiency'], seriesColors: ['#123456'] } })).data |
| | | expect(saved.segments[0].statistics['1'].finalVoltage1).toBe(0) |
| | | expect(saved.segments[0].statistics['1'].energyEfficiency).toBeCloseTo(124.46730345) |
| | | expect(saved.segments[0].statistics['3'].finalVoltage1 ?? null).toBeNull() |
| | | await page.goto(`/report-splice/${saved.id}/edit?mode=view`) |
| | | await expect(page.locator('.splice-chart')).toHaveCount(6) |
| | | await expect(page.locator('.splice-statistics').first()).toContainText('124.4673') |
| | | await expect(page.locator('.splice-statistics').first()).toContainText('缺失') |
| | | for (const width of [1440, 390]) { |
| | | await page.setViewportSize({ width, height: 900 }) |
| | | await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true) |
| | | await expect(page.getByRole('button', { name: '打印 / 另存 PDF', exact: true })).toBeInViewport() |
| | | await page.locator('.splice-chart').first().scrollIntoViewIfNeeded() |
| | | await page.screenshot({ path: `tests/e2e/.artifacts/splice-saved-${width}.png` }) |
| | | } |
| | | await page.context().addInitScript(() => { window.print = () => { document.body.dataset.printRequested = 'true' } }) |
| | | const popupPromise = page.waitForEvent('popup') |
| | | await page.getByRole('button', { name: '打印 / 另存 PDF', exact: true }).click() |
| | | const popup = await popupPromise |
| | | await expect(popup.locator('body')).toHaveAttribute('data-print-requested', 'true') |
| | | await expect(popup.locator('.splice-chart')).toHaveCount(6) |
| | | await expect(popup.locator('.splice-chart svg path[stroke="#123456"]').first()).toBeAttached() |
| | | expect(await popup.locator('.splice-snapshot').innerText()).toEqual(await page.locator('.splice-snapshot').innerText()) |
| | | await popup.emulateMedia({ media: 'print' }) |
| | | await popup.screenshot({ path: 'tests/e2e/.artifacts/splice-print-long.png', fullPage: true }) |
| | | await popup.pdf({ path: 'tests/e2e/.artifacts/splice-print-long.pdf', preferCSSPageSize: true, printBackground: true }) |
| | | await popup.close() |
| | | expect((await api(page, `splice-report/${saved.id}/versions`)).data).toHaveLength(1) |
| | | }) |