| | |
| | | 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'] |
| | |
| | | |
| | | ensureDetailFieldValues() |
| | | |
| | | // 测试名称只用于初始占位;详情已读回后不能覆盖用户保存的实验名称。 |
| | | watch(() => props.testName, value => { |
| | | if (value && !props.consoleDetail && !experimentName.value) experimentName.value = value |
| | | if (value) experimentName.value = value |
| | | }) |
| | | |
| | | watch(() => props.consoleDetail, value => applyConsoleDetail(value), { immediate: true, deep: true }) |
| | |
| | | delete detailFieldValues[field] |
| | | } |
| | | |
| | | function pruneDetailValues() { |
| | | const allowed = new Set(detailFields.value) |
| | | Object.keys(detailFieldValues).forEach(field => { |
| | | if (!allowed.has(field)) delete detailFieldValues[field] |
| | | }) |
| | | } |
| | | |
| | | async function saveConsoleDetail() { |
| | | detailSaving.value = true |
| | | try { |
| | |
| | | experimentName: experimentName.value.trim() || null, |
| | | batteryFields: [...batteryFields.value], |
| | | specialFields: [...specialFields.value], |
| | | fieldValues: Object.fromEntries(detailFields.value |
| | | .filter(field => detailFieldValues[field] != null) |
| | | .map(field => [field, { ...detailFieldValues[field] }])) 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 : '测试详情保存失败') |
| | |
| | | } else { |
| | | specialFields.value = [...specialTemplates[selectedSpecialTemplate.value]].filter(field => !batteryFields.value.includes(field)) |
| | | } |
| | | pruneDetailValues() |
| | | ensureDetailFieldValues() |
| | | templateDialogVisible.value = false |
| | | ElMessage.success('字段模板已应用') |
| | |
| | | <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` }"> |
| | |
| | | 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('') |
| | |
| | | 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') { |
| | |
| | | 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() { |
| | |
| | | </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 }))" /> |
| 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_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) |
| | | }) |
| | |
| | | } |
| | | |
| | | 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) |