- 新增导入策略单选:跳过重复行 / 覆盖更新 - 预览状态新增「将被更新」标签(橙色),区分「通过」「失败」 - 支持部分导入:有错误行时显示「忽略错误,仅导入正确的 N 条」按钮 - 预览时传递 mode 参数给后端 - 导入结果展示新增/更新/跳过数量
395 lines
12 KiB
Vue
395 lines
12 KiB
Vue
<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="importType === 'material'" class="import-strategy">
|
||
<el-divider />
|
||
<span class="step-label">3. 导入策略</span>
|
||
<el-radio-group v-model="importMode" class="strategy-group">
|
||
<el-radio value="skip">
|
||
<span class="strategy-label">跳过重复行</span>
|
||
<span class="strategy-desc">仅新增不重复的数据,已存在的直接跳过</span>
|
||
</el-radio>
|
||
<el-radio value="update">
|
||
<span class="strategy-label">覆盖更新</span>
|
||
<span class="strategy-desc">用 Excel 中的新数据覆盖更新数据库已有记录</span>
|
||
</el-radio>
|
||
</el-radio-group>
|
||
</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="100" align="center">
|
||
<template #default="{ row }">
|
||
<el-tag v-if="row.status === 'success'" type="success" size="small">通过</el-tag>
|
||
<el-tag v-else-if="row.status === 'update'" type="warning" size="small">将被更新</el-tag>
|
||
<el-tag v-else type="danger" size="small">失败</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>
|
||
|
||
<!-- Step 1: 预览按钮 -->
|
||
<el-button v-if="step === 1" type="primary" @click="handlePreview" :loading="previewLoading" :disabled="!fileReady">
|
||
预览验证
|
||
</el-button>
|
||
|
||
<!-- Step 2: 全部通过/将被更新 → 直接导入 -->
|
||
<el-button v-if="step === 2 && errorCount === 0" type="primary" @click="handleExecute" :loading="executing">
|
||
{{ executing ? '导入中...' : `全部导入 (${importableCount} 条)` }}
|
||
</el-button>
|
||
|
||
<!-- Step 2: 部分通过 → 允许跳过错误行导入 -->
|
||
<el-button v-else-if="step === 2 && importableCount > 0" type="warning" @click="handleExecute" :loading="executing">
|
||
{{ executing ? '导入中...' : `忽略错误,仅导入正确的 ${importableCount} 条` }}
|
||
</el-button>
|
||
|
||
<!-- Step 2: 全部失败 → 禁用 -->
|
||
<el-button v-else-if="step === 2" disabled>无可导入数据</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)
|
||
|
||
// ★ 新增:导入策略(仅 material 类型生效)
|
||
const importMode = ref<'skip' | 'update'>('skip')
|
||
|
||
const previewRows = ref<any[]>([])
|
||
const successCount = computed(() => previewRows.value.filter(r => r.status === 'success').length)
|
||
const updateCount = computed(() => previewRows.value.filter(r => r.status === 'update').length)
|
||
const errorCount = computed(() => previewRows.value.filter(r => r.status === 'error').length)
|
||
const importableCount = computed(() => successCount.value + updateCount.value)
|
||
const previewSummary = computed(() => {
|
||
const parts = [`共 ${previewRows.value.length} 条`]
|
||
if (successCount.value > 0) parts.push(`${successCount.value} 条通过`)
|
||
if (updateCount.value > 0) parts.push(`${updateCount.value} 条将被更新`)
|
||
if (errorCount.value > 0) parts.push(`${errorCount.value} 条失败`)
|
||
return parts.join(',')
|
||
})
|
||
|
||
// ═══ 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)
|
||
formData.append('mode', importMode.value) // ★ 预览时传递导入策略
|
||
|
||
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 (importableCount.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,
|
||
mode: importMode.value,
|
||
}
|
||
})
|
||
|
||
if (res.code === 200) {
|
||
const detail = res.data || {}
|
||
// ★ 构建详细结果提示
|
||
const parts: string[] = []
|
||
if (detail.inserted > 0) parts.push(`新增 ${detail.inserted} 条`)
|
||
if (detail.updated > 0) parts.push(`更新 ${detail.updated} 条`)
|
||
if (detail.skipped > 0) parts.push(`跳过 ${detail.skipped} 条重复`)
|
||
ElMessage.success(parts.length > 0 ? parts.join(',') : (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 = []
|
||
importMode.value = 'skip'
|
||
visible.value = false
|
||
}
|
||
|
||
// 关闭时重置
|
||
watch(visible, (val) => {
|
||
if (!val) {
|
||
step.value = 1
|
||
fileList.value = []
|
||
rawFile.value = null
|
||
fileReady.value = false
|
||
previewRows.value = []
|
||
importMode.value = 'skip'
|
||
}
|
||
})
|
||
</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;
|
||
}
|
||
.import-strategy {
|
||
margin-top: 8px;
|
||
}
|
||
.strategy-group {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
margin-top: 8px;
|
||
}
|
||
.strategy-group .el-radio {
|
||
margin-right: 0;
|
||
height: auto;
|
||
padding: 10px 14px;
|
||
border: 1px solid #DCDFE6;
|
||
border-radius: 6px;
|
||
transition: border-color 0.2s;
|
||
}
|
||
.strategy-group .el-radio.is-checked {
|
||
border-color: #409EFF;
|
||
background: #ECF5FF;
|
||
}
|
||
.strategy-label {
|
||
font-weight: 600;
|
||
font-size: 14px;
|
||
display: block;
|
||
}
|
||
.strategy-desc {
|
||
font-size: 12px;
|
||
color: #909399;
|
||
display: block;
|
||
margin-top: 2px;
|
||
}
|
||
:deep(.el-upload-dragger) {
|
||
width: 100%;
|
||
}
|
||
</style>
|