feat(import): 前端导入策略选择器 + 部分导入支持
- 新增导入策略单选:跳过重复行 / 覆盖更新 - 预览状态新增「将被更新」标签(橙色),区分「通过」「失败」 - 支持部分导入:有错误行时显示「忽略错误,仅导入正确的 N 条」按钮 - 预览时传递 mode 参数给后端 - 导入结果展示新增/更新/跳过数量
This commit is contained in:
@ -42,6 +42,22 @@
|
|||||||
</el-upload>
|
</el-upload>
|
||||||
</div>
|
</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">
|
<div v-if="previewLoading" class="preview-loading">
|
||||||
<el-icon class="is-loading" style="font-size:20px"><Loading /></el-icon>
|
<el-icon class="is-loading" style="font-size:20px"><Loading /></el-icon>
|
||||||
<span style="margin-left:8px">正在解析和验证数据...</span>
|
<span style="margin-left:8px">正在解析和验证数据...</span>
|
||||||
@ -60,11 +76,11 @@
|
|||||||
|
|
||||||
<el-table :data="previewRows" border stripe max-height="450" style="width:100%">
|
<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 type="index" label="#" width="50" align="center" />
|
||||||
<el-table-column label="状态" width="90" align="center">
|
<el-table-column label="状态" width="100" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="row.status === 'success' ? 'success' : 'danger'" size="small">
|
<el-tag v-if="row.status === 'success'" type="success" size="small">通过</el-tag>
|
||||||
{{ row.status === 'success' ? '通过' : '失败' }}
|
<el-tag v-else-if="row.status === 'update'" type="warning" size="small">将被更新</el-tag>
|
||||||
</el-tag>
|
<el-tag v-else type="danger" size="small">失败</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="错误信息" min-width="200" show-overflow-tooltip>
|
<el-table-column label="错误信息" min-width="200" show-overflow-tooltip>
|
||||||
@ -91,12 +107,24 @@
|
|||||||
<!-- ═══ Footer ═══ -->
|
<!-- ═══ Footer ═══ -->
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="handleClose" :disabled="executing">取消</el-button>
|
<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 v-if="step === 1" type="primary" @click="handlePreview" :loading="previewLoading" :disabled="!fileReady">
|
||||||
预览验证
|
预览验证
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button v-if="step === 2" type="primary" @click="handleExecute" :loading="executing" :disabled="errorCount > 0 || executing">
|
|
||||||
{{ executing ? '导入中...' : `确认导入 (${successCount} 条)` }}
|
<!-- Step 2: 全部通过/将被更新 → 直接导入 -->
|
||||||
|
<el-button v-if="step === 2 && errorCount === 0" type="primary" @click="handleExecute" :loading="executing">
|
||||||
|
{{ executing ? '导入中...' : `全部导入 (${importableCount} 条)` }}
|
||||||
</el-button>
|
</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>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</template>
|
</template>
|
||||||
@ -138,12 +166,21 @@ const fileList = ref<any[]>([])
|
|||||||
const fileReady = ref(false)
|
const fileReady = ref(false)
|
||||||
const rawFile = ref<File | null>(null)
|
const rawFile = ref<File | null>(null)
|
||||||
|
|
||||||
|
// ★ 新增:导入策略(仅 material 类型生效)
|
||||||
|
const importMode = ref<'skip' | 'update'>('skip')
|
||||||
|
|
||||||
const previewRows = ref<any[]>([])
|
const previewRows = ref<any[]>([])
|
||||||
const successCount = computed(() => previewRows.value.filter(r => r.status === 'success').length)
|
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 errorCount = computed(() => previewRows.value.filter(r => r.status === 'error').length)
|
||||||
const previewSummary = computed(() =>
|
const importableCount = computed(() => successCount.value + updateCount.value)
|
||||||
`共 ${previewRows.value.length} 条: ${successCount.value} 条通过, ${errorCount.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) ═══
|
// ═══ Preview Columns (depends on type) ═══
|
||||||
const materialColumns = [
|
const materialColumns = [
|
||||||
@ -218,6 +255,7 @@ const handlePreview = async () => {
|
|||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', rawFile.value)
|
formData.append('file', rawFile.value)
|
||||||
formData.append('type', props.importType)
|
formData.append('type', props.importType)
|
||||||
|
formData.append('mode', importMode.value) // ★ 预览时传递导入策略
|
||||||
|
|
||||||
const res: any = await request({
|
const res: any = await request({
|
||||||
url: '/v1/import/preview',
|
url: '/v1/import/preview',
|
||||||
@ -240,7 +278,7 @@ const handlePreview = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleExecute = async () => {
|
const handleExecute = async () => {
|
||||||
if (errorCount.value > 0) return
|
if (importableCount.value === 0) return
|
||||||
executing.value = true
|
executing.value = true
|
||||||
try {
|
try {
|
||||||
const res: any = await request({
|
const res: any = await request({
|
||||||
@ -248,12 +286,19 @@ const handleExecute = async () => {
|
|||||||
method: 'post',
|
method: 'post',
|
||||||
data: {
|
data: {
|
||||||
type: props.importType,
|
type: props.importType,
|
||||||
rows: previewRows.value
|
rows: previewRows.value,
|
||||||
|
mode: importMode.value,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
ElMessage.success(res.msg || '导入成功')
|
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')
|
emit('success')
|
||||||
handleClose()
|
handleClose()
|
||||||
} else {
|
} else {
|
||||||
@ -272,6 +317,7 @@ const handleClose = () => {
|
|||||||
rawFile.value = null
|
rawFile.value = null
|
||||||
fileReady.value = false
|
fileReady.value = false
|
||||||
previewRows.value = []
|
previewRows.value = []
|
||||||
|
importMode.value = 'skip'
|
||||||
visible.value = false
|
visible.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -283,6 +329,7 @@ watch(visible, (val) => {
|
|||||||
rawFile.value = null
|
rawFile.value = null
|
||||||
fileReady.value = false
|
fileReady.value = false
|
||||||
previewRows.value = []
|
previewRows.value = []
|
||||||
|
importMode.value = 'skip'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@ -309,6 +356,38 @@ watch(visible, (val) => {
|
|||||||
color: #909399;
|
color: #909399;
|
||||||
font-size: 14px;
|
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) {
|
:deep(.el-upload-dragger) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user