yeg
2026-09-01 81f048247645f7e476c56c3bb26d0872e8b421ad
接入数据修订行为页面

新增服务端分页查询和行为详情,支持三类数据筛选、删除摘要和返回测试控制台上下文。
1个文件已添加
3个文件已修改
466 ■■■■■ 已修改文件
src/api/dataRevisions.ts 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/ConsoleDataListPanel.vue 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/DataRevision.vue 240 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/DataRevisionDetail.vue 166 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/dataRevisions.ts
New file
@@ -0,0 +1,51 @@
import { request, type ApiResponse } from './request'
export type RevisionDataType = 'REALTIME' | 'STEP' | 'CYCLE'
export type RevisionOperationType = 'EDIT' | 'IMPORT' | 'DELETE'
export const revisionDataTypeLabels: Record<RevisionDataType, string> = { REALTIME: '实时', STEP: '工步', CYCLE: '循环' }
export const revisionOperationLabels: Record<RevisionOperationType, string> = { EDIT: '编辑', IMPORT: '导入', DELETE: '删除' }
export function formatRevisionTime(value: string | null | undefined) { return value ? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'medium' }).format(new Date(value)) : '-' }
export interface DataRevisionAction {
  actionId: number
  testId: number
  testName: string
  testReceptionStatus?: string
  dataType: RevisionDataType
  operationType: RevisionOperationType
  scopeSummary: string
  identitySummary: string
  changesSummary: string | null
  reason: string
  operatorId: number | null
  operatorName: string
  createdAtUtc: string
}
interface RevisionListResponse extends ApiResponse { total: number; rows: DataRevisionAction[] }
interface RevisionDetailResponse extends ApiResponse { data: DataRevisionAction }
export interface DataRevisionQuery {
  pageNum: number
  pageSize: number
  testId?: number
  testName?: string
  dataType?: RevisionDataType
  operationType?: RevisionOperationType
  beginCreatedAt?: string
  endCreatedAt?: string
}
export async function getDataRevisionActions(query: DataRevisionQuery) {
  const params = new URLSearchParams()
  Object.entries(query).forEach(([key, value]) => {
    if (value !== undefined && value !== null && value !== '') params.set(key, String(value))
  })
  const result = await request<RevisionListResponse>(`/system/data-revision/list?${params.toString()}`)
  return { total: Number(result.total) || 0, rows: result.rows || [] }
}
export async function getDataRevisionAction(actionId: number) {
  return (await request<RevisionDetailResponse>(`/system/data-revision/${actionId}`)).data
}
src/components/ConsoleDataListPanel.vue
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import * as XLSX from 'xlsx'
import { Download, EditPen, Refresh, Search, UploadFilled, Clock, Check } from '@element-plus/icons-vue'
import { getBtsCycles, getBtsRecords, getBtsSteps, type BtsCycleRecord, type BtsRecord, type BtsStepRecord } from '@/api/tests'
@@ -12,8 +12,9 @@
interface FilterState { batteryNo: string; dateRange: [string, string] | null; cycleNo: string; stepNo: string }
interface DataRow { id: string; recordNo: string; batteryNo: string; cycleNo: number; stepNo: number; stepName: string; sequenceId: number; recordedAt: string; absoluteTime: string; stepDuration: string; startVoltage: string; endVoltage: string; chargePeakVoltage: string; dischargeValleyVoltage: string; voltage: string; current: string; capacity: string; energy: string; chargeCapacity: string; dischargeCapacity: string; efficiency: string; chargeEnergy: string; dischargeEnergy: string }
const props = defineProps<{ testId: number; testName: string; cells: ConsoleCell[]; canImport?: boolean; bindings?: Array<{ batteryNo: string; devid?: number | null; chlid?: number | null }> }>()
const router = useRouter()
const activeTab = ref<DataTab>('realtime'); const loading = ref(false); const loadError = ref(''); const rows = ref<DataRow[]>([]); const stepRows = ref<DataRow[]>([]); const cycleRows = ref<DataRow[]>([])
const route = useRoute(); const router = useRouter()
const requestedDataType = String(route.query.dataType || 'REALTIME')
const activeTab = ref<DataTab>(requestedDataType === 'STEP' ? 'step' : requestedDataType === 'CYCLE' ? 'cycle' : 'realtime'); const loading = ref(false); const loadError = ref(''); const rows = ref<DataRow[]>([]); const stepRows = ref<DataRow[]>([]); const cycleRows = ref<DataRow[]>([])
const dataTabs = [{ key: 'realtime', label: '实时' }, { key: 'step', label: '工步' }, { key: 'cycle', label: '循环' }] as const
const filters = reactive<FilterState>({ batteryNo: '', dateRange: null, cycleNo: '', stepNo: '' }); const appliedFilters = ref<FilterState>({ batteryNo: '', dateRange: null, cycleNo: '', stepNo: '' }); const currentPage = ref(1); const pageSize = ref(20)
const selectedIds = ref<Set<string>>(new Set()); const editorVisible = ref(false); const editing = ref(false); const singleEditing = ref(false); const draftRows = ref<Record<string, DataRow>>({}); const editForm = reactive({ field: 'voltage' as keyof DataRow, value: '', remark: '' })
@@ -42,7 +43,7 @@
const allPageSelected = computed(() => pagedRows.value.length > 0 && pagedRows.value.every(row => selectedIds.value.has(row.id)))
const somePageSelected = computed(() => pagedRows.value.some(row => selectedIds.value.has(row.id)) && !allPageSelected.value)
function goToImport() { router.push({ path: `/test-list/${props.testId}/console/data/import`, query: { testName: props.testName, completed: props.canImport ? '1' : '0', dataType: dataType.value } }) }
function goToRevisions() { router.push({ path: '/data-revision', query: { testId: String(props.testId), testName: props.testName } }) }
function goToRevisions() { router.push({ path: '/data-revision', query: { testId: String(props.testId), dataType: dataType.value } }) }
function templateHeaders() { return activeTab.value === 'cycle' ? ['记录编号', '电池号', '循环序号', '充放电容量(Ah)', '效率(%)', '能量(Wh)'] : activeTab.value === 'step' ? ['工步记录编号', '电池号', '循环序号', '工步序号', '工步名称', '工步时间', '容量(Ah)', '能量(Wh)', '起止电压(V)', '充电峰值电压(V)', '放电谷值电压(V)'] : ['记录编号', '电池号', '记录时间', '循环序号', '工步序号', '工步名称', '记录序号', '电压(V)', '电流(A)', '容量(Ah)', '能量(Wh)'] }
function downloadTemplate() { const worksheet = XLSX.utils.aoa_to_sheet([templateHeaders()]); const workbook = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, worksheet, `${activeTab.value}模板`); XLSX.writeFile(workbook, `测试${activeTab.value}数据导入模板.xlsx`); ElMessage.success('导入模板已下载') }
function openBatchEditor() { singleEditing.value = false; editing.value = true; draftRows.value = Object.fromEntries(sourceRows.value.map(row => [row.id, { ...row }])) }
src/views/DataRevision.vue
@@ -1,226 +1,22 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { dataRevisionRecords, type DataRevisionRecord } from '@/stores/dataRevisions'
interface FilterState {
  keyword: string
  dateRange: [string, string] | null
}
const router = useRouter()
const route = useRoute()
const initialKeyword = String(route.query.testId || route.query.testName || '')
const filters = reactive<FilterState>({ keyword: initialKeyword, dateRange: null })
const appliedFilters = ref<FilterState>({ keyword: initialKeyword, dateRange: null })
const currentPage = ref(1)
const pageSize = ref(10)
const filteredRecords = computed(() => {
  const keyword = appliedFilters.value.keyword.trim().toLowerCase()
  const dateRange = appliedFilters.value.dateRange
  return dataRevisionRecords
    .filter(item => !keyword
      || String(item.testId).includes(keyword)
      || item.testName.toLowerCase().includes(keyword)
      || item.id.toLowerCase().includes(keyword))
    .filter(item => {
      if (!dateRange) return true
      const generatedDate = item.generatedAt.slice(0, 10)
      return generatedDate >= dateRange[0] && generatedDate <= dateRange[1]
    })
    .sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt))
})
const pagedRecords = computed(() => {
  const start = (currentPage.value - 1) * pageSize.value
  return filteredRecords.value.slice(start, start + pageSize.value)
})
function applySearch() {
  appliedFilters.value = {
    keyword: filters.keyword,
    dateRange: filters.dateRange ? [...filters.dateRange] as [string, string] : null,
  }
  currentPage.value = 1
  ElMessage.success(`已找到 ${filteredRecords.value.length} 条修订记录`)
}
function resetSearch() {
  filters.keyword = ''
  filters.dateRange = null
  appliedFilters.value = { keyword: '', dateRange: null }
  currentPage.value = 1
}
function viewRecord(item: DataRevisionRecord) {
  router.push(`/data-revision/${item.id}`)
}
function escapeCsv(value: string | number) {
  return `"${String(value).replaceAll('"', '""')}"`
}
function downloadRecords() {
  if (!filteredRecords.value.length) {
    ElMessage.warning('当前筛选条件下没有可下载的数据')
    return
  }
  const headers = ['修改记录ID', '测试编号', '测试名称', '测试时间', '记录生成时间', '记录修改时间', '修改人', '修改类型', '修改备注']
  const rows = filteredRecords.value.map(item => [
    item.id, item.testId, item.testName, item.testedAt, item.generatedAt,
    item.modifiedAt, item.modifier, item.operation, item.remark,
  ])
  const csv = `\uFEFF${[headers, ...rows].map(row => row.map(escapeCsv).join(',')).join('\n')}`
  const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }))
  const anchor = document.createElement('a')
  anchor.href = url
  anchor.download = `数据修订记录_${new Date().toISOString().slice(0, 10)}.csv`
  anchor.style.display = 'none'
  document.body.appendChild(anchor)
  anchor.click()
  anchor.remove()
  window.setTimeout(() => URL.revokeObjectURL(url), 1000)
  ElMessage.success(`已导出 ${filteredRecords.value.length} 条修订记录`)
}
import { formatRevisionTime, getDataRevisionActions, revisionDataTypeLabels, revisionOperationLabels, type DataRevisionAction, type RevisionDataType, type RevisionOperationType } from '@/api/dataRevisions'
interface FilterState { test: string; dataType: RevisionDataType | ''; operationType: RevisionOperationType | ''; dateRange: [string, string] | null }
const route = useRoute(); const router = useRouter(); const initialTest = String(route.query.testId || route.query.testName || '')
const initialDataType = ['REALTIME', 'STEP', 'CYCLE'].includes(String(route.query.dataType)) ? String(route.query.dataType) as RevisionDataType : ''
const filters = reactive<FilterState>({ test: initialTest, dataType: initialDataType, operationType: '', dateRange: null }); const applied = ref<FilterState>({ ...filters })
const records = ref<DataRevisionAction[]>([]); const total = ref(0); const currentPage = ref(1); const pageSize = ref(10); const loading = ref(false); const loadError = ref('')
const dataTypeLabels = revisionDataTypeLabels; const operationLabels = revisionOperationLabels; const formatTime = formatRevisionTime
function localDayBoundary(date: string, dayOffset = 0) { const value = new Date(`${date}T00:00:00`); value.setDate(value.getDate() + dayOffset); return value.toISOString() }
async function loadRecords(showResult = false) { loading.value = true; loadError.value = ''; const query = applied.value; const keyword = query.test.trim(); try { const result = await getDataRevisionActions({ pageNum: currentPage.value, pageSize: pageSize.value, testId: /^\d+$/.test(keyword) ? Number(keyword) : undefined, testName: keyword && !/^\d+$/.test(keyword) ? keyword : undefined, dataType: query.dataType || undefined, operationType: query.operationType || undefined, beginCreatedAt: query.dateRange ? localDayBoundary(query.dateRange[0]) : undefined, endCreatedAt: query.dateRange ? localDayBoundary(query.dateRange[1], 1) : undefined }); records.value = result.rows; total.value = result.total; if (showResult) ElMessage.success(`已找到 ${result.total} 条修订行为`) } catch (error) { records.value = []; total.value = 0; loadError.value = error instanceof Error ? error.message : '数据修订行为加载失败' } finally { loading.value = false } }
function applySearch() { applied.value = { test: filters.test, dataType: filters.dataType, operationType: filters.operationType, dateRange: filters.dateRange ? [...filters.dateRange] as [string, string] : null }; currentPage.value = 1; void loadRecords(true) }
function resetSearch() { Object.assign(filters, { test: '', dataType: '', operationType: '', dateRange: null }); applied.value = { test: '', dataType: '', operationType: '', dateRange: null }; currentPage.value = 1; void loadRecords() }
function viewRecord(item: DataRevisionAction) { router.push(`/data-revision/${item.actionId}`) }
function returnToTest(item: DataRevisionAction) { router.push({ path: `/test-list/${item.testId}/console`, query: { tab: 'data-list', dataType: item.dataType, ...(item.testReceptionStatus === 'ENDED' ? { state: 'ended' } : {}) } }) }
function handlePageChange(page: number) { currentPage.value = page; void loadRecords() }; function handleSizeChange(size: number) { pageSize.value = size; currentPage.value = 1; void loadRecords() }
onMounted(loadRecords)
</script>
<template>
  <div class="data-revision-page">
    <section class="filter-panel" aria-label="数据修订记录查询条件">
      <header class="section-heading">
        <div><i></i><strong>查询条件</strong><span>FILTER CONDITIONS</span></div>
        <small>共 {{ dataRevisionRecords.length }} 条记录</small>
      </header>
      <div class="filter-grid">
        <label>
          <span>测试搜索</span>
          <el-input v-model="filters.keyword" clearable placeholder="输入测试ID、测试名称" @keyup.enter="applySearch" />
        </label>
        <label>
          <span>创建时间</span>
          <el-date-picker
            v-model="filters.dateRange"
            type="daterange"
            value-format="YYYY-MM-DD"
            start-placeholder="开始日期"
            end-placeholder="结束日期"
            range-separator="至"
            unlink-panels
          />
        </label>
        <div class="filter-actions">
          <button class="primary-button" type="button" @click="applySearch"><el-icon><Search /></el-icon>查询</button>
          <button class="secondary-button" type="button" @click="resetSearch"><el-icon><RefreshLeft /></el-icon>重置</button>
        </div>
      </div>
    </section>
    <section class="result-panel">
      <div class="result-toolbar">
        <div><i></i><strong>数据修订记录</strong><span>{{ filteredRecords.length }} RESULTS · 按记录修改时间倒序</span></div>
        <button class="secondary-button" type="button" @click="downloadRecords"><el-icon><Download /></el-icon>下载结果</button>
      </div>
      <div class="table-wrap">
        <table>
          <thead>
            <tr>
              <th>修改记录ID</th><th>测试编号</th><th>测试名称</th><th>测试时间</th><th>记录生成时间</th>
              <th>记录修改时间</th><th>修改人</th><th>修改备注</th><th class="action-column">操作</th>
            </tr>
          </thead>
          <tbody>
            <tr v-for="item in pagedRecords" :key="item.id">
              <td class="id-cell">{{ item.id }}</td>
              <td class="code-cell">#{{ item.testId }}</td>
              <td><strong>{{ item.testName }}</strong></td>
              <td>{{ item.testedAt }}</td>
              <td>{{ item.generatedAt }}</td>
              <td>{{ item.modifiedAt }}</td>
              <td>{{ item.modifier }}</td>
              <td :title="item.remark"><span class="operation-dot" :class="item.operation === '删除' ? 'deleted' : 'modified'"></span>{{ item.remark }}</td>
              <td class="action-column"><button class="view-action" type="button" @click="viewRecord(item)">查看</button></td>
            </tr>
            <tr v-if="!pagedRecords.length"><td colspan="9" class="empty-cell">暂无符合条件的数据修订记录</td></tr>
          </tbody>
        </table>
      </div>
      <footer class="pagination-bar">
        <span>显示 {{ pagedRecords.length }} 条,共 {{ filteredRecords.length }} 条</span>
        <el-pagination
          v-model:current-page="currentPage"
          v-model:page-size="pageSize"
          :page-sizes="[10, 20, 50]"
          :total="filteredRecords.length"
          layout="sizes, prev, pager, next"
          background
        />
      </footer>
    </section>
  </div>
</template>
<style scoped>
.data-revision-page { width: 100%; }
.primary-button, .secondary-button { height: 34px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 0 13px; border: 1px solid transparent; border-radius: 3px; font-size: 13px; font-weight: 500; cursor: pointer; white-space: nowrap; }
.primary-button { color: #fff; background: var(--blue); border-color: var(--blue); }
.primary-button:hover { background: #176fcb; border-color: #176fcb; }
.secondary-button { color: #52606d; background: #fff; border-color: var(--line-strong); }
.secondary-button:hover { color: var(--blue); border-color: #9dc8f5; }
.filter-panel, .result-panel { background: #fff; border: 1px solid #d8e1e9; }
.filter-panel { margin-bottom: 16px; }
.section-heading, .result-toolbar { display: flex; align-items: center; justify-content: space-between; background: var(--surface-soft); border-bottom: 1px solid var(--line); }
.section-heading { height: 44px; padding: 0 16px; }
.section-heading > div, .result-toolbar > div { display: flex; align-items: center; gap: 8px; }
.section-heading > div > i, .result-toolbar > div:first-child > i { width: 3px; height: 16px; background: #2b78a3; }
.section-heading strong, .result-toolbar strong { color: var(--ink); font-size: 15px; font-weight: 600; }
.section-heading span, .result-toolbar span { color: #81909d; font-size: 10px; }
.section-heading small { color: #647380; font-size: 11px; }
.filter-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px 18px; padding: 16px; }
.filter-grid label { width: 100%; min-width: 0; display: grid; grid-template-columns: 72px minmax(0, 1fr); align-items: center; gap: 8px; }
.filter-grid label > span { color: #52606d; font-size: 12px; font-weight: 500; }
.filter-grid :deep(.el-date-editor) { width: 100%; }
.filter-grid :deep(.el-input__wrapper) { min-height: 34px; border-radius: 3px; box-shadow: 0 0 0 1px #d8e1e9 inset; background: #f8fafc; }
.filter-actions { grid-column: -2 / -1; min-width: 0; display: flex; align-items: center; justify-content: flex-end; gap: 8px; }
.result-toolbar { height: 50px; padding: 0 16px; }
.table-wrap { overflow-x: auto; }
table { width: 100%; min-width: 1460px; table-layout: fixed; border-collapse: collapse; color: #52606d; font-size: 13px; }
th, td { height: 50px; padding: 0 12px; border-right: 1px solid #dde7f0; border-bottom: 1px solid #dde7f0; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
th { height: 40px; color: #52606d; background: #eaf4ff; border-color: #c7e0ff; font-size: 12px; font-weight: 600; }
th:nth-child(1) { width: 145px; } th:nth-child(2) { width: 90px; } th:nth-child(3) { width: 190px; }
th:nth-child(4), th:nth-child(5), th:nth-child(6) { width: 160px; } th:nth-child(7) { width: 85px; }
th:nth-child(8) { width: 300px; } th:nth-child(9) { width: 85px; }
th:last-child, td:last-child { border-right: 0; text-align: center; }
.action-column { position: sticky; right: 0; z-index: 2; background: #fff; overflow: visible; border-left: 0; box-shadow: inset 1px 0 0 #b4c7d9, -8px 0 14px rgba(23, 49, 74, .12); }
th.action-column { z-index: 3; background: #eaf4ff; }
tbody tr:hover td.action-column { background: #f7fbff; }
tbody tr:hover { background: #f7fbff; }
tbody tr:last-child td { border-bottom: 0; }
td strong { color: #26333f; font-weight: 600; }
.id-cell, .code-cell { color: #647380; font-family: 'Cascadia Mono', Consolas, monospace; font-size: 12px; }
.operation-dot { display: inline-block; width: 6px; height: 6px; margin-right: 7px; border-radius: 50%; vertical-align: 1px; }
.operation-dot.modified { background: #2188f5; }
.operation-dot.deleted { background: #d15b5b; }
.view-action { border: 0; color: var(--blue); background: transparent; font-size: 12px; font-weight: 500; cursor: pointer; }
.view-action:hover { color: #176fcb; text-decoration: underline; }
.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; }
.pagination-bar :deep(.el-pagination) { --el-pagination-button-bg-color: #edf4fa; --el-pagination-hover-color: var(--blue); }
@media (max-width: 1320px) { .filter-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (max-width: 980px) { .filter-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 680px) {
  .filter-grid { grid-template-columns: minmax(0, 1fr); gap: 12px; padding: 14px 12px; }
  .filter-actions { justify-content: stretch; }
  .filter-actions button { flex: 1; }
  .section-heading, .result-toolbar { padding-left: 12px; }
  .section-heading span, .result-toolbar span { display: none; }
  .pagination-bar { align-items: flex-start; flex-direction: column; overflow-x: auto; }
}
</style>
<template><div class="data-revision-page"><section class="filter-panel"><header class="section-heading"><div><i></i><strong>查询条件</strong><span>FILTER CONDITIONS</span></div><small>共 {{ total }} 条行为</small></header><div class="filter-grid"><label><span>测试</span><el-input v-model="filters.test" clearable placeholder="测试ID或名称" @keyup.enter="applySearch" /></label><label><span>数据类型</span><el-select v-model="filters.dataType" clearable placeholder="全部"><el-option label="实时" value="REALTIME" /><el-option label="工步" value="STEP" /><el-option label="循环" value="CYCLE" /></el-select></label><label><span>操作类型</span><el-select v-model="filters.operationType" clearable placeholder="全部"><el-option label="编辑" value="EDIT" /><el-option label="导入" value="IMPORT" /><el-option label="删除" value="DELETE" /></el-select></label><label><span>操作时间</span><el-date-picker v-model="filters.dateRange" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="结束日期" range-separator="至" unlink-panels /></label><div class="filter-actions"><button class="primary-button" type="button" :disabled="loading" @click="applySearch"><el-icon><Search /></el-icon>查询</button><button class="secondary-button" type="button" :disabled="loading" @click="resetSearch"><el-icon><RefreshLeft /></el-icon>重置</button></div></div></section><section class="result-panel"><div class="result-toolbar"><div><i></i><strong>数据修订行为</strong><span>{{ total }} RESULTS · 按操作时间倒序</span></div><span v-if="loadError" class="load-error">{{ loadError }}</span></div><div v-loading="loading" class="table-wrap"><table><thead><tr><th>行为ID</th><th>测试</th><th>数据类型</th><th>操作类型</th><th>操作范围</th><th>操作人</th><th>操作时间</th><th>原因</th><th class="action-column">操作</th></tr></thead><tbody><tr v-for="item in records" :key="item.actionId"><td class="id-cell">#{{ item.actionId }}</td><td><strong>{{ item.testName }}</strong><small>#{{ item.testId }}</small></td><td><span class="type-tag">{{ dataTypeLabels[item.dataType] }}</span></td><td><span class="operation-tag" :class="item.operationType.toLowerCase()">{{ operationLabels[item.operationType] }}</span></td><td :title="item.scopeSummary">{{ item.scopeSummary }}</td><td>{{ item.operatorName }}</td><td>{{ formatTime(item.createdAtUtc) }}</td><td :title="item.reason">{{ item.reason }}</td><td class="action-column"><div class="row-actions"><button type="button" @click="viewRecord(item)">查看</button><button type="button" @click="returnToTest(item)">回到测试</button></div></td></tr><tr v-if="!loading && !records.length"><td colspan="9" class="empty-cell">{{ loadError || '暂无符合条件的数据修订行为' }}</td></tr></tbody></table></div><footer class="pagination-bar"><span>显示 {{ records.length }} 条,共 {{ total }} 条</span><el-pagination :current-page="currentPage" :page-size="pageSize" :page-sizes="[10, 20, 50]" :total="total" :disabled="loading" layout="sizes, prev, pager, next" background @current-change="handlePageChange" @size-change="handleSizeChange" /></footer></section></div></template>
<style scoped>.data-revision-page{width:100%}.primary-button,.secondary-button{height:34px;display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:0 13px;border:1px solid transparent;border-radius:3px;font-size:13px;cursor:pointer}.primary-button{color:#fff;background:var(--blue)}.secondary-button{color:#52606d;background:#fff;border-color:var(--line-strong)}button:disabled{opacity:.55}.filter-panel,.result-panel{background:#fff;border:1px solid #d8e1e9}.filter-panel{margin-bottom:16px}.section-heading,.result-toolbar{display:flex;align-items:center;justify-content:space-between;padding:0 16px;background:var(--surface-soft);border-bottom:1px solid var(--line)}.section-heading{height:44px}.result-toolbar{height:50px}.section-heading>div,.result-toolbar>div{display:flex;align-items:center;gap:8px}.section-heading i,.result-toolbar i{width:3px;height:16px;background:#2b78a3}.section-heading strong,.result-toolbar strong{color:var(--ink);font-size:15px}.section-heading span,.result-toolbar span,.section-heading small{color:#81909d;font-size:10px}.filter-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px 18px;padding:16px}.filter-grid label{min-width:0;display:grid;grid-template-columns:70px minmax(0,1fr);align-items:center;gap:8px}.filter-grid label>span{color:#52606d;font-size:12px}.filter-grid :deep(.el-select),.filter-grid :deep(.el-date-editor){width:100%}.filter-actions{grid-column:4;display:flex;justify-content:flex-end;gap:8px}.load-error{color:#b34f4f!important}.table-wrap{overflow-x:auto}table{width:100%;min-width:1380px;table-layout:fixed;border-collapse:collapse;color:#52606d;font-size:12px}th,td{height:50px;padding:0 12px;border-right:1px solid #dde7f0;border-bottom:1px solid #dde7f0;text-align:left;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}th{height:40px;background:#eaf4ff;border-color:#c7e0ff;font-weight:600}th:nth-child(1){width:85px}th:nth-child(2){width:190px}th:nth-child(3),th:nth-child(4){width:90px}th:nth-child(5){width:250px}th:nth-child(6){width:100px}th:nth-child(7){width:165px}th:nth-child(8){width:250px}th:nth-child(9){width:145px}td strong,td small{display:block}td strong{color:#26333f}td small{margin-top:3px;color:#8795a3}.id-cell{font-family:'Cascadia Mono',Consolas,monospace}.type-tag,.operation-tag{display:inline-flex;padding:3px 8px;border:1px solid #c9d9e7;background:#f5f8fb}.operation-tag.edit{color:#176fcb;background:#edf6ff;border-color:#b8d9fb}.operation-tag.import{color:#23865e;background:#edf9f3;border-color:#b8dfcd}.operation-tag.delete{color:#b34f4f;background:#fff1f1;border-color:#e9bcbc}.action-column{position:sticky;right:0;z-index:2;background:#fff;box-shadow:inset 1px 0 0 #b4c7d9,-8px 0 14px rgba(23,49,74,.1)}th.action-column{z-index:3;background:#eaf4ff}.row-actions{display:flex;justify-content:center;gap:12px}.row-actions button{border:0;color:var(--blue);background:transparent;cursor:pointer}.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}.pagination-bar>span{color:#7b8794;font-size:11px}@media(max-width:1100px){.filter-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.filter-actions{grid-column:2}}@media(max-width:680px){.filter-grid{grid-template-columns:1fr}.filter-actions{grid-column:auto}.filter-actions button{flex:1}.pagination-bar{align-items:flex-start;flex-direction:column;overflow-x:auto}}</style>
src/views/DataRevisionDetail.vue
@@ -1,158 +1,14 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getDataRevision, type RevisionDataValues } from '@/stores/dataRevisions'
const route = useRoute()
const router = useRouter()
const record = computed(() => getDataRevision(String(route.params.id)))
const dataFields: Array<{ key: keyof RevisionDataValues; label: string }> = [
  { key: 'voltage', label: '电压 (V)' },
  { key: 'current', label: '电流 (mA)' },
  { key: 'capacity', label: '容量 (mAh)' },
  { key: 'energy', label: '能量 (mWh)' },
  { key: 'temperature', label: '温度(采集器)' },
  { key: 'positivePotential', label: '正电位(采集器)' },
  { key: 'negativePotential', label: '负电位(采集器)' },
]
function isChanged(key: keyof RevisionDataValues) {
  const item = record.value
  return Boolean(item?.revisedData && item.originalData[key] !== item.revisedData[key])
}
function goBack() {
  router.push('/data-revision')
}
import { formatRevisionTime, getDataRevisionAction, revisionDataTypeLabels, revisionOperationLabels, type DataRevisionAction } from '@/api/dataRevisions'
const route = useRoute(); const router = useRouter(); const record = ref<DataRevisionAction | null>(null); const loading = ref(true); const loadError = ref('')
const dataTypeLabels = revisionDataTypeLabels; const operationLabels = revisionOperationLabels
const fieldLabels: Record<string, string> = { voltage: '电压', current: '电流', capacity: '容量', energy: '能量', stepDuration: '工步时间', startVoltage: '起始电压', endVoltage: '终止电压', chargePeakVoltage: '充电峰值电压', dischargeValleyVoltage: '放电谷值电压', chargeCapacity: '充电容量', dischargeCapacity: '放电容量', efficiency: '效率', chargeEnergy: '充电能量', dischargeEnergy: '放电能量' }
function parseArray(value: string | null) { if (!value) return [] as Array<Record<string, unknown> | string>; try { const parsed = JSON.parse(value); return Array.isArray(parsed) ? parsed : [] } catch { return [] as Array<Record<string, unknown> | string> } }
const identities = computed(() => parseArray(record.value?.identitySummary || null).map(item => String(item))); const changeRows = computed(() => parseArray(record.value?.changesSummary || null).map((item, index) => { if (!item || typeof item !== 'object') return { identity: identities.value[index] || '-', changes: [] as Array<{ field: string; before: unknown; after: unknown }> }; const row = item as Record<string, unknown>; const before = row.before && typeof row.before === 'object' ? row.before as Record<string, unknown> : {}; const after = row.after && typeof row.after === 'object' ? row.after as Record<string, unknown> : row; const changes = Object.keys(after).filter(key => before[key] !== after[key] && fieldLabels[key]).map(key => ({ field: fieldLabels[key], before: before[key], after: after[key] })); return { identity: String(row.identity || row.recordNo || identities.value[index] || '-'), changes } }))
const formatTime = formatRevisionTime; function goBack() { router.push('/data-revision') }; function returnToTest() { if (record.value) router.push({ path: `/test-list/${record.value.testId}/console`, query: { tab: 'data-list', dataType: record.value.dataType, ...(record.value.testReceptionStatus === 'ENDED' ? { state: 'ended' } : {}) } }) }
onMounted(async () => { try { record.value = await getDataRevisionAction(Number(route.params.id)) } catch (error) { loadError.value = error instanceof Error ? error.message : '数据修订行为加载失败' } finally { loading.value = false } })
</script>
<template>
  <div class="revision-detail-page">
    <div class="page-tools">
      <button class="back-button" type="button" @click="goBack"><el-icon><ArrowLeft /></el-icon>返回数据修订记录</button>
      <span>DATA REVISION DETAILS</span>
    </div>
    <section v-if="record" class="detail-panel">
      <header class="detail-toolbar">
        <div class="heading-group">
          <i></i>
          <div><strong>数据详情</strong><span>{{ record.id }} · 只读审计记录</span></div>
        </div>
        <span class="operation-tag" :class="record.operation === '删除' ? 'deleted' : 'modified'">
          <el-icon><Delete v-if="record.operation === '删除'" /><EditPen v-else /></el-icon>{{ record.operation }}
        </span>
      </header>
      <section class="summary-section" aria-label="修订记录概要">
        <div class="summary-grid">
          <div><span>测试编号</span><strong>#{{ record.testId }}</strong></div>
          <div><span>测试名称</span><strong>{{ record.testName }}</strong></div>
          <div><span>工步名称</span><strong>{{ record.stepName }}</strong></div>
          <div><span>循环编号</span><strong>{{ record.cycleNo }}</strong></div>
          <div><span>工步序号</span><strong>{{ record.stepNo }}</strong></div>
          <div><span>工步时间</span><strong class="mono-value">{{ record.stepDuration }}</strong></div>
          <div><span>绝对时间</span><strong>{{ record.absoluteTime }}</strong></div>
          <div><span>记录生成时间</span><strong>{{ record.generatedAt }}</strong></div>
          <div><span>修改时间</span><strong>{{ record.modifiedAt }}</strong></div>
          <div><span>修改人</span><strong>{{ record.modifier }}</strong></div>
        </div>
        <div class="remark-line"><span>修改备注</span><p>{{ record.remark }}</p></div>
      </section>
      <section class="data-section" aria-label="原始数据">
        <div class="section-title"><i></i><strong>原始数据</strong><span>ORIGINAL DATA</span></div>
        <div class="data-grid">
          <div v-for="field in dataFields" :key="field.key" class="data-field">
            <span>{{ field.label }}</span>
            <output :class="{ changed: isChanged(field.key) }">{{ record.originalData[field.key] }}</output>
          </div>
        </div>
      </section>
      <section class="data-section revised-section" aria-label="修改结果">
        <div class="section-title"><i></i><strong>修改后数据</strong><span>REVISION RESULT</span></div>
        <div v-if="record.revisedData" class="data-grid">
          <div v-for="field in dataFields" :key="field.key" class="data-field">
            <span>{{ field.label }}</span>
            <output :class="{ changed: isChanged(field.key) }">
              {{ record.revisedData[field.key] }}
              <small v-if="isChanged(field.key)">已修订</small>
            </output>
          </div>
        </div>
        <div v-else class="deleted-result">
          <span class="deleted-icon"><el-icon><Delete /></el-icon></span>
          <div><strong>原始数据已删除</strong><p>该采样记录已从测试结果中移除,原始值仅在本审计页面保留展示。</p></div>
        </div>
      </section>
    </section>
    <section v-else class="not-found-panel">
      <el-empty description="未找到该数据修订记录" />
      <button class="secondary-button" type="button" @click="goBack"><el-icon><ArrowLeft /></el-icon>返回记录列表</button>
    </section>
  </div>
</template>
<style scoped>
.revision-detail-page { width: 100%; }
.page-tools { height: 34px; display: flex; align-items: center; justify-content: space-between; }
.page-tools > span { color: #7f8d99; font-size: 10px; font-weight: 600; }
.back-button { display: inline-flex; align-items: center; gap: 6px; border: 0; color: #52606d; background: transparent; font-size: 13px; cursor: pointer; }
.back-button:hover { color: var(--blue); }
.detail-panel, .not-found-panel { background: #fff; border: 1px solid #d8e1e9; }
.detail-toolbar { height: 54px; display: flex; align-items: center; justify-content: space-between; padding: 0 16px; background: var(--surface-soft); border-bottom: 1px solid var(--line); }
.heading-group { display: flex; align-items: center; gap: 9px; min-width: 0; }
.heading-group > i { width: 3px; height: 18px; background: #2b78a3; }
.heading-group > div { min-width: 0; }
.heading-group strong, .heading-group span { display: block; }
.heading-group strong { color: var(--ink); font-size: 15px; font-weight: 600; }
.heading-group span { margin-top: 2px; color: #81909d; font-size: 10px; }
.operation-tag { height: 26px; display: inline-flex; align-items: center; gap: 5px; padding: 0 9px; border: 1px solid; font-size: 12px; font-weight: 600; }
.operation-tag.modified { color: #176fcb; background: #edf6ff; border-color: #b8d9fb; }
.operation-tag.deleted { color: #b34f4f; background: #fff1f1; border-color: #e9bcbc; }
.summary-section { padding: 20px 22px; }
.summary-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px 28px; }
.summary-grid > div { min-width: 0; display: grid; grid-template-columns: 92px minmax(0, 1fr); align-items: baseline; gap: 8px; }
.summary-grid span, .remark-line > span { color: #7b8794; font-size: 12px; }
.summary-grid strong { min-width: 0; color: #34414d; font-size: 13px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mono-value { font-family: 'Cascadia Mono', Consolas, monospace; }
.remark-line { display: grid; grid-template-columns: 92px minmax(0, 1fr); gap: 8px; margin-top: 20px; padding-top: 16px; border-top: 1px dashed #dce5ed; }
.remark-line p { color: #34414d; font-size: 13px; line-height: 1.6; }
.data-section { padding: 20px 22px 24px; border-top: 1px solid var(--line); }
.section-title { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
.section-title i { width: 3px; height: 15px; background: #2b78a3; }
.section-title strong { color: #26333f; font-size: 14px; font-weight: 600; }
.section-title span { color: #8a98a5; font-size: 10px; }
.data-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 18px 28px; }
.data-field { min-width: 0; }
.data-field > span { display: block; margin-bottom: 7px; color: #52606d; font-size: 12px; font-weight: 500; }
.data-field output { min-height: 36px; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 0 10px; border: 1px solid #e0e8ef; color: #34414d; background: #f6f8fa; font-family: 'Cascadia Mono', Consolas, monospace; font-size: 13px; }
.data-field output.changed { color: #176fcb; background: #eef7ff; border-color: #b8d9fb; }
.data-field output small { color: #176fcb; font-family: 'Noto Sans SC', 'Microsoft YaHei', sans-serif; font-size: 10px; font-weight: 500; }
.revised-section { min-height: 210px; }
.deleted-result { min-height: 118px; display: flex; align-items: center; gap: 16px; padding: 18px; border: 1px solid #efd0d0; background: #fff7f7; }
.deleted-icon { width: 42px; height: 42px; flex: 0 0 42px; display: grid; place-items: center; color: #b34f4f; background: #ffe9e9; font-size: 20px; }
.deleted-result strong { color: #9e4141; font-size: 14px; font-weight: 600; }
.deleted-result p { margin-top: 6px; color: #785c5c; font-size: 12px; line-height: 1.6; }
.not-found-panel { min-height: 420px; display: flex; align-items: center; justify-content: center; flex-direction: column; }
.secondary-button { height: 34px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 0 13px; border: 1px solid var(--line-strong); border-radius: 3px; color: #52606d; background: #fff; font-size: 13px; font-weight: 500; cursor: pointer; }
.secondary-button:hover { color: var(--blue); border-color: #9dc8f5; }
@media (max-width: 1280px) {
  .summary-grid, .data-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 900px) {
  .summary-grid, .data-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 620px) {
  .detail-toolbar { padding: 0 12px; }
  .heading-group span { display: none; }
  .summary-section, .data-section { padding: 16px 12px 20px; }
  .summary-grid, .data-grid { grid-template-columns: minmax(0, 1fr); gap: 16px; }
  .summary-grid > div, .remark-line { grid-template-columns: 86px minmax(0, 1fr); }
}
</style>
<template><div class="revision-detail-page"><div class="page-tools"><button class="back-button" type="button" @click="goBack"><el-icon><ArrowLeft /></el-icon>返回数据修订记录</button><span>DATA REVISION ACTION</span></div><section v-loading="loading" class="detail-panel"><template v-if="record"><header class="detail-toolbar"><div class="heading-group"><i></i><div><strong>修订行为 #{{ record.actionId }}</strong><span>一次用户提交 · 只读审计记录</span></div></div><span class="operation-tag" :class="record.operationType.toLowerCase()">{{ operationLabels[record.operationType] }}</span></header><section class="summary-section"><div class="summary-grid"><div><span>测试</span><strong>{{ record.testName }} (#{{ record.testId }})</strong></div><div><span>数据类型</span><strong>{{ dataTypeLabels[record.dataType] }}</strong></div><div><span>操作范围</span><strong>{{ record.scopeSummary }}</strong></div><div><span>涉及记录</span><strong>{{ identities.length }} 条</strong></div><div><span>操作人</span><strong>{{ record.operatorName }}</strong></div><div><span>操作时间</span><strong>{{ formatTime(record.createdAtUtc) }}</strong></div></div><div class="reason-line"><span>操作原因</span><p>{{ record.reason }}</p></div><button class="primary-button" type="button" @click="returnToTest">回到对应测试数据列表</button></section><section class="data-section"><div class="section-title"><i></i><strong>记录身份摘要</strong><span>IDENTITY SUMMARY</span></div><div class="identity-list"><code v-for="identity in identities" :key="identity">{{ identity }}</code><span v-if="!identities.length">无身份摘要</span></div></section><section class="data-section"><div class="section-title"><i></i><strong>字段变化摘要</strong><span>CHANGE SUMMARY</span></div><div v-if="record.operationType === 'DELETE'" class="deleted-notice"><el-icon><Delete /></el-icon><div><strong>数据已物理删除</strong><p>本页仅保留本次删除行为的范围、身份和原因摘要,不恢复或展示已删除数据。</p></div></div><div v-else-if="changeRows.length" class="change-list"><article v-for="row in changeRows" :key="row.identity"><header>{{ row.identity }}</header><dl v-if="row.changes.length"><div v-for="change in row.changes" :key="change.field"><dt>{{ change.field }}</dt><dd><span>{{ change.before ?? '-' }}</span><el-icon><Right /></el-icon><strong>{{ change.after ?? '-' }}</strong></dd></div></dl><p v-else>本次导入包含该记录,字段值已按提交内容保存。</p></article></div><div v-else class="empty-summary">无字段变化摘要</div></section></template><div v-else-if="!loading" class="not-found"><el-empty :description="loadError || '未找到该数据修订行为'" /><button class="secondary-button" type="button" @click="goBack">返回记录列表</button></div></section></div></template>
<style scoped>.revision-detail-page{width:100%}.page-tools{height:34px;display:flex;align-items:center;justify-content:space-between}.page-tools>span{color:#7f8d99;font-size:10px}.back-button{display:inline-flex;align-items:center;gap:6px;border:0;color:#52606d;background:transparent;cursor:pointer}.detail-panel{min-height:460px;background:#fff;border:1px solid #d8e1e9}.detail-toolbar{height:54px;display:flex;align-items:center;justify-content:space-between;padding:0 16px;background:var(--surface-soft);border-bottom:1px solid var(--line)}.heading-group{display:flex;align-items:center;gap:9px}.heading-group>i,.section-title i{width:3px;height:18px;background:#2b78a3}.heading-group strong,.heading-group span{display:block}.heading-group strong{color:var(--ink);font-size:15px}.heading-group span{margin-top:2px;color:#81909d;font-size:10px}.operation-tag{padding:4px 9px;border:1px solid}.operation-tag.edit{color:#176fcb;background:#edf6ff;border-color:#b8d9fb}.operation-tag.import{color:#23865e;background:#edf9f3;border-color:#b8dfcd}.operation-tag.delete{color:#b34f4f;background:#fff1f1;border-color:#e9bcbc}.summary-section,.data-section{padding:20px 22px}.data-section{border-top:1px solid var(--line)}.summary-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:20px 28px}.summary-grid>div{display:grid;grid-template-columns:72px minmax(0,1fr);gap:8px}.summary-grid span,.reason-line>span{color:#7b8794;font-size:12px}.summary-grid strong{min-width:0;color:#34414d;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.reason-line{display:grid;grid-template-columns:72px minmax(0,1fr);gap:8px;margin:18px 0;padding-top:16px;border-top:1px dashed #dce5ed}.reason-line p{color:#34414d;font-size:13px}.primary-button,.secondary-button{height:34px;padding:0 13px;border:1px solid transparent;border-radius:3px;cursor:pointer}.primary-button{color:#fff;background:var(--blue)}.secondary-button{color:#52606d;background:#fff;border-color:var(--line-strong)}.section-title{display:flex;align-items:center;gap:8px;margin-bottom:16px}.section-title i{height:15px}.section-title strong{color:#26333f;font-size:14px}.section-title span{color:#8a98a5;font-size:10px}.identity-list{display:flex;flex-wrap:wrap;gap:8px}.identity-list code{padding:6px 8px;border:1px solid #d8e3ec;background:#f6f8fa;color:#405261}.identity-list>span,.empty-summary{color:#8795a3;font-size:12px}.change-list{display:grid;gap:12px}.change-list article{border:1px solid #d8e3ec}.change-list article>header{padding:9px 12px;background:#f6f9fc;color:#405261;font-family:'Cascadia Mono',Consolas,monospace;font-size:12px}.change-list dl{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:1px;background:#e2e9ef}.change-list dl>div{padding:10px 12px;background:#fff}.change-list dt{margin-bottom:6px;color:#71808d;font-size:11px}.change-list dd{display:flex;align-items:center;gap:8px;font-size:12px}.change-list dd span{color:#8795a3;text-decoration:line-through}.change-list dd strong{color:#176fcb}.change-list article>p{padding:12px;color:#52606d;font-size:12px}.deleted-notice{display:flex;align-items:center;gap:14px;padding:18px;border:1px solid #efd0d0;background:#fff7f7;color:#b34f4f}.deleted-notice>.el-icon{font-size:24px}.deleted-notice p{margin-top:5px;color:#785c5c;font-size:12px}.not-found{min-height:420px;display:flex;align-items:center;justify-content:center;flex-direction:column}@media(max-width:900px){.summary-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.change-list dl{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:620px){.summary-section,.data-section{padding:16px 12px}.summary-grid,.change-list dl{grid-template-columns:1fr}.reason-line{grid-template-columns:1fr}}</style>