Appearance
BizImport 数据导入
统一的批量数据导入组件(原 BizImportButton「一键导入按钮」与 BizImportWizard「高级导入向导」合并而来),按 mode 切换两条通道:
mode="quick"(默认,标准模板):一键导入——下载模板 → 选择文件(类型 / 大小校验)→ 上传导入 → 结果弹窗(成功 / 失败条数、失败明细表格 + 明细 csv 下载)。适合模板固定、直接整表上传的场景。mode="wizard"(异构模板):三步向导——① 上传(拖拽 + 模板下载 + 行数上限校验)→ ② 列映射(表头自动匹配label/aliases、persist-key本地记忆映射)→ ③ 数据预览(必填校验 + 错误行红标 + 异常原因列,服务端错误经setErrors回填)。适合用户模板列名五花八门、需要人工对列的场景。
组件零接口:模板下载(template-url / template-request)、导入执行(request)、文件解析(parse-request)全部由业务注入,组件不含任何业务接口与解析依赖。
quick 基础用法
默认 mode="quick":下载模板 → 选择文件(.xls/.xlsx/.csv、≤10MB)→ 自动上传 → 结果弹窗(成功/失败条数 + 失败明细 csv 下载)
mode="quick"(默认):request 注入导入实现(入参原始 File,resolve 结构化结果);template-request 注入模板下载(resolve Blob),也可用 template-url 直链。导入完成自动弹结果反馈。<script setup lang="ts">
/** 场景:quick 基础——商品库存一键导入(模板下载 + 文件校验 + 上传导入 + 结果反馈全链路) */
import { BizImport } from 'vue3-ep-biz-lib'
import type { BizImportResult } from 'vue3-ep-biz-lib'
/** 模拟服务端导入:约八成成功,失败行返回明细(真实项目替换为 axios/form-data 上传) */
const request = (_file: File): Promise<BizImportResult> => {
return new Promise((resolve) => {
setTimeout(() => {
const total = 20
const failRows = Array.from({ length: 3 }, (_unused, i) => ({
row: i * 5 + 3,
reason: i === 0 ? '仓库编码不存在' : i === 1 ? '商品编码已停售' : `第 ${i * 5 + 3} 行缺少必填列`,
}))
resolve({ successCount: total - failRows.length, failCount: failRows.length, failRows })
}, 800)
})
}
/** 模拟模板接口返回 Blob(真实项目为接口文件流) */
const templateRequest = (): Promise<Blob> => {
const header = '商品编码,仓库名称,期初库存\nSKU-1001,华东一号仓,320\nSKU-1002,华南二号仓,150\n'
return Promise.resolve(new Blob(['\ufeff' + header], { type: 'text/csv;charset=utf-8' }))
}
const onSuccess = (result: BizImportResult): void => {
console.log(`导入完成:成功 ${result.successCount} 条,失败 ${result.failCount} 条(示例文件无需真实存在,演示交互链路)`)
}
</script>
<template>
<div style="max-width: 560px">
<BizImport mode="quick" :request="request" :template-request="templateRequest" @success="onSuccess" />
<div style="margin-top: 8px; font-size: 13px; color: var(--el-text-color-secondary)">
默认 mode="quick":下载模板 → 选择文件(.xls/.xlsx/.csv、≤10MB)→ 自动上传 → 结果弹窗(成功/失败条数 + 失败明细 csv 下载)
</div>
</div>
</template>
显示代码
quick 会员批量导入
CRM 会员批量场景:csv / xlsx 双格式、单文件 ≤ 2MB;导入完成自动弹结果反馈,重复手机号按「先到先得」保留并给出冲突行号
CRM 场景:csv / xlsx 双格式、单文件 ≤ 2MB;导入 resolve 部分失败结果(successCount / failCount / failRows),重复手机号按「先到先得」保留并给出冲突行号。
<script setup lang="ts">
/** 场景:quick 会员批量导入——手机号重复/格式双校验,冲突行明细反馈 */
import { BizImport } from 'vue3-ep-biz-lib'
import type { BizImportResult } from 'vue3-ep-biz-lib'
/** 导入接口:重复手机号按「先到先得」,其余给出脱敏号码(真实项目为服务端接口) */
const doImport = async (_file: File): Promise<BizImportResult> => {
await new Promise((resolve) => setTimeout(resolve, 700))
return {
successCount: 58,
failCount: 4,
failRows: [
{ row: 2, reason: '手机号已存在(138****0001)' },
{ row: 9, reason: '手机号格式不正确' },
{ row: 15, reason: '等级编码无效(VIP9)' },
{ row: 31, reason: '手机号已存在(139****6688)' },
],
}
}
</script>
<template>
<div style="max-width: 560px">
<BizImport
mode="quick"
:request="doImport"
button-text="导入会员"
result-title="会员导入结果"
accept=".csv,.xlsx"
:max-size="2"
round
/>
<div style="margin-top: 8px; font-size: 13px; color: var(--el-text-color-secondary)">
CRM 会员批量场景:csv / xlsx 双格式、单文件 ≤ 2MB;导入完成自动弹结果反馈,重复手机号按「先到先得」保留并给出冲突行号
</div>
</div>
</template>
显示代码
quick 失败明细下载
影响在售商品 1,243 个;结果弹窗内「下载失败明细」导出 csv(\ufeff 防 Excel 乱码),修正后重导
调价场景:导入前模板确认影响面(1,243 个商品),结果弹窗给出红线拦截行(售价低于成本),「下载失败明细」导出 csv(
\ufeff 防 Excel 乱码)逐行修正后重导。<script setup lang="ts">
/** 场景:quick 失败明细下载——价格批量调整,红线拦截行可下载 csv 逐行修正 */
import { ElMessage } from 'element-plus'
import { BizImport } from 'vue3-ep-biz-lib'
import type { BizImportResult } from 'vue3-ep-biz-lib'
const downloadTemplate = async (): Promise<Blob> => {
await new Promise((resolve) => setTimeout(resolve, 300))
return new Blob(['\uFEFFSKU,调整后售价\nSKU-1001,39.90'], { type: 'text/csv;charset=utf-8' })
}
/** 调价接口:低价红线拦截 + 不存在 SKU(真实项目为服务端接口) */
const doImport = async (_file: File): Promise<BizImportResult> => {
await new Promise((resolve) => setTimeout(resolve, 900))
return {
successCount: 1240,
failCount: 3,
failRows: [
{ row: 8, reason: '售价低于成本价下限' },
{ row: 21, reason: 'SKU 不存在' },
{ row: 57, reason: '售价低于成本价下限' },
],
}
}
const onError = (error: unknown): void => {
ElMessage.error(String(error))
}
</script>
<template>
<div style="display: flex; gap: 12px; align-items: center; max-width: 560px">
<BizImport
mode="quick"
:request="doImport"
:template-request="downloadTemplate"
button-text="批量调价"
result-title="调价执行结果"
type="primary"
@error="onError"
/>
<span style="font-size: 13px; color: var(--el-text-color-secondary)">
影响在售商品 1,243 个;结果弹窗内「下载失败明细」导出 csv(\ufeff 防 Excel 乱码),修正后重导
</span>
</div>
</template>
显示代码
模板下载注入
quick:template-url 直链(不占接口)
wizard:template-request 文件流(接口生成模板)
0
上传文件
0
列映射
0
数据预览
拖拽文件到此处,或点击选择
支持 .csv 文件,单次最多 1000 行
template-request 优先级高于 template-url;两通道注入其一即出现「下载模板」入口
两通道通用:
template-url 直链(点击直接下载,不占接口)或 template-request 文件流(接口生成,优先级更高),注入其一即出现「下载模板」入口。<script setup lang="ts">
/** 场景:模板下载注入——template-url 直链与 template-request 文件流两通道通用 */
import { onBeforeUnmount } from 'vue'
import { BizImport } from 'vue3-ep-biz-lib'
import type { BizImportField, BizImportParseResult, BizImportResult } from 'vue3-ep-biz-lib'
/** 演示用对象直链(真实项目通常是 OSS / CDN 上的固定 xlsx 模板地址) */
const templateUrl = URL.createObjectURL(
new Blob(['\ufeff商品编码,商品名称,售价,库存\nSKU-001,无线键鼠套装,199.00,320'], { type: 'text/csv' }),
)
/** 模板接口返回文件流(真实项目为服务端接口) */
const templateRequest = async (): Promise<Blob> => {
await new Promise((resolve) => setTimeout(resolve, 400))
return new Blob(['\uFEFF设备编码,设备名称,购置日期\nSB-001,激光打印机,2026-01-12'], {
type: 'text/csv;charset=utf-8',
})
}
onBeforeUnmount(() => {
URL.revokeObjectURL(templateUrl)
})
const quickRequest = async (_file: File): Promise<BizImportResult> => {
await new Promise((resolve) => setTimeout(resolve, 600))
return { successCount: 12, failCount: 0 }
}
const fields: BizImportField[] = [
{ key: 'code', label: '设备编码', required: true },
{ key: 'name', label: '设备名称', required: true },
{ key: 'purchasedAt', label: '购置日期' },
]
/** CSV 解析注入(真实项目用 SheetJS 解析 xlsx) */
const parseCsv = async (file: File): Promise<BizImportParseResult> => {
const lines = (await file.text()).replace(/^\ufeff/, '').split(/\r?\n/).filter((l) => l.trim() !== '')
const headers = (lines[0] ?? '').split(',')
return {
headers,
rows: lines.slice(1).map((line) => {
const cells = line.split(',')
const row: Record<string, unknown> = {}
headers.forEach((h, i) => {
row[h] = cells[i] ?? ''
})
return row
}),
}
}
</script>
<template>
<div style="display: flex; flex-direction: column; gap: 24px; max-width: 680px">
<div>
<div style="font-size: 14px; font-weight: 600; margin-bottom: 8px">quick:template-url 直链(不占接口)</div>
<BizImport mode="quick" :request="quickRequest" :template-url="templateUrl" button-text="导入商品" accept=".csv" />
</div>
<div>
<div style="font-size: 14px; font-weight: 600; margin-bottom: 8px">wizard:template-request 文件流(接口生成模板)</div>
<BizImport
mode="wizard"
:fields="fields"
:parse-request="parseCsv"
:template-request="templateRequest"
accept=".csv"
/>
<div style="margin-top: 8px; font-size: 13px; color: var(--el-text-color-secondary)">
template-request 优先级高于 template-url;两通道注入其一即出现「下载模板」入口
</div>
</div>
</div>
</template>
显示代码
wizard 三步全流程
0
上传文件
0
列映射
0
数据预览
拖拽文件到此处,或点击选择
支持 .csv 文件,单次最多 1000 行
考勤导入场景:CSV 解析注入 + 示例文件生成下载。示例数据刻意覆盖三种映射情况:表头与字段同名直接匹配、别名匹配(员工编号→工号)、需手工映射(上班打卡→上班时间);并含一行缺必填(姓名)的脏数据在预览页红标。
<script setup lang="ts">
/** 场景:wizard 三步全流程——考勤导入(上传 → 列映射自动匹配 → 预览校验红标) */
import { ref } from 'vue'
import { ElButton, ElMessage } from 'element-plus'
import { BizImport } from 'vue3-ep-biz-lib'
import type { BizImportField, BizImportParseResult } from 'vue3-ep-biz-lib'
const fields: BizImportField[] = [
{ key: 'jobNo', label: '工号', required: true, aliases: ['员工编号'] },
{ key: 'name', label: '姓名', required: true },
{ key: 'workDate', label: '考勤日期', required: true },
{ key: 'checkIn', label: '上班时间' },
{ key: 'checkOut', label: '下班时间' },
]
/** 示例 CSV:覆盖三种映射情况——同名直接匹配(姓名/考勤日期)、别名匹配(员工编号→工号)、需手工映射(上/下班打卡→上/下班时间),并含一行缺「姓名」的脏数据 */
const sampleCsv = [
'员工编号,姓名,考勤日期,上班打卡,下班打卡',
'HR-1024,王小明,2026-10-08,09:02,18:35',
'HR-1025,李芳,2026-10-08,08:55,19:10',
'HR-1026,,2026-10-08,09:30,18:00',
'HR-1027,赵磊,2026-10-08,,20:05',
].join('\n')
/** 生成示例文件(真实项目这里是固定模板下载) */
const downloadSample = (): void => {
const url = URL.createObjectURL(new Blob(['\ufeff' + sampleCsv], { type: 'text/csv' }))
const link = document.createElement('a')
link.href = url
link.download = '考勤导入示例.csv'
link.click()
URL.revokeObjectURL(url)
}
/** CSV 解析注入:真实项目用 SheetJS 解析 xlsx,组件不感知格式 */
const parseCsv = async (file: File): Promise<BizImportParseResult> => {
const text = await file.text()
const lines = text.replace(/^\ufeff/, '').split(/\r?\n/).filter((line) => line.trim() !== '')
const headers = (lines[0] ?? '').split(',')
const rows = lines.slice(1).map((line) => {
const cells = line.split(',')
const row: Record<string, unknown> = {}
headers.forEach((header, i) => {
row[header] = cells[i] ?? ''
})
return row
})
return { headers, rows }
}
const submitting = ref(false)
const onSubmit = (rows: Array<Record<string, unknown>>): void => {
submitting.value = true
setTimeout(() => {
submitting.value = false
ElMessage.success(`已导入 ${rows.length} 条考勤记录`)
}, 1000)
}
</script>
<template>
<div style="display: flex; flex-direction: column; gap: 12px; max-width: 720px">
<ElButton size="small" style="align-self: flex-start" @click="downloadSample">先下载示例 CSV 再上传体验</ElButton>
<BizImport
mode="wizard"
:fields="fields"
:parse-request="parseCsv"
accept=".csv"
:submit-loading="submitting"
@submit="onSubmit"
/>
</div>
</template>
显示代码
列映射记忆(persist-key)
0
上传文件
0
列映射
0
数据预览
拖拽文件到此处,或点击选择
支持 .csv 文件,单次最多 1000 行
上传 A:「货号 / 库存数量」命中 aliases 自动匹配(绿标),「销售单价」需手工映射到「售价」,点下一步即写入本地记忆; 再次上传 A 或 B(两份都含「销售单价」列):售价下拉已自动带出上次的选择(记忆命中不显示绿标)。记忆键为
biz-import:demo-goods-import,可点「清除记忆」复位 手工映射过的列名按字段写入 localStorage(键
biz-import:<persist-key>),再次导入含同列名的文件自动带出,免重选;命中记忆不显示「自动匹配」绿标。可随时清除记忆复位。<script setup lang="ts">
/** 场景:列映射记忆(persist-key)——手工映射过的列名按字段记忆,同列名再导入免重选 */
import { ElButton, ElMessage } from 'element-plus'
import { BizImport } from 'vue3-ep-biz-lib'
import type { BizImportField, BizImportParseResult } from 'vue3-ep-biz-lib'
const PERSIST_KEY = 'demo-goods-import'
const fields: BizImportField[] = [
{ key: 'sku', label: '商品编码', required: true, aliases: ['货号'] },
{ key: 'name', label: '商品名称', required: true },
{ key: 'price', label: '售价' },
{ key: 'stock', label: '库存', aliases: ['库存数量'] },
]
/** 两份同构文件:均含非标准列「销售单价」(不在 label/aliases 中,首次需手工映射),A 另用别名表头、B 另用全新表头 */
const sampleA = ['货号,商品名称,销售单价,库存数量', 'SKU-1001,机械键盘,329.00,86'].join('\n')
const sampleB = ['sku 编号,商品名称,销售单价,现存数量', 'SKU-2002,电竞显示器,1599.00,40'].join('\n')
const download = (csv: string, filename: string): void => {
const url = URL.createObjectURL(new Blob(['\ufeff' + csv], { type: 'text/csv' }))
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
}
const parseCsv = async (file: File): Promise<BizImportParseResult> => {
const lines = (await file.text()).replace(/^\ufeff/, '').split(/\r?\n/).filter((l) => l.trim() !== '')
const headers = (lines[0] ?? '').split(',')
return {
headers,
rows: lines.slice(1).map((line) => {
const cells = line.split(',')
const row: Record<string, unknown> = {}
headers.forEach((h, i) => {
row[h] = cells[i] ?? ''
})
return row
}),
}
}
/** 清除本地记忆(localStorage 键:biz-import:<persist-key>) */
const clearMemory = (): void => {
try {
localStorage.removeItem(`biz-import:${PERSIST_KEY}`)
ElMessage.success('已清除本地映射记忆')
} catch {
ElMessage.warning('localStorage 不可用')
}
}
</script>
<template>
<div style="display: flex; flex-direction: column; gap: 12px; max-width: 680px">
<div style="display: flex; gap: 8px">
<ElButton size="small" @click="download(sampleA, '商品导入A-货号.csv')">下载示例 A(货号表头)</ElButton>
<ElButton size="small" @click="download(sampleB, '商品导入B-sku编号.csv')">下载示例 B(sku 编号表头)</ElButton>
<ElButton size="small" type="danger" plain @click="clearMemory">清除记忆</ElButton>
</div>
<BizImport mode="wizard" :fields="fields" :parse-request="parseCsv" :persist-key="PERSIST_KEY" accept=".csv" />
<div style="font-size: 13px; color: var(--el-text-color-secondary)">
上传 A:「货号 / 库存数量」命中 aliases 自动匹配(绿标),「销售单价」需手工映射到「售价」,点下一步即写入本地记忆;
再次上传 A 或 B(两份都含「销售单价」列):售价下拉已自动带出上次的选择(记忆命中不显示绿标)。记忆键为
<code>biz-import:demo-goods-import</code>,可点「清除记忆」复位
</div>
</div>
</template>
显示代码
服务端错误回填(setErrors)
BizModal 纯展示壳(
show-confirm=false)内嵌向导;提交后服务端校验失败的行经 setErrors 回填预览页红标 + 异常原因列。<script setup lang="ts">
/** 场景:服务端错误回填(setErrors)——弹窗内嵌向导,提交后校验失败行红标 + 异常原因列 */
import { ref } from 'vue'
import { ElButton, ElMessage } from 'element-plus'
import { BizImport, BizModal } from 'vue3-ep-biz-lib'
import type { BizImportField, BizImportParseResult } from 'vue3-ep-biz-lib'
const visible = ref(false)
const submitting = ref(false)
const importRef = ref<{
setErrors: (errors: Record<number, string[]>) => void
reset: () => void
}>()
const fields: BizImportField[] = [
{ key: 'sku', label: '商品编码', required: true },
{ key: 'name', label: '商品名称', required: true },
{ key: 'price', label: '售价' },
]
/** 已上架商品(服务端已有数据):第 2 行 SKU-2002 重复 */
const sampleCsv = [
'商品编码,商品名称,售价',
'SKU-1001,无线键鼠套装,199.00',
'SKU-2002,电竞显示器,1599.00',
'SKU-3003,USB-C 扩展坞,429.00',
].join('\n')
const parseCsv = async (file: File): Promise<BizImportParseResult> => {
const lines = (await file.text()).replace(/^\ufeff/, '').split(/\r?\n/).filter((l) => l.trim() !== '')
const headers = (lines[0] ?? '').split(',')
return {
headers,
rows: lines.slice(1).map((line) => {
const cells = line.split(',')
const row: Record<string, unknown> = {}
headers.forEach((h, i) => {
row[h] = cells[i] ?? ''
})
return row
}),
}
}
const downloadTemplate = async (): Promise<Blob> =>
new Blob(['\ufeff' + sampleCsv], { type: 'text/csv' })
/** 模拟服务端:SKU-2002 已存在 → 第 2 行(索引 1)报错回填预览页 */
const onSubmit = (rows: Array<Record<string, unknown>>): void => {
submitting.value = true
setTimeout(() => {
submitting.value = false
importRef.value?.setErrors({ 1: ['商品编码已存在(服务端校验)'] })
ElMessage.warning(`提交 ${rows.length} 行,服务端校验失败 1 行,已在预览页标红`)
}, 1000)
}
const open = (): void => {
importRef.value?.reset()
visible.value = true
}
</script>
<template>
<ElButton type="primary" @click="open">批量上架商品</ElButton>
<BizModal v-model="visible" title="批量上架商品" size="medium" :show-confirm="false" cancel-text="关闭">
<BizImport
ref="importRef"
mode="wizard"
:fields="fields"
:parse-request="parseCsv"
:template-request="downloadTemplate"
accept=".csv"
:submit-loading="submitting"
@submit="onSubmit"
/>
</BizModal>
</template>
显示代码
错误行在线修正(editable)
editable 开启后,第三步预览页异常行的单元格直接变为输入框:改完即时重算校验(必填本地即时、该行服务端错误随编辑清除),全部通过即可直接导入——免去「下载失败明细改文件重传」的往返。<script setup lang="ts">
/** 场景:错误行在线修正(editable)——预览页异常单元格直接改,即时重算,免改文件重传 */
import { ref } from 'vue'
import { ElButton, ElMessage } from 'element-plus'
import { BizImport, BizModal } from 'vue3-ep-biz-lib'
import type { BizImportField, BizImportParseResult } from 'vue3-ep-biz-lib'
const visible = ref(false)
const submitting = ref(false)
const submitted = ref<Array<Record<string, unknown>>> ([])
const fields: BizImportField[] = [
{ key: 'code', label: '设备编号', required: true },
{ key: 'name', label: '设备名称', required: true },
{ key: 'area', label: '安装区域' },
]
/** 混入两行问题数据:第 2 行缺名称、第 4 行缺编号 */
const sampleCsv = [
'设备编号,设备名称,安装区域',
'GW-0101,边缘网关,一号车间',
'GW-0102,,二号车间',
'TH-0203,温湿度传感器,一号车间',
',工业显示屏,中控室',
].join('\n')
const parseCsv = async (file: File): Promise<BizImportParseResult> => {
const lines = (await file.text()).replace(/^\ufeff/, '').split(/\r?\n/).filter((l) => l.trim() !== '')
const headers = (lines[0] ?? '').split(',')
return {
headers,
rows: lines.slice(1).map((line) => {
const cells = line.split(',')
const row: Record<string, unknown> = {}
headers.forEach((h, i) => {
row[h] = cells[i] ?? ''
})
return row
}),
}
}
const downloadTemplate = async (): Promise<Blob> =>
new Blob(['\ufeff' + sampleCsv], { type: 'text/csv' })
const onSubmit = (rows: Array<Record<string, unknown>>): void => {
submitting.value = true
setTimeout(() => {
submitting.value = false
submitted.value = rows
ElMessage.success(`导入成功 ${rows.length} 行(含在线修正后的行)`)
visible.value = false
}, 800)
}
const open = (): void => {
submitted.value = []
visible.value = true
}
</script>
<template>
<ElButton type="primary" @click="open">批量导入设备</ElButton>
<BizModal v-model="visible" title="批量导入设备(异常行可在线修正)" size="medium" :show-confirm="false" cancel-text="关闭">
<BizImport
mode="wizard"
:fields="fields"
:parse-request="parseCsv"
:template-request="downloadTemplate"
accept=".csv"
editable
:submit-loading="submitting"
@submit="onSubmit"
/>
<el-alert
v-if="submitted.length > 0"
type="success"
:closable="false"
style="margin-top: 12px"
:title="`本次实际入库 ${submitted.length} 行`"
/>
</BizModal>
</template>
显示代码
行数上限拦截
0
上传文件
0
列映射
0
数据预览
拖拽文件到此处,或点击选择
支持 .csv 文件,单次最多 3 行
max-rows 超限文件解析后直接拦截报错(防一次导入拖垮服务),请拆分文件再传。<script setup lang="ts">
/** 场景:行数上限拦截——max-rows=3,超限文件解析后直接报错,防一次导入拖垮服务 */
import { ElButton, ElMessage } from 'element-plus'
import { BizImport } from 'vue3-ep-biz-lib'
import type { BizImportField, BizImportParseResult } from 'vue3-ep-biz-lib'
const fields: BizImportField[] = [
{ key: 'jobNo', label: '工号', required: true },
{ key: 'name', label: '姓名', required: true },
{ key: 'workDate', label: '考勤日期' },
]
/** 5 行示例(超出 max-rows=3) */
const overLimitCsv = [
'工号,姓名,考勤日期',
'HR-001,王小明,2026-10-08',
'HR-002,李芳,2026-10-08',
'HR-003,赵磊,2026-10-08',
'HR-004,钱进,2026-10-08',
'HR-005,孙悦,2026-10-08',
].join('\n')
/** 3 行示例(合规) */
const okCsv = ['工号,姓名,考勤日期', 'HR-001,王小明,2026-10-08', 'HR-002,李芳,2026-10-08', 'HR-003,赵磊,2026-10-08'].join('\n')
const download = (csv: string, filename: string): void => {
const url = URL.createObjectURL(new Blob(['\ufeff' + csv], { type: 'text/csv' }))
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
}
const parseCsv = async (file: File): Promise<BizImportParseResult> => {
const lines = (await file.text()).replace(/^\ufeff/, '').split(/\r?\n/).filter((l) => l.trim() !== '')
const headers = (lines[0] ?? '').split(',')
return {
headers,
rows: lines.slice(1).map((line) => {
const cells = line.split(',')
const row: Record<string, unknown> = {}
headers.forEach((h, i) => {
row[h] = cells[i] ?? ''
})
return row
}),
}
}
</script>
<template>
<div style="display: flex; flex-direction: column; gap: 12px; max-width: 680px">
<div style="display: flex; gap: 8px">
<ElButton size="small" @click="download(overLimitCsv, '考勤超限示例-5行.csv')">下载 5 行超限示例</ElButton>
<ElButton size="small" @click="download(okCsv, '考勤合规示例-3行.csv')">下载 3 行合规示例</ElButton>
</div>
<BizImport
mode="wizard"
:fields="fields"
:parse-request="parseCsv"
:max-rows="3"
accept=".csv"
@submit="ElMessage.success('导入成功')"
/>
</div>
</template>
显示代码
事件链路日志
quick:选错类型 / 超限 → error;成功 → success
选非 csv 或大于 1MB 的文件触发 error
wizard:解析完成 → parsed;确认导入 → submit 下载示例 CSV
0
上传文件
0
列映射
0
数据预览
拖拽文件到此处,或点击选择
支持 .csv 文件,单次最多 1000 行
事件时间线(最新在前,保留 12 条)
暂无事件——左侧选一个超大 / 非法文件触发 error,右侧走完三步触发 parsed / submit
quick 通道
error(类型 / 大小校验拦截)与 success(导入完成),wizard 通道 parsed(解析完成回传原始表头与行)与 submit(确认导入映射行)全事件时间线。<script setup lang="ts">
/** 场景:事件链路日志——quick(error/success)与 wizard(parsed/submit)全事件时间线 */
import { ref } from 'vue'
import { ElTag } from 'element-plus'
import { BizImport } from 'vue3-ep-biz-lib'
import type { BizImportField, BizImportParseResult, BizImportResult } from 'vue3-ep-biz-lib'
interface LogItem {
time: string
channel: 'quick' | 'wizard'
name: string
detail: string
}
const logs = ref<LogItem[]>([])
const now = (): string => new Date().toLocaleTimeString('zh-CN', { hour12: false })
const addLog = (channel: 'quick' | 'wizard', name: string, detail: string): void => {
logs.value = [{ time: now(), channel, name, detail }, ...logs.value].slice(0, 12)
}
/* ---------- quick 通道 ---------- */
const quickRequest = async (_file: File): Promise<BizImportResult> => {
await new Promise((resolve) => setTimeout(resolve, 700))
return {
successCount: 18,
failCount: 1,
failRows: [{ row: 6, reason: '仓库编码不存在' }],
}
}
const onQuickError = (error: unknown): void => {
addLog('quick', 'error', (error as Error).message)
}
const onQuickSuccess = (result: BizImportResult): void => {
addLog('quick', 'success', `成功 ${result.successCount} 条 / 失败 ${result.failCount} 条${result.failRows === undefined ? '' : '(明细已弹窗)'}`)
}
/* ---------- wizard 通道 ---------- */
const fields: BizImportField[] = [
{ key: 'store', label: '门店', required: true },
{ key: 'target', label: '目标额', required: true },
{ key: 'month', label: '月份' },
]
const parseCsv = async (file: File): Promise<BizImportParseResult> => {
const lines = (await file.text()).replace(/^\ufeff/, '').split(/\r?\n/).filter((l) => l.trim() !== '')
const headers = (lines[0] ?? '').split(',')
const rows = lines.slice(1).map((line) => {
const cells = line.split(',')
const row: Record<string, unknown> = {}
headers.forEach((h, i) => {
row[h] = cells[i] ?? ''
})
return row
})
return { headers, rows }
}
const onParsed = (result: BizImportParseResult): void => {
addLog('wizard', 'parsed', `解析 ${result.rows.length} 行,表头:${result.headers.join('、')}`)
}
const onSubmit = (rows: Array<Record<string, unknown>>): void => {
addLog('wizard', 'submit', `确认导入 ${rows.length} 行(门店 / 目标额 / 月份)`)
}
const sampleCsv = '门店,目标额,月份\n深圳福田店,580000,2026-10\n广州天河店,620000,2026-10'
const downloadSample = (): void => {
const url = URL.createObjectURL(new Blob(['\ufeff' + sampleCsv], { type: 'text/csv' }))
const link = document.createElement('a')
link.href = url
link.download = '门店目标.csv'
link.click()
URL.revokeObjectURL(url)
}
</script>
<template>
<div style="display: flex; flex-direction: column; gap: 16px; max-width: 760px">
<div style="display: flex; gap: 24px; align-items: flex-start; flex-wrap: wrap">
<div>
<div style="font-size: 14px; font-weight: 600; margin-bottom: 8px">quick:选错类型 / 超限 → error;成功 → success</div>
<BizImport
mode="quick"
:request="quickRequest"
accept=".csv"
:max-size="1"
button-text="导入门店目标"
@error="onQuickError"
@success="onQuickSuccess"
/>
<div style="margin-top: 6px; font-size: 12px; color: var(--el-text-color-placeholder)">
选非 csv 或大于 1MB 的文件触发 error
</div>
</div>
<div style="flex: 1; min-width: 300px">
<div style="font-size: 14px; font-weight: 600; margin-bottom: 8px">
wizard:解析完成 → parsed;确认导入 → submit
<ElTag size="small" style="margin-left: 6px; cursor: pointer" @click="downloadSample">下载示例 CSV</ElTag>
</div>
<BizImport
mode="wizard"
:fields="fields"
:parse-request="parseCsv"
accept=".csv"
@parsed="onParsed"
@submit="onSubmit"
/>
</div>
</div>
<div style="border: 1px solid var(--el-border-color-lighter); border-radius: 4px; padding: 10px 12px; min-height: 96px">
<div style="font-size: 13px; font-weight: 600; color: var(--el-text-color-regular); margin-bottom: 6px">事件时间线(最新在前,保留 12 条)</div>
<div v-if="logs.length === 0" style="font-size: 12px; color: var(--el-text-color-placeholder)">
暂无事件——左侧选一个超大 / 非法文件触发 error,右侧走完三步触发 parsed / submit
</div>
<div
v-for="(log, index) in logs"
:key="`${log.time}-${index}`"
style="display: flex; gap: 8px; align-items: center; font-size: 12px; line-height: 2; color: var(--el-text-color-regular)"
>
<span style="color: var(--el-text-color-placeholder); font-family: monospace">{{ log.time }}</span>
<ElTag size="small" :type="log.channel === 'quick' ? 'primary' : 'warning'" effect="plain">{{ log.channel }}</ElTag>
<ElTag size="small" effect="plain">{{ log.name }}</ElTag>
<span>{{ log.detail }}</span>
</div>
</div>
</div>
</template>
显示代码
与表格页联动
导入结果确认后,success 事件里重新查询列表,新导入行带「本次导入」标签
库存导入场景:
success 事件里重新查询列表,新导入行打「本次导入」标签——导入与列表刷新的标准联动姿势。<script setup lang="ts">
/** 场景:与表格页联动——库存导入成功后自动刷新商品列表(success 事件驱动重查) */
import { ref } from 'vue'
import { ElMessage, ElTable, ElTableColumn, ElTag } from 'element-plus'
import { BizImport } from 'vue3-ep-biz-lib'
import type { BizImportResult } from 'vue3-ep-biz-lib'
interface StockRow {
sku: string
name: string
warehouse: string
stock: number
fromImport: boolean
}
/** 列表数据(真实项目为分页接口查询) */
const rows = ref<StockRow[]>([
{ sku: 'SKU-1001', name: '机械键盘', warehouse: '华东一号仓', stock: 86, fromImport: false },
{ sku: 'SKU-1002', name: '电竞显示器', warehouse: '华东一号仓', stock: 40, fromImport: false },
])
const loading = ref(false)
/** 导入接口:3 行新库存入库(真实项目为服务端接口) */
const doImport = async (_file: File): Promise<BizImportResult> => {
await new Promise((resolve) => setTimeout(resolve, 900))
return { successCount: 3, failCount: 0 }
}
/** success 事件 → 重新拉取列表:新导入行打「本次导入」标签 */
const onSuccess = (_result: BizImportResult): void => {
loading.value = true
ElMessage.info('导入完成,正在刷新库存列表…')
setTimeout(() => {
const imported: StockRow[] = [
{ sku: 'SKU-2003', name: 'USB-C 扩展坞', warehouse: '华南二号仓', stock: 120, fromImport: true },
{ sku: 'SKU-2004', name: '无线充电板', warehouse: '华南二号仓', stock: 75, fromImport: true },
{ sku: 'SKU-2005', name: '降噪耳机', warehouse: '华北三号仓', stock: 58, fromImport: true },
]
rows.value = [...imported, ...rows.value.map((row) => ({ ...row, fromImport: false }))]
loading.value = false
ElMessage.success('库存列表已刷新')
}, 600)
}
</script>
<template>
<div style="display: flex; flex-direction: column; gap: 12px; max-width: 720px">
<div style="display: flex; gap: 12px; align-items: center">
<BizImport
mode="quick"
:request="doImport"
button-text="导入库存"
result-title="库存导入结果"
@success="onSuccess"
/>
<span style="font-size: 13px; color: var(--el-text-color-secondary)">
导入结果确认后,success 事件里重新查询列表,新导入行带「本次导入」标签
</span>
</div>
<ElTable v-loading="loading" :data="rows" size="small" border>
<ElTableColumn prop="sku" label="商品编码" width="120" />
<ElTableColumn prop="name" label="商品名称" min-width="120" />
<ElTableColumn prop="warehouse" label="仓库" width="130" />
<ElTableColumn prop="stock" label="库存" width="90" align="right" />
<ElTableColumn label="来源" width="100" align="center">
<template #default="{ row }">
<ElTag v-if="row.fromImport" size="small" type="success" effect="plain">本次导入</ElTag>
<span v-else style="color: var(--el-text-color-placeholder)">存量</span>
</template>
</ElTableColumn>
</ElTable>
</div>
</template>
显示代码
后端异步任务(大数据量)
任务失败剧本
点击「模拟选择文件」注入 CSV 触发流程:提交任务 → 进度条(已处理 / 总行数)→ partial 终态弹出结果(支持服务端 fail-file-url 直链下载失败明细);开启开关后走 failed 剧本;处理中「后台运行」仅停前端轮询,不打断服务端任务。
mode="async":task-request 提交文件创建任务 → task-poll 轮询进度(percent / 已处理 / 总行数)→ 终态弹结果(partial 也弹,支持服务端 fail-file-url 失败明细直链下载)。「后台运行」仅停前端轮询不打断服务端任务,task-complete 携带任务 id 可登记进任务中心。<script setup lang="ts">
/** 场景:后端异步任务导入——提交建任务 → 轮询进度 → 终态结果弹窗(支持失败明细直链) */
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { BizImport } from 'vue3-ep-biz-lib'
import type { BizImportTaskStatus } from 'vue3-ep-biz-lib'
/** 演示开关:true 走「任务失败」剧本 */
const failMode = ref(false)
let ticks = 0
const taskRequest = (file: File): Promise<string> =>
new Promise((resolve) => {
ElMessage.info(`已接收 ${file.name}(${(file.size / 1024).toFixed(1)}KB),创建导入任务`)
setTimeout(() => resolve(`task-${Date.now()}`), 600)
})
const taskPoll = (_taskId: string): Promise<BizImportTaskStatus> =>
new Promise((resolve) => {
ticks += 1
setTimeout(() => {
if (failMode.value) {
resolve({ status: 'failed', message: '第 3 行「设备编码」重复提交,任务已回滚' })
return
}
if (ticks >= 4) {
resolve({
status: 'partial',
percent: 100,
processed: 1200,
total: 1200,
result: {
successCount: 1196,
failCount: 4,
failRows: [
{ row: 8, reason: '设备编码重复' },
{ row: 213, reason: '所属部门不存在' },
{ row: 677, reason: '购入日期格式错误' },
{ row: 1104, reason: '金额超出单据上限' },
],
},
})
return
}
resolve({
status: 'processing',
percent: ticks * 25,
processed: ticks * 300,
total: 1200,
})
}, 700)
})
/** 页内模拟文件注入(文档演示用;真实场景由用户选择文件) */
const pickDemoFile = (): void => {
const csv = 'code,name,dept\nDEV-001,网关设备,研发部\nDEV-002,温感设备,研发部\n'
const dt = new DataTransfer()
dt.items.add(new File([csv], 'devices.csv', { type: 'text/csv' }))
const host = document.querySelector('.biz-import--async input[type="file"]') as HTMLInputElement | null
if (host === null) return
host.files = dt.files
host.dispatchEvent(new Event('change', { bubbles: true }))
ticks = 0
}
</script>
<template>
<div style="display: flex; flex-wrap: wrap; gap: 10px; align-items: center">
<BizImport
mode="async"
class="biz-import--async"
:task-request="taskRequest"
:task-poll="taskPoll"
:poll-interval="1200"
button-text="异步导入设备台账"
@task-complete="(id, status) => ElMessage.info(`任务 ${id} 终态:${status.status}`)"
/>
<el-button @click="pickDemoFile">模拟选择文件</el-button>
<el-switch v-model="failMode" active-text="任务失败剧本" />
<p style="width: 100%; margin: 6px 0 0; font-size: 13px; color: var(--el-text-color-secondary)">
点击「模拟选择文件」注入 CSV 触发流程:提交任务 → 进度条(已处理 / 总行数)→ partial 终态弹出结果(支持服务端
fail-file-url 直链下载失败明细);开启开关后走 failed 剧本;处理中「后台运行」仅停前端轮询,不打断服务端任务。
</p>
</div>
</template>
显示代码
Props
| 属性名 | 类型 | 默认值 | 适用 mode | 说明 |
|---|---|---|---|---|
| mode | 'quick' | 'wizard' | 'async' | 'quick' | 全部 | 导入模式:quick 一键导入 / wizard 三步向导 / async 后端异步任务 |
| request | (file: File) => Promise<BizImportResult> | — | quick(必传) | 导入实现,resolve 后自动弹结果反馈 |
| task-request | (file: File) => Promise<string> | — | async(必传) | 提交文件创建任务,resolve 任务 id |
| task-poll | (taskId) => Promise<BizImportTaskStatus> | — | async(必传) | 轮询任务状态,直到终态(success / partial / failed) |
| poll-interval | number | 1500 | async | 轮询间隔(ms),轮询瞬时失败自动下个间隔重试 |
| accept | string | '.xls,.xlsx,.csv' | 全部 | 接受的文件类型(逗号分隔扩展名) |
| template-url | string | — | 全部 | 模板直链,点击「下载模板」直接下载 |
| template-request | () => Promise<Blob> | — | 全部 | 模板下载实现(接口文件流),优先级高于 template-url |
| max-size | number | 10 | quick / async | 单文件大小上限(MB),超限拦截并触发 error |
| button-text | string | '导入' | quick / async | 按钮文案 |
| result-title | string | '导入结果' | quick / async | 结果弹窗标题 |
| fields | BizImportField[] | — | wizard(必传) | 目标字段(列映射左侧固定项) |
| parse-request | (file) => Promise<BizImportParseResult> | — | wizard(必传) | 文件解析注入(SheetJS 等,组件零解析依赖) |
| persist-key | string | — | wizard | 列映射记忆 localStorage 键(缺省不记忆) |
| max-rows | number | 1000 | wizard | 单次导入行数上限,超限解析后拦截 |
| editable | boolean | false | wizard | 预览页异常行单元格在线修正(即时重算校验,修正值随 submit 一并提交) |
| submit-loading | boolean | false | wizard | 「确认导入」按钮 loading(业务提交期间锁定) |
quick / async 通道 size / type / plain / round 等按钮语义属性经 attrs 透传内部按钮,其余 attrs 透传内部 el-upload;wizard 通道 attrs 落根元素。
BizImportResult
{ successCount: number; failCount: number; failRows?: { row: number; reason: string }[] }
BizImportTaskStatus
| 字段 | 类型 | 说明 |
|---|---|---|
| status | 'processing' | 'success' | 'partial' | 'failed' | 任务状态(前两者为终态外的处理中) |
| percent | number | 进度百分比 0-100(缺省走不确定进度动画) |
| processed / total | number | 已处理 / 总行数(进度弹窗展示) |
| result | BizImportResult | 终态结果(success / partial) |
| failFileUrl | string | 服务端失败明细文件直链(优先于 result.failRows 的 csv 下载) |
| message | string | 任务失败说明(failed) |
BizImportField
| 属性 | 类型 | 说明 |
|---|---|---|
| key | string | 字段标识(提交行的键) |
| label | string | 显示名(自动匹配优先项) |
| required | boolean | 必填(映射必选 + 预览非空校验) |
| aliases | string[] | 表头别名(自动匹配) |
BizImportParseResult
{ headers: string[]; rows: Array<Record<string, unknown>> }(键为原始表头名,解析实现由业务注入)
Events
| 事件名 | 参数 | 适用 mode | 说明 |
|---|---|---|---|
| success | (result: BizImportResult) | quick / async | 导入成功(已弹出结果反馈;async 为终态 success / partial) |
| error | (error: unknown) | quick / async | 校验不通过 / request reject / async 任务终态 failed |
| task-complete | (taskId, status: BizImportTaskStatus) | async | 任务终态(含任务 id,供登记任务中心) |
| parsed | (result: BizImportParseResult) | wizard | 文件解析完成(原始表头与行,可做服务端预检) |
| submit | (rows: Array<Record<string, unknown>>) | wizard | 确认导入(已通过校验的映射行,键为 fields.key) |
Slots
| 插槽名 | 说明 |
|---|---|
| — | 无自定义插槽(quick 按钮文案用 button-text 控制,wizard 步骤结构固定) |
Expose
| 属性/方法 | 适用 mode | 说明 |
|---|---|---|
| open() | 两条通道 | 主动打开文件选择(quick:等同点击导入按钮;wizard:第一步拖拽区) |
setErrors(errors: Record<number, string[]>) | wizard | 服务端校验错误回填(行号 → 原因列表),预览页红标展示 |
| reset() | 两条通道 | 重置(wizard:回第一步并清空文件 / 映射 / 错误;quick:清空结果反馈状态) |
getMappedRows(): Array<Record<string, unknown>> | wizard | 当前映射行(含未通过校验的行,供业务自行处理) |