<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>
|