Files
KCGL/inventory-web/src/components/ImportDialog.vue
yueli d0776f1036 feat(import): 前端导入策略选择器 + 部分导入支持
- 新增导入策略单选:跳过重复行 / 覆盖更新
- 预览状态新增「将被更新」标签(橙色),区分「通过」「失败」
- 支持部分导入:有错误行时显示「忽略错误,仅导入正确的 N 条」按钮
- 预览时传递 mode 参数给后端
- 导入结果展示新增/更新/跳过数量
2026-08-03 15:53:24 +08:00

395 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>