From ce135477fd1703b5001264687656e5a363d29988 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期二, 08 九月 2026 14:49:55 +0800
Subject: [PATCH] Merge commit '3f30e0c' into codex/ticket-03

---
 src/views/TestConsole.vue                 |   10 +++
 src/components/ConsoleChartPanel.vue      |    9 ++
 tests/e2e/support/ticket04-stack.ts       |    2 
 tests/e2e/console-detail-readback.spec.ts |  114 ++++++++++++++++++++++++++++++++++++++
 4 files changed, 132 insertions(+), 3 deletions(-)

diff --git a/src/components/ConsoleChartPanel.vue b/src/components/ConsoleChartPanel.vue
index dca28c8..11b3ba1 100644
--- a/src/components/ConsoleChartPanel.vue
+++ b/src/components/ConsoleChartPanel.vue
@@ -45,6 +45,8 @@
   consoleDetail: null,
 })
 
+const emit = defineEmits<{ detailSaved: [testId: number, detail: TestConsoleDetail] }>()
+
 use([CanvasRenderer, LineChart, GridComponent, LegendComponent, TooltipComponent])
 
 const palette = ['#3978f6', '#7d42d8', '#f08a3e', '#23a983', '#e15268', '#1b9ac8', '#b5892f', '#6957c8', '#4f8d44', '#d264a3', '#46748e', '#a95d37']
@@ -241,10 +243,14 @@
       experimentName: experimentName.value.trim() || null,
       batteryFields: [...batteryFields.value],
       specialFields: [...specialFields.value],
-      fieldValues: JSON.parse(JSON.stringify(detailFieldValues)) as Record<string, Record<string, string>>,
+      // 模板移除的字段不再提交,仍选中的字段保留全部电池填写值和空字符串。
+      fieldValues: Object.fromEntries(detailFields.value.map(field => [field, { ...detailFieldValues[field] }])),
     }
     const saved = await saveTestConsoleDetail(props.testId, detail)
+    if (!saved.consoleDetail) throw new Error('保存后未能读取测试详情')
     applyConsoleDetail(saved.consoleDetail)
+    // 父页持有正式详情缓存;切换页签重新挂载时必须使用这次保存结果。
+    emit('detailSaved', props.testId, saved.consoleDetail)
     ElMessage.success('测试详情已保存')
   } catch (error) {
     ElMessage.error(error instanceof Error ? error.message : '测试详情保存失败')
@@ -476,6 +482,7 @@
   <div class="report-chart-page">
     <section class="detail-panel">
       <header class="panel-heading"><div><strong>测试详情</strong><el-icon><ArrowUp /></el-icon></div></header>
+      <p v-if="!consoleDetail" class="detail-meta" role="status">暂无已保存的测试详情</p>
       <div class="detail-meta"><label><span>实验</span><el-input v-model="experimentName" /></label></div>
       <div class="field-table-wrap">
         <table class="field-table" :style="{ minWidth: `${Math.max(900, detailCells.length * 220 + 214)}px` }">
diff --git a/src/views/TestConsole.vue b/src/views/TestConsole.vue
index f90d0d9..1d62180 100644
--- a/src/views/TestConsole.vue
+++ b/src/views/TestConsole.vue
@@ -17,6 +17,7 @@
 const router = useRouter()
 const testId = computed(() => Number(route.params.id))
 const backendDetail = ref<TestConfigurationDetail | null>(null)
+const consoleLoadError = ref('')
 const liveState = ref<TestLiveState | null>(null)
 const liveConnection = ref<'连接中' | '已连接' | '已断开' | '权限不足' | '空数据'>('连接中')
 const liveError = ref('')
@@ -159,6 +160,7 @@
     const detail = await getTestDetail(testId.value)
     if (disposed) return
     backendDetail.value = detail
+    consoleLoadError.value = ''
     stepSettings.value = detail.logicStepMappings || []
     runtimeAttach.value = detail.runtimeAttach === true
     if (detail.receptionStatus === 'NOT_STARTED') {
@@ -186,10 +188,15 @@
     if (activeTab.value === 'step-chart') await loadRealtimeRecords()
     if (activeTab.value === 'project-chart') await loadProjectChartData()
   } catch (error) {
+    consoleLoadError.value = error instanceof Error ? error.message : '测试详情读取失败'
     handleLiveError(error instanceof Error ? error : new Error('测试状态加载失败'))
   }
 }
 onMounted(loadConsole)
+
+function applySavedConsoleDetail(savedTestId: number, detail: NonNullable<TestConfigurationDetail['consoleDetail']>) {
+  if (backendDetail.value?.testId === savedTestId) backendDetail.value.consoleDetail = detail
+}
 onBeforeUnmount(() => { disposed = true; closeLiveStream?.(); if (reconnectTimer !== null) window.clearTimeout(reconnectTimer) })
 
 async function loadRealtimeRecords() {
@@ -534,7 +541,8 @@
     </div>
     </div>
 
-    <ConsoleChartPanel v-if="activeTab === 'step-chart'" :test-id="testId" :cells="cells" :records="realtimeRecords" :step-plan-rows="detectorPlanRows" :logic-step-mappings="stepSettings" :console-detail="backendDetail?.consoleDetail" :test-name="profile.name" :battery-group="profile.batteryGroup" />
+    <p v-if="activeTab === 'step-chart' && !backendDetail && consoleLoadError" class="live-error" role="alert">{{ consoleLoadError }}</p>
+    <ConsoleChartPanel v-if="activeTab === 'step-chart' && backendDetail" :test-id="testId" :cells="cells" :records="realtimeRecords" :step-plan-rows="detectorPlanRows" :logic-step-mappings="stepSettings" :console-detail="backendDetail.consoleDetail" :test-name="profile.name" :battery-group="profile.batteryGroup" @detail-saved="applySavedConsoleDetail" />
     <ConsoleProjectChartPanel v-if="activeTab === 'project-chart'" :cells="cells" :steps="projectSteps" :cycles="projectCycles" :capacity-factor="projectCapacityFactor" />
 
 <ConsoleDataListPanel v-if="activeTab === 'data-list' && backendDetail" :test-id="testId" :test-name="profile.name" :cells="cells" :can-import="state === '检测完成'" :reception-status="backendDetail.receptionStatus" :bindings="backendDetail.batteries.map(item => ({ batteryNo: item.batteryNo, bindingId: item.binding?.bindingId, channelId: item.binding?.channelId, devid: item.binding?.devid, chlid: item.binding?.chlid }))" />
diff --git a/tests/e2e/console-detail-readback.spec.ts b/tests/e2e/console-detail-readback.spec.ts
new file mode 100644
index 0000000..9b60394
--- /dev/null
+++ b/tests/e2e/console-detail-readback.spec.ts
@@ -0,0 +1,114 @@
+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_battery_group_field(field_id,field_name,field_type) VALUES(9201,'额定容量','text'),(9202,'电流','text');
+    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'),(9203,'无详情测试',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,'BAT-A',9201),(9202,9201,9201,'BAT-B',9202),(9203,9202,9201,'BAT-A',9203),(9204,9202,9201,'BAT-B',9204);
+  `)
+})
+test.afterAll(async () => stack.stop())
+
+async function login(page: Page) {
+  await page.goto('/login')
+  await page.locator('input[autocomplete="username"]').fill('admin')
+  await page.locator('input[autocomplete="current-password"]').fill('admin123')
+  await page.getByRole('button', { name: '登录', exact: true }).click()
+  await expect(page).toHaveURL(/\/dashboard$/)
+}
+
+async function api(page: Page, id: number, data?: unknown) {
+  const token = await page.evaluate(() => localStorage.getItem('battery_detection_auth_token'))
+  const response = await page.request.fetch(`${stack.backendUrl}/system/test/${id}${data ? '/console-detail' : ''}`, {
+    method: data ? 'PUT' : 'GET', data, headers: { Authorization: `Bearer ${token}` },
+  })
+  const body = await response.json()
+  expect(body.code, body.msg).toBe(200)
+  return body.data
+}
+
+const initial = {
+  experimentName: '原实验', batteryFields: ['额定容量', '电流'], specialFields: ['人工说明'],
+  fieldValues: { 额定容量: { 'BAT-A': '甲容量', 'BAT-B': '' }, 电流: { 'BAT-A': '甲电流', 'BAT-B': '乙电流' }, 人工说明: { 'BAT-A': '甲说明', 'BAT-B': '乙说明' } },
+}
+const row = (page: Page, field: string) => page.locator('.field-table tbody tr').filter({ has: page.locator('td').filter({ hasText: new RegExp(`^${field}$`) }) })
+async function save(page: Page) {
+  const response = page.waitForResponse(r => r.url().endsWith('/console-detail') && r.request().method() === 'PUT')
+  await page.getByRole('button', { name: '保存详情', exact: true }).click()
+  return (await response).json()
+}
+
+test('保存后页签返回、刷新及正式重读保持逐电池详情', async ({ page }) => {
+  await login(page)
+  await api(page, 9201, initial)
+  await page.goto('/test-list/9201/console?tab=step-chart')
+  await expect(page.locator('.detail-meta input')).toHaveValue('原实验')
+  await page.locator('.detail-meta input').fill('新实验甲')
+  await row(page, '人工说明').locator('input').nth(0).fill('新甲说明')
+  expect((await save(page)).code).toBe(200)
+  const saved = (await api(page, 9201)).consoleDetail
+  expect(saved.experimentName).toBe('新实验甲')
+  expect(saved.fieldValues['额定容量']['BAT-B']).toBe('')
+  expect(saved.fieldValues['人工说明']).toEqual({ 'BAT-A': '新甲说明', 'BAT-B': '乙说明' })
+  await page.getByRole('button', { name: '实时监控', exact: true }).click()
+  await page.getByRole('button', { name: '工步图表', exact: true }).click()
+  await expect(page.locator('.detail-meta input')).toHaveValue('新实验甲')
+  await expect(row(page, '人工说明').locator('input').nth(0)).toHaveValue('新甲说明')
+  await page.reload()
+  await expect(page.locator('.detail-meta input')).toHaveValue('新实验甲')
+  await expect(row(page, '额定容量').locator('input').nth(1)).toHaveValue('')
+})
+
+test('取消模板字段再次保存只提交当前字段且保留其他电池值', async ({ page }) => {
+  await login(page)
+  await api(page, 9202, { ...initial, experimentName: '实验乙' })
+  await page.goto('/test-list/9202/console?tab=step-chart')
+  await expect(page.locator('.detail-meta input')).toHaveValue('实验乙')
+  await page.getByRole('button', { name: '电池组字段模板', exact: true }).click()
+  await page.getByRole('dialog').getByText('电流', { exact: true }).click()
+  await page.getByRole('dialog').getByRole('button', { name: '应用模板', exact: true }).click()
+  expect((await save(page)).code).toBe(200)
+  const saved = (await api(page, 9202)).consoleDetail
+  expect(saved.batteryFields).toEqual(['额定容量'])
+  expect(saved.fieldValues).toEqual({ 额定容量: initial.fieldValues['额定容量'], 人工说明: initial.fieldValues['人工说明'] })
+  await page.reload()
+  await expect(page.locator('.detail-meta input')).toHaveValue('实验乙')
+  await expect(row(page, '人工说明').locator('input').nth(1)).toHaveValue('乙说明')
+  await page.setViewportSize({ width: 1440, height: 1000 })
+  await page.screenshot({ path: 'tests/e2e/.artifacts/console-detail-desktop.png', fullPage: true, animations: 'disabled' })
+  await page.setViewportSize({ width: 390, height: 844 })
+  await expect(page.getByRole('button', { name: '保存详情', exact: true })).toBeVisible()
+  await page.screenshot({ path: 'tests/e2e/.artifacts/console-detail-narrow.png', fullPage: true, animations: 'disabled' })
+})
+
+test('无详情、读取失败与保存失败分别反馈且失败保留填写', async ({ page }) => {
+  await login(page)
+  expect((await api(page, 9203)).consoleDetail).toBeNull()
+  await page.goto('/test-list/9203/console?tab=step-chart')
+  await expect(page.getByText('暂无已保存的测试详情', { exact: true })).toBeVisible()
+  await page.route('**/dev-api/system/test/9203', route => route.fulfill({ json: { code: 500, msg: '详情读取失败验收' } }))
+  await page.reload()
+  await expect(page.getByText('详情读取失败验收', { exact: true }).first()).toBeVisible()
+  await expect(page.getByRole('button', { name: '保存详情', exact: true })).toHaveCount(0)
+  await page.unroute('**/dev-api/system/test/9203')
+  await api(page, 9202, initial)
+  await page.goto('/test-list/9202/console?tab=step-chart')
+  await expect(page.locator('.detail-meta input')).toHaveValue('原实验')
+  await page.locator('.detail-meta input').fill('失败时保留实验')
+  await row(page, '人工说明').locator('input').nth(1).fill('失败时保留乙说明')
+  await page.route('**/dev-api/system/test/9202/console-detail', route => route.fulfill({ json: { code: 500, msg: '详情保存失败验收' } }), { times: 1 })
+  expect((await save(page)).code).toBe(500)
+  await expect(page.getByText('详情保存失败验收', { exact: true })).toBeVisible()
+  await expect(page.getByText('测试详情已保存', { exact: true })).toHaveCount(0)
+  await expect(page.locator('.detail-meta input')).toHaveValue('失败时保留实验')
+  await expect(row(page, '人工说明').locator('input').nth(1)).toHaveValue('失败时保留乙说明')
+  expect((await api(page, 9202)).consoleDetail).toEqual(initial)
+})
diff --git a/tests/e2e/support/ticket04-stack.ts b/tests/e2e/support/ticket04-stack.ts
index d5ec5bf..a0b8d29 100644
--- a/tests/e2e/support/ticket04-stack.ts
+++ b/tests/e2e/support/ticket04-stack.ts
@@ -9,7 +9,7 @@
 }
 
 const webRepo = resolve(import.meta.dirname, '../../..')
-const backendRepo = resolve(webRepo, '../BatteryDetection')
+const backendRepo = resolve(process.env.TICKET04_BACKEND_REPO || resolve(webRepo, '../BatteryDetection'))
 const artifactDir = resolve(webRepo, 'tests/e2e/.artifacts')
 const backendJar = resolve(backendRepo, 'ruoyi-admin/target/ruoyi-admin.jar')
 const apiPort = Number(process.env.TICKET04_API_PORT || 18001)

--
Gitblit v1.7.1