yeg
2026-09-08 ce135477fd1703b5001264687656e5a363d29988
Merge commit '3f30e0c' into codex/ticket-03
1个文件已添加
3个文件已修改
135 ■■■■■ 已修改文件
src/components/ConsoleChartPanel.vue 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/TestConsole.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/e2e/console-detail-readback.spec.ts 114 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/e2e/support/ticket04-stack.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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` }">
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 }))" />
tests/e2e/console-detail-readback.spec.ts
New file
@@ -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)
})
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)