yeg
2026-09-06 1761c7efd3dbd621752dad0d78f9d9b3f629b364
接通对比报表删除和正式操作日志查询

保留列表确认流程,按后端权限删除全部版本,失败保留记录且成功刷新。操作日志页替换静态数据,补充隔离浏览器验收覆盖事务、权限、来源独立及日志保留。
1个文件已删除
2个文件已添加
4个文件已修改
347 ■■■■■ 已修改文件
docs/pages/report-compare.md 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/compareReports.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/operationLogs.ts 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/stores/operationLogs.ts 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/OperationLog.vue 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ReportCompare.vue 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/e2e/compare-delete-with-audit.spec.ts 184 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/pages/report-compare.md
@@ -1,6 +1,6 @@
# 对比报表页面
## 当前实现(数据分析 Ticket04)
## 当前实现(数据分析 Ticket06 待用户验收)
- 正式数据来自 `src/api/compareReports.ts`,不使用本地报表 Store;首次保存与更新来源由后端重新读取所选持久化当前有效数据。
- 原列表筛选和分页保留;“编辑报表”读取最新快照,“修订记录”与详情“历史版本”共用正式版本摘要。
@@ -8,7 +8,10 @@
- 默认详情读取最新版本;`?mode=view&version=N` 查看指定历史版,刷新保持对应版本,不提供回滚或删除历史版本。
- “按最新来源生成版本”是独立动作,填写说明后重读当前来源并冻结;保留人工表格值,旧版和来源事实不被改写。来源测试已删除则拒绝更新,普通编辑及历史查看仍可使用保存快照。
- 查看与历史接口执行 `system:compare-report:query`,编辑基准、普通编辑和更新来源执行 `system:compare-report:edit`;不限制为创建人本人。
- 打印、模板持久化和整份报表删除尚未接入。本节为当前实施事实;下文为保留的旧原型说明,不作为新 Ticket 验收依据。
- Ticket05 已接入指定保存版本打印及真实修订 CSV,导出权限为 `system:compare-report:export`;未保存先提示保存,打印不记录 PDF 下载成功。
- 列表“删除”按 `system:compare-report:remove` 显示并调用正式 DELETE 接口,不限制创建人;确认范围包含整份报表及全部版本,不影响来源测试。取消不提交,失败保留正式记录,成功后刷新列表。
- “操作日志”页复用平台查询接口显示真实日志,报表删除只保留身份、操作人和时间,不包含测量快照;删除与日志同步提交,平台清理接口不能删除该记录。日志查询沿用 `monitor:operlog:list` 权限。
- 模板持久化仍待后续 Ticket。本节为当前实施事实;下文为保留的旧原型说明,不作为新 Ticket 验收依据。
- 路由:`/report-compare`
- 新增路由:`/report-compare/new`
src/api/compareReports.ts
@@ -14,6 +14,11 @@
  return (await request<ApiResponse & { rows: CompareReportSummary[] }>('/system/compare-report/list')).rows
}
/** 删除整份报表及全部版本,后端在同一事务保留最小删除日志。 */
export async function deleteCompareReport(id: number) {
  await request<ApiResponse>(`/system/compare-report/${id}`, { method: 'DELETE' })
}
export interface CompareSelection {
  testId: number
  testBatteryId: number
src/api/operationLogs.ts
New file
@@ -0,0 +1,44 @@
import { request, type ApiResponse } from './request'
interface PlatformOperationLog {
  operId: number
  operName: string
  title: string
  method: string
  businessType: number
  operParam: string
  operTime: string
  status: number
}
export interface OperationLogRecord {
  id: number
  operator: string
  content: string
  operatedAt: string
}
function operationContent(item: PlatformOperationLog) {
  if (item.method === 'compare-report.delete') {
    try {
      const identity = JSON.parse(item.operParam)
      if (typeof identity.reportId === 'number' && typeof identity.reportName === 'string')
        return `删除对比报表“${identity.reportName}”(#${identity.reportId})及全部版本`
    } catch { /* 非结构化平台日志仍展示模块与动作。 */ }
  }
  const actions = ['操作', '新增', '修改', '删除', '授权', '导出', '导入', '强退', '生成代码', '清空数据']
  return `${item.title}:${actions[item.businessType] || '操作'}(${item.status === 0 ? '成功' : '失败'})`
}
/** 读取正式平台日志供既有本地筛选分页使用,不展示通用请求或响应中的业务内容。 */
export async function listOperationLogs(): Promise<OperationLogRecord[]> {
  const records: PlatformOperationLog[] = []
  for (let pageNum = 1; ; pageNum++) {
    const result = await request<ApiResponse & { rows: PlatformOperationLog[]; total: number }>(`/monitor/operlog/list?pageNum=${pageNum}&pageSize=100`)
    records.push(...result.rows)
    if (!result.rows.length || records.length >= result.total) break
  }
  return records.map(item => ({ id: item.operId, operator: item.operName || '-',
    content: operationContent(item), operatedAt: item.operTime || '',
  }))
}
src/stores/operationLogs.ts
File was deleted
src/views/OperationLog.vue
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { operationLogRecords } from '@/stores/operationLogs'
import { listOperationLogs, type OperationLogRecord } from '@/api/operationLogs'
interface FilterState {
  id: string
@@ -13,11 +13,20 @@
const appliedFilters = ref<FilterState>({ id: '', operator: '全部', dateRange: null })
const currentPage = ref(1)
const pageSize = ref(10)
const operatorOptions = computed(() => [...new Set(operationLogRecords.map(item => item.operator))].sort((a, b) => a.localeCompare(b, 'zh-CN')))
const operationLogRecords = ref<OperationLogRecord[]>([])
const loading = ref(false)
const loadError = ref('')
onMounted(async () => {
  loading.value = true
  try { operationLogRecords.value = await listOperationLogs() } catch (error) {
    loadError.value = error instanceof Error ? error.message : '操作日志加载失败'
  } finally { loading.value = false }
})
const operatorOptions = computed(() => [...new Set(operationLogRecords.value.map(item => item.operator))].sort((a, b) => a.localeCompare(b, 'zh-CN')))
const filteredRecords = computed(() => {
  const query = appliedFilters.value
  return operationLogRecords
  return operationLogRecords.value
    .filter(item => !query.id.trim() || String(item.id).includes(query.id.trim()))
    .filter(item => query.operator === '全部' || item.operator === query.operator)
    .filter(item => !query.dateRange || (item.operatedAt.slice(0, 10) >= query.dateRange[0] && item.operatedAt.slice(0, 10) <= query.dateRange[1]))
@@ -48,6 +57,7 @@
<template>
  <div class="operation-log-page">
    <el-alert v-if="loadError" :title="loadError" type="error" :closable="false" show-icon />
    <section class="filter-panel" aria-label="操作日志查询条件">
      <header class="section-heading"><div><i></i><strong>查询条件</strong><span>OPERATION LOG QUERY</span></div><small>共 {{ operationLogRecords.length }} 条日志</small></header>
      <div class="filter-grid">
@@ -65,7 +75,7 @@
          <thead><tr><th>日志ID</th><th>操作人</th><th>操作内容</th><th>操作时间</th></tr></thead>
          <tbody>
            <tr v-for="item in pagedRecords" :key="item.id"><td class="id-cell">#{{ item.id }}</td><td><span class="operator-avatar">{{ item.operator.slice(0, 1) }}</span><strong>{{ item.operator }}</strong></td><td :title="item.content">{{ item.content }}</td><td class="time-cell">{{ item.operatedAt }}</td></tr>
            <tr v-if="!pagedRecords.length"><td colspan="4" class="empty-cell">暂无符合条件的操作日志</td></tr>
            <tr v-if="!pagedRecords.length"><td colspan="4" class="empty-cell">{{ loading ? '正在加载操作日志' : loadError ? '操作日志加载失败' : '暂无符合条件的操作日志' }}</td></tr>
          </tbody>
        </table>
      </div>
src/views/ReportCompare.vue
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import { listCompareReports } from '@/api/compareReports'
import { deleteCompareReport, listCompareReports } from '@/api/compareReports'
import type { CompareReportRecord } from '@/stores/compareReports'
import { getUserInfo } from '@/api/auth'
import CompareReportHistoryDialog from '@/components/CompareReportHistoryDialog.vue'
@@ -20,6 +20,8 @@
const canCreate = ref(false)
const canEdit = ref(false)
const canExport = ref(false)
const canDelete = ref(false)
const deletingId = ref<number | null>(null)
const historyVisible = ref(false)
const historyReportId = ref<number | null>(null)
onMounted(async () => {
@@ -29,10 +31,8 @@
    canCreate.value = Boolean(info.permissions?.some(item => item === '*:*:*' || item === 'system:compare-report:add'))
    canEdit.value = Boolean(info.permissions?.some(item => item === '*:*:*' || item === 'system:compare-report:edit'))
    canExport.value = Boolean(info.permissions?.some(item => item === '*:*:*' || item === 'system:compare-report:export'))
    records.value = (await listCompareReports()).map(item => ({ ...item,
      createdAt: new Date(item.createdAt).toLocaleString('sv-SE'),
      baselineTest: '', comparisonTest: '', description: '', revisions: [],
    }))
    canDelete.value = Boolean(info.permissions?.some(item => item === '*:*:*' || item === 'system:compare-report:remove'))
    await loadReports()
  } catch (error) {
    loadError.value = error instanceof Error ? error.message : '报表列表加载失败'
  } finally { loading.value = false }
@@ -87,6 +87,39 @@
  router.push({ path: `/report-compare/${item.id}/edit`, query: { mode: 'view' } })
}
async function loadReports() {
  records.value = (await listCompareReports()).map(item => ({ ...item,
    createdAt: new Date(item.createdAt).toLocaleString('sv-SE'),
    baselineTest: '', comparisonTest: '', description: '', revisions: [],
  }))
  currentPage.value = Math.min(currentPage.value, Math.max(1, Math.ceil(filteredRecords.value.length / pageSize.value)))
  loadError.value = ''
}
async function deleteReport(item: CompareReportRecord) {
  if (deletingId.value !== null) return
  deletingId.value = item.id
  try {
    await ElMessageBox.confirm(`将删除对比报表“${item.name}”(#${item.id})及全部版本,删除后无法恢复,不影响来源测试及其数据。是否继续?`, '确认删除对比报表', {
      confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning',
    })
  } catch {
    deletingId.value = null
    return
  }
  try {
    await deleteCompareReport(item.id)
    // 正式删除成功后才移除展示缓存;刷新失败不能误报删除失败。
    records.value = records.value.filter(record => record.id !== item.id)
    ElMessage.success('对比报表及全部版本已删除')
    try { await loadReports() } catch (error) {
      loadError.value = `报表已删除,列表刷新失败:${error instanceof Error ? error.message : '请刷新页面重试'}`
    }
  } catch (error) {
    ElMessage.error(`删除失败:${error instanceof Error ? error.message : '请稍后重试'}`)
  } finally { deletingId.value = null }
}
</script>
<template>
@@ -110,7 +143,7 @@
          <tbody>
            <tr v-for="item in pagedRecords" :key="item.id">
              <td class="id-cell">#{{ item.id }}</td><td :title="item.name"><strong>{{ item.name }}</strong></td><td>{{ item.createdAt }}</td><td>{{ item.creator }}</td>
              <td class="action-column"><div class="row-actions"><button type="button" @click="openView(item)">查看报表</button><button v-if="canEdit" type="button" @click="router.push({ path: `/report-compare/${item.id}/edit`, query: { mode: 'edit' } })">编辑报表</button><button type="button" @click="historyReportId = item.id; historyVisible = true">修订记录</button></div></td>
              <td class="action-column"><div class="row-actions"><button type="button" @click="openView(item)">查看报表</button><button v-if="canEdit" type="button" @click="router.push({ path: `/report-compare/${item.id}/edit`, query: { mode: 'edit' } })">编辑报表</button><button type="button" @click="historyReportId = item.id; historyVisible = true">修订记录</button><button v-if="canDelete" class="delete-action" type="button" :disabled="deletingId !== null" @click="deleteReport(item)">删除</button></div></td>
            </tr>
            <tr v-if="!pagedRecords.length"><td colspan="5" class="empty-cell">{{ loading ? '正在加载报表' : loadError ? '报表列表加载失败' : '暂无符合条件的对比报表' }}</td></tr>
          </tbody>
@@ -164,6 +197,7 @@
.row-actions button { border: 0; color: var(--blue); background: transparent; font-size: 12px; font-weight: 500; cursor: pointer; }
.row-actions button:hover { color: #176fcb; text-decoration: underline; }
.row-actions .delete-action { color: #ad4a4a; }
.row-actions button:disabled { opacity: .55; cursor: wait; }
.empty-cell { height: 160px; color: #8795a3; text-align: center; }
.pagination-bar { min-height: 58px; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 8px 14px 8px 16px; border-top: 1px solid var(--line); }
.pagination-bar > span { color: #7b8794; font-size: 11px; }
tests/e2e/compare-delete-with-audit.spec.ts
New file
@@ -0,0 +1,184 @@
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 () => stack.start())
test.afterAll(async () => stack.stop())
async function login(page: Page, username = 'admin') {
  if (page.url().startsWith('http')) await page.evaluate(() => {
    localStorage.removeItem('battery_detection_auth_token')
    localStorage.removeItem('battery_detection_auth_user')
  })
  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 response = await page.request.fetch(`${stack.backendUrl}${path}`, {
    method, data, headers: { Authorization: `Bearer ${token}` },
  })
  const body = await response.json()
  expect(body.code, `${path}: ${body.msg}`).toBe(code)
  return body
}
test('删除整份报表及全部版本,日志同步保留且失败完整回滚', async ({ page }) => {
  // 已保存版本作为受控前置数据;删除只能依赖报表身份,不能重读来源或快照内容。
  stack.executeIsolatedSql(`
    INSERT INTO t_compare_report(report_id,report_name,creator_id,creator_name,created_at_utc)
    VALUES(8601,'删除验收报表',1,'admin',UTC_TIMESTAMP(3));
    INSERT INTO t_compare_report_version(report_id,version_no,snapshot_json,operator_id,operator_name,created_at_utc,change_summary)
    VALUES(8601,1,'{"name":"旧版","items":[{"voltage":3.7}]}',1,'admin',UTC_TIMESTAMP(3),'首次保存'),
          (8601,2,'{"name":"删除验收报表","items":[{"voltage":3.8}]}',1,'admin',UTC_TIMESTAMP(3),'修改名称');
  `)
  await login(page)
  const current = (await api(page, '/system/compare-report/8601')).data
  const history = (await api(page, '/system/compare-report/8601/versions/1')).data
  // 在最后一步日志写入注入失败,已执行的版本和身份删除必须一并回滚。
  stack.executeIsolatedSql("CREATE TRIGGER reject_report_delete_log BEFORE INSERT ON sys_oper_log FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='删除日志写入失败'")
  try {
    await api(page, '/system/compare-report/8601', 'DELETE', undefined, 500)
    expect((await api(page, '/system/compare-report/8601')).data).toEqual(current)
    expect((await api(page, '/system/compare-report/8601/versions/1')).data).toEqual(history)
    expect(stack.queryDatabase("SELECT COUNT(*) FROM sys_oper_log WHERE method='compare-report.delete'")).toBe('0')
  } finally { stack.executeIsolatedSql('DROP TRIGGER reject_report_delete_log') }
  await api(page, '/system/compare-report/8601', 'DELETE')
  expect(stack.queryDatabase('SELECT COUNT(*) FROM t_compare_report WHERE report_id=8601')).toBe('0')
  expect(stack.queryDatabase('SELECT COUNT(*) FROM t_compare_report_version WHERE report_id=8601')).toBe('0')
  await api(page, '/system/compare-report/8601', 'GET', undefined, 500)
  await api(page, '/system/compare-report/8601/versions/1', 'GET', undefined, 500)
  await api(page, '/system/compare-report/8601/versions/2/print', 'GET', undefined, 500)
  expect((await api(page, '/system/compare-report/list')).rows).toEqual([])
  const logs = (await api(page, '/monitor/operlog/list?title=' + encodeURIComponent('对比报表删除'))).rows
  expect(logs).toHaveLength(1)
  expect(logs[0]).toMatchObject({ title: '对比报表删除', businessType: 3, operName: 'admin', status: 0 })
  expect(JSON.parse(logs[0].operParam)).toEqual({ reportId: 8601, reportName: '删除验收报表', operatorId: 1 })
  expect(logs[0].operTime).toBeTruthy()
  expect(logs[0].jsonResult).toBe('')
  await api(page, '/system/compare-report/8601', 'DELETE', undefined, 500)
  expect(stack.queryDatabase("SELECT COUNT(*) FROM sys_oper_log WHERE method='compare-report.delete'")).toBe('1')
})
test('列表取消与失败保留记录,跨创建人删除不改变来源,日志只读可查', async ({ page }) => {
  const pageErrors: string[] = []
  page.on('pageerror', error => pageErrors.push(error.message))
  stack.executeIsolatedSql(`
    INSERT INTO t_battery_group(group_id,group_name,model) VALUES(8602,'删除验收组','型号');
    INSERT INTO t_test_type(test_type_id,test_type_name) VALUES(8602,'删除验收类型');
    INSERT INTO t_test(test_id,test_name,creator_id,principal_id,test_type_id,test_purpose,test_status,reception_status)
    VALUES(8602,'删除报表保留的来源',1,1,8602,'隔离验收','COMPLETED','ENDED');
    INSERT INTO t_test_battery(test_battery_id,test_id,group_id,battery_no,collector_id) VALUES(8602,8602,8602,'BAT-D',8602);
    INSERT INTO t_detection_cabinet(cabinet_id,adapter_code,cabinet_code,cabinet_name,vendor_device_type,vendor_device_id)
    VALUES(8602,'delete-fixture','delete-fixture','删除验收检测柜',27,1);
    INSERT INTO t_detection_channel(channel_id,adapter_code,cabinet_id,devtype,devid,subdevid,chlid) VALUES(8602,'delete-fixture',8602,27,1,1,1);
    INSERT INTO t_test_channel_binding(binding_id,test_id,test_battery_id,channel_id) VALUES(8602,8602,8602,8602);
    INSERT INTO t_test_realtime_record(test_id,test_battery_id,binding_id,adapter_code,source_identity,source_record_id,cabinet_devtype,cabinet_devid,cabinet_subdevid,cabinet_chlid,cabinet_sequence_id,cabinet_device_time_utc,server_received_at_utc,cycle_no,step_no,step_name,voltage_v,current_a,content_sha256)
    VALUES(8602,8602,8602,'delete-fixture','delete:27:1:1:1','1',27,1,1,1,1,'2026-09-01 00:00:10.123','2026-09-01 00:00:11.123',1,1,'充电',3.7,-2,UNHEX(SHA2('d1',256))),
          (8602,8602,8602,'delete-fixture','delete:27:1:1:1','2',27,1,1,1,2,'2026-09-01 00:01:10.123','2026-09-01 00:01:11.123',1,2,'放电',3.6,1,UNHEX(SHA2('d2',256)));
    INSERT INTO t_test_step_record(test_id,test_battery_id,binding_id,dedupe_key,server_received_at_utc,adapter_code,source_identity,source_record_id,adapter_timezone_id,device_time_utc,device_time_raw,cycle_no,step_no,voltage_v,content_sha256)
    VALUES(8602,8602,8602,'record:1','2026-09-01 00:01:11.123','delete-fixture','delete:27:1:1:1','1','Asia/Hong_Kong','2026-09-01 00:01:10.123','2026-09-01 08:01:10.123',1,1,3.7,UNHEX(SHA2('s1',256)));
    INSERT INTO t_test_cycle_record(test_id,test_battery_id,binding_id,dedupe_key,server_received_at_utc,adapter_code,source_identity,source_record_id,adapter_timezone_id,device_time_utc,device_time_raw,cycle_no,charge_energy_wh,discharge_energy_wh,content_sha256)
    VALUES(8602,8602,8602,'record:1','2026-09-01 00:01:11.123','delete-fixture','delete:27:1:1:1','1','Asia/Hong_Kong','2026-09-01 00:01:10.123','2026-09-01 08:01:10.123',1,0.1361,0.1694,UNHEX(SHA2('c1',256)));
  `)
  await login(page)
  const payload = {
    name: '跨用户删除报表', description: '说明不进入删除日志', experimentName: '删除验收',
    selections: [1, 2].map(stepNo => ({ testId: 8602, testBatteryId: 8602, cycleNo: 1, stepNo, logicalNo: null })),
    settings: { metrics: ['voltage'], seriesColors: ['#123456', '#109854'], batteryTemplateFields: [], specialFields: [], detailFieldValues: {} },
  }
  const v1 = (await api(page, '/system/compare-report', 'POST', payload)).data
  const v2 = (await api(page, `/system/compare-report/${v1.id}/versions`, 'POST', {
    name: '跨用户删除报表V2', description: payload.description, experimentName: payload.experimentName,
    settings: payload.settings, baseVersionNo: 1, changeSummary: '删除前保留两版',
  })).data
  const survivor = (await api(page, '/system/compare-report', 'POST', { ...payload, name: '来源删除仍保留报表' })).data
  const sourceTables = ['t_test', 't_test_battery', 't_test_realtime_record', 't_test_step_record', 't_test_cycle_record', 't_test_data_revision_action']
  const beforeSources = sourceTables.map(table => stack.queryDatabase(`SELECT * FROM ${table} WHERE test_id=8602`))
  const menus = (await api(page, '/system/menu/permissionList')).data
  const comparePage = menus.flatMap((module: any) => module.pages).find((item: any) => item.pageName === '对比报表')
  expect(comparePage.permissions.some((item: any) => item.perms === 'system:compare-report:remove')).toBe(true)
  for (const [username, actions] of [['delete_reader', ['查看']], ['delete_operator', ['查看', '删除']], ['delete_only', ['删除']]] as const) {
    const menuIds = comparePage.permissions.filter((item: any) => (actions as readonly string[]).includes(item.menuName)).map((item: any) => item.menuId)
    await api(page, '/system/role', 'POST', { roleName: username, roleKey: 'common', roleSort: 1, status: '0', menuIds })
    const role = (await api(page, `/system/role/list?roleName=${username}`)).rows[0]
    await api(page, '/system/user', 'POST', { userName: username, nickName: username, password: 'Report123!', status: '0', roleIds: [role.roleId] })
  }
  await login(page, 'delete_reader')
  await api(page, `/system/compare-report/${v1.id}`, 'DELETE', undefined, 403)
  await page.goto('/report-compare')
  await expect(page.getByRole('row').filter({ hasText: '跨用户删除报表V2' })).toBeVisible()
  await expect(page.getByRole('button', { name: '删除', exact: true })).toHaveCount(0)
  await api(page, '/monitor/operlog/list', 'GET', undefined, 403)
  await page.goto('/operation-log')
  await expect(page.locator('.operation-log-page .el-alert')).toBeVisible()
  await expect(page.locator('.operation-log-page .empty-cell')).toHaveText('操作日志加载失败')
  await login(page, 'delete_operator')
  await page.goto('/report-compare')
  const row = page.getByRole('row').filter({ hasText: '跨用户删除报表V2' })
  await row.getByRole('button', { name: '删除', exact: true }).click()
  const dialog = page.getByRole('dialog', { name: '确认删除对比报表' })
  await expect(dialog).toContainText('全部版本')
  await expect(dialog).toContainText('不影响来源测试')
  for (const width of [1440, 390]) {
    await page.setViewportSize({ width, height: 900 })
    await expect(dialog.getByRole('button', { name: '删除', exact: true })).toBeInViewport()
    await page.screenshot({ path: `tests/e2e/.artifacts/compare-delete-confirm-${width}.png`, fullPage: true })
  }
  await dialog.getByRole('button', { name: '取消', exact: true }).click()
  expect((await api(page, `/system/compare-report/${v1.id}`)).data).toEqual(v2)
  await page.setViewportSize({ width: 1440, height: 900 })
  // 正式后端日志故障在浏览器反馈失败,列表仍保留尚未删除的记录。
  stack.executeIsolatedSql("CREATE TRIGGER reject_browser_delete_log BEFORE INSERT ON sys_oper_log FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='删除日志写入失败'")
  try {
    await row.getByRole('button', { name: '删除', exact: true }).click()
    await dialog.getByRole('button', { name: '删除', exact: true }).click()
    await expect(page.locator('.el-message--error')).toBeVisible()
    await expect(row).toBeVisible()
    expect((await api(page, `/system/compare-report/${v1.id}`)).data).toEqual(v2)
  } finally { stack.executeIsolatedSql('DROP TRIGGER reject_browser_delete_log') }
  await row.getByRole('button', { name: '删除', exact: true }).click()
  await dialog.getByRole('button', { name: '删除', exact: true }).click()
  await expect(page.getByText('对比报表及全部版本已删除', { exact: true })).toBeVisible()
  await expect(row).toHaveCount(0)
  await page.reload()
  await expect(row).toHaveCount(0)
  expect(sourceTables.map(table => stack.queryDatabase(`SELECT * FROM ${table} WHERE test_id=8602`))).toEqual(beforeSources)
  expect(stack.queryDatabase(`SELECT COUNT(*) FROM t_compare_report_version WHERE report_id=${v1.id}`)).toBe('0')
  await login(page)
  const logs = (await api(page, '/monitor/operlog/list?title=' + encodeURIComponent('对比报表删除'))).rows
  const log = logs.find((item: any) => JSON.parse(item.operParam).reportId === v1.id)
  expect(log.operName).toBe('delete_operator')
  expect(JSON.parse(log.operParam).reportName).toBe('跨用户删除报表V2')
  await page.goto('/operation-log')
  await expect(page.getByRole('row').filter({ hasText: '跨用户删除报表V2' })).toContainText('delete_operator')
  await page.getByPlaceholder('输入日志ID').fill(String(log.operId))
  await page.getByRole('button', { name: '查询', exact: true }).click()
  await expect(page.locator('.operation-log-page .table-wrap tbody tr')).toHaveCount(1)
  await expect(page.locator('.operation-log-page .table-wrap tbody')).toContainText(`#${v1.id}`)
  await page.screenshot({ path: 'tests/e2e/.artifacts/compare-delete-log.png', fullPage: true })
  await page.route('**/dev-api/monitor/operlog/list?*', route => route.fulfill({ json: { code: 500, msg: '日志查询故障' } }), { times: 1 })
  await page.reload()
  await expect(page.locator('.operation-log-page .el-alert')).toContainText('日志查询故障')
  await page.reload()
  await expect(page.getByRole('row').filter({ hasText: '跨用户删除报表V2' })).toContainText('delete_operator')
  // 现有平台批量删除和清空接口不得移除必须保留的报表删除日志。
  await api(page, `/monitor/operlog/${log.operId}`, 'DELETE', undefined, 500)
  await api(page, '/monitor/operlog/clean', 'DELETE')
  expect(stack.queryDatabase(`SELECT COUNT(*) FROM sys_oper_log WHERE oper_id=${log.operId}`)).toBe('1')
  await api(page, '/system/test/8602', 'DELETE')
  expect((await api(page, `/system/compare-report/${survivor.id}`)).data).toEqual(survivor)
  expect(stack.queryDatabase("SELECT COUNT(*) FROM sys_oper_log WHERE oper_url='/system/test/8602'")).toBe('0')
  await login(page, 'delete_only')
  await api(page, `/system/compare-report/${survivor.id}`, 'GET', undefined, 403)
  await api(page, `/system/compare-report/${survivor.id}`, 'DELETE')
  expect(stack.queryDatabase(`SELECT COUNT(*) FROM t_compare_report_version WHERE report_id=${survivor.id}`)).toBe('0')
  expect(pageErrors).toEqual([])
})