增加三 Sheet 模板与一键导入预览
沿用现有单页导入入口和页面,增加三表模板、逐表预览、错误清单与单次原因提交。展示共同批次和三类修订标签,覆盖模板下载、错误阻断、提交及子页刷新。
| | |
| | | export { consoleImportColumns } from '../utils/consoleDataImport' |
| | | |
| | | export interface ConsoleDataActionPayload { |
| | | dataType: ConsoleDataType |
| | | dataType: ConsoleDataType | 'ALL' |
| | | operationType: ConsoleDataOperation |
| | | scopeSummary: string |
| | | identitySummary: string |
| | |
| | | import { request, type ApiResponse } from './request' |
| | | import { formatBusinessTime } from '@/utils/businessTime' |
| | | |
| | | export type RevisionDataType = 'REALTIME' | 'STEP' | 'CYCLE' |
| | | export type RevisionDataType = 'REALTIME' | 'STEP' | 'CYCLE' | 'ALL' |
| | | export type RevisionOperationType = 'EDIT' | 'IMPORT' | 'DELETE' |
| | | |
| | | export const revisionDataTypeLabels: Record<RevisionDataType, string> = { REALTIME: '实时', STEP: '工步', CYCLE: '循环' } |
| | | export const revisionDataTypeLabels: Record<RevisionDataType, string> = { REALTIME: '实时', STEP: '工步', CYCLE: '循环', ALL: '三 Sheet' } |
| | | export const revisionOperationLabels: Record<RevisionOperationType, string> = { EDIT: '编辑', IMPORT: '导入', DELETE: '删除' } |
| | | export const formatRevisionTime = formatBusinessTime |
| | | export function formatRevisionScope(value: string) { return value.replace(/^按格(?=修改)/, '') } |
| | |
| | | dataType: RevisionDataType |
| | | operationType: RevisionOperationType |
| | | scopeSummary: string |
| | | identitySummary: string |
| | | identitySummary: string | null |
| | | changesSummary: string | null |
| | | reason: string |
| | | operatorId: number | null |
| | |
| | | } |
| | | 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 goToImport() { |
| | | router.push({ path: `/test-list/${props.testId}/console/data/import`, query: { testName: props.testName, completed: props.canImport ? '1' : '0', dataType: dataType.value } }) |
| | | function goToImport(workbook = false) { |
| | | router.push({ path: `/test-list/${props.testId}/console/data/import`, query: { testName: props.testName, completed: props.canImport ? '1' : '0', dataType: dataType.value, ...(workbook ? { mode: 'workbook' } : {}) } }) |
| | | } |
| | | function goToRevisions() { |
| | | router.push({ path: '/data-revision', query: { testId: String(props.testId), dataType: dataType.value } }) |
| | |
| | | </div> |
| | | <div class="data-toolbar"> |
| | | <div class="toolbar-group"> |
| | | <button class="primary-button" type="button" @click="goToImport"> |
| | | <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" :disabled="!canImport || editing" @click="goToImport(true)"> |
| | | <el-icon><UploadFilled /></el-icon>三 Sheet 一键导入</button |
| | | ><button class="primary-button" type="button" @click="goToRevisions"> |
| | | <el-icon><Clock /></el-icon>修订记录</button |
| | | ><button v-if="!editing" class="primary-button" type="button" @click="openBatchEditor"> |
| | |
| | | } |
| | | |
| | | 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(): XLSX.WorkBook { |
| | | const workbook = XLSX.utils.book_new() |
| | | for (const type of Object.keys(consoleSheetLabels) as ConsoleDataType[]) { |
| | | XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([Object.values(consoleImportColumns[type])]), consoleSheetLabels[type]) |
| | | } |
| | | 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)', |
| | |
| | | import { useRoute, useRouter } from 'vue-router' |
| | | import * as XLSX from 'xlsx' |
| | | import { submitConsoleDataAction, type ConsoleDataType } from '@/api/consoleData' |
| | | import { parseConsoleImport, type ValidatedImportRow } from '@/utils/consoleDataImport' |
| | | import { parseConsoleImport, parseConsoleImportWorkbook, createConsoleImportWorkbook, consoleSheetLabels, consoleImportColumns, type ValidatedImportSheet, type ValidatedImportRow } from '@/utils/consoleDataImport' |
| | | import { getTestDetail } from '@/api/tests' |
| | | import { formatBusinessTime } from '@/utils/businessTime' |
| | | |
| | |
| | | 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) |
| | | 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 submitting = ref(false) |
| | | const importedCount = ref(0) |
| | | const skippedCount = ref(0) |
| | | const batchId = ref<number | null>(null) |
| | | const submitError = ref('') |
| | | |
| | | function downloadWorkbookTemplate() { |
| | | XLSX.writeFile(createConsoleImportWorkbook(), '测试三 Sheet 数据导入模板.xlsx') |
| | | } |
| | | |
| | | function backToDataList() { |
| | | router.push({ path: `/test-list/${testId}/console`, query: { tab: 'data-list', dataType } }) |
| | |
| | | |
| | | try { |
| | | const workbook = XLSX.read(await file.arrayBuffer(), { type: 'array' }) |
| | | const rows = parseConsoleImport(workbook, dataType, [...knownBatteries.value], selectedBattery.value) |
| | | if (!rows.length) { |
| | | 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 |
| | | } |
| | |
| | | } |
| | | |
| | | 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 (failedRows.value.length) { ElMessage.warning('请修正全部错误后再导入,本页数据尚未写入'); return } |
| | | if (allErrors.value.length) { ElMessage.warning('请修正全部错误后再导入,数据尚未写入'); return } |
| | | if (!validRows.value.length) { ElMessage.warning('没有可导入的有效数据'); return } |
| | | if (!canImport.value) { |
| | | ElMessage.warning('测试完成后才可以导入数据') |
| | |
| | | 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 result = await submitConsoleDataAction(testId, { dataType, operationType: 'IMPORT', scopeSummary: `导入${imported.length}条${dataType}数据`, identitySummary: JSON.stringify(imported.map(row => row.recordNo)), changesSummary: JSON.stringify(imported), reason }) |
| | | 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 |
| | | } catch (error) { ElMessage.error(error instanceof Error ? error.message : '导入提交失败'); return } |
| | | batchId.value = result.actionId |
| | | } catch (error) { submitError.value = error instanceof Error ? error.message : '导入提交失败'; ElMessage.error(submitError.value); return } |
| | | finally { submitting.value = false } |
| | | stage.value = 'result' |
| | | } |
| | | |
| | | function downloadFailedRows() { |
| | | if (!failedRows.value.length) return |
| | | const records = failedRows.value.map(item => ({ ...item.raw, Excel行号: item.sourceRow, 错误原因: item.errors.join(';') })) |
| | | 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, '失败数据') |
| | |
| | | <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> |
| | | <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"> |
| | |
| | | <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>容量(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> |
| | | <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 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 v-if="workbookMode" 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">{{ field === 'recordedAt' ? formatBusinessTime(item.data[field]) : item.data[field] ?? '-' }}</td><td>{{ item.errors.length ? item.errors.join(';') : '通过' }}</td></tr></tbody></table></div> |
| | | <div v-else 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 previewRows.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="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 && batchId">导入批次 #{{ batchId }}</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> |
| | |
| | | </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}} |
| | |
| | | import { readFile, mkdir } from 'node:fs/promises' |
| | | import { chromium, expect } from '@playwright/test' |
| | | import XLSX from 'xlsx' |
| | | import { consoleImportColumns } from '../src/utils/consoleDataImport.ts' |
| | | import { consoleImportColumns, createConsoleImportWorkbook } from '../src/utils/consoleDataImport.ts' |
| | | |
| | | // 拦截构建产物和 API 完成页面验收,不启动业务服务或连接共享数据库。 |
| | | const origin = 'http://single-sheet-import.test' |
| | |
| | | await mkdir(output, { recursive: true }) |
| | | const browser = await chromium.launch({ channel: 'chrome', headless: true }) |
| | | const calls = [] |
| | | const liveCalls = [] |
| | | let workbookRows = null |
| | | 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 } }] } |
| | |
| | | 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()) |
| | | if (calls.at(-1).dataType === 'ALL') workbookRows = JSON.parse(calls.at(-1).changesSummary) |
| | | 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('/live-state')) { liveCalls.push(path); return route.fulfill({ json: { code: 200, data: { ...detail, sources: [], exceptions: [] } } }) } |
| | | if (/\/data\/(records|steps|cycles)$/.test(path)) { |
| | | const type = { records: 'REALTIME', steps: 'STEP', cycles: 'CYCLE' }[path.split('/').at(-1)] |
| | | const data = workbookRows?.[type]?.slice(0, 1).map(row => ({ ...row, recordId: 1, stepRecordId: 1, cycleRecordId: 1, stableIdentity: `${type.toLowerCase()}:1` })) || [] |
| | | 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' }) |
| | |
| | | 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 }) |
| | | |
| | | function workbookFile(mode = 'valid') { |
| | | const workbook = createConsoleImportWorkbook() |
| | | for (const [index, type] of ['REALTIME', 'STEP', 'CYCLE'].entries()) { |
| | | const single = XLSX.read(standardFile(type).buffer) |
| | | workbook.Sheets[workbook.SheetNames[index]] = single.Sheets[single.SheetNames[0]] |
| | | } |
| | | if (mode === 'missing') { workbook.SheetNames.pop(); delete workbook.Sheets['循环'] } |
| | | if (mode === 'invalid') XLSX.utils.sheet_add_aoa(workbook.Sheets['循环'], [['错误数值']], { origin: 'F20' }) |
| | | if (mode === 'identity') XLSX.utils.sheet_add_aoa(workbook.Sheets['循环'], [['OTHER']], { origin: 'B2' }) |
| | | return { name: '三 Sheet.xlsx', mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', buffer: XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }) } |
| | | } |
| | | await page.setViewportSize({ width: 1440, height: 1000 }) |
| | | await page.goto(`${origin}/test-list/1/console?tab=data-list`) |
| | | await page.getByRole('button', { name: '三 Sheet 一键导入', exact: true }).click() |
| | | const templateEvent = page.waitForEvent('download') |
| | | await page.getByRole('button', { name: '下载三 Sheet 模板', exact: true }).click() |
| | | const template = XLSX.read(await readFile(await (await templateEvent).path())) |
| | | assert.deepEqual(template.SheetNames, ['实时', '工步', '循环']) |
| | | for (const [index, type] of ['REALTIME', 'STEP', 'CYCLE'].entries()) assert.deepEqual(XLSX.utils.sheet_to_json(template.Sheets[template.SheetNames[index]], { header: 1 })[0], Object.values(consoleImportColumns[type])) |
| | | const beforeInvalid = calls.length |
| | | for (const mode of ['missing', 'invalid', 'identity']) { |
| | | await page.goto(`${origin}/test-list/1/console/data/import?mode=workbook`) |
| | | await page.locator('input[type=file]').setInputFiles(workbookFile(mode)) |
| | | await expect(page.locator('.sheet-summary tbody tr')).toHaveCount(3) |
| | | await expect(page.getByRole('button', { name: '确认导入', exact: true })).toBeDisabled() |
| | | await expect(page.locator('.el-alert')).toContainText('循环') |
| | | const errorDownload = page.waitForEvent('download') |
| | | await page.getByRole('button', { name: '下载全部错误', exact: true }).click() |
| | | const errorsWorkbook = XLSX.read(await readFile(await (await errorDownload).path())) |
| | | assert.equal(XLSX.utils.sheet_to_json(errorsWorkbook.Sheets['失败数据'])[0]['工作表'], '循环') |
| | | } |
| | | assert.equal(calls.length, beforeInvalid) |
| | | await page.goto(`${origin}/test-list/1/console/data/import?mode=workbook`) |
| | | const beforeLive = liveCalls.length |
| | | result = { importedCount: process.env.PARTY_EXCEL ? 7767 : 3, skippedCount: 0 } |
| | | await page.locator('input[type=file]').setInputFiles(process.env.PARTY_EXCEL || workbookFile()) |
| | | await expect(page.getByRole('button', { name: '确认导入', exact: true })).toBeEnabled() |
| | | for (const label of ['实时', '工步', '循环']) { |
| | | await page.getByRole('tab', { name: label, exact: true }).click() |
| | | await expect(page.locator('.preview-table-wrap tbody tr')).not.toHaveCount(0) |
| | | } |
| | | await page.screenshot({ path: `${output}/workbook-preview-1440.png`, fullPage: true }) |
| | | await page.setViewportSize({ width: 390, height: 844 }) |
| | | await expect(page.locator('.app-main')).toHaveCSS('margin-left', '0px') |
| | | await page.screenshot({ path: `${output}/workbook-preview-390.png`, fullPage: true, animations: 'disabled' }) |
| | | assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false) |
| | | await page.getByRole('button', { name: '确认导入', exact: true }).click() |
| | | await page.locator('.el-message-box input').fill('三个 Sheet 共用原因') |
| | | await page.locator('.el-message-box').getByRole('button', { name: '确定', exact: true }).click() |
| | | await expect(page.locator('.result-stage')).toContainText('导入批次 #1') |
| | | assert.equal(calls.length, beforeInvalid + 1) |
| | | assert.equal(calls.at(-1).dataType, 'ALL') |
| | | assert.equal(calls.at(-1).reason, '三个 Sheet 共用原因') |
| | | assert.deepEqual(Object.keys(workbookRows), ['REALTIME', 'STEP', 'CYCLE']) |
| | | assert.deepEqual(Object.values(workbookRows).map(rows => rows.length), process.env.PARTY_EXCEL ? [7605, 129, 33] : [1, 1, 1]) |
| | | assert.equal(liveCalls.length, beforeLive) |
| | | await page.screenshot({ path: `${output}/workbook-result-390.png`, fullPage: true }) |
| | | for (const type of ['REALTIME', 'STEP', 'CYCLE']) { |
| | | await page.goto(`${origin}/test-list/1/console?tab=data-list&dataType=${type}`) |
| | | await expect(page.locator('.data-toolbar')).toBeVisible() |
| | | await expect(page.locator('.table-wrap tbody')).toContainText('BAT-1') |
| | | await page.reload() |
| | | await expect(page.locator('.table-wrap tbody')).toContainText('BAT-1') |
| | | } |
| | | assert.deepEqual(errors, []) |
| | | console.log('Ticket03 浏览器:三个独立模板、三子页提交、整页错误阻断、重复结果与窄屏通过') |
| | | console.log('Ticket03/04 浏览器:独立模板、一键模板、三 Sheet 预览、错误下载和阻断、单次提交原因与批次、子页刷新及窄屏通过;导入未请求实时监控') |
| | | } finally { |
| | | await browser.close() |
| | | } |
| | |
| | | import { test } from 'node:test' |
| | | import XLSX from 'xlsx' |
| | | import { parseConsoleImport, consoleImportColumns } from '../src/utils/consoleDataImport.ts' |
| | | import * as importUtils from '../src/utils/consoleDataImport.ts' |
| | | |
| | | test('一键模板包含三个标准 Sheet,并逐 Sheet 预览与报告缺失表', () => { |
| | | const workbook = importUtils.createConsoleImportWorkbook() |
| | | assert.deepEqual(workbook.SheetNames, ['实时', '工步', '循环']) |
| | | const row = { recordNo: '17', batteryNo: 'BAT-1', recordedAt: '2023-05-08 11:42:11', cycleNo: 1, stepNo: 1, stepName: '恒流放电', sequenceId: 17 } |
| | | for (const [index, type] of ['REALTIME', 'STEP', 'CYCLE'].entries()) { |
| | | const sheet = workbook.Sheets[workbook.SheetNames[index]] |
| | | assert.deepEqual(XLSX.utils.sheet_to_json(sheet, { header: 1 })[0], Object.values(consoleImportColumns[type])) |
| | | XLSX.utils.sheet_add_aoa(sheet, [Object.keys(consoleImportColumns[type]).map(key => row[key] ?? '')], { origin: 'A2' }) |
| | | } |
| | | const sheets = importUtils.parseConsoleImportWorkbook(workbook, ['BAT-1']) |
| | | assert.deepEqual(sheets.map(sheet => [sheet.type, sheet.rows.length, sheet.errors]), [['REALTIME', 1, []], ['STEP', 1, []], ['CYCLE', 1, []]]) |
| | | delete workbook.Sheets['循环'] |
| | | const missing = importUtils.parseConsoleImportWorkbook(workbook, ['BAT-1']) |
| | | assert.match(missing[2].errors.join(), /缺少循环工作表/) |
| | | assert.equal(missing[0].rows.length, 1) |
| | | }) |
| | | |
| | | function partyWorkbook() { |
| | | const workbook = XLSX.utils.book_new() |