feat: ImportDialog组件 — 三步骤批量导入弹窗(拖拽上传+预览+执行)

## ImportDialog.vue (新增)
- Step1: 模板下载 + el-upload drag 拖拽上传
- Step2: el-table 预览验证结果(绿色通过/红色失败+错误详情)
- Step3: 确认导入(事务保护), errorCount>0时按钮disabled
- Props: importType ('material'|'bom'), modelValue
- Emits: success (刷新列表)
This commit is contained in:
yueli
2026-07-16 14:00:36 +08:00
parent 5411cabd0a
commit ebe79be9cb

View File

@ -0,0 +1,315 @@
<template>
<el-dialog
v-model="visible"
:title="title"
width="900px"
destroy-on-close
:close-on-click-modal="false"
:close-on-press-escape="!executing"
:show-close="!executing"
>
<!-- ═══ Step 1: 下载模板 + 上传文件 ═══ -->
<div v-if="step === 1" class="step-upload">
<div class="template-download">
<span class="step-label">1. 下载模板</span>
<el-button type="primary" plain @click="downloadTemplate" :loading="downloading">
<el-icon style="margin-right:4px"><Download /></el-icon>
下载 {{ importType === 'material' ? '基础信息' : 'BOM表' }} 模板
</el-button>
</div>
<el-divider />
<div>
<span class="step-label">2. 上传填好的文件</span>
<el-upload
ref="uploadRef"
:auto-upload="false"
:limit="1"
accept=".xlsx,.xls,.csv"
drag
:on-change="handleFileChange"
:on-remove="handleFileRemove"
:file-list="fileList"
>
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text">
将文件拖到此处,或 <em>点击选择文件</em>
</div>
<template #tip>
<div class="el-upload__tip">支持 .xlsx / .xls / .csv 格式,单文件上传</div>
</template>
</el-upload>
</div>
<div v-if="previewLoading" class="preview-loading">
<el-icon class="is-loading" style="font-size:20px"><Loading /></el-icon>
<span style="margin-left:8px">正在解析和验证数据...</span>
</div>
</div>
<!-- ═══ Step 2: 预览验证结果 ═══ -->
<div v-else-if="step === 2" class="step-preview">
<el-alert
:title="previewSummary"
:type="errorCount > 0 ? 'warning' : 'success'"
:closable="false"
show-icon
style="margin-bottom:16px"
/>
<el-table :data="previewRows" border stripe max-height="450" style="width:100%">
<el-table-column type="index" label="#" width="50" align="center" />
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 'success' ? 'success' : 'danger'" size="small">
{{ row.status === 'success' ? '通过' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="错误信息" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.error_msg" style="color:#F56C6C;font-size:12px">{{ row.error_msg }}</span>
<span v-else style="color:#C0C4CC">-</span>
</template>
</el-table-column>
<el-table-column
v-for="col in previewColumns"
:key="col.prop"
:prop="col.prop"
:label="col.label"
:min-width="col.width || 120"
show-overflow-tooltip
>
<template #default="{ row }">
{{ row.data?.[col.prop] ?? '-' }}
</template>
</el-table-column>
</el-table>
</div>
<!-- ═══ Footer ═══ -->
<template #footer>
<el-button @click="handleClose" :disabled="executing">取消</el-button>
<el-button v-if="step === 1" type="primary" @click="handlePreview" :loading="previewLoading" :disabled="!fileReady">
预览验证
</el-button>
<el-button v-if="step === 2" type="primary" @click="handleExecute" :loading="executing" :disabled="errorCount > 0 || executing">
{{ executing ? '导入中...' : `确认导入 (${successCount} 条)` }}
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Download, UploadFilled, Loading } from '@element-plus/icons-vue'
import request from '@/utils/request'
// ═══ Props ═══
const props = defineProps<{
modelValue: boolean
importType: 'material' | 'bom'
}>()
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
(e: 'success'): void
}>()
// ═══ State ═══
const visible = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v)
})
const title = computed(() =>
props.importType === 'material' ? '批量导入基础信息' : '批量导入 BOM 表'
)
const step = ref(1)
const downloading = ref(false)
const previewLoading = ref(false)
const executing = ref(false)
const uploadRef = ref<any>(null)
const fileList = ref<any[]>([])
const fileReady = ref(false)
const rawFile = ref<File | null>(null)
const previewRows = ref<any[]>([])
const successCount = computed(() => previewRows.value.filter(r => r.status === 'success').length)
const errorCount = computed(() => previewRows.value.filter(r => r.status === 'error').length)
const previewSummary = computed(() =>
`共 ${previewRows.value.length} 条: ${successCount.value} 条通过, ${errorCount.value} 条失败`
)
// ═══ Preview Columns (depends on type) ═══
const materialColumns = [
{ prop: 'company_name', label: '所属公司', width: 100 },
{ prop: 'name', label: '名称', width: 120 },
{ prop: 'common_name', label: '专业名称', width: 100 },
{ prop: 'category', label: '类别', width: 150 },
{ prop: 'material_type', label: '类型', width: 90 },
{ prop: 'spec_model', label: '规格型号', width: 150 },
{ prop: 'unit', label: '单位', width: 70 },
{ prop: 'reference_price', label: '参考价', width: 90 },
{ prop: 'is_inspection_required', label: '强制质检', width: 85 },
]
const bomColumns = [
{ prop: 'bom_no', label: 'BOM编号', width: 140 },
{ prop: 'version', label: '版本', width: 70 },
{ prop: 'parent_name', label: '父件名称', width: 120 },
{ prop: 'parent_spec', label: '父件规格', width: 120 },
{ prop: 'child_name', label: '子件名称', width: 120 },
{ prop: 'child_spec', label: '子件规格', width: 120 },
{ prop: 'dosage', label: '用量', width: 70 },
{ prop: 'loss_rate', label: '损耗率%', width: 75 },
{ prop: 'remark', label: '备注', width: 100 },
]
const previewColumns = computed(() =>
props.importType === 'material' ? materialColumns : bomColumns
)
// ═══ Methods ═══
const downloadTemplate = async () => {
downloading.value = true
try {
const res = await request({
url: '/v1/import/template',
method: 'get',
params: { type: props.importType },
responseType: 'blob'
})
const blob = new Blob([res as any], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
})
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${props.importType === 'material' ? '基础信息' : 'BOM表'}_导入模板.xlsx`
a.click()
window.URL.revokeObjectURL(url)
ElMessage.success('模板下载成功')
} catch (e) {
ElMessage.error('模板下载失败')
} finally {
downloading.value = false
}
}
const handleFileChange = (file: any) => {
rawFile.value = file.raw
fileReady.value = true
}
const handleFileRemove = () => {
rawFile.value = null
fileReady.value = false
previewRows.value = []
}
const handlePreview = async () => {
if (!rawFile.value) return
previewLoading.value = true
try {
const formData = new FormData()
formData.append('file', rawFile.value)
formData.append('type', props.importType)
const res: any = await request({
url: '/v1/import/preview',
method: 'post',
data: formData,
headers: { 'Content-Type': 'multipart/form-data' }
})
if (res.code === 200) {
previewRows.value = res.data?.rows || []
step.value = 2
} else {
ElMessage.error(res.msg || '预览失败')
}
} catch (e) {
ElMessage.error('文件解析失败,请检查文件格式')
} finally {
previewLoading.value = false
}
}
const handleExecute = async () => {
if (errorCount.value > 0) return
executing.value = true
try {
const res: any = await request({
url: '/v1/import/execute',
method: 'post',
data: {
type: props.importType,
rows: previewRows.value
}
})
if (res.code === 200) {
ElMessage.success(res.msg || '导入成功')
emit('success')
handleClose()
} else {
ElMessage.error(res.msg || '导入失败')
}
} catch (e) {
ElMessage.error('导入请求失败')
} finally {
executing.value = false
}
}
const handleClose = () => {
step.value = 1
fileList.value = []
rawFile.value = null
fileReady.value = false
previewRows.value = []
visible.value = false
}
// 关闭时重置
watch(visible, (val) => {
if (!val) {
step.value = 1
fileList.value = []
rawFile.value = null
fileReady.value = false
previewRows.value = []
}
})
</script>
<style scoped>
.step-upload {
min-height: 200px;
}
.step-label {
font-weight: 600;
font-size: 14px;
margin-right: 16px;
}
.template-download {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.preview-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
color: #909399;
font-size: 14px;
}
:deep(.el-upload-dragger) {
width: 100%;
}
</style>