yeg
2026-09-28 6a4ca47df73d99e952d926f6103bcccea1ecea89
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
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import * as XLSX from 'xlsx'
import { submitConsoleDataAction, type ConsoleDataType } from '@/api/consoleData'
import { parseConsoleImport, parseConsoleImportWorkbook, createConsoleImportWorkbook, consoleSheetLabels, consoleImportColumns, type ValidatedImportSheet, type ValidatedImportRow } from '@/utils/consoleDataImport'
import { getTestDetail } from '@/api/tests'
import { formatBusinessTime } from '@/utils/businessTime'
import { toTestDisplayValue } from '@/utils/testDataUnits'
import { formatTestDuration } from '@/utils/testDataDuration'
 
type ImportStage = 'upload' | 'preview' | 'result'
 
const route = useRoute()
const router = useRouter()
const testId = Number(route.params.id)
const testName = String(route.query.testName || `测试任务 #${testId}`)
const canImport = ref(false)
const dataType = String(route.query.dataType || 'REALTIME') as ConsoleDataType
const workbookMode = route.query.mode === 'workbook'
const sheets = ref<ValidatedImportSheet[]>([])
const previewType = ref<ConsoleDataType>(dataType)
// 预览记录保留 API 单位,仅渲染时换算;确认导入直接提交同一批原值。
const previewRows = computed(() => workbookMode ? sheets.value.find(sheet => sheet.type === previewType.value)?.rows || [] : validatedRows.value)
const allErrors = computed(() => workbookMode
  ? sheets.value.flatMap(sheet => [...sheet.errors, ...sheet.rows.flatMap(row => row.errors)].map(error => `${consoleSheetLabels[sheet.type]}:${error}`))
  : failedRows.value.flatMap(row => row.errors))
const fileInput = ref<HTMLInputElement | null>(null)
const selectedFile = ref<File | null>(null)
const validatedRows = ref<ValidatedImportRow[]>([])
const stage = ref<ImportStage>('upload')
const dragging = ref(false)
 
const validRows = computed(() => validatedRows.value.filter(row => row.errors.length === 0))
const failedRows = computed(() => validatedRows.value.filter(row => row.errors.length > 0))
const knownBatteries = ref<Set<string>>(new Set())
const selectedBattery = ref('')
const submitting = ref(false)
const importedCount = ref(0)
const skippedCount = ref(0)
const actionIds = ref<number[]>([])
const submitError = ref('')
 
function downloadWorkbookTemplate() {
  XLSX.writeFile(createConsoleImportWorkbook(), '测试三 Sheet 数据导入模板.xlsx')
}
 
function previewValue(item: ValidatedImportRow, field: string): string | number {
  // 文件中的系统编号只作展示;真正入库时由系统分配,来源编号不占用此列。
  if (field === 'recordNo') return String(item.raw['记录编号'] || '-')
  if (field === 'stepDuration' || field === 'relativeTimeSeconds') return formatTestDuration(item.data[field])
  if (field === 'recordedAt') return formatBusinessTime(item.data[field])
  const value = item.data[field]
  return typeof value === 'number' ? toTestDisplayValue(field, value) : value ?? '-'
}
 
function backToDataList() {
  router.push({ path: `/test-list/${testId}/console`, query: { tab: 'data-list', dataType } })
}
 
onMounted(async () => {
  try {
    const detail = await getTestDetail(testId)
    knownBatteries.value = new Set((detail.batteries || []).map(item => item.batteryNo))
    if (knownBatteries.value.size === 1) selectedBattery.value = [...knownBatteries.value][0]!
    canImport.value = detail.receptionStatus === 'ENDED' && detail.testStatus === 'COMPLETED'
  } catch {
    canImport.value = false
  }
})
 
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', cellNF: true })
    sheets.value = workbookMode ? parseConsoleImportWorkbook(workbook, [...knownBatteries.value], selectedBattery.value) : []
    const rows = workbookMode ? sheets.value.flatMap(sheet => sheet.rows) : parseConsoleImport(workbook, dataType, [...knownBatteries.value], selectedBattery.value)
    if (!rows.length && !workbookMode) {
      ElMessage.warning('Excel 中没有可导入的数据')
      return
    }
    selectedFile.value = file
    validatedRows.value = rows
    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() {
  if (submitting.value) return
  selectedFile.value = null
  validatedRows.value = []
  sheets.value = []
  submitError.value = ''
  stage.value = 'upload'
}
 
async function confirmImport() {
  if (submitting.value) return
  if (allErrors.value.length) { ElMessage.warning('请修正全部错误后再导入,数据尚未写入'); return }
  if (!validRows.value.length) { ElMessage.warning('没有可导入的有效数据'); return }
  if (!canImport.value) {
    ElMessage.warning('测试完成后才可以导入数据')
    return
  }
  let reason = ''
  try { const result = await ElMessageBox.prompt('请输入本次导入原因', '提交导入', { inputPlaceholder: '必填,将写入数据修订记录', inputValidator: value => value.trim() ? true : '导入原因不能为空' }); reason = result.value.trim() } catch { return }
  const imported = validatedRows.value.map(item => ({ ...item.data }))
  submitting.value = true
  submitError.value = ''
  try {
    const changes = workbookMode ? Object.fromEntries(sheets.value.map(sheet => [sheet.type, sheet.rows.map(row => row.data)])) : imported
    const result = await submitConsoleDataAction(testId, { dataType: workbookMode ? 'ALL' : dataType, operationType: 'IMPORT', scopeSummary: workbookMode ? `三 Sheet 导入,共${imported.length}条` : `导入${imported.length}条${dataType}数据`, identitySummary: JSON.stringify(imported.map(row => row.recordNo)), changesSummary: JSON.stringify(changes), reason })
    importedCount.value = result.importedCount
    skippedCount.value = result.skippedCount
    actionIds.value = result.actionIds
  } catch (error) { submitError.value = error instanceof Error ? error.message : '导入提交失败'; ElMessage.error(submitError.value); return }
  finally { submitting.value = false }
  stage.value = 'result'
}
 
function downloadFailedRows() {
  if (!allErrors.value.length) return
  const records = workbookMode
    ? sheets.value.flatMap(sheet => [...sheet.errors.map(error => ({ 工作表: consoleSheetLabels[sheet.type], 错误原因: error })), ...sheet.rows.filter(row => row.errors.length).map(item => ({ ...item.raw, 工作表: consoleSheetLabels[sheet.type], Excel行号: item.sourceRow, 错误原因: item.errors.join(';') }))])
    : failedRows.value.map(item => ({ ...item.raw, Excel行号: item.sourceRow, 错误原因: 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' ? '导入结果' : workbookMode ? '三 Sheet 一键导入' : '导入数据' }}</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">
        <button v-if="workbookMode" class="secondary-button template-download" type="button" @click="downloadWorkbookTemplate"><el-icon><Download /></el-icon>下载三 Sheet 模板</button>
        <label v-if="knownBatteries.size > 1" class="battery-choice">甲方文件归属电池号 <el-select v-model="selectedBattery" placeholder="请选择电池号"><el-option v-for="battery in knownBatteries" :key="battery" :label="battery" :value="battery" /></el-select></label>
        <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 v-if="workbookMode" class="sheet-summary">
          <table><thead><tr><th>工作表</th><th>行数</th><th>有效数</th><th>错误行数</th><th>工作表错误数</th></tr></thead><tbody><tr v-for="sheet in sheets" :key="sheet.type"><td>{{ consoleSheetLabels[sheet.type] }}</td><td>{{ sheet.rows.length }}</td><td>{{ sheet.rows.filter(row => !row.errors.length).length }}</td><td>{{ sheet.rows.filter(row => row.errors.length).length }}</td><td>{{ sheet.errors.length }}</td></tr></tbody></table>
          <el-tabs v-model="previewType"><el-tab-pane v-for="sheet in sheets" :key="sheet.type" :name="sheet.type" :label="consoleSheetLabels[sheet.type]" /></el-tabs>
        </div>
        <div class="preview-table-wrap"><table><thead><tr><th>Excel行号</th><th v-for="(label, field) in consoleImportColumns[previewType]" :key="field">{{ label }}</th><th>校验结果</th></tr></thead><tbody><tr v-for="item in previewRows.slice(0, 12)" :key="item.sourceRow"><td>{{ item.sourceRow }}</td><td v-for="(label, field) in consoleImportColumns[previewType]" :key="label">{{ previewValue(item, field) }}</td><td>{{ item.errors.length ? item.errors.join(';') : '通过' }}</td></tr></tbody></table></div>
        <p v-if="previewRows.length > 12" class="preview-tip">当前预览前 12 条,共 {{ previewRows.length }} 条数据</p>
        <el-alert v-if="allErrors.length" type="error" :closable="false" :title="workbookMode ? '三 Sheet 校验失败,整批尚未写入数据' : '本页校验失败,尚未写入数据'"><p v-for="(error, index) in allErrors.slice(0, 20)" :key="index">{{ error }}</p></el-alert>
        <el-alert v-if="submitError" class="submit-error" type="error" :closable="false" title="导入未成功"><p>{{ submitError }}</p></el-alert>
        <div class="stage-actions"><button class="primary-button" type="button" :disabled="!canImport || !!allErrors.length || !validRows.length || submitting" @click="confirmImport"><el-icon><Check /></el-icon>{{ submitting ? '提交中' : '确认导入' }}</button><button v-if="allErrors.length" class="secondary-button" type="button" @click="downloadFailedRows"><el-icon><Download /></el-icon>下载全部错误</button><button class="secondary-button" type="button" :disabled="submitting" @click="resetUpload">返回上传</button></div>
      </div>
 
      <div v-else class="result-stage">
        <p v-if="workbookMode && actionIds.length">数据修订记录:{{ actionIds.map(id => `#${id}`).join('、') }}</p>
        <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>{{ importedCount }} 条</dd></div><div><dt>重复跳过</dt><dd>{{ skippedCount }} 条</dd></div></dl>
        <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>
.template-download{margin-bottom:18px}.sheet-summary{overflow-x:auto}.sheet-summary table{min-width:480px;margin-bottom:16px}.section-heading small{min-width:0;overflow-wrap:anywhere}.section-heading{gap:12px}
.battery-choice{display:flex;align-items:center;gap:12px;max-width:100%;margin-bottom:18px;font-size:13px}.battery-choice .el-select{width:220px;max-width:50%}
.stage-actions{flex-wrap:wrap}.file-summary>div{min-width:0}.file-summary strong{overflow-wrap:anywhere}.file-summary button{flex-shrink:0}
.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>