yeg
2026-09-09 b226aa0db7fbecb8fff92476537adc0ca3e16a4f
src/views/ConsoleDataImport.vue
@@ -3,19 +3,14 @@
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import * as XLSX from 'xlsx'
import type { ConsoleDataRow } from '@/stores/consoleTestData'
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'
type RawExcelRow = Record<string, unknown>
interface ValidatedRow {
  sourceRow: number
  raw: RawExcelRow
  errors: string[]
  data: Omit<ConsoleDataRow, 'id'>
}
const route = useRoute()
const router = useRouter()
@@ -23,80 +18,57 @@
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<ValidatedRow[]>([])
const validatedRows = ref<ValidatedImportRow[]>([])
const stage = ref<ImportStage>('upload')
const dragging = ref(false)
const requiredHeaders = dataType === 'CYCLE' ? ['记录编号', '电池号', '循环序号', '充电容量(Ah)', '放电容量(Ah)', '充放电效率(%)', '充电能量(Wh)', '放电能量(Wh)'] : dataType === 'STEP' ? ['记录编号', '电池号', '循环序号', '工步序号', '工步', '工步时间', '容量(Ah)', '能量(Wh)', '起始电压(V)', '终止电压(V)', '充电峰值电压(V)', '放电谷值电压(V)'] : ['记录编号', '电池号', '记录时间', '循环序号', '工步序号', '工步名称', '记录序号', '电压(V)', '电流(A)', '容量(Ah)', '能量(Wh)']
const numericHeaders = requiredHeaders.filter(header => !['记录编号', '电池号', '记录时间', '工步', '工步名称', '工步时间'].includes(header))
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', state: canImport.value ? 'ended' : 'running' } })
  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
  }
})
function textValue(row: RawExcelRow, key: string) {
  return String(row[key] ?? '').trim()
}
function normalizeImportRow(row: RawExcelRow): RawExcelRow {
  if (dataType === 'REALTIME') return { ...row, '工步时间': row['工步时间'] || '-', '电流(A)': row['电流(A)'], '容量(Ah)': row['容量(Ah)'], '能量(Wh)': row['能量(Wh)'], '温度(℃)': row['温度(℃)'] || '0', '正电位(V)': row['正电位(V)'] || '0', '负电位(V)': row['负电位(V)'] || '0' }
  if (dataType === 'STEP') return { ...row, '记录时间': row['工步时间'], '电流(A)': '0', '容量(Ah)': row['容量(Ah)'], '能量(Wh)': row['能量(Wh)'], '温度(℃)': '0', '正电位(V)': '0', '负电位(V)': '0' }
  return { ...row, '记录时间': '-', '工步时间': '-', '工步名称': '循环统计', '工步序号': '1', '电压(V)': '0', '电流(A)': '0', '容量(Ah)': row['充电容量(Ah)'], '能量(Wh)': row['充电能量(Wh)'], '温度(℃)': '0', '正电位(V)': '0', '负电位(V)': '0' }
}
function validateRow(raw: RawExcelRow, sourceRow: number): ValidatedRow {
  const errors: string[] = []
  requiredHeaders.forEach(header => {
    if (!textValue(raw, header)) errors.push(`${header}不能为空`)
  })
  numericHeaders.forEach(header => {
    const value = textValue(raw, header)
    if (value && !Number.isFinite(Number(value))) errors.push(`${header}必须是数字`)
  })
  const batteryNo = textValue(raw, '电池号')
  if (batteryNo && knownBatteries.value.size && !knownBatteries.value.has(batteryNo)) errors.push('电池号不属于当前测试')
  const cycleNo = Number(textValue(raw, '循环序号'))
  const stepNo = Number(textValue(raw, '工步序号'))
  if (!Number.isInteger(cycleNo) || cycleNo < 1) errors.push('循环序号必须是正整数')
  if (!Number.isInteger(stepNo) || stepNo < 1) errors.push('工步序号必须是正整数')
  return {
    sourceRow,
    raw,
    errors: [...new Set(errors)],
    data: {
      recordNo: textValue(raw, '记录编号'),
      batteryNo,
      recordedAt: textValue(raw, '记录时间'),
      stepDuration: textValue(raw, '工步时间'),
      cycleNo: cycleNo || 1,
      stepNo: stepNo || 1,
      stepName: textValue(raw, dataType === 'STEP' ? '工步' : '工步名称'),
      voltage: textValue(raw, '电压(V)'),
      current: textValue(raw, '电流(A)'),
      capacity: textValue(raw, '容量(Ah)'),
      energy: textValue(raw, '能量(Wh)'),
      temperature: textValue(raw, '温度(℃)'),
      positivePotential: textValue(raw, '正电位(V)'),
      negativePotential: textValue(raw, '负电位(V)'),
    },
  }
}
async function processFile(file: File) {
  const extension = file.name.toLowerCase().slice(file.name.lastIndexOf('.'))
@@ -110,31 +82,15 @@
  }
  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 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
    const seen = new Set<string>()
    validatedRows.value = rawRows.map((row, index) => {
      const normalized = normalizeImportRow(row)
      const validated = validateRow(normalized, index + 2)
      const identity = 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 文件解析失败')
@@ -155,28 +111,43 @@
}
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 = validRows.value.map(item => ({ ...item.data }))
  const imported = validatedRows.value.map(item => ({ ...item.data }))
  submitting.value = true
  submitError.value = ''
  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: row.recordNo, fieldValues: JSON.stringify(row), deleted: false })) })
  } catch (error) { ElMessage.error(error instanceof Error ? error.message : '导入提交失败'); return }
    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 (!failedRows.value.length) return
  const records = failedRows.value.map(item => ({ ...item.raw, 错误原因: 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, '失败数据')
@@ -190,11 +161,13 @@
    <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">
          <el-icon><UploadFilled /></el-icon><span><strong>点击上传</strong> 或拖拽文件到此区域</span><small>支持 .xlsx / .xls 文件,大小不超过 60MB</small>
@@ -206,13 +179,20 @@
      <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>{{ item.data.recordedAt }}</td><td>{{ item.data.cycleNo }}</td><td>{{ item.data.stepNo }}</td><td>{{ item.data.stepName }}</td><td>{{ item.data.voltage }}</td><td>{{ item.data.capacity }}</td><td><span class="validation-state" :class="item.errors.length ? 'failed' : 'success'">{{ item.errors.length ? item.errors.join(';') : '通过' }}</span></td></tr></tbody></table></div>
        <p v-if="validatedRows.length > 12" class="preview-tip">当前预览前 12 条,共 {{ validatedRows.length }} 条数据</p>
        <div class="stage-actions"><button class="primary-button" type="button" :disabled="!canImport" @click="confirmImport"><el-icon><Check /></el-icon>确认导入</button><button class="secondary-button" type="button" @click="resetUpload">返回上传</button></div>
        <div 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">
        <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>
        <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>
@@ -220,5 +200,8 @@
</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>