优化
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import BatchImportDialog, { type BatchImportResult } from './BatchImportDialog.vue'
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
function button(label: string) {
|
||||
return [...document.body.querySelectorAll('button')].find((item) => item.textContent?.trim() === label)
|
||||
}
|
||||
|
||||
async function chooseFile(file: File) {
|
||||
const input = document.body.querySelector<HTMLInputElement>('input[type="file"]')!
|
||||
Object.defineProperty(input, 'files', { configurable: true, value: [file] })
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('BatchImportDialog', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('uses the shared two-step flow and submits a validated XLSX file', async () => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
const result: BatchImportResult = {
|
||||
fileName: 'partners.xlsx', totalRows: 2, importedRows: 2, success: true, errors: [],
|
||||
}
|
||||
const importFile = vi.fn().mockResolvedValue(result)
|
||||
const wrapper = mount(BatchImportDialog, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
modelValue: true,
|
||||
title: '批量导入往来单位',
|
||||
entityLabel: '往来单位',
|
||||
templateFileName: '往来单位导入模板-v1.xlsx',
|
||||
downloadTemplate: vi.fn(),
|
||||
importFile,
|
||||
},
|
||||
global: { plugins: [ElementPlus] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.textContent).toContain('下载标准模板')
|
||||
expect(document.body.textContent).toContain('选择填写后的文件')
|
||||
expect(button('开始导入')?.hasAttribute('disabled')).toBe(true)
|
||||
|
||||
const file = new File(['xlsx'], 'partners.xlsx', {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
})
|
||||
await chooseFile(file)
|
||||
expect(button('开始导入')?.hasAttribute('disabled')).toBe(false)
|
||||
button('开始导入')?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(importFile).toHaveBeenCalledWith(file)
|
||||
expect(wrapper.emitted('success')?.[0]).toEqual([result])
|
||||
expect(document.body.textContent).toContain('导入完成:成功新增 2 条往来单位')
|
||||
})
|
||||
|
||||
it('rejects files that do not use the XLSX template format', async () => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
const importFile = vi.fn()
|
||||
mount(BatchImportDialog, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
modelValue: true,
|
||||
title: '批量导入快递公司',
|
||||
entityLabel: '快递公司',
|
||||
templateFileName: '快递公司批量导入模板-v1.xlsx',
|
||||
downloadTemplate: vi.fn(),
|
||||
importFile,
|
||||
},
|
||||
global: { plugins: [ElementPlus] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
await chooseFile(new File(['csv'], 'companies.csv', { type: 'text/csv' }))
|
||||
|
||||
expect(button('开始导入')?.hasAttribute('disabled')).toBe(true)
|
||||
expect(importFile).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,245 @@
|
||||
<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: 16px; }
|
||||
.batch-import-step { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fafbfc; }
|
||||
.batch-import-step__heading { display: flex; min-width: 0; align-items: flex-start; gap: 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: 12px; font-weight: 700; }
|
||||
.batch-import-step__heading strong, .batch-import-errors__heading strong { color: var(--cc-text); font-size: 14px; }
|
||||
.batch-import-step__heading p, .batch-import-errors__heading p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.55; }
|
||||
.batch-import-file { display: inline-flex; min-width: 190px; max-width: 280px; align-items: center; justify-content: center; gap: 8px; padding: 9px 14px; border: 1px solid var(--cc-border); border-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: 5px 0 0; }
|
||||
.batch-import-errors { overflow: hidden; border: 1px solid #f4c7c7; border-radius: 8px; }
|
||||
.batch-import-errors__heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 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: 10px; padding: 10px 16px; border-top: 1px solid #f4dddd; font-size: 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: 10px 16px; border-top: 1px solid #f4dddd; color: var(--cc-muted); font-size: 12px; }
|
||||
.batch-import-footer { display: flex; justify-content: flex-end; gap: 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>
|
||||
@@ -16,6 +16,7 @@ export interface QueryOption {
|
||||
export interface QueryField {
|
||||
key: string
|
||||
type: QueryFieldType
|
||||
label?: string
|
||||
width?: string | number
|
||||
placeholder?: string
|
||||
options?: QueryOption[]
|
||||
@@ -86,14 +87,14 @@ defineExpose({ search, reset })
|
||||
:aria-label="ariaLabel"
|
||||
>
|
||||
<div class="query-toolbar__filters">
|
||||
<template v-for="field in fields" :key="field.key">
|
||||
<div v-for="field in fields" :key="field.key" class="query-toolbar__field" :style="fieldStyle(field)">
|
||||
<span v-if="field.label" class="query-toolbar__label">{{ field.label }}</span>
|
||||
<el-input
|
||||
v-if="field.type === 'input'"
|
||||
:model-value="model[field.key] as string"
|
||||
:placeholder="field.placeholder || defaultPlaceholder(field)"
|
||||
clearable
|
||||
v-bind="field.props"
|
||||
:style="fieldStyle(field)"
|
||||
@update:model-value="updateField(field.key, $event)"
|
||||
>
|
||||
<template #prefix><Search :size="16" /></template>
|
||||
@@ -104,7 +105,6 @@ defineExpose({ search, reset })
|
||||
:placeholder="field.placeholder || defaultPlaceholder(field)"
|
||||
clearable
|
||||
v-bind="field.props"
|
||||
:style="fieldStyle(field)"
|
||||
@update:model-value="updateField(field.key, $event)"
|
||||
>
|
||||
<el-option v-for="option in field.options || []" :key="String(option.value)" :label="option.label" :value="option.value" :disabled="option.disabled" />
|
||||
@@ -120,10 +120,9 @@ defineExpose({ search, reset })
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
v-bind="field.props"
|
||||
:style="fieldStyle(field)"
|
||||
@update:model-value="updateField(field.key, $event)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="query-toolbar__actions">
|
||||
<el-button type="primary" @click="search"><Search :size="16" />{{ searchText }}</el-button>
|
||||
@@ -135,9 +134,11 @@ defineExpose({ search, reset })
|
||||
|
||||
<style scoped>
|
||||
.query-toolbar { display: flex; align-items: flex-end; justify-content: flex-start; flex-wrap: wrap; gap: 10px; padding: 16px; border: 1px solid var(--cc-border); background: #fff; }
|
||||
.query-toolbar__filters { display: flex; flex: 0 1 auto; flex-wrap: wrap; gap: 10px; }
|
||||
.query-toolbar__filters :deep(.el-input), .query-toolbar__filters :deep(.el-select), .query-toolbar__filters :deep(.el-date-editor) { width: var(--query-field-width, 260px); max-width: 100%; min-width: 0; }
|
||||
.query-toolbar__filters { display: flex; flex: 0 1 auto; flex-wrap: wrap; gap: 12px 16px; }
|
||||
.query-toolbar__field { display: grid; width: var(--query-field-width, 260px); max-width: 100%; min-width: 0; gap: 6px; }
|
||||
.query-toolbar__label { color: var(--cc-text); font-size: 12px; font-weight: 600; line-height: 1.4; }
|
||||
.query-toolbar__field :deep(.el-input), .query-toolbar__field :deep(.el-select), .query-toolbar__field :deep(.el-date-editor) { width: 100%; max-width: 100%; min-width: 0; }
|
||||
.query-toolbar__actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-start; gap: 8px; }
|
||||
.query-toolbar__actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
@media (max-width: 720px) { .query-toolbar { align-items: stretch; flex-direction: column; } .query-toolbar__filters { width: 100%; flex-direction: column; } .query-toolbar__filters :deep(.el-input), .query-toolbar__filters :deep(.el-select), .query-toolbar__filters :deep(.el-date-editor) { width: 100%; } .query-toolbar__actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); width: 100%; } .query-toolbar__actions :deep(.el-button) { min-height: 40px; margin-left: 0; } }
|
||||
@media (max-width: 720px) { .query-toolbar { align-items: stretch; flex-direction: column; } .query-toolbar__filters { width: 100%; flex-direction: column; } .query-toolbar__field { width: 100%; } .query-toolbar__actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); width: 100%; } .query-toolbar__actions :deep(.el-button) { min-height: 40px; margin-left: 0; } }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user