Files
admin-web/src/shared/components/BatchImportDialog.vue
T
2026-09-15 09:58:15 +08:00

246 lines
11 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.
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Download, FileSpreadsheet, Upload } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
export interface BatchImportError {
row: number
field: string
message: string
}
export interface BatchImportResult {
fileName: string
totalRows: number
importedRows: number
success: boolean
errors: BatchImportError[]
}
const props = withDefaults(defineProps<{
title: string
entityLabel: string
successUnit?: string
templateFileName: string
errorReportFileName?: string
maxFileSizeMb?: number
maxRows?: number
downloadTemplate: () => Promise<Blob>
importFile: (file: File) => Promise<BatchImportResult>
}>(), {
successUnit: '条',
errorReportFileName: '',
maxFileSizeMb: 5,
maxRows: 1000,
})
const open = defineModel<boolean>({ required: true })
const emit = defineEmits<{
success: [result: BatchImportResult]
}>()
const fileInputRef = ref<HTMLInputElement>()
const selectedFile = ref<File | null>(null)
const downloadingTemplate = ref(false)
const importing = ref(false)
const result = ref<BatchImportResult | null>(null)
const importError = ref<ApiErrorDetails | null>(null)
const displayedErrors = computed(() => result.value?.errors.slice(0, 100) || [])
watch(open, (isOpen) => {
if (isOpen) resetState()
})
function resetFileInput() {
if (fileInputRef.value) fileInputRef.value.value = ''
}
function resetState() {
selectedFile.value = null
result.value = null
importError.value = null
resetFileInput()
}
function saveBlob(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = fileName
anchor.click()
URL.revokeObjectURL(url)
}
async function downloadTemplateFile() {
downloadingTemplate.value = true
try {
saveBlob(await props.downloadTemplate(), props.templateFileName)
} catch (error) {
const details = getApiErrorDetails(error, '导入模板下载失败,请稍后重试')
ElMessage.error(details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message)
} finally {
downloadingTemplate.value = false
}
}
function selectFile(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0] || null
result.value = null
importError.value = null
if (!file) {
selectedFile.value = null
return
}
if (!file.name.toLowerCase().endsWith('.xlsx')) {
selectedFile.value = null
resetFileInput()
ElMessage.error('请选择 .xlsx 格式的标准模板文件')
return
}
if (file.size > props.maxFileSizeMb * 1024 * 1024) {
selectedFile.value = null
resetFileInput()
ElMessage.error(`导入文件不能超过 ${props.maxFileSizeMb}MB`)
return
}
selectedFile.value = file
}
async function startImport() {
if (!selectedFile.value) {
ElMessage.warning('请先选择填写完成的 XLSX 文件')
return
}
importing.value = true
importError.value = null
result.value = null
try {
const importResult = await props.importFile(selectedFile.value)
result.value = importResult
if (importResult.success) {
selectedFile.value = null
resetFileInput()
ElMessage.success(`成功导入 ${importResult.importedRows} ${props.successUnit}${props.entityLabel}`)
emit('success', importResult)
}
} catch (error) {
importError.value = getApiErrorDetails(error, `${props.entityLabel}导入失败,请稍后重试`)
} finally {
importing.value = false
}
}
function downloadErrors() {
if (!result.value?.errors.length) return
const csvCell = (value: string | number) => `"${String(value).replaceAll('"', '""')}"`
const lines = [
['行号', '字段', '错误原因'].map(csvCell).join(','),
...result.value.errors.map((item) => [item.row || '文件', item.field, item.message].map(csvCell).join(',')),
]
const fallbackName = `${result.value.fileName.replace(/\.xlsx$/i, '') || props.entityLabel}-错误报告.csv`
saveBlob(new Blob([`\ufeff${lines.join('\r\n')}`], { type: 'text/csv;charset=utf-8' }), props.errorReportFileName || fallbackName)
}
</script>
<template>
<el-dialog
v-model="open"
:title="title"
width="min(720px, calc(100vw - 32px))"
:close-on-click-modal="!importing"
:close-on-press-escape="!importing"
:show-close="!importing"
class="batch-import-dialog"
>
<div class="batch-import-flow">
<section class="batch-import-step">
<div class="batch-import-step__heading">
<span>1</span>
<div><strong>下载标准模板</strong><p>请勿修改模板版本或表头,仅填写模板中的数据行。</p></div>
</div>
<el-button :loading="downloadingTemplate" @click="downloadTemplateFile"><Download :size="16" />下载模板</el-button>
</section>
<section class="batch-import-step">
<div class="batch-import-step__heading">
<span>2</span>
<div><strong>选择填写后的文件</strong><p>仅支持 .xlsx,最大 {{ maxFileSizeMb }}MB、{{ maxRows }} 行;当前仅新增,不更新已有档案。</p></div>
</div>
<label class="batch-import-file" :class="{ disabled: importing }">
<input ref="fileInputRef" type="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" :disabled="importing" @change="selectFile" />
<FileSpreadsheet :size="20" />
<span>{{ selectedFile?.name || '选择 XLSX 文件' }}</span>
</label>
</section>
<el-alert type="info" :closable="false" show-icon title="整批校验通过后才会写入;任一行有误时整批不写入。" />
<el-alert v-if="importError" type="error" :closable="false" show-icon title="导入请求失败">
<p>{{ importError.message }}</p>
<ApiErrorMeta :details="importError" />
</el-alert>
<el-alert v-if="result?.success" type="success" :closable="false" show-icon :title="`导入完成:成功新增 ${result.importedRows} ${successUnit}${entityLabel}`" />
<section v-if="result && !result.success" class="batch-import-errors" aria-live="polite">
<div class="batch-import-errors__heading">
<div><strong>校验未通过,未写入任何数据</strong><p>发现 {{ result.errors.length }} 项问题,请修正后重新上传。</p></div>
<el-button size="small" @click="downloadErrors"><Download :size="14" />下载错误报告</el-button>
</div>
<div class="batch-import-errors__list">
<div v-for="(item, index) in displayedErrors" :key="`${item.row}-${item.field}-${index}`" class="batch-import-error">
<span>{{ item.row > 0 ? `第 ${item.row} 行` : '文件' }}</span>
<strong>{{ item.field }}</strong>
<p>{{ item.message }}</p>
</div>
<p v-if="result.errors.length > displayedErrors.length" class="batch-import-errors__overflow">
仅展示前 {{ displayedErrors.length }} 项,完整内容请下载错误报告。
</p>
</div>
</section>
</div>
<template #footer>
<div class="batch-import-footer">
<el-button :disabled="importing" @click="open = false">关闭</el-button>
<el-button type="primary" :loading="importing" :disabled="!selectedFile" @click="startImport"><Upload :size="16" />开始导入</el-button>
</div>
</template>
</el-dialog>
</template>
<style scoped>
.batch-import-flow { display: grid; gap: var(--admin-space-5, 16px); }
.batch-import-step { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-6, 20px); padding: var(--admin-space-5, 16px); border: 1px solid var(--cc-border); border-radius: var(--admin-radius, 8px); background: #fafbfc; }
.batch-import-step__heading { display: flex; min-width: 0; align-items: flex-start; gap: var(--admin-space-5, 12px); }
.batch-import-step__heading > span { display: grid; width: 24px; height: 24px; flex: 0 0 24px; place-items: center; border-radius: 50%; background: var(--cc-primary); color: #fff; font-size: var(--admin-font-caption, 12px); font-weight: 700; }
.batch-import-step__heading strong, .batch-import-errors__heading strong { color: var(--cc-text); font-size: var(--admin-font-section, 14px); }
.batch-import-step__heading p, .batch-import-errors__heading p { margin: var(--admin-space-1, 4px) 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); line-height: 1.55; }
.batch-import-file { display: inline-flex; min-width: 190px; max-width: 280px; align-items: center; justify-content: center; gap: var(--admin-space-3, 8px); padding: var(--admin-space-2, 9px) var(--admin-space-5, 14px); border: 1px solid var(--cc-border); border-radius: var(--admin-radius, 6px); background: #fff; color: var(--cc-primary); cursor: pointer; }
.batch-import-file:hover { border-color: var(--cc-primary); }
.batch-import-file:focus-within { outline: 2px solid color-mix(in srgb, var(--cc-primary) 28%, transparent); outline-offset: 2px; }
.batch-import-file.disabled { cursor: not-allowed; opacity: .6; }
.batch-import-file input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
.batch-import-file span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.batch-import-flow :deep(.el-alert p) { margin: var(--admin-space-1, 5px) 0 0; }
.batch-import-errors { overflow: hidden; border: 1px solid #f4c7c7; border-radius: var(--admin-radius, 8px); }
.batch-import-errors__heading { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5, 16px); padding: var(--admin-space-5, 14px) var(--admin-space-5, 16px); background: #fff5f5; }
.batch-import-errors__list { max-height: 220px; overflow: auto; }
.batch-import-error { display: grid; grid-template-columns: 72px 130px minmax(0, 1fr); gap: var(--admin-space-4, 10px); padding: var(--admin-space-4, 10px) var(--admin-space-5, 16px); border-top: 1px solid #f4dddd; font-size: var(--admin-font-caption, 12px); }
.batch-import-error span { color: #b42318; font-weight: 600; }
.batch-import-error p { margin: 0; overflow-wrap: anywhere; }
.batch-import-errors__overflow { margin: 0; padding: var(--admin-space-4, 10px) var(--admin-space-5, 16px); border-top: 1px solid #f4dddd; color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
.batch-import-footer { display: flex; justify-content: flex-end; gap: var(--admin-space-4, 10px); }
.batch-import-footer :deep(.el-button + .el-button) { margin-left: 0; }
@media (max-width: 720px) {
.batch-import-step, .batch-import-errors__heading { align-items: stretch; flex-direction: column; }
.batch-import-file, .batch-import-step > :deep(.el-button) { width: 100%; max-width: none; min-height: 44px; }
.batch-import-error { grid-template-columns: 64px minmax(0, 1fr); }
.batch-import-error p { grid-column: 1 / -1; }
.batch-import-footer { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.batch-import-footer :deep(.el-button) { width: 100%; min-height: 44px; }
}
</style>