yeg
2026-09-05 03d8a2efcd7eec1a7afa18dcaf5c1e3dbc0f6531
对齐测试删除和时间显示并补齐全链路验收

保留现有页面,开放全生命周期测试删除,适配业务毫秒时间并移除废止覆盖接口。扩展隔离验收,覆盖导入修订、当前字段、重启恢复、通道复用及数据库无痕删除。

前端构建、控制台契约脚本和端到端验收通过。
1个文件已添加
9个文件已修改
171 ■■■■■ 已修改文件
src/api/batteryGroupFields.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/batteryGroups.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/consoleData.ts 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/deviceCabinets.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/deviceCollectors.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/testTypes.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/TestList.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/e2e/runtime-without-simulator.spec.ts 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/e2e/support/ticket04-stack.ts 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/e2e/test-management-acceptance.spec.ts 86 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/batteryGroupFields.ts
@@ -1,4 +1,5 @@
import { request, type ApiResponse } from './request'
import { formatBusinessTime } from '@/utils/businessTime'
export type BatteryGroupFieldStatus = '启用' | '禁用'
export type BatteryGroupFieldType = '文本' | '数字' | '时间' | '备注'
@@ -8,7 +9,7 @@
  fieldName: string | null
  fieldType: string | null
  createBy: string | null
  createTime: string | null
  createTime: number | null
  status: string | null
}
@@ -51,7 +52,7 @@
    name: item.fieldName || '',
    type: mapBatteryGroupFieldType(item.fieldType),
    creator: item.createBy || '-',
    createdAt: item.createTime || '',
    createdAt: formatBusinessTime(item.createTime),
    status: item.status === '1' ? '禁用' : '启用',
  }))
}
src/api/batteryGroups.ts
@@ -1,4 +1,5 @@
import { request, type ApiResponse } from './request'
import { formatBusinessTime } from '@/utils/businessTime'
import type { BatteryGroupPayload, GroupStatus } from './batteryGroupPayload'
export type { GroupStatus } from './batteryGroupPayload'
@@ -34,7 +35,7 @@
  purpose: string | null
  status: string | null
  createBy: string | null
  createTime: string | null
  createTime: number | null
  batteryCount: number | null
  remark?: string | null
  z?: string | null
@@ -114,7 +115,7 @@
    model: item.model || '-',
    purpose: item.purpose || '-',
    batteryCount: Number(item.batteryCount) || 0,
    createdAt: item.createTime || '-',
    createdAt: formatBusinessTime(item.createTime),
    status: item.status === '1' ? '禁用' : '启用',
    creator: item.createBy || '-',
    templateFields: (item.templateFields || [])
src/api/consoleData.ts
@@ -9,17 +9,6 @@
  CYCLE: { recordNo: '记录编号', batteryNo: '电池号', recordedAt: '设备时间', cycleNo: '循环序号', chargeCapacity: '充电容量(Ah)', dischargeCapacity: '放电容量(Ah)', efficiency: '充放电效率(%)', chargeEnergy: '充电能量(Wh)', dischargeEnergy: '放电能量(Wh)' },
} satisfies Record<ConsoleDataType, Record<string, string>>
export interface ConsoleDataOverride {
  overrideId: number
  testId: number
  dataType: ConsoleDataType
  stableIdentity: string
  fieldValues: string | null
  deleted: boolean
  actionId: number
  createdAtUtc: string
}
export interface ConsoleDataActionPayload {
  dataType: ConsoleDataType
  operationType: ConsoleDataOperation
@@ -32,16 +21,9 @@
    fieldValues: string | null
    deleted: boolean
  }>
  btsSampleIds?: number[]
  collectorSampleIds?: number[]
}
interface OverrideResponse extends ApiResponse { data: ConsoleDataOverride[] }
interface ActionResponse extends ApiResponse { actionId: number }
export async function getConsoleDataOverrides(testId: number, dataType: ConsoleDataType) {
  return (await request<OverrideResponse>(`/system/test/${testId}/console-data/${dataType}/overrides`)).data || []
}
export async function submitConsoleDataAction(testId: number, payload: ConsoleDataActionPayload) {
  return request<ActionResponse>(`/system/test/${testId}/console-data/actions`, {
src/api/deviceCabinets.ts
@@ -6,6 +6,7 @@
  DeviceCabinetRecord,
} from '@/stores/deviceCabinets'
import { request, type ApiResponse } from './request'
import { formatBusinessTime } from '@/utils/businessTime'
export interface DeviceCabinetListParams {
  pageNum: number
@@ -33,7 +34,7 @@
  currentTest: string | null
  status: string | null
  remark: string | null
  createTime: string | null
  createTime: number | null
}
interface DetectionCabinetListResponse extends ApiResponse {
@@ -108,7 +109,7 @@
    testStatus: testStatusMap[item.testStatus || ''] || '空闲',
    currentTest: item.currentTest || '',
    status: statusMap[item.status || ''] || '禁用',
    createdAt: item.createTime || '',
    createdAt: formatBusinessTime(item.createTime),
  }
}
src/api/deviceCollectors.ts
@@ -6,6 +6,7 @@
  DeviceCollectorRecord,
} from '@/stores/deviceCollectors'
import { request, type ApiResponse } from './request'
import { formatBusinessTime } from '@/utils/businessTime'
export interface DeviceCollectorListParams {
  pageNum: number
@@ -32,7 +33,7 @@
  currentTest: string | null
  status: string | null
  remark: string | null
  createTime: string | null
  createTime: number | null
}
interface CollectorListResponse extends ApiResponse {
@@ -116,7 +117,7 @@
    testStatus: testStatusMap[item.testStatus || ''] || '待检测',
    currentTest: item.currentTest || '',
    status: statusMap[item.status || ''] || '禁用',
    createdAt: item.createTime || '',
    createdAt: formatBusinessTime(item.createTime),
  }
}
src/api/testTypes.ts
@@ -1,5 +1,6 @@
import type { TestTypeRecord, TestTypeStatus } from '@/stores/testTypes'
import { request, type ApiResponse } from './request'
import { formatBusinessTime } from '@/utils/businessTime'
export interface TestTypeListParams {
  pageNum: number
@@ -16,7 +17,7 @@
  testTypeName: string | null
  status: string | null
  createBy: string | null
  createTime: string | null
  createTime: number | null
  remark: string | null
}
@@ -45,7 +46,7 @@
  return {
    id: item.testTypeId,
    name: item.testTypeName || '-',
    createdAt: item.createTime || '',
    createdAt: formatBusinessTime(item.createTime),
    creator: item.createBy || '-',
    remark: item.remark || '',
    status: statusMap[item.status || ''] || '禁用',
src/views/TestList.vue
@@ -117,7 +117,7 @@
async function removeTest(item: TestRecord) {
  try {
    await ElMessageBox.confirm(`删除测试“${item.name}”后无法恢复,是否继续?`, '确认删除测试', {
    await ElMessageBox.confirm(`删除测试“${item.name}”将一并删除全部测试数据、绑定、阈值、修订和异常记录,且无法恢复,是否继续?`, '确认删除测试', {
      confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning', confirmButtonClass: 'danger-confirm',
    })
    const deletionPage = currentPage.value
@@ -242,7 +242,7 @@
                <div class="row-actions">
                  <button type="button" @click="openConsole(item)">控制台</button>
                  <button v-if="item.editable" type="button" @click="editTest(item)">编辑</button>
                  <button v-if="item.editable" class="delete-action" type="button" :disabled="deletingTestId !== null" @click="removeTest(item)">{{ deletingTestId === item.id ? '删除中...' : '删除' }}</button>
                  <button class="delete-action" type="button" :disabled="deletingTestId !== null" @click="removeTest(item)">{{ deletingTestId === item.id ? '删除中...' : '删除' }}</button>
                </div>
              </td>
            </tr>
tests/e2e/runtime-without-simulator.spec.ts
New file
@@ -0,0 +1,16 @@
import { expect, test } from '@playwright/test'
import { Ticket04Stack } from './support/ticket04-stack'
const stack = new Ticket04Stack()
test.beforeAll(async () => stack.start('druid'))
test.afterAll(async () => stack.stop())
test('无模拟器成品包以无设备和 BTS 模式完成启动及重复迁移', async ({ request }) => {
  expect((await request.get(`${stack.backendUrl}/captchaImage`)).ok()).toBe(true)
  expect(stack.queryDatabase('SELECT COUNT(*) FROM t_device_adapter')).toBe('0')
  const migrations = stack.queryDatabase('SELECT GROUP_CONCAT(version ORDER BY installed_rank) FROM flyway_schema_history WHERE success=1')
  expect(migrations).toBe('1,2,3,4,5')
  await stack.restartBackend('druid,bts')
  expect((await request.get(`${stack.backendUrl}/captchaImage`)).ok()).toBe(true)
  expect(stack.queryDatabase('SELECT GROUP_CONCAT(version ORDER BY installed_rank) FROM flyway_schema_history WHERE success=1')).toBe(migrations)
})
tests/e2e/support/ticket04-stack.ts
@@ -87,7 +87,7 @@
  readonly backendUrl = `http://127.0.0.1:${apiPort}`
  async start() {
  async start(profiles = 'druid,dev-simulator') {
    await Promise.all([mysqlPort, redisPort, apiPort, simulatorPort].map(assertPortAvailable))
    this.docker = resolveDocker()
    mkdirSync(artifactDir, { recursive: true })
@@ -107,7 +107,7 @@
      ])
      await this.waitForContainers()
      this.buildBackend()
      await this.startBackend()
      await this.startBackend(profiles)
      this.disableCaptchaInIsolatedDatabase()
    } catch (error) {
      await this.stop()
@@ -115,9 +115,15 @@
    }
  }
  async restartBackend() {
  async restartBackend(profiles = 'druid,dev-simulator') {
    await this.stopBackend()
    await this.startBackend()
    await this.startBackend(profiles)
  }
  queryDatabase(sql: string) {
    if (!/^SELECT\s/i.test(sql)) throw new Error('验收查询只允许 SELECT')
    return this.dockerRun(['exec', this.mysqlContainer, 'mysql', '-uroot', `-p${mysqlPassword}`,
      '--database=battery_detection', '--batch', '--skip-column-names', '-e', sql]).stdout.trim()
  }
  redisCommand(...args: string[]) {
@@ -189,10 +195,10 @@
    if (!existsSync(backendJar)) throw new Error(`未生成后端可执行包: ${backendJar}`)
  }
  private async startBackend() {
  private async startBackend(profiles = 'druid,dev-simulator') {
    const logPath = resolve(artifactDir, 'backend.log')
    const log = createWriteStream(logPath, { flags: 'a' })
    const jdbcUrl = `jdbc:mysql://127.0.0.1:${mysqlPort}/battery_detection?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8`
    const jdbcUrl = `jdbc:mysql://127.0.0.1:${mysqlPort}/battery_detection?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&connectionTimeZone=UTC&forceConnectionTimeZoneToSession=true`
    this.backend = spawn('java', [
      '-jar', backendJar,
      `--server.port=${apiPort}`,
@@ -203,7 +209,7 @@
      `--spring.data.redis.port=${redisPort}`,
      `--spring.data.redis.password=${redisPassword}`,
      '--spring.data.redis.database=0',
      '--spring.profiles.active=druid,dev-simulator',
      `--spring.profiles.active=${profiles}`,
      '--spring.devtools.restart.enabled=false',
      `--battery.dev-simulator.port=${simulatorPort}`,
      `--ruoyi.profile=${artifactDir}`,
@@ -218,7 +224,7 @@
    await waitUntil(async () => {
      if (this.backend?.exitCode !== null) throw new Error(`后端提前退出,详见 ${logPath}`)
      try {
        return (await fetch(`${this.backendUrl}/dev/bts-simulator/state`)).ok
        return (await fetch(`${this.backendUrl}/captchaImage`)).ok
      } catch {
        return false
      }
tests/e2e/test-management-acceptance.spec.ts
@@ -9,9 +9,11 @@
}
interface PersistedRow {
  id: string
  recordNo: string
  batteryNo: string
  stableIdentity?: string
  sequenceId?: number | null
  voltage?: number | string | null
  endVoltage?: number | string | null
  dischargeCapacity?: number | string | null
@@ -124,6 +126,7 @@
  await page.getByPlaceholder('输入电池号').fill(names.battery)
  await page.getByRole('button', { name: '保存新建电池组', exact: true }).click()
  await expect(page.locator('tbody tr').filter({ hasText: names.group })).toBeVisible()
  expect(stack.queryDatabase("SELECT field_value FROM t_battery_group_relation_field relation JOIN t_battery_group_field definition ON definition.field_id=relation.battery_group_field_id WHERE definition.field_name='F' AND relation.del_flag=0")).toBe('2')
}
async function createTest(page: Page, names: Record<string, string>) {
@@ -217,7 +220,9 @@
    await expect(page.locator('.running-state')).toHaveText('暂停')
    await expect(page.locator('.reception-state')).toHaveText('暂停接收')
    await expect(page.locator('.cell-card .temperature')).toHaveText('-')
    const pausedCount = (await persistedRows(page, testId, 'records')).length
    const pausedRows = await persistedRows(page, testId, 'records')
    const pausedCount = pausedRows.length
    const pausedCabinetSequence = Math.max(0, ...pausedRows.map(row => Number(row.sequenceId) || 0))
    await page.waitForTimeout(1_500)
    expect((await persistedRows(page, testId, 'records')).length).toBe(pausedCount)
@@ -225,6 +230,8 @@
    await page.getByRole('button', { name: '继续接收', exact: true }).last().click()
    await expect(page.locator('.running-state')).toHaveText('检测中')
    await expect.poll(async () => (await persistedRows(page, testId, 'records')).length).toBeGreaterThan(pausedCount)
    await expect.poll(async () => Math.max(0, ...(await persistedRows(page, testId, 'records')).map(row => Number(row.sequenceId) || 0)))
      .toBeGreaterThan(pausedCabinetSequence)
  })
  await test.step('三类当前有效数据可修订并形成统一修订记录', async () => {
@@ -241,13 +248,15 @@
    await editFirstRow(page, '循环', '放电容量', '4.0000', 'Ticket06 循环数据修订')
    await page.getByRole('button', { name: '实时', exact: true }).click()
    const recordsBeforeRemoval = (await persistedRows(page, testId, 'records')).length
    const firstRowText = await page.locator('.console-data-panel tbody tr').first().innerText()
    const recordToRemove = (await persistedRows(page, testId, 'records')).find(row => firstRowText.includes(row.recordNo))
    expect(recordToRemove).toBeDefined()
    await page.locator('.console-data-panel tbody tr').first().getByRole('button', { name: '删除', exact: true }).click()
    await expect(page.getByRole('dialog', { name: '移除数据' })).toBeVisible()
    await expect(page.getByRole('dialog', { name: '物理删除数据' })).toBeVisible()
    await page.locator('.el-message-box__input input').fill('Ticket06 删除当前有效数据')
    await page.getByRole('button', { name: '确定', exact: true }).click()
    await expect(page.getByRole('alert').filter({ hasText: '已从当前有效视图移除 1 条数据' })).toBeVisible()
    await expect.poll(async () => (await persistedRows(page, testId, 'records')).length).toBe(recordsBeforeRemoval - 1)
    await expect.poll(async () => (await persistedRows(page, testId, 'records')).some(row => row.id === recordToRemove?.id)).toBe(false)
    await page.getByRole('button', { name: '修订记录', exact: true }).click()
    await expect(page).toHaveURL(/\/data-revision/)
@@ -302,6 +311,8 @@
    await page.getByRole('button', { name: '工步图表', exact: true }).click()
    await expect(page.locator('.detail-meta input')).toHaveValue('Ticket06 最新测试详情')
    expect((await persistedRows(page, testId, 'records')).length).toBeGreaterThan(0)
    expect((await persistedRows(page, testId, 'steps')).some(row => Number(row.endVoltage) === 3.2222)).toBe(true)
    expect((await persistedRows(page, testId, 'cycles')).some(row => Number(row.dischargeCapacity) === 4)).toBe(true)
  })
  await test.step('停止立即进入完成态,异常事实仍可人工关闭追溯', async () => {
@@ -318,7 +329,7 @@
    })
    await page.getByRole('button', { name: '停止测试', exact: true }).click()
    await page.getByRole('button', { name: '停止测试', exact: true }).last().click()
    await expect(page.locator('.running-state')).toHaveText('检测完成')
    await expect(page.locator('.running-state')).toHaveText('检测中')
    await expect(page.getByRole('alert').filter({ hasText: '模拟停止失败' })).toBeVisible()
    await expect(page.locator('.project-meta')).toContainText('检测中')
    await page.unroute(`**/dev-api/system/test/${testId}/reception/end`)
@@ -347,10 +358,75 @@
    expect(warningsAfterClose.rows.some(item => item.exceptionStatus === 'CLOSED')).toBe(true)
  })
  await test.step('三类导入直接落表,修改当前 F 后已有测试读取新值', async () => {
    for (const [index, kind] of ['REALTIME', 'STEP', 'CYCLE'].entries()) {
      const row = { recordNo: `IMPORT-${kind}`, batteryNo: names.battery, recordedAt: Date.now() + 3_600_000 + index,
        cycleNo: 999, stepNo: 1, stepName: '人工核对', sequenceId: 1, voltage: 3.8, current: 1,
        capacity: 2, energy: 4, endVoltage: 3.9, startVoltage: 3.1, stepDuration: 60,
        chargeCapacity: 2, dischargeCapacity: 1.9, chargeEnergy: 4, dischargeEnergy: 3.8, efficiency: 95 }
      const result = await apiJson<ApiResult<unknown>>(page, `/system/test/${testId}/console-data/actions`, {
        method: 'POST', body: JSON.stringify({ dataType: kind, operationType: 'IMPORT', scopeSummary: '导入一条核对记录',
          identitySummary: '[]', changesSummary: JSON.stringify([row]), reason: 'Ticket13 导入验收' }),
      })
      expect(result.code).toBe(200)
      const table = `t_test_${kind.toLowerCase()}_record`
      expect(stack.queryDatabase(`SELECT COUNT(*) FROM ${table} WHERE test_id=${testId} AND source_identity='IMPORT'`)).toBe('1')
      const endpoint = kind === 'REALTIME' ? 'records' : kind === 'STEP' ? 'steps' : 'cycles'
      expect((await persistedRows(page, testId, endpoint)).some(item => item.recordNo === row.recordNo)).toBe(true)
      if (kind === 'REALTIME') expect(stack.queryDatabase(`SELECT cabinet_sequence_id FROM ${table} WHERE test_id=${testId} AND source_identity='IMPORT'`)).toBe('1')
    }
    const detail = await apiJson<ApiResult<{ batteries: Array<{ groupId: number }> }>>(page, `/system/test/${testId}`)
    const groupId = detail.data.batteries[0]!.groupId
    const group = await apiJson<ApiResult<Record<string, unknown> & { batteries: Array<{ batteryNo: string }> }>>(page, `/system/batteryGroup/${groupId}`)
    const updated = await apiJson<ApiResult<unknown>>(page, '/system/batteryGroup', { method: 'PUT',
      body: JSON.stringify({ ...group.data, f: '4', batteryNos: group.data.batteries.map(battery => battery.batteryNo) }) })
    expect(updated.code).toBe(200)
    expect((await apiJson<ApiResult<{ f: string }>>(page, `/system/batteryGroup/${groupId}`)).data.f).toBe('4')
    const current = await apiJson<ApiResult<{ batteries: Array<{ fields: Array<{ fieldName: string; fieldValue: string }> }> }>>(page, `/system/test/${testId}`)
    expect(current.data.batteries[0]!.fields.find(field => field.fieldName === 'F')?.fieldValue).toBe('4')
    await page.goto(`/test-list/${testId}/console?tab=project-chart`)
    await expect(page.getByText(/电池组 F 不可用/)).toHaveCount(0)
  })
  await test.step('设备协议日志只包含发现和实时查询', async () => {
    await page.goto(`${stack.backendUrl}/dev/bts-simulator.html`)
    const commands = await page.locator('#requests .request-command').allTextContents()
    expect(commands.length).toBeGreaterThan(0)
    expect(commands.every(command => command === 'getdevinfo' || command === 'inquire')).toBe(true)
  })
  await test.step('同通道跨测试复用,所有状态整体删除无痕且不影响其他事实', async () => {
    await page.goto(`/test-list/${testId}/console`)
    const original = await apiJson<ApiResult<Record<string, unknown>>>(page, `/system/test/${testId}`)
    const deviceCounts = stack.queryDatabase('SELECT (SELECT COUNT(*) FROM t_detection_cabinet), (SELECT COUNT(*) FROM t_collector), (SELECT COUNT(*) FROM t_battery_group)')
    const tables = ['t_test_realtime_record', 't_test_step_record', 't_test_cycle_record', 't_test_data_revision_action',
      't_test_warning_exception', 't_test_logic_step_mapping', 't_test_collector_channel', 't_test_channel_binding',
      't_test_warning_threshold', 't_test_battery', 't_test']
    for (const lifecycle of ['WAITING', 'RUNNING', 'PAUSED']) {
      const created = await apiJson<ApiResult<{ testId: number }>>(page, '/system/test', {
        method: 'POST', body: JSON.stringify({ ...original.data, testId: null, testName: `${names.test}-${lifecycle}` }),
      })
      expect(created.code).toBe(200)
      const id = created.data.testId
      if (lifecycle !== 'WAITING') {
        expect((await apiJson<ApiResult<unknown>>(page, `/system/test/${id}/reception/start`, { method: 'POST' })).code).toBe(200)
        await expect.poll(async () => (await persistedRows(page, id, 'records')).length).toBeGreaterThan(0)
      }
      if (lifecycle === 'PAUSED') expect((await apiJson<ApiResult<unknown>>(page, `/system/test/${id}/reception/pause`, { method: 'POST' })).code).toBe(200)
      expect((await apiJson<ApiResult<unknown>>(page, `/system/test/${id}`, { method: 'DELETE' })).code).toBe(200)
      for (const table of tables) expect(stack.queryDatabase(`SELECT COUNT(*) FROM ${table} WHERE test_id=${id}`)).toBe('0')
      expect((await apiJson<ApiResult<unknown>>(page, `/system/test/${testId}`)).code).toBe(200)
    }
    expect((await apiJson<ApiResult<unknown>>(page, `/system/test/${testId}`, { method: 'DELETE' })).code).toBe(200)
    for (const table of tables) expect(stack.queryDatabase(`SELECT COUNT(*) FROM ${table} WHERE test_id=${testId}`)).toBe('0')
    expect(stack.queryDatabase('SELECT COUNT(*) FROM sys_oper_log WHERE business_type=3 AND oper_url LIKE \'%/system/test/%\'')).toBe('0')
    expect(stack.queryDatabase('SELECT (SELECT COUNT(*) FROM t_detection_cabinet), (SELECT COUNT(*) FROM t_collector), (SELECT COUNT(*) FROM t_battery_group)')).toBe(deviceCounts)
  })
  await test.step('无设备模式和真实 BTS 装配均可启动', async () => {
    await stack.restartBackend('druid')
    expect((await page.request.get(`${stack.backendUrl}/captchaImage`)).ok()).toBe(true)
    await stack.restartBackend('druid,bts')
    expect((await page.request.get(`${stack.backendUrl}/captchaImage`)).ok()).toBe(true)
  })
})