兼容甲方 Excel 单页导入并阻断整页校验错误
沿用三子页独立模板和上传流程,转换甲方工作表、香港绝对时间及测量单位,补齐实时开发测试值;展示完整错误和重复跳过数量,多电池测试明确选择归属。
导入转换测试、生产构建和桌面窄屏浏览器验证通过。额外旧契约测试有两项既有失败,详情已记录到 Ticket03。
| | |
| | | export type ConsoleDataType = 'REALTIME' | 'STEP' | 'CYCLE' |
| | | export type ConsoleDataOperation = 'EDIT' | 'IMPORT' | 'DELETE' |
| | | |
| | | export const consoleImportColumns = { |
| | | REALTIME: { recordNo: '记录编号', batteryNo: '电池号', recordedAt: '设备时间', cycleNo: '循环序号', stepNo: '工步序号', stepName: '工步名称', sequenceId: '记录序号', voltage: '电压(V)', current: '电流(A)', capacity: '容量(Ah)', energy: '能量(Wh)', temperature: '温度(℃)', positivePotential: '正电位(V)', negativePotential: '负电位(V)' }, |
| | | STEP: { recordNo: '记录编号', batteryNo: '电池号', recordedAt: '设备时间', cycleNo: '循环序号', stepNo: '工步序号', stepName: '工步', stepDuration: '工步时间', capacity: '容量(Ah)', energy: '能量(Wh)', startVoltage: '起始电压(V)', endVoltage: '终止电压(V)', chargePeakVoltage: '充电峰值电压(V)', dischargeValleyVoltage: '放电谷值电压(V)' }, |
| | | CYCLE: { recordNo: '记录编号', batteryNo: '电池号', recordedAt: '设备时间', cycleNo: '循环序号', chargeCapacity: '充电容量(Ah)', dischargeCapacity: '放电容量(Ah)', efficiency: '充放电效率(%)', chargeEnergy: '充电能量(Wh)', dischargeEnergy: '放电能量(Wh)' }, |
| | | } satisfies Record<ConsoleDataType, Record<string, string>> |
| | | export { consoleImportColumns } from '../utils/consoleDataImport' |
| | | |
| | | export interface ConsoleDataActionPayload { |
| | | dataType: ConsoleDataType |
| | |
| | | }> |
| | | } |
| | | |
| | | interface ActionResponse extends ApiResponse { actionId: number } |
| | | interface ActionResponse extends ApiResponse { actionId: number | null; importedCount: number; skippedCount: number } |
| | | |
| | | export async function submitConsoleDataAction(testId: number, payload: ConsoleDataActionPayload) { |
| | | return request<ActionResponse>(`/system/test/${testId}/console-data/actions`, { |
| New file |
| | |
| | | import * as XLSX from 'xlsx' |
| | | import type { ConsoleDataType } from '../api/consoleData' |
| | | |
| | | export const consoleImportColumns = { |
| | | REALTIME: { recordNo: '记录编号', batteryNo: '电池号', recordedAt: '设备时间', cycleNo: '循环序号', stepNo: '工步序号', stepName: '工步名称', sequenceId: '记录序号', voltage: '电压(V)', current: '电流(A)', capacity: '容量(Ah)', energy: '能量(Wh)', temperature: '温度(℃)', positivePotential: '正电位(V)', negativePotential: '负电位(V)' }, |
| | | STEP: { recordNo: '记录编号', batteryNo: '电池号', recordedAt: '设备时间', cycleNo: '循环序号', stepNo: '工步序号', stepName: '工步', stepDuration: '工步时间', capacity: '容量(Ah)', energy: '能量(Wh)', startVoltage: '起始电压(V)', endVoltage: '终止电压(V)', chargePeakVoltage: '充电峰值电压(V)', dischargeValleyVoltage: '放电谷值电压(V)' }, |
| | | CYCLE: { recordNo: '记录编号', batteryNo: '电池号', recordedAt: '设备时间', cycleNo: '循环序号', chargeCapacity: '充电容量(Ah)', dischargeCapacity: '放电容量(Ah)', efficiency: '充放电效率(%)', chargeEnergy: '充电能量(Wh)', dischargeEnergy: '放电能量(Wh)' }, |
| | | } satisfies Record<ConsoleDataType, Record<string, string>> |
| | | |
| | | export interface ValidatedImportRow { |
| | | sourceRow: number |
| | | raw: Record<string, unknown> |
| | | errors: string[] |
| | | data: Record<string, string | number | null> |
| | | } |
| | | |
| | | const sheetNames = { REALTIME: ['实时', '实时模板', 'record'], STEP: ['工步', '工步模板', 'step'], CYCLE: ['循环', '循环模板', 'cycle'] } |
| | | const partyHeaders: Record<string, string> = { |
| | | recordedAt: '绝对时间', stepName: '工步名称', current: '电流(mA)', capacity: '容量(mAh)', energy: '能量(mWh)', |
| | | stepDuration: '工步时间(h:min:s.ms)', chargeCapacity: '充电容量(mAh)', dischargeCapacity: '放电容量(mAh)', |
| | | chargeEnergy: '充电能量(mWh)', dischargeEnergy: '放电能量(mWh)', |
| | | } |
| | | |
| | | // 无时区文本与 Excel 日期都按香港设备时区解释,禁止依赖浏览器所在时区。 |
| | | function deviceTime(value: unknown, date1904 = false): number { |
| | | if (typeof value === 'number' && value > 0 && value < 2958466) { |
| | | const text = XLSX.utils.format_cell({ t: 'n', v: value + (date1904 ? 1462 : 0), z: 'yyyy-mm-dd hh:mm:ss.000' }) |
| | | return deviceTime(text) |
| | | } |
| | | const text = String(value ?? '').trim() |
| | | if (/^\d{13}$/.test(text)) return Number(text) |
| | | const match = /^(\d{4})[-/](\d{1,2})[-/](\d{1,2})[ T](\d{1,2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?$/.exec(text) |
| | | if (!match) return NaN |
| | | const [year, month, day, hour, minute, second] = match.slice(1, 7).map(Number) as [number, number, number, number, number, number] |
| | | const local = new Date(Date.UTC(year, month - 1, day, hour, minute, second, Number((match[7] || '').padEnd(3, '0')))) |
| | | if (local.getUTCFullYear() !== year || local.getUTCMonth() !== month - 1 || local.getUTCDate() !== day || hour > 23 || minute > 59 || second > 59) return NaN |
| | | return local.getTime() - 8 * 3600000 |
| | | } |
| | | |
| | | /** 将当前子页工作表转换为待提交记录;错误保留真实 Excel 行号和列名。 */ |
| | | export function parseConsoleImport(workbook: XLSX.WorkBook, type: ConsoleDataType, batteries: string[], selectedBattery = ''): ValidatedImportRow[] { |
| | | const name = workbook.SheetNames.find(name => sheetNames[type].includes(name)) |
| | | || (workbook.SheetNames.length === 1 ? workbook.SheetNames[0] : undefined) |
| | | if (!name || !workbook.Sheets[name]) throw new Error(`缺少${sheetNames[type][0]}工作表`) |
| | | const sheet = workbook.Sheets[name]! |
| | | const table = XLSX.utils.sheet_to_json<unknown[]>(sheet, { header: 1, raw: true, defval: '', blankrows: true }) |
| | | const headers = (table[0] || []).map(value => String(value).trim()) |
| | | const party = name === sheetNames[type][2] |
| | | const date1904 = !!workbook.Workbook?.WBProps?.date1904 |
| | | const endTimes = new Map<string, unknown>() |
| | | if (party && type !== 'REALTIME' && workbook.Sheets.record) { |
| | | // 汇总表没有绝对时间,只借用匹配实时记录的末次时间,不导入其他 Sheet 的行。 |
| | | const records = XLSX.utils.sheet_to_json<Record<string, unknown>>(workbook.Sheets.record, { raw: true }) |
| | | for (const record of records) { |
| | | const key = type === 'STEP' ? `${record['循环序号']}:${record['工步序号']}` : String(record['循环序号']) |
| | | const time = deviceTime(record['绝对时间'], date1904) |
| | | if (Number.isFinite(time) && (!endTimes.has(key) || time > deviceTime(endTimes.get(key), date1904))) endTimes.set(key, record['绝对时间']) |
| | | } |
| | | } |
| | | const columns: Record<string, string> = consoleImportColumns[type] |
| | | const missing = Object.entries(columns).filter(([field, header]) => { |
| | | if (party && ['recordNo', 'batteryNo', 'temperature', 'positivePotential', 'negativePotential', 'chargePeakVoltage', 'dischargeValleyVoltage'].includes(field)) return false |
| | | if (party && type !== 'REALTIME' && field === 'recordedAt') return false |
| | | return !headers.includes(party ? partyHeaders[field] || header : header) |
| | | }).map(([field, header]) => party ? partyHeaders[field] || header : header) |
| | | if (missing.length) throw new Error(`第1行,列「${missing.join('、')}」:模板字段缺失`) |
| | | return table.slice(1).flatMap((cells, index) => { |
| | | if (cells.every(value => value === '' || value === null)) return [] |
| | | const sourceRow = index + 2 + XLSX.utils.decode_range(sheet['!ref'] || 'A1').s.r |
| | | const raw = Object.fromEntries(headers.map((header, index) => [header, cells[index] ?? ''])) |
| | | const data: ValidatedImportRow['data'] = { sourceRow } |
| | | const errors: string[] = [] |
| | | for (const [field, standardHeader] of Object.entries(columns)) { |
| | | const header = party ? partyHeaders[field] || standardHeader : standardHeader |
| | | let value: unknown = raw[header] |
| | | if (party && field === 'batteryNo') value = selectedBattery || (batteries.length === 1 ? batteries[0] : '') |
| | | if (party && field === 'recordNo') value = raw['记录编号'] || (type === 'REALTIME' ? raw['记录序号'] : type === 'STEP' ? `step:${raw['循环序号']}:${raw['工步序号']}` : `cycle:${raw['循环序号']}`) |
| | | if (party && type !== 'REALTIME' && field === 'recordedAt' && !value) value = endTimes.get(type === 'STEP' ? `${raw['循环序号']}:${raw['工步序号']}` : String(raw['循环序号'])) |
| | | if (field === 'stepDuration' && typeof value === 'string' && value.includes(':')) { |
| | | const duration = /^(\d+):([0-5]\d):([0-5]\d)(?:\.(\d{1,3}))?$/.exec(value.trim()) |
| | | value = duration ? Number(duration[1]) * 3600 + Number(duration[2]) * 60 + Number(duration[3]) + Number((duration[4] || '').padEnd(3, '0')) / 1000 : NaN |
| | | } |
| | | if (party && ['temperature', 'positivePotential', 'negativePotential'].includes(field) && (value === undefined || value === '')) { |
| | | // 开发测试值只在甲方实时表缺值时补齐,按记录序号确定以保持重复导入幂等。 |
| | | const offset = Number(raw['记录序号']) % 10 |
| | | value = field === 'temperature' ? 25 + offset / 10 : field === 'positivePotential' ? (1500 + offset) / 1000 : (-100 - offset) / 1000 |
| | | } |
| | | const text = String(value ?? '').trim() |
| | | const fail = (reason: string) => errors.push(`第${sourceRow}行,列「${header}」:${reason}`) |
| | | if (['recordNo', 'batteryNo', 'stepName'].includes(field)) { |
| | | data[field] = text |
| | | if (!text) fail('不能为空') |
| | | else if (field === 'batteryNo' && !batteries.includes(text)) fail('不属于当前测试') |
| | | } else if (field === 'recordedAt') { |
| | | const time = deviceTime(value, date1904) |
| | | data.recordedAt = Number.isFinite(time) ? time : null |
| | | data.deviceTimeRaw = text |
| | | data.adapterTimezoneId = 'Asia/Hong_Kong' |
| | | if (!Number.isFinite(time)) fail('绝对设备时间无效') |
| | | } else if (['cycleNo', 'stepNo', 'sequenceId'].includes(field)) { |
| | | data[field] = Number(text) |
| | | if (!text || !Number.isSafeInteger(data[field]) || Number(data[field]) < 1) fail('必须是正整数') |
| | | } else { |
| | | const number = !text || text === '-' ? null : Number(text) |
| | | data[field] = number |
| | | if (number !== null && !Number.isFinite(number)) fail('必须是数字或空值') |
| | | else if (number !== null && /\(mA\)|\(mAh\)|\(mWh\)/.test(header)) data[field] = Number((number / 1000).toFixed(12)) |
| | | } |
| | | } |
| | | return [{ sourceRow, raw, data, errors }] |
| | | }) |
| | | } |
| | |
| | | import { ElMessage, ElMessageBox } from 'element-plus' |
| | | import { useRoute, useRouter } from 'vue-router' |
| | | import * as XLSX from 'xlsx' |
| | | import { consoleImportColumns, submitConsoleDataAction, type ConsoleDataType } from '@/api/consoleData' |
| | | import { submitConsoleDataAction, type ConsoleDataType } from '@/api/consoleData' |
| | | import { parseConsoleImport, type ValidatedImportRow } from '@/utils/consoleDataImport' |
| | | import { getTestDetail } from '@/api/tests' |
| | | import { formatBusinessTime } from '@/utils/businessTime' |
| | | |
| | | type ImportStage = 'upload' | 'preview' | 'result' |
| | | type RawExcelRow = Record<string, unknown> |
| | | |
| | | interface ValidatedRow { |
| | | sourceRow: number |
| | | raw: RawExcelRow |
| | | errors: string[] |
| | | data: Record<string, string | number | null> |
| | | } |
| | | |
| | | const route = useRoute() |
| | | const router = useRouter() |
| | |
| | | const dataType = String(route.query.dataType || 'REALTIME') as ConsoleDataType |
| | | const fileInput = ref<HTMLInputElement | null>(null) |
| | | const selectedFile = ref<File | null>(null) |
| | | const validatedRows = ref<ValidatedRow[]>([]) |
| | | const validatedRows = ref<ValidatedImportRow[]>([]) |
| | | const stage = ref<ImportStage>('upload') |
| | | const dragging = ref(false) |
| | | |
| | | const columns = consoleImportColumns[dataType] |
| | | const requiredHeaders = Object.values(columns) |
| | | 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) |
| | | |
| | | function backToDataList() { |
| | | router.push({ path: `/test-list/${testId}/console`, query: { tab: 'data-list', dataType } }) |
| | |
| | | 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 |
| | | } |
| | | }) |
| | | |
| | | function textValue(row: RawExcelRow, key: string) { |
| | | return String(row[key] ?? '').trim() |
| | | } |
| | | |
| | | function validateRow(raw: RawExcelRow, sourceRow: number): ValidatedRow { |
| | | const errors: string[] = [] |
| | | const data: ValidatedRow['data'] = { sourceType: dataType === 'REALTIME' ? 'SNAPSHOT' : 'CABINET' } |
| | | Object.entries(columns).forEach(([field, header]) => { |
| | | const value = textValue(raw, header) |
| | | if (['recordNo', 'batteryNo', 'stepName'].includes(field)) { |
| | | data[field] = value |
| | | if (!value) errors.push(`${header}不能为空`) |
| | | } else if (field === 'recordedAt') { |
| | | // Excel 文本按浏览器本地时间解释,提交边界只发送毫秒数。 |
| | | const parsed = /^\d{13}$/.test(value) ? Number(value) : new Date(value).getTime() |
| | | if (!value || !Number.isFinite(parsed)) errors.push('设备时间无效') |
| | | data.recordedAt = Number.isFinite(parsed) ? parsed : null |
| | | data.deviceTime = data.recordedAt |
| | | data.absoluteTime = data.recordedAt |
| | | } else if (['cycleNo', 'stepNo', 'sequenceId'].includes(field)) { |
| | | data[field] = Number(value) |
| | | if (!value || !Number.isInteger(data[field]) || Number(data[field]) < 1) errors.push(`${header}必须是正整数`) |
| | | } else { |
| | | data[field] = !value || value === '-' ? null : Number(value) |
| | | if (data[field] !== null && !Number.isFinite(data[field])) errors.push(`${header}必须是数字`) |
| | | } |
| | | }) |
| | | const batteryNo = textValue(raw, '电池号') |
| | | if (batteryNo && knownBatteries.value.size && !knownBatteries.value.has(batteryNo)) errors.push('电池号不属于当前测试') |
| | | return { sourceRow, raw, errors: [...new Set(errors)], data } |
| | | } |
| | | |
| | | async function processFile(file: File) { |
| | | const extension = file.name.toLowerCase().slice(file.name.lastIndexOf('.')) |
| | |
| | | |
| | | 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) { |
| | | const rows = parseConsoleImport(workbook, dataType, [...knownBatteries.value], selectedBattery.value) |
| | | if (!rows.length) { |
| | | ElMessage.warning('Excel 中没有可导入的数据') |
| | | return |
| | | } |
| | | selectedFile.value = file |
| | | const seen = new Set<string>() |
| | | validatedRows.value = rawRows.map((row, index) => { |
| | | const validated = validateRow(row, index + 2) |
| | | const identity = String(validated.data.recordNo || '') |
| | | if (identity && seen.has(identity)) validated.errors.push('本批次存在重复稳定身份') |
| | | if (identity) seen.add(identity) |
| | | return validated |
| | | }) |
| | | validatedRows.value = rows |
| | | stage.value = 'preview' |
| | | } catch (error) { |
| | | ElMessage.error(error instanceof Error ? error.message : 'Excel 文件解析失败') |
| | |
| | | } |
| | | |
| | | async function confirmImport() { |
| | | if (submitting.value) return |
| | | if (failedRows.value.length) { ElMessage.warning('请修正全部错误后再导入,本页数据尚未写入'); return } |
| | | if (!validRows.value.length) { ElMessage.warning('没有可导入的有效数据'); return } |
| | | if (!canImport.value) { |
| | | ElMessage.warning('测试完成后才可以导入数据') |
| | |
| | | } |
| | | let reason = '' |
| | | try { const result = await ElMessageBox.prompt('请输入本次导入原因', '提交导入', { inputPlaceholder: '必填,将写入数据修订记录', inputValidator: value => value.trim() ? true : '导入原因不能为空' }); reason = result.value.trim() } catch { return } |
| | | const imported = validRows.value.map(item => ({ ...item.data })) |
| | | const imported = validatedRows.value.map(item => ({ ...item.data })) |
| | | submitting.value = true |
| | | try { |
| | | await submitConsoleDataAction(testId, { dataType, operationType: 'IMPORT', scopeSummary: `导入${validRows.value.length}条${dataType}数据`, identitySummary: JSON.stringify(imported.map(row => row.recordNo)), changesSummary: JSON.stringify(imported), reason, overrides: imported.map(row => ({ stableIdentity: String(row.recordNo), fieldValues: JSON.stringify(row), deleted: false })) }) |
| | | const result = await submitConsoleDataAction(testId, { dataType, operationType: 'IMPORT', scopeSummary: `导入${imported.length}条${dataType}数据`, identitySummary: JSON.stringify(imported.map(row => row.recordNo)), changesSummary: JSON.stringify(imported), reason }) |
| | | importedCount.value = result.importedCount |
| | | skippedCount.value = result.skippedCount |
| | | } catch (error) { ElMessage.error(error instanceof Error ? error.message : '导入提交失败'); return } |
| | | finally { submitting.value = false } |
| | | stage.value = 'result' |
| | | } |
| | | |
| | | function downloadFailedRows() { |
| | | if (!failedRows.value.length) return |
| | | const records = failedRows.value.map(item => ({ ...item.raw, 错误原因: item.errors.join(';') })) |
| | | const records = 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, '失败数据') |
| | |
| | | <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"> |
| | | <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> |
| | |
| | | <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>容量(Ah)</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>{{ formatBusinessTime(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> |
| | | <el-alert v-if="failedRows.length" type="error" :closable="false" title="本页校验失败,尚未写入数据"><p v-for="item in failedRows.slice(0, 20)" :key="item.sourceRow">{{ item.errors.join(';') }}</p></el-alert> |
| | | <div class="stage-actions"><button class="primary-button" type="button" :disabled="!canImport || !!failedRows.length || submitting" @click="confirmImport"><el-icon><Check /></el-icon>{{ submitting ? '提交中' : '确认导入' }}</button><button v-if="failedRows.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"> |
| | | <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="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> |
| | |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .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> |
| New file |
| | |
| | | import assert from 'node:assert/strict' |
| | | import { readFile, mkdir } from 'node:fs/promises' |
| | | import { chromium, expect } from '@playwright/test' |
| | | import XLSX from 'xlsx' |
| | | import { consoleImportColumns } from '../src/utils/consoleDataImport.ts' |
| | | |
| | | // 拦截构建产物和 API 完成页面验收,不启动业务服务或连接共享数据库。 |
| | | const origin = 'http://single-sheet-import.test' |
| | | const output = '../.scratch/simulator-and-data-import-v1/single-sheet-browser' |
| | | await mkdir(output, { recursive: true }) |
| | | const browser = await chromium.launch({ channel: 'chrome', headless: true }) |
| | | const calls = [] |
| | | let result = { importedCount: 1, skippedCount: 0 } |
| | | const detail = { testId: 1, testName: '甲方数据导入验收', testStatus: 'COMPLETED', receptionStatus: 'ENDED', |
| | | thresholds: [], logicStepMappings: [], batteries: [{ testBatteryId: 1, batteryNo: 'BAT-1', groupName: '演示电池组', fields: [], collectorChannels: [], binding: { channelId: 1 } }] } |
| | | |
| | | function standardFile(type, invalid = false) { |
| | | const fields = consoleImportColumns[type] |
| | | const row = { recordNo: '17', batteryNo: 'BAT-1', recordedAt: '2023-05-08 11:42:11', cycleNo: 1, stepNo: 1, stepName: '恒流放电', sequenceId: 17, capacity: 8.5122, temperature: 25, positivePotential: 1.5, negativePotential: -0.1 } |
| | | const rows = [Object.values(fields), Object.keys(fields).map(field => row[field] ?? '')] |
| | | if (invalid) { |
| | | while (rows.length < 19) rows.push([]) |
| | | rows.push(Object.keys(fields).map(field => field === 'cycleNo' ? 0 : row[field] ?? '')) |
| | | } |
| | | const workbook = XLSX.utils.book_new() |
| | | XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet(rows), { REALTIME: '实时', STEP: '工步', CYCLE: '循环' }[type]) |
| | | return { name: '单页数据.xlsx', mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', buffer: XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }) } |
| | | } |
| | | |
| | | try { |
| | | const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }) |
| | | const errors = [] |
| | | page.on('pageerror', error => errors.push(error.message)) |
| | | await page.addInitScript(() => localStorage.setItem('battery_detection_auth_token', 'browser-test')) |
| | | await page.route(`${origin}/**`, async route => { |
| | | const path = new URL(route.request().url()).pathname |
| | | if (path.startsWith('/assets/')) return route.fulfill({ body: await readFile(`dist${path}`), contentType: path.endsWith('.css') ? 'text/css' : 'application/javascript' }) |
| | | if (path.endsWith('/console-data/actions')) { |
| | | calls.push(route.request().postDataJSON()) |
| | | return route.fulfill({ json: { code: 200, actionId: 1, ...result } }) |
| | | } |
| | | if (path.endsWith('/live-state')) return route.fulfill({ json: { code: 200, data: { ...detail, sources: [], exceptions: [] } } }) |
| | | if (/\/data\/(records|steps|cycles)$/.test(path)) return route.fulfill({ json: { code: 200, data: [] } }) |
| | | if (path.endsWith('/system/test/1')) return route.fulfill({ json: { code: 200, data: detail } }) |
| | | if (path.endsWith('/getInfo')) return route.fulfill({ json: { code: 200, user: { userName: 'admin' }, roles: ['admin'] } }) |
| | | return route.fulfill({ body: await readFile('dist/index.html'), contentType: 'text/html' }) |
| | | }) |
| | | for (const type of ['REALTIME', 'STEP', 'CYCLE']) { |
| | | await page.goto(`${origin}/test-list/1/console?tab=data-list&dataType=${type}`) |
| | | const downloadEvent = page.waitForEvent('download') |
| | | await page.getByRole('button', { name: '下载模板', exact: true }).click() |
| | | const download = await downloadEvent |
| | | const workbook = XLSX.read(await readFile(await download.path())) |
| | | assert.equal(workbook.SheetNames.length, 1) |
| | | assert.deepEqual(XLSX.utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[0]], { header: 1 })[0], Object.values(consoleImportColumns[type])) |
| | | await page.goto(`${origin}/test-list/1/console/data/import?dataType=${type}`) |
| | | await expect(page.locator('.status-notice')).toHaveCount(0) |
| | | await page.locator('input[type=file]').setInputFiles(process.env.PARTY_EXCEL || standardFile(type)) |
| | | await expect(page.getByRole('button', { name: '确认导入', exact: true })).toBeEnabled() |
| | | if (type === 'REALTIME') await page.screenshot({ path: `${output}/preview-1440.png`, fullPage: true }) |
| | | await page.getByRole('button', { name: '确认导入', exact: true }).click() |
| | | await page.locator('.el-message-box input').fill('甲方数据兼容验收') |
| | | await page.locator('.el-message-box').getByRole('button', { name: '确定', exact: true }).click() |
| | | await expect(page.locator('.result-stage')).toContainText('导入完成') |
| | | assert.equal(calls.at(-1).dataType, type) |
| | | const imported = JSON.parse(calls.at(-1).changesSummary) |
| | | assert.equal(imported[0].batteryNo, 'BAT-1') |
| | | if (process.env.PARTY_EXCEL) assert.equal(imported.length, { REALTIME: 7605, STEP: 129, CYCLE: 33 }[type]) |
| | | } |
| | | await page.goto(`${origin}/test-list/1/console/data/import?dataType=REALTIME`) |
| | | await page.locator('input[type=file]').setInputFiles(standardFile('REALTIME', true)) |
| | | await expect(page.getByRole('button', { name: '确认导入', exact: true })).toBeDisabled() |
| | | await expect(page.locator('.el-alert')).toContainText('第20行,列「循环序号」') |
| | | assert.equal(calls.length, 3) |
| | | await page.setViewportSize({ width: 390, height: 844 }) |
| | | await expect(page.locator('.app-main')).toHaveCSS('margin-left', '0px') |
| | | await page.screenshot({ path: `${output}/errors-390.png`, fullPage: true }) |
| | | assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false) |
| | | result = { importedCount: 0, skippedCount: 1 } |
| | | await page.goto(`${origin}/test-list/1/console/data/import?dataType=REALTIME`) |
| | | await page.locator('input[type=file]').setInputFiles(standardFile('REALTIME')) |
| | | await page.getByRole('button', { name: '确认导入', exact: true }).click() |
| | | await page.locator('.el-message-box input').fill('重复导入核对') |
| | | await page.locator('.el-message-box').getByRole('button', { name: '确定', exact: true }).click() |
| | | await expect(page.locator('.result-stage')).toContainText('重复跳过1 条') |
| | | await page.screenshot({ path: `${output}/result-390.png`, fullPage: true }) |
| | | assert.deepEqual(errors, []) |
| | | console.log('Ticket03 浏览器:三个独立模板、三子页提交、整页错误阻断、重复结果与窄屏通过') |
| | | } finally { |
| | | await browser.close() |
| | | } |
| New file |
| | |
| | | import assert from 'node:assert/strict' |
| | | import { test } from 'node:test' |
| | | import XLSX from 'xlsx' |
| | | import { parseConsoleImport, consoleImportColumns } from '../src/utils/consoleDataImport.ts' |
| | | |
| | | function partyWorkbook() { |
| | | const workbook = XLSX.utils.book_new() |
| | | XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([['设备信息']]), 'Unit') |
| | | XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([ |
| | | ['循环序号', '工步序号', '工步名称', '记录序号', '记录时间(h:min:s.ms)', '电压(V)', '电流(mA)', '容量(mAh)', '能量(mWh)', '绝对时间'], |
| | | [1, 1, '恒流放电', 1, '0:00:00.000', 1.3349, -8165.1, 0, 0, '2023-05-08 11:42:11'], |
| | | [1, 1, '恒流放电', 2, '1:02:32.000', 0.8999, -8165.1, 8512.2, 9717.8, '2023-05-08 12:44:43'], |
| | | ]), 'record') |
| | | return workbook |
| | | } |
| | | |
| | | test('实时子页选择 record 并保留身份、香港绝对时间及系统单位', () => { |
| | | const rows = parseConsoleImport(partyWorkbook(), 'REALTIME', ['BAT-1'], 'BAT-1') |
| | | assert.equal(rows.length, 2) |
| | | assert.deepEqual(rows[0].errors, []) |
| | | assert.equal(rows[0].data.recordNo, '1') |
| | | assert.equal(rows[0].data.sequenceId, 1) |
| | | assert.equal(rows[0].data.cycleNo, 1) |
| | | assert.equal(rows[0].data.stepNo, 1) |
| | | assert.equal(rows[0].data.recordedAt, 1683517331000) |
| | | assert.equal(rows[0].data.deviceTimeRaw, '2023-05-08 11:42:11') |
| | | assert.equal(rows[0].data.current, -8.1651) |
| | | assert.equal(rows[1].data.capacity, 8.5122) |
| | | assert.equal(rows[1].data.energy, 9.7178) |
| | | for (const field of ['temperature', 'positivePotential', 'negativePotential']) { |
| | | assert.equal(typeof rows[0].data[field], 'number') |
| | | assert.equal(rows[0].data[field], parseConsoleImport(partyWorkbook(), 'REALTIME', ['BAT-1'], 'BAT-1')[0].data[field]) |
| | | } |
| | | }) |
| | | |
| | | test('标准模板兼容文本、毫秒时间戳和 Excel 日期系统,并拒绝非法日期', () => { |
| | | const make = (time, date1904 = false) => { |
| | | const workbook = XLSX.utils.book_new() |
| | | workbook.Workbook = { WBProps: { date1904 } } |
| | | const data = { recordNo: '17', batteryNo: 'BAT-1', recordedAt: time, cycleNo: 1, stepNo: 1, stepName: '恒流放电', sequenceId: 17 } |
| | | XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([ |
| | | Object.values(consoleImportColumns.REALTIME), Object.keys(consoleImportColumns.REALTIME).map(field => data[field] ?? ''), |
| | | ]), '实时模板') |
| | | return parseConsoleImport(workbook, 'REALTIME', ['BAT-1'])[0] |
| | | } |
| | | assert.equal(make('2023/5/8 11:42:11.123').data.recordedAt, 1683517331123) |
| | | assert.equal(make(1683517331123).data.recordedAt, 1683517331123) |
| | | assert.equal(make(45054).data.recordedAt, 1683475200000) |
| | | assert.equal(make(43592, true).data.recordedAt, 1683475200000) |
| | | assert.match(make('2023-02-30 11:00:00').errors.join(), /第2行.*设备时间.*无效/) |
| | | assert.match(make('1:02:32.000').errors.join(), /设备时间.*无效/) |
| | | }) |
| | | |
| | | test('多电池文件要求明确归属,保留空白行后的实际错误位置', () => { |
| | | assert.match(parseConsoleImport(partyWorkbook(), 'REALTIME', ['BAT-1', 'BAT-2'])[0].errors.join(), /电池号.*不能为空/) |
| | | assert.equal(parseConsoleImport(partyWorkbook(), 'REALTIME', ['BAT-1', 'BAT-2'], 'BAT-2')[0].data.batteryNo, 'BAT-2') |
| | | const workbook = partyWorkbook() |
| | | XLSX.utils.sheet_add_aoa(workbook.Sheets.record, [[1, 1, '恒流放电', 19, '0:01:00.000', '不是数值', 0, 0, 0, '2023-05-08 11:44:11']], { origin: 'A20' }) |
| | | const last = parseConsoleImport(workbook, 'REALTIME', ['BAT-1']).at(-1) |
| | | assert.equal(last.sourceRow, 20) |
| | | assert.match(last.errors.join(), /第20行,列「电压\(V\)」/) |
| | | }) |
| | | |
| | | test('工步和循环仅转换对应页,时间取匹配 record 的末次绝对时间', () => { |
| | | const workbook = partyWorkbook() |
| | | XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([ |
| | | ['循环序号', '工步序号', '工步名称', '工步时间(h:min:s.ms)', '容量(mAh)', '能量(mWh)', '起始电压(V)', '终止电压(V)', '充电中值电压(V)', '放电中值电压(V)'], |
| | | [1, 1, '恒流放电', '1:02:32.000', 8512.2, 9717.8, 1.3349, 0.8999, 0, 1.1709], |
| | | ]), 'step') |
| | | XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([ |
| | | ['循环序号', '充电容量(mAh)', '放电容量(mAh)', '充放电效率(%)', '充电能量(mWh)', '放电能量(mWh)'], |
| | | [1, 0, 8512.2, 100, 0, 9717.8], |
| | | ]), 'cycle') |
| | | const step = parseConsoleImport(workbook, 'STEP', ['BAT-1'])[0] |
| | | assert.deepEqual(step.errors, []) |
| | | assert.equal(step.data.recordNo, 'step:1:1') |
| | | assert.equal(step.data.recordedAt, 1683521083000) |
| | | assert.equal(step.data.stepDuration, 3752) |
| | | assert.equal(step.data.chargePeakVoltage, null) |
| | | assert.equal(step.data.dischargeValleyVoltage, null) |
| | | const cycle = parseConsoleImport(workbook, 'CYCLE', ['BAT-1'])[0] |
| | | assert.deepEqual(cycle.errors, []) |
| | | assert.equal(cycle.data.recordNo, 'cycle:1') |
| | | assert.equal(cycle.data.recordedAt, 1683521083000) |
| | | assert.equal(cycle.data.dischargeCapacity, 8.5122) |
| | | delete workbook.Sheets.record |
| | | assert.match(parseConsoleImport(workbook, 'STEP', ['BAT-1'])[0].errors.join(), /第2行.*绝对时间.*无效/) |
| | | }) |