import * as XLSX from 'xlsx'
|
import type { ConsoleDataType } from '../api/consoleData'
|
import { parseTestDuration } from './testDataDuration.ts'
|
|
export const consoleImportColumns = {
|
REALTIME: { recordNo: '记录编号', batteryNo: '电池', cycleNo: '循环序号', stepNo: '工步序号', stepName: '工步名称', sequenceId: '记录序号', relativeTimeSeconds: '记录时间(h:min:s.ms)', voltage: '电压(V)', current: '电流(mA)', capacity: '容量(mAh)', energy: '能量(mWh)', recordedAt: '绝对时间', temperature: '温度(℃)', positivePotential: '正电位(V)', negativePotential: '负电位(V)' },
|
STEP: { recordNo: '记录编号', batteryNo: '电池', cycleNo: '循环序号', stepNo: '工步序号', stepName: '工步名称', stepDuration: '工步时间(h:min:s.ms)', capacity: '容量(mAh)', energy: '能量(mWh)', startVoltage: '起始电压(V)', endVoltage: '终止电压(V)', chargeMedianVoltage: '充电中值电压(V)', dischargeMedianVoltage: '放电中值电压(V)' },
|
CYCLE: { recordNo: '记录编号', batteryNo: '电池', cycleNo: '循环序号', chargeCapacity: '充电容量(mAh)', dischargeCapacity: '放电容量(mAh)', efficiency: '充放电效率(%)', chargeEnergy: '充电能量(mWh)', dischargeEnergy: '放电能量(mWh)' },
|
} 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'] }
|
export const consoleSheetLabels: Record<ConsoleDataType, string> = { REALTIME: '实时', STEP: '工步', CYCLE: '循环' }
|
export interface ValidatedImportSheet {
|
type: ConsoleDataType
|
rows: ValidatedImportRow[]
|
errors: string[]
|
}
|
|
/** 创建包含三个标准列头的空工作簿,不生成测量数据。 */
|
export function createConsoleImportWorkbook(selectedType?: ConsoleDataType): XLSX.WorkBook {
|
const workbook = XLSX.utils.book_new()
|
for (const type of selectedType ? [selectedType] : Object.keys(consoleSheetLabels) as ConsoleDataType[]) {
|
XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([Object.values(consoleImportColumns[type])]), consoleSheetLabels[type])
|
}
|
// 单页汇总没有绝对时间,辅助页只供匹配,不隐式导入实时数据。
|
if (selectedType && selectedType !== 'REALTIME') XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([Object.values(consoleImportColumns.REALTIME)]), 'record')
|
return workbook
|
}
|
|
/** 三表独立校验并汇总错误;一键导入不使用单页的任意名称回退规则。 */
|
export function parseConsoleImportWorkbook(workbook: XLSX.WorkBook, batteries: string[], selectedBattery = ''): ValidatedImportSheet[] {
|
const sheets = (Object.keys(consoleSheetLabels) as ConsoleDataType[]).map(type => {
|
const result: ValidatedImportSheet = { type, rows: [], errors: [] }
|
const names = workbook.SheetNames.filter(name => sheetNames[type].includes(name) && workbook.Sheets[name])
|
try {
|
if (!names.length) throw new Error(`缺少${consoleSheetLabels[type]}工作表`)
|
if (names.length > 1) throw new Error(`存在多个${consoleSheetLabels[type]}工作表,无法确定导入来源`)
|
result.rows = parseConsoleImport(workbook, type, batteries, selectedBattery)
|
if (!result.rows.length) result.errors.push('工作表没有可导入的数据')
|
} catch (error) { result.errors.push(error instanceof Error ? error.message : '工作表解析失败') }
|
return result
|
})
|
const batterySets = sheets.map(sheet => [...new Set(sheet.rows.map(row => row.data.batteryNo))].sort().join('\u0000'))
|
sheets.forEach((sheet, index) => {
|
if (sheet.rows.length && sheets[0]!.rows.length && batterySets[index] !== batterySets[0]) sheet.errors.push('列「电池号」:三个 Sheet 的电池号集合不一致')
|
})
|
return sheets
|
}
|
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 legacyRealtime = type === 'REALTIME' && !headers.includes('绝对时间') && (headers.includes('设备时间') || headers.includes('记录时间(h:min:s.ms)'))
|
const legacySourceNumber = headers.includes('电池号') && (headers.includes('设备时间') || legacyRealtime)
|
const date1904 = !!workbook.Workbook?.WBProps?.date1904
|
const endTimes = new Map<string, unknown>()
|
const invalidEndTimes = new Set<string>()
|
const batteryFor = (row: Record<string, unknown>) => String(row['电池'] || row['电池号'] || selectedBattery || (batteries.length === 1 ? batteries[0] : '')).trim()
|
const timeKey = (row: Record<string, unknown>) => JSON.stringify([batteryFor(row), String(row['循环序号']), ...(type === 'STEP' ? [String(row['工步序号'])] : [])])
|
const recordNames = workbook.SheetNames.filter(name => sheetNames.REALTIME.includes(name) && workbook.Sheets[name])
|
if (type !== 'REALTIME' && recordNames.length > 1) throw new Error('存在多个实时辅助工作表,无法确定时间来源')
|
if (type !== 'REALTIME' && recordNames.length === 1) {
|
// 汇总表没有绝对时间,只借用匹配实时记录的末次时间,不导入其他 Sheet 的行。
|
const records = XLSX.utils.sheet_to_json<Record<string, unknown>>(workbook.Sheets[recordNames[0]!]!, { raw: true })
|
for (const record of records) {
|
const key = timeKey(record)
|
const time = deviceTime(record['绝对时间'], date1904)
|
if (!Number.isFinite(time)) invalidEndTimes.add(key)
|
if (Number.isFinite(time) && (!endTimes.has(key) || time > deviceTime(endTimes.get(key), date1904))) endTimes.set(key, record['绝对时间'])
|
}
|
// 组内有未知时间就无法可靠确定末次记录,不能静默退到更早的有效行。
|
for (const key of invalidEndTimes) endTimes.delete(key)
|
}
|
// 旧标准模板按原列头单位读取,避免对 A/Ah/Wh 再次缩放。
|
const headerFor = (field: string, standardHeader: string) => {
|
if (field === 'recordedAt' && legacyRealtime) return headers.includes('设备时间') ? '设备时间' : '记录时间(h:min:s.ms)'
|
if (party) return partyHeaders[field] || standardHeader
|
if (field === 'batteryNo' && headers.includes('电池号')) return '电池号'
|
if (field === 'stepName' && headers.includes('工步')) return '工步'
|
if (field === 'stepDuration' && headers.includes('工步时间')) return '工步时间'
|
if (field === 'recordedAt' && headers.includes('设备时间')) return '设备时间'
|
const legacyHeader = standardHeader.replace(/\(m(A|Ah|Wh)\)/, '($1)')
|
return headers.includes(standardHeader) ? standardHeader : headers.includes(legacyHeader) ? legacyHeader : standardHeader
|
}
|
const columns: Record<string, string> = { ...consoleImportColumns[type], ...(type !== 'REALTIME' ? { recordedAt: '绝对时间' } : {}) }
|
const missing = Object.entries(columns).filter(([field, header]) => {
|
// 中值只读取对应列;缺列可为空,不借用旧峰值或谷值。
|
if (['recordNo', 'batteryNo'].includes(field)) return false
|
if (field === 'relativeTimeSeconds' && legacyRealtime) return false
|
if (party && ['temperature', 'positivePotential', 'negativePotential', 'chargeMedianVoltage', 'dischargeMedianVoltage'].includes(field)) return false
|
if (type !== 'REALTIME' && field === 'recordedAt') return false
|
return !headers.includes(headerFor(field, header))
|
}).map(([field, header]) => headerFor(field, 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 = headerFor(field, standardHeader)
|
let value: unknown = raw[header]
|
if (field === 'batteryNo') value = batteryFor(raw)
|
// 系统记录编号只供定位修订,导入不能拿它代替设备/文件的来源编号。
|
if (field === 'recordNo') value = type === 'REALTIME' ? raw['记录序号'] : type === 'STEP' ? `step:${raw['循环序号']}:${raw['工步序号']}` : `cycle:${raw['循环序号']}`
|
// 旧标准模板的编号曾表示来源,保留其重复导入身份;新模板编号不参与去重。
|
if (field === 'recordNo' && legacySourceNumber && raw['记录编号']) value = raw['记录编号']
|
if (type !== 'REALTIME' && field === 'recordedAt' && !value) value = endTimes.get(timeKey(raw))
|
if (field === 'stepDuration' || field === 'relativeTimeSeconds') {
|
const cell = sheet[XLSX.utils.encode_cell({ r: sourceRow - 1, c: headers.indexOf(header) })]
|
if (typeof value === 'number' && cell?.z && /[hs]/i.test(cell.z)) value = XLSX.utils.format_cell(cell)
|
value = field === 'relativeTimeSeconds' && legacyRealtime ? null : parseTestDuration(value)
|
}
|
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 }]
|
})
|
}
|