yeg
2026-08-26 9f48020dfd3e29d1c0049a3da9df3203c40bc264
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import * as XLSX from 'xlsx'
import { dataRevisionRecords, type RevisionOperation } from '@/stores/dataRevisions'
import { consoleDataRowsByTest, type ConsoleDataRow as DataRow } from '@/stores/consoleTestData'
 
interface ConsoleCell {
  no: string
  voltage: string
  temperature: string
  positive: string
  negative: string
  capacity: string
  current: string
}
 
interface FilterState {
  batteryNo: string
  dateRange: [string, string] | null
  cycleNo: string
  stepNo: string
}
 
type EditableField = 'voltage' | 'current' | 'capacity' | 'energy' | 'temperature' | 'positivePotential' | 'negativePotential'
 
const props = defineProps<{
  testId: number
  testName: string
  startedAt: string
  cells: ConsoleCell[]
  canImport: boolean
}>()
 
const router = useRouter()
const stepNames = ['恒流充电', '搁置', '恒流放电', '搁置', '恒流充电']
const fieldOptions: Array<{ value: EditableField; label: string; unit: string }> = [
  { value: 'voltage', label: '电压', unit: 'V' },
  { value: 'current', label: '电流', unit: 'mA' },
  { value: 'capacity', label: '容量', unit: 'mAh' },
  { value: 'energy', label: '能量', unit: 'mWh' },
  { value: 'temperature', label: '温度', unit: '℃' },
  { value: 'positivePotential', label: '正电位', unit: 'V' },
  { value: 'negativePotential', label: '负电位', unit: 'V' },
]
 
function formatDate(date: Date) {
  const pad = (value: number) => String(value).padStart(2, '0')
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
 
function buildRows(): DataRow[] {
  const start = new Date(props.startedAt.replace(' ', 'T')).getTime()
  return Array.from({ length: 658 }, (_, index) => {
    const cell = props.cells[index % props.cells.length]
    const stepIndex = index % stepNames.length
    const point = Math.floor(index / Math.max(props.cells.length, 1))
    const voltage = Number(cell?.voltage || 0) + Math.sin(index / 17) * 0.018
    const capacity = Number(cell?.capacity || 0) + point * 0.82
    return {
      id: index + 1,
      recordNo: `RD-${String(props.testId).padStart(4, '0')}-${String(index + 1).padStart(6, '0')}`,
      batteryNo: cell?.no || '-',
      recordedAt: formatDate(new Date(start + index * 60_000)),
      stepDuration: `00:${String(point % 60).padStart(2, '0')}:${String(index % 60).padStart(2, '0')}.000`,
      cycleNo: Math.floor(index / (stepNames.length * Math.max(props.cells.length, 1))) + 1,
      stepNo: stepIndex + 1,
      stepName: stepNames[stepIndex],
      voltage: voltage.toFixed(4),
      current: (Number(cell?.current || 0) + Math.cos(index / 13) * 8).toFixed(1),
      capacity: capacity.toFixed(1),
      energy: (capacity * voltage).toFixed(1),
      temperature: (Number(cell?.temperature || 0) + Math.sin(index / 23) * 0.5).toFixed(1),
      positivePotential: (Number(cell?.positive || 0) + Math.sin(index / 19) * 0.006).toFixed(4),
      negativePotential: (Number(cell?.negative || 0) + Math.cos(index / 21) * 0.006).toFixed(4),
    }
  })
}
 
if (!consoleDataRowsByTest[props.testId]) consoleDataRowsByTest[props.testId] = buildRows()
const rows = computed<DataRow[]>({
  get: () => consoleDataRowsByTest[props.testId] || [],
  set: value => { consoleDataRowsByTest[props.testId] = value },
})
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<number>>(new Set())
const editorVisible = ref(false)
const editForm = reactive<{ field: EditableField; value: string; remark: string }>({ field: 'voltage', value: '', remark: '' })
 
const batteryOptions = computed(() => [...new Set(props.cells.map(cell => cell.no))])
const cycleOptions = computed(() => [...new Set(rows.value.map(row => row.cycleNo))].sort((a, b) => a - b))
const filteredRows = computed(() => rows.value.filter(row => {
  const query = appliedFilters.value
  if (query.batteryNo && row.batteryNo !== query.batteryNo) return false
  if (query.cycleNo && row.cycleNo !== Number(query.cycleNo)) return false
  if (query.stepNo && row.stepNo !== Number(query.stepNo)) return false
  if (query.dateRange) {
    const date = row.recordedAt.slice(0, 10)
    if (date < query.dateRange[0] || date > query.dateRange[1]) return false
  }
  return true
}))
const pagedRows = computed(() => {
  const start = (currentPage.value - 1) * pageSize.value
  return filteredRows.value.slice(start, start + pageSize.value)
})
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 applySearch() {
  appliedFilters.value = {
    batteryNo: filters.batteryNo,
    dateRange: filters.dateRange ? [...filters.dateRange] as [string, string] : null,
    cycleNo: filters.cycleNo,
    stepNo: filters.stepNo,
  }
  currentPage.value = 1
  selectedIds.value = new Set()
  ElMessage.success(`已找到 ${filteredRows.value.length} 条数据`)
}
 
function resetSearch() {
  filters.batteryNo = ''
  filters.dateRange = null
  filters.cycleNo = ''
  filters.stepNo = ''
  appliedFilters.value = { batteryNo: '', dateRange: null, cycleNo: '', stepNo: '' }
  currentPage.value = 1
  selectedIds.value = new Set()
}
 
function togglePageSelection(checked: boolean) {
  const next = new Set(selectedIds.value)
  pagedRows.value.forEach(row => checked ? next.add(row.id) : next.delete(row.id))
  selectedIds.value = next
}
 
function toggleRowSelection(id: number, checked: boolean) {
  const next = new Set(selectedIds.value)
  checked ? next.add(id) : next.delete(id)
  selectedIds.value = next
}
 
function openSingleEditor(row: DataRow) {
  router.push({
    path: `/test-list/${props.testId}/console/data/${row.id}/edit`,
    query: { testName: props.testName, completed: props.canImport ? '1' : '0' },
  })
}
 
function openBatchEditor() {
  if (!selectedIds.value.size) {
    ElMessage.warning('请先勾选需要批量修改的数据')
    return
  }
  editForm.field = 'voltage'
  editForm.value = ''
  editForm.remark = ''
  editorVisible.value = true
}
 
function revisionValues(row: DataRow) {
  return {
    voltage: row.voltage,
    current: row.current,
    capacity: row.capacity,
    energy: row.energy,
    temperature: row.temperature,
    positivePotential: row.positivePotential,
    negativePotential: row.negativePotential,
  }
}
 
function recordRevision(row: DataRow, operation: RevisionOperation, remark: string, revisedRow: DataRow | null) {
  const now = formatDate(new Date())
  dataRevisionRecords.unshift({
    id: `DR-${Date.now()}-${row.id}`,
    testId: props.testId,
    testName: props.testName,
    testedAt: row.recordedAt,
    generatedAt: row.recordedAt,
    modifiedAt: now,
    modifier: '管理员',
    remark,
    operation,
    stepName: row.stepName,
    cycleNo: row.cycleNo,
    stepNo: row.stepNo,
    stepDuration: row.stepDuration,
    absoluteTime: row.recordedAt,
    originalData: revisionValues(row),
    revisedData: revisedRow ? revisionValues(revisedRow) : null,
  })
}
 
function saveEdit() {
  if (!editForm.value.trim()) {
    ElMessage.warning('请输入修改后的数值')
    return
  }
  if (!Number.isFinite(Number(editForm.value))) {
    ElMessage.warning('修改值必须是有效数字')
    return
  }
  if (!editForm.remark.trim()) {
    ElMessage.warning('请填写修改原因')
    return
  }
  const targetIds = selectedIds.value
  rows.value = rows.value.map(row => {
    if (!targetIds.has(row.id)) return row
    const revised = { ...row, [editForm.field]: editForm.value.trim() }
    recordRevision(row, '修改', editForm.remark.trim(), revised)
    return revised
  })
  editorVisible.value = false
  ElMessage.success(`已修改 ${targetIds.size} 条数据并生成修订记录`)
}
 
async function removeRow(row: DataRow) {
  try {
    const { value } = await ElMessageBox.prompt('删除后数据不可恢复,请填写删除原因。原始数据和原因会保留在修订记录中。', '删除数据', {
      confirmButtonText: '确认删除', cancelButtonText: '取消', type: 'warning', confirmButtonClass: 'danger-confirm',
      inputPlaceholder: '请输入删除原因', inputValidator: input => Boolean(input.trim()) || '删除原因不能为空',
    })
    recordRevision(row, '删除', value.trim(), null)
    rows.value = rows.value.filter(item => item.id !== row.id)
    const next = new Set(selectedIds.value)
    next.delete(row.id)
    selectedIds.value = next
    if (pagedRows.value.length === 0 && currentPage.value > 1) currentPage.value -= 1
    ElMessage.success('数据已删除,修订记录已保留')
  } catch { /* 用户取消 */ }
}
 
function escapeCsv(value: string | number) {
  return `"${String(value).replaceAll('"', '""')}"`
}
 
function downloadCsv(source: DataRow[], filename: string) {
  if (!source.length) {
    ElMessage.warning('没有可下载的数据')
    return
  }
  const headers = ['记录编号', '电池号', '记录时间', '工步时间', '循环序号', '工步序号', '工步名称', '电压(V)', '电流(mA)', '容量(mAh)', '能量(mWh)', '温度(℃)', '正电位(V)', '负电位(V)']
  const records = source.map(row => [row.recordNo, row.batteryNo, row.recordedAt, row.stepDuration, row.cycleNo, row.stepNo, row.stepName, row.voltage, row.current, row.capacity, row.energy, row.temperature, row.positivePotential, row.negativePotential])
  const content = `\uFEFF${[headers, ...records].map(record => record.map(escapeCsv).join(',')).join('\n')}`
  const url = URL.createObjectURL(new Blob([content], { type: 'text/csv;charset=utf-8' }))
  const anchor = document.createElement('a')
  anchor.href = url
  anchor.download = filename
  anchor.click()
  window.setTimeout(() => URL.revokeObjectURL(url), 1000)
  ElMessage.success(`已下载 ${source.length} 条数据`)
}
 
function downloadSelected() {
  downloadCsv(rows.value.filter(row => selectedIds.value.has(row.id)), `${props.testName}_选中数据.csv`)
}
 
function downloadTemplate() {
  const headers = [['记录编号', '电池号', '记录时间', '工步时间', '循环序号', '工步序号', '工步名称', '电压(V)', '电流(mA)', '容量(mAh)', '能量(mWh)', '温度(℃)', '正电位(V)', '负电位(V)']]
  const worksheet = XLSX.utils.aoa_to_sheet(headers)
  worksheet['!cols'] = headers[0].map(header => ({ wch: Math.max(header.length + 4, 12) }))
  const workbook = XLSX.utils.book_new()
  XLSX.utils.book_append_sheet(workbook, worksheet, '测试数据')
  XLSX.writeFile(workbook, '测试数据导入模板.xlsx')
  ElMessage.success('导入模板已下载')
}
 
function goToImport() {
  router.push({
    path: `/test-list/${props.testId}/console/data/import`,
    query: { testName: props.testName, completed: props.canImport ? '1' : '0' },
  })
}
 
function goToRevisions() {
  router.push({ path: '/data-revision', query: { testId: String(props.testId), testName: props.testName } })
}
</script>
 
<template>
  <section class="console-data-panel">
    <header class="section-heading"><div><i></i><strong>查询表格</strong><span>TEST DATA QUERY</span></div><small>共 {{ filteredRows.length }} 条数据</small></header>
    <div class="filter-grid">
      <label><span>电池</span><el-select v-model="filters.batteryNo" clearable placeholder="全部"><el-option label="全部" value="" /><el-option v-for="battery in batteryOptions" :key="battery" :label="battery" :value="battery" /></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>
      <label><span>循环</span><el-select v-model="filters.cycleNo" clearable placeholder="全部"><el-option label="全部" value="" /><el-option v-for="cycle in cycleOptions" :key="cycle" :label="`循环 ${cycle}`" :value="String(cycle)" /></el-select></label>
      <label><span>工步</span><el-select v-model="filters.stepNo" clearable placeholder="全部"><el-option label="全部" value="" /><el-option v-for="(name, index) in stepNames" :key="index" :label="`${index + 1}-${name}`" :value="String(index + 1)" /></el-select></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>
 
    <div class="data-toolbar">
      <div class="toolbar-group">
        <button class="primary-button" type="button" @click="goToImport"><el-icon><UploadFilled /></el-icon>导入</button>
        <button class="primary-button" type="button" @click="downloadTemplate"><el-icon><Download /></el-icon>下载模板</button>
        <button class="primary-button" type="button" @click="goToRevisions"><el-icon><Clock /></el-icon>修订记录</button>
        <button class="primary-button" type="button" @click="openBatchEditor"><el-icon><EditPen /></el-icon>批量修改</button>
      </div>
      <div class="toolbar-group">
        <button class="secondary-button" type="button" @click="downloadCsv(filteredRows, `${testName}_全部数据.csv`)"><el-icon><Download /></el-icon>下载全部</button>
        <button class="secondary-button" type="button" @click="downloadSelected"><el-icon><Download /></el-icon>下载选中</button>
      </div>
    </div>
 
    <div class="table-wrap">
      <table>
        <thead><tr><th class="selection-column"><el-checkbox :model-value="allPageSelected" :indeterminate="somePageSelected" aria-label="选择本页" @change="(checked: string | number | boolean) => togglePageSelection(Boolean(checked))" /></th><th>记录编号</th><th>电池</th><th>记录时间</th><th>工步时间(h:min:s.ms)</th><th>循环序号</th><th>工步序号</th><th>工步名称</th><th>电压(V)</th><th>电流(mA)</th><th>容量(mAh)</th><th>能量(mWh)</th><th>温度(℃)</th><th>正电位(V)</th><th>负电位(V)</th><th class="action-column">操作</th></tr></thead>
        <tbody>
          <tr v-for="row in pagedRows" :key="row.id">
            <td class="selection-column"><el-checkbox :model-value="selectedIds.has(row.id)" :aria-label="`选择 ${row.recordNo}`" @change="(checked: string | number | boolean) => toggleRowSelection(row.id, Boolean(checked))" /></td><td class="record-code">{{ row.recordNo }}</td><td><strong>{{ row.batteryNo }}</strong></td><td>{{ row.recordedAt }}</td><td>{{ row.stepDuration }}</td><td>{{ row.cycleNo }}</td><td>{{ row.stepNo }}</td><td>{{ row.stepName }}</td><td>{{ row.voltage }}</td><td>{{ row.current }}</td><td>{{ row.capacity }}</td><td>{{ row.energy }}</td><td>{{ row.temperature }}</td><td>{{ row.positivePotential }}</td><td>{{ row.negativePotential }}</td>
            <td class="action-column"><div class="row-actions"><button type="button" @click="openSingleEditor(row)">编辑</button><button class="delete-action" type="button" @click="removeRow(row)">删除</button></div></td>
          </tr>
          <tr v-if="!pagedRows.length"><td colspan="16" class="empty-cell">暂无符合条件的测试数据</td></tr>
        </tbody>
      </table>
    </div>
    <footer class="pagination-bar"><span>显示 {{ pagedRows.length }} 条,共 {{ filteredRows.length }} 条</span><el-pagination v-model:current-page="currentPage" v-model:page-size="pageSize" :page-sizes="[20, 50, 100]" :total="filteredRows.length" layout="sizes, prev, pager, next" background /></footer>
 
    <el-dialog v-model="editorVisible" :title="`批量修改(${selectedIds.size} 条)`" width="520px" align-center>
      <div class="edit-form"><label><span>修改字段</span><el-select v-model="editForm.field"><el-option v-for="field in fieldOptions" :key="field.value" :label="`${field.label}(${field.unit})`" :value="field.value" /></el-select></label><label><span>修改值</span><el-input v-model="editForm.value" placeholder="请输入修改后的数值" /></label><label class="remark-field"><span>修改原因</span><el-input v-model="editForm.remark" type="textarea" :rows="3" maxlength="120" show-word-limit placeholder="必填,将写入数据修订记录" /></label></div>
      <template #footer><button class="secondary-button" type="button" @click="editorVisible=false">取消</button><button class="primary-button" type="button" @click="saveEdit"><el-icon><Check /></el-icon>确认修改</button></template>
    </el-dialog>
  </section>
</template>
 
<style scoped>
.console-data-panel{min-width:0;border:1px solid #d8e1e9;background:#fff}.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}.secondary-button{color:#52606d;background:#fff;border-color:var(--line-strong)}.secondary-button:hover{color:var(--blue);border-color:#9dc8f5}.section-heading{height:46px;display:flex;align-items:center;justify-content:space-between;padding:0 16px;border-bottom:1px solid var(--line);background:var(--surface-soft)}.section-heading>div{display:flex;align-items:center;gap:8px}.section-heading i{width:3px;height:16px;background:#2b78a3}.section-heading strong{color:var(--ink);font-size:14px}.section-heading 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{min-width:0;display:grid;grid-template-columns:68px minmax(0,1fr);align-items:center;gap:8px}.filter-grid label>span{color:#52606d;font-size:12px;font-weight:500}.filter-grid :deep(.el-select),.filter-grid :deep(.el-date-editor){width:100%}.filter-grid :deep(.el-input__wrapper),.filter-grid :deep(.el-select__wrapper){min-height:34px;border-radius:3px;background:#f8fafc;box-shadow:0 0 0 1px #d8e1e9 inset}.filter-actions{grid-column:-2/-1;display:flex;justify-content:flex-end;gap:8px}.data-toolbar{min-height:58px;display:flex;align-items:center;justify-content:space-between;gap:14px;margin:0 16px;padding:10px 0;border-top:1px solid #d8e1e9}.toolbar-group{display:flex;align-items:center;gap:8px}.file-input{display:none}.table-wrap{width:100%;overflow-x:auto}table{width:100%;min-width:1820px;table-layout:fixed;border-collapse:collapse;color:#52606d;font-size:12px}th,td{height:46px;padding:0 10px;border-right:1px solid #dde7f0;border-bottom:1px solid #dde7f0;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}th{height:40px;color:#52606d;background:#eaf4ff;border-color:#c7e0ff;font-weight:600}th:nth-child(2){width:158px}th:nth-child(3){width:110px}th:nth-child(4){width:158px}th:nth-child(5){width:150px}th:nth-child(6),th:nth-child(7){width:78px}th:nth-child(8){width:100px}th:nth-child(n+9):nth-child(-n+15){width:96px}.selection-column{width:46px;padding:0}.record-code{color:#647380;font-family:'Cascadia Mono',Consolas,monospace;font-size:11px}td strong{color:#26333f}.action-column{position:sticky;right:0;z-index:2;width:112px;background:#fff;overflow:visible;box-shadow:inset 1px 0 0 #b4c7d9,-8px 0 14px rgba(23,49,74,.1)}th.action-column{z-index:3;background:#eaf4ff}tbody tr:hover td{background:#f7fbff}tbody tr:hover td.action-column{background:#f7fbff}.row-actions{display:flex;align-items:center;justify-content:center;gap:14px}.row-actions button{border:0;color:var(--blue);background:transparent;font-size:12px;cursor:pointer}.row-actions .delete-action{color:#ad4a4a}.empty-cell{height:150px;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}.pagination-bar :deep(.el-pagination){--el-pagination-button-bg-color:#edf4fa;--el-pagination-hover-color:var(--blue)}.edit-form{display:grid;gap:14px}.edit-form label{display:grid;grid-template-columns:82px minmax(0,1fr);align-items:center;gap:10px}.edit-form label>span{color:#52606d;font-size:12px}.edit-form .remark-field{align-items:start}.edit-form :deep(.el-select){width:100%}@media(max-width:1250px){.filter-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.filter-actions{grid-column:-2/-1}}@media(max-width:900px){.filter-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.data-toolbar{align-items:flex-start;flex-direction:column}.toolbar-group{flex-wrap:wrap}}@media(max-width:600px){.filter-grid{grid-template-columns:1fr;padding:14px 12px}.filter-actions{grid-column:auto}.filter-actions button{flex:1}.data-toolbar{margin:0 12px}.toolbar-group{width:100%}.toolbar-group button{flex:1;padding:0 8px}.pagination-bar{align-items:flex-start;flex-direction:column;overflow-x:auto}.section-heading span{display:none}}
</style>