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
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import * as XLSX from 'xlsx'
import { consoleDataRowsByTest, type ConsoleDataRow } from '@/stores/consoleTestData'
 
type ImportStage = 'upload' | 'preview' | 'result'
type RawExcelRow = Record<string, unknown>
 
interface ValidatedRow {
  sourceRow: number
  raw: RawExcelRow
  errors: string[]
  data: Omit<ConsoleDataRow, 'id'>
}
 
const route = useRoute()
const router = useRouter()
const testId = Number(route.params.id)
const testName = String(route.query.testName || `测试任务 #${testId}`)
const canImport = route.query.completed === '1'
const fileInput = ref<HTMLInputElement | null>(null)
const selectedFile = ref<File | null>(null)
const validatedRows = ref<ValidatedRow[]>([])
const stage = ref<ImportStage>('upload')
const dragging = ref(false)
 
const requiredHeaders = ['电池号', '记录时间', '工步时间', '循环序号', '工步序号', '工步名称', '电压(V)', '电流(mA)', '容量(mAh)', '能量(mWh)', '温度(℃)', '正电位(V)', '负电位(V)']
const numericHeaders = ['电压(V)', '电流(mA)', '容量(mAh)', '能量(mWh)', '温度(℃)', '正电位(V)', '负电位(V)']
const validRows = computed(() => validatedRows.value.filter(row => row.errors.length === 0))
const failedRows = computed(() => validatedRows.value.filter(row => row.errors.length > 0))
const knownBatteries = computed(() => new Set((consoleDataRowsByTest[testId] || []).map(row => row.batteryNo)))
 
function backToDataList() {
  router.push({ path: `/test-list/${testId}/console`, query: { tab: 'data-list', state: canImport ? 'ended' : 'running' } })
}
 
function textValue(row: RawExcelRow, key: string) {
  return String(row[key] ?? '').trim()
}
 
function validateRow(raw: RawExcelRow, sourceRow: number): ValidatedRow {
  const errors: string[] = []
  requiredHeaders.forEach(header => {
    if (!textValue(raw, header)) errors.push(`${header}不能为空`)
  })
  numericHeaders.forEach(header => {
    const value = textValue(raw, header)
    if (value && !Number.isFinite(Number(value))) errors.push(`${header}必须是数字`)
  })
  const batteryNo = textValue(raw, '电池号')
  if (batteryNo && knownBatteries.value.size && !knownBatteries.value.has(batteryNo)) errors.push('电池号不属于当前测试')
  const cycleNo = Number(textValue(raw, '循环序号'))
  const stepNo = Number(textValue(raw, '工步序号'))
  if (!Number.isInteger(cycleNo) || cycleNo < 1) errors.push('循环序号必须是正整数')
  if (!Number.isInteger(stepNo) || stepNo < 1) errors.push('工步序号必须是正整数')
 
  return {
    sourceRow,
    raw,
    errors: [...new Set(errors)],
    data: {
      recordNo: textValue(raw, '记录编号'),
      batteryNo,
      recordedAt: textValue(raw, '记录时间'),
      stepDuration: textValue(raw, '工步时间'),
      cycleNo: cycleNo || 1,
      stepNo: stepNo || 1,
      stepName: textValue(raw, '工步名称'),
      voltage: textValue(raw, '电压(V)'),
      current: textValue(raw, '电流(mA)'),
      capacity: textValue(raw, '容量(mAh)'),
      energy: textValue(raw, '能量(mWh)'),
      temperature: textValue(raw, '温度(℃)'),
      positivePotential: textValue(raw, '正电位(V)'),
      negativePotential: textValue(raw, '负电位(V)'),
    },
  }
}
 
async function processFile(file: File) {
  const extension = file.name.toLowerCase().slice(file.name.lastIndexOf('.'))
  if (!['.xlsx', '.xls'].includes(extension)) {
    ElMessage.error('仅支持 .xlsx 或 .xls 格式的 Excel 文件')
    return
  }
  if (file.size > 60 * 1024 * 1024) {
    ElMessage.error('文件大小不能超过 60MB')
    return
  }
 
  try {
    const workbook = XLSX.read(await file.arrayBuffer(), { type: 'array' })
    const sheet = workbook.Sheets[workbook.SheetNames[0]]
    if (!sheet) throw new Error('Excel 中没有可读取的工作表')
    const headerRows = XLSX.utils.sheet_to_json<unknown[]>(sheet, { header: 1, defval: '', raw: false })
    const headers = (headerRows[0] || []).map(value => String(value).trim())
    const missingHeaders = requiredHeaders.filter(header => !headers.includes(header))
    if (missingHeaders.length) {
      ElMessage.error(`模板字段缺失:${missingHeaders.join('、')}`)
      return
    }
    const rawRows = XLSX.utils.sheet_to_json<RawExcelRow>(sheet, { defval: '', raw: false })
    if (!rawRows.length) {
      ElMessage.warning('Excel 中没有可导入的数据')
      return
    }
    selectedFile.value = file
    validatedRows.value = rawRows.map((row, index) => validateRow(row, index + 2))
    stage.value = 'preview'
  } catch (error) {
    ElMessage.error(error instanceof Error ? error.message : 'Excel 文件解析失败')
  }
}
 
function chooseFile(event: Event) {
  const input = event.target as HTMLInputElement
  const file = input.files?.[0]
  input.value = ''
  if (file) void processFile(file)
}
 
function handleDrop(event: DragEvent) {
  dragging.value = false
  const file = event.dataTransfer?.files[0]
  if (file) void processFile(file)
}
 
function resetUpload() {
  selectedFile.value = null
  validatedRows.value = []
  stage.value = 'upload'
}
 
function confirmImport() {
  if (!canImport) {
    ElMessage.warning('测试完成后才可以导入数据')
    return
  }
  const currentRows = consoleDataRowsByTest[testId] || []
  let nextId = Math.max(0, ...currentRows.map(row => row.id)) + 1
  const imported = validRows.value.map(item => {
    const id = nextId++
    return {
      ...item.data,
      id,
      recordNo: item.data.recordNo || `RD-${String(testId).padStart(4, '0')}-IMP-${String(id).padStart(5, '0')}`,
    }
  })
  consoleDataRowsByTest[testId] = [...imported, ...currentRows]
  stage.value = 'result'
}
 
function downloadFailedRows() {
  if (!failedRows.value.length) return
  const records = failedRows.value.map(item => ({ ...item.raw, 错误原因: item.errors.join(';') }))
  const worksheet = XLSX.utils.json_to_sheet(records)
  const workbook = XLSX.utils.book_new()
  XLSX.utils.book_append_sheet(workbook, worksheet, '失败数据')
  XLSX.writeFile(workbook, `${testName}_导入失败数据.xlsx`)
  ElMessage.success(`已下载 ${records.length} 条失败数据`)
}
</script>
 
<template>
  <div class="import-page">
    <div class="page-tools"><button class="back-button" type="button" @click="backToDataList"><el-icon><ArrowLeft /></el-icon>返回数据列表</button><span>DATA IMPORT · #{{ testId }}</span></div>
 
    <section class="import-panel">
      <header class="section-heading"><div><i></i><strong>{{ stage === 'result' ? '导入结果' : '导入数据' }}</strong><span>EXCEL DATA IMPORT</span></div><small>{{ testName }}</small></header>
 
      <div v-if="stage !== 'result' && !canImport" class="status-notice"><el-icon><WarningFilled /></el-icon><span><strong>当前测试尚未完成</strong><small>可以选择并校验文件,测试完成后才能确认导入。</small></span></div>
 
      <div v-if="stage === 'upload'" class="upload-stage">
        <input ref="fileInput" class="file-input" type="file" accept=".xlsx,.xls,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" @change="chooseFile">
        <button class="drop-zone" :class="{ dragging }" type="button" @click="fileInput?.click()" @dragenter.prevent="dragging=true" @dragover.prevent="dragging=true" @dragleave.prevent="dragging=false" @drop.prevent="handleDrop">
          <el-icon><UploadFilled /></el-icon><span><strong>点击上传</strong> 或拖拽文件到此区域</span><small>支持 .xlsx / .xls 文件,大小不超过 60MB</small>
        </button>
        <div class="upload-rules"><span><el-icon><CircleCheck /></el-icon>使用数据列表下载的标准模板</span><span><el-icon><CircleCheck /></el-icon>系统自动校验字段完整性和数值格式</span><span><el-icon><CircleCheck /></el-icon>失败数据可下载修改后重新导入</span></div>
        <div class="stage-actions"><button class="secondary-button" type="button" @click="backToDataList">返回</button></div>
      </div>
 
      <div v-else-if="stage === 'preview'" class="preview-stage">
        <div class="file-summary"><div><el-icon><Document /></el-icon><span><strong>{{ selectedFile?.name }}</strong><small>{{ ((selectedFile?.size || 0) / 1024).toFixed(1) }} KB · 已完成字段校验</small></span></div><button type="button" @click="resetUpload">重新选择</button></div>
        <div class="validation-summary"><div><span>总数据</span><strong>{{ validatedRows.length }}</strong></div><div class="success"><span>校验通过</span><strong>{{ validRows.length }}</strong></div><div class="failed"><span>校验失败</span><strong>{{ failedRows.length }}</strong></div></div>
        <div class="preview-table-wrap"><table><thead><tr><th>Excel行号</th><th>电池号</th><th>记录时间</th><th>循环</th><th>工步</th><th>工步名称</th><th>电压(V)</th><th>容量(mAh)</th><th>校验结果</th></tr></thead><tbody><tr v-for="item in validatedRows.slice(0, 12)" :key="item.sourceRow"><td>{{ item.sourceRow }}</td><td>{{ item.data.batteryNo }}</td><td>{{ item.data.recordedAt }}</td><td>{{ item.data.cycleNo }}</td><td>{{ item.data.stepNo }}</td><td>{{ item.data.stepName }}</td><td>{{ item.data.voltage }}</td><td>{{ item.data.capacity }}</td><td><span class="validation-state" :class="item.errors.length ? 'failed' : 'success'">{{ item.errors.length ? item.errors.join(';') : '通过' }}</span></td></tr></tbody></table></div>
        <p v-if="validatedRows.length > 12" class="preview-tip">当前预览前 12 条,共 {{ validatedRows.length }} 条数据</p>
        <div class="stage-actions"><button class="primary-button" type="button" :disabled="!canImport" @click="confirmImport"><el-icon><Check /></el-icon>确认导入</button><button class="secondary-button" type="button" @click="resetUpload">返回上传</button></div>
      </div>
 
      <div v-else class="result-stage">
        <div class="result-icon"><el-icon><CircleCheckFilled /></el-icon></div><h2>导入完成</h2><dl><div><dt>总数据</dt><dd>{{ validatedRows.length }} 条</dd></div><div class="success"><dt>成功</dt><dd>{{ validRows.length }} 条</dd></div><div class="failed"><dt>失败</dt><dd>{{ failedRows.length }} 条</dd></div></dl><p>校验通过的数据已加入当前测试,失败数据未写入。</p>
        <div class="stage-actions"><button v-if="failedRows.length" class="primary-button" type="button" @click="downloadFailedRows"><el-icon><Download /></el-icon>下载失败数据</button><button class="secondary-button" type="button" @click="backToDataList">返回数据列表</button></div>
      </div>
    </section>
  </div>
</template>
 
<style scoped>
.import-page{width:100%;padding-bottom:24px}.page-tools{height:42px;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)}.import-panel{min-height:640px;border:1px solid #d8e1e9;background:#fff}.section-heading{height:50px;display:flex;align-items:center;justify-content:space-between;padding:0 20px;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:15px}.section-heading span{color:#81909d;font-size:10px}.section-heading small{color:#71808d;font-size:11px}.status-notice{display:flex;align-items:center;gap:10px;margin:18px 24px 0;padding:12px 14px;border:1px solid #efd59e;background:#fff8e9;color:#b06a18}.status-notice>.el-icon{font-size:18px}.status-notice span,.status-notice strong,.status-notice small{display:block}.status-notice strong{font-size:12px}.status-notice small{margin-top:3px;color:#8c6849;font-size:11px}.upload-stage{display:flex;align-items:center;flex-direction:column;padding:58px 24px}.file-input{display:none}.drop-zone{width:min(560px,100%);height:210px;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:13px;border:1px dashed #b9c9d8;background:#fbfdff;color:#71808d;cursor:pointer;transition:.16s ease}.drop-zone:hover,.drop-zone.dragging{border-color:var(--blue);background:#f2f8ff}.drop-zone>.el-icon{color:#3989df;font-size:34px}.drop-zone span{font-size:14px}.drop-zone span strong{color:var(--blue)}.drop-zone small{color:#9aa6b1;font-size:11px}.upload-rules{width:min(560px,100%);display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-top:18px}.upload-rules span{display:flex;align-items:flex-start;gap:5px;color:#71808d;font-size:10px;line-height:1.5}.upload-rules .el-icon{flex:0 0 auto;margin-top:1px;color:#23865e}.primary-button,.secondary-button{height:34px;min-width:92px;display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:0 14px;border:1px solid transparent;border-radius:3px;font-size:13px;font-weight:500;cursor:pointer}.primary-button{color:#fff;background:var(--blue);border-color:var(--blue)}.primary-button:hover{background:#176fcb}.primary-button:disabled{opacity:.45;cursor:not-allowed}.secondary-button{color:#52606d;background:#fff;border-color:var(--line-strong)}.secondary-button:hover{color:var(--blue);border-color:#9dc8f5}.stage-actions{display:flex;align-items:center;justify-content:center;gap:12px;margin-top:34px}.preview-stage{padding:22px 24px 30px}.file-summary{display:flex;align-items:center;justify-content:space-between;padding:13px 15px;border:1px solid #d8e1e9;background:#f8fafc}.file-summary>div{display:flex;align-items:center;gap:10px}.file-summary .el-icon{color:#23865e;font-size:22px}.file-summary span,.file-summary strong,.file-summary small{display:block}.file-summary strong{color:#405261;font-size:12px}.file-summary small{margin-top:3px;color:#8795a3;font-size:10px}.file-summary button{border:0;color:var(--blue);background:transparent;font-size:12px;cursor:pointer}.validation-summary{display:grid;grid-template-columns:repeat(3,180px);gap:12px;margin:18px 0}.validation-summary>div{display:flex;align-items:center;justify-content:space-between;padding:13px 15px;border:1px solid #d8e1e9;background:#fff}.validation-summary span{color:#71808d;font-size:11px}.validation-summary strong{color:#26333f;font-size:18px}.validation-summary .success strong{color:#23865e}.validation-summary .failed strong{color:#c44f4f}.preview-table-wrap{overflow-x:auto;border:1px solid #d8e1e9}table{width:100%;min-width:1120px;border-collapse:collapse;color:#52606d;font-size:12px}th,td{height:42px;padding:0 10px;border-right:1px solid #dde7f0;border-bottom:1px solid #dde7f0;text-align:center;white-space:nowrap}th{height:38px;background:#eaf4ff;font-weight:600}.validation-state{font-size:11px}.validation-state.success{color:#23865e}.validation-state.failed{color:#c44f4f}.preview-tip{margin-top:10px;color:#8795a3;font-size:10px}.result-stage{display:flex;align-items:center;flex-direction:column;padding:72px 24px}.result-icon{width:56px;height:56px;display:grid;place-items:center;border:1px solid #b8dfcd;background:#edf9f3;color:#23865e}.result-icon .el-icon{font-size:30px}.result-stage h2{margin-top:16px;color:#26333f;font-size:18px}.result-stage dl{display:grid;grid-template-columns:repeat(3,170px);gap:12px;margin-top:28px}.result-stage dl>div{padding:16px;border:1px solid #d8e1e9;text-align:center}.result-stage dt{color:#71808d;font-size:11px}.result-stage dd{margin-top:7px;color:#26333f;font-size:20px;font-weight:600}.result-stage .success dd{color:#23865e}.result-stage .failed dd{color:#c44f4f}.result-stage>p{margin-top:18px;color:#8795a3;font-size:11px}@media(max-width:760px){.section-heading{padding:0 14px}.section-heading span{display:none}.upload-stage{padding:36px 14px}.upload-rules{grid-template-columns:1fr}.preview-stage{padding:16px 12px}.validation-summary{grid-template-columns:1fr}.result-stage dl{width:100%;grid-template-columns:1fr}.status-notice{margin:12px 12px 0}}
</style>