fix: 修复和完善 Excel 功能。
This commit is contained in:
@@ -3,56 +3,104 @@ import { Upload, Download } from "@icon-park/vue-next";
|
||||
import BaseForm from "../base-form/BaseForm.vue";
|
||||
import { ElMessage, type UploadInstance, type UploadUserFile } from "element-plus";
|
||||
import * as XLSX from "xlsx";
|
||||
import { type ExcelDataMapping } from "./type";
|
||||
import { type ExcelColumnConfig, type FieldMappingConfig, type DetailLoadConfig } from "./type";
|
||||
import { Delete } from "@element-plus/icons-vue";
|
||||
import { get } from "@/common/http/request";
|
||||
import { $t } from "@/common/languages";
|
||||
|
||||
interface Emits {
|
||||
(e: "update:mappedData", data: any[]): void;
|
||||
(e: "error", msg: string): void;
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
baseTitle: String,
|
||||
tableTitle: String,
|
||||
uploadDesc: String,
|
||||
itemArrayName: { type: String, required: true },
|
||||
mappingConfig: { type: Object, required: true },
|
||||
templateFileName: { type: String, default: "模板" },
|
||||
mappingConfig: { type: Object as () => FieldMappingConfig, required: true },
|
||||
templateFileName: { type: String, required: true },
|
||||
/**
|
||||
* 明细数据加载配置
|
||||
* 传入此配置后,组件会在编辑模式下自动加载明细数据
|
||||
*/
|
||||
detailConfig: { type: Object as () => DetailLoadConfig | null, default: null },
|
||||
/**
|
||||
* 模板列配置
|
||||
* 用于控制模板中显示哪些列,如果不传则使用 mappingConfig 中的所有列
|
||||
* 可以设置 include 或 exclude 来控制列的显示
|
||||
* include: 只包含指定的列
|
||||
* exclude: 排除指定的列
|
||||
*/
|
||||
templateColumns: {
|
||||
type: Object as () => {
|
||||
include?: string[];
|
||||
exclude?: string[];
|
||||
} | null,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
const form = defineModel<any>("form");
|
||||
const visible = defineModel<boolean>("visible");
|
||||
const uploadRef = ref<UploadInstance>();
|
||||
const baseFormComponentRef = ref<InstanceType<typeof BaseForm> | null>(null);
|
||||
const baseFormRef = ref<InstanceType<typeof BaseForm> | null>(null);
|
||||
|
||||
const getValue = (row: any, config: ExcelDataMapping) => {
|
||||
const { sourceKey, defaultValue, transform } = config;
|
||||
// 加载状态
|
||||
const loading = ref(false);
|
||||
|
||||
/**
|
||||
* 获取 Excel 列配置
|
||||
* 统一处理为数组形式
|
||||
*/
|
||||
const getSourceKeys = (config: ExcelColumnConfig): string[] => {
|
||||
const { sourceKey } = config;
|
||||
if (Array.isArray(sourceKey)) {
|
||||
return sourceKey;
|
||||
}
|
||||
return [sourceKey];
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取表头文本
|
||||
* 优先使用 header,如果没有则使用 sourceKey 的第一个值
|
||||
*/
|
||||
const getHeaderText = (config: ExcelColumnConfig): string => {
|
||||
if (config.header) {
|
||||
return config.header;
|
||||
}
|
||||
const sourceKeys = getSourceKeys(config);
|
||||
return sourceKeys[0] || "";
|
||||
};
|
||||
|
||||
/**
|
||||
* 从行数据中获取值
|
||||
*/
|
||||
const getValue = (row: any, config: ExcelColumnConfig) => {
|
||||
const sourceKeys = getSourceKeys(config);
|
||||
const { defaultValue, transform } = config;
|
||||
let rawValue: any;
|
||||
|
||||
// 1. 处理 sourceKey 是数组的情况 (多对一 / 降级策略)
|
||||
if (Array.isArray(sourceKey)) {
|
||||
for (const key of sourceKey) {
|
||||
const val = row[key];
|
||||
// 只要找到非 undefined 且非 null 的值,就立即停止查找
|
||||
if (val !== undefined && val !== null) {
|
||||
rawValue = val;
|
||||
break;
|
||||
}
|
||||
for (const key of sourceKeys) {
|
||||
const val = row[key];
|
||||
// 只要找到非 undefined 且非 null 的值,就立即停止查找
|
||||
if (val !== undefined && val !== null && val !== "") {
|
||||
rawValue = val;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 2. 处理 sourceKey 是字符串的情况 (一对一)
|
||||
else if (typeof sourceKey === "string") {
|
||||
rawValue = row[sourceKey];
|
||||
}
|
||||
|
||||
// 3. 如果没找到值,使用默认值
|
||||
// 2. 如果没找到值,使用默认值
|
||||
if (rawValue === undefined || rawValue === null) {
|
||||
rawValue = defaultValue;
|
||||
}
|
||||
|
||||
// 4. 执行转换函数 (如果有)
|
||||
// 3. 执行转换函数 (如果有)
|
||||
if (transform && typeof transform === "function") {
|
||||
try {
|
||||
return transform(rawValue);
|
||||
} catch (e) {
|
||||
console.warn(`Transform error for key ${Array.isArray(sourceKey) ? sourceKey.join("/") : sourceKey}:`, e);
|
||||
console.warn(`Transform error for key ${sourceKeys.join("/")}:`, e);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
@@ -60,6 +108,9 @@ const getValue = (row: any, config: ExcelDataMapping) => {
|
||||
return rawValue;
|
||||
};
|
||||
|
||||
/**
|
||||
* 转换 Excel 数据
|
||||
*/
|
||||
const convertData = (jsonData: any) => {
|
||||
if (!Array.isArray(jsonData)) {
|
||||
emit("error", "jsonData 必须是数组");
|
||||
@@ -69,20 +120,67 @@ const convertData = (jsonData: any) => {
|
||||
const result = jsonData.map(row => {
|
||||
const newRow: any = {};
|
||||
|
||||
// Object.entries 返回的是 [string, any][],我们需要断言为正确的类型
|
||||
// 或者直接在循环内部使用 config 变量
|
||||
for (const [targetKey, config] of Object.entries(props.mappingConfig)) {
|
||||
// 显式断言 config 的类型,防止 entries 推断丢失泛型信息
|
||||
const fieldConfig = config as ExcelDataMapping;
|
||||
const fieldConfig = config as ExcelColumnConfig;
|
||||
newRow[targetKey] = getValue(row, fieldConfig);
|
||||
}
|
||||
|
||||
return newRow;
|
||||
});
|
||||
|
||||
emit("update:mappedData", result);
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* 加载明细数据
|
||||
*/
|
||||
const loadDetailData = async () => {
|
||||
if (!props.detailConfig || !form.value?.id) return;
|
||||
|
||||
const { url, paramName, dataPath = "data" } = props.detailConfig;
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const res = await get(url, { [paramName]: form.value.id });
|
||||
|
||||
// 根据 dataPath 获取数据
|
||||
const keys = dataPath.split(".");
|
||||
let items = res;
|
||||
for (const key of keys) {
|
||||
items = items?.[key];
|
||||
if (items === undefined || items === null) break;
|
||||
}
|
||||
|
||||
form.value[props.itemArrayName] = items || [];
|
||||
} catch (error) {
|
||||
console.error("加载明细数据失败:", error);
|
||||
ElMessage.error("加载明细数据失败");
|
||||
form.value[props.itemArrayName] = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 监听 visible 变化
|
||||
* 当弹窗打开且是编辑模式(有 id)时,自动加载明细数据
|
||||
*/
|
||||
watch(
|
||||
() => visible.value,
|
||||
newVisible => {
|
||||
if (newVisible && props.detailConfig && form.value?.id) {
|
||||
// 编辑模式,自动加载明细
|
||||
loadDetailData();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 存储当前上传的文件信息,用于显示和移除
|
||||
const currentFile = ref<UploadUserFile | null>(null);
|
||||
|
||||
/**
|
||||
* 清空所有数据(文件 + 表格)
|
||||
*/
|
||||
const clearAllData = () => {
|
||||
// 1. 清空文件引用
|
||||
currentFile.value = null;
|
||||
@@ -96,10 +194,14 @@ const clearAllData = () => {
|
||||
}
|
||||
|
||||
// 4. 清空验证
|
||||
if (baseFormComponentRef.value) {
|
||||
baseFormComponentRef.value.clearValidate();
|
||||
if (baseFormRef.value) {
|
||||
baseFormRef.value.clearValidate();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理文件变化
|
||||
*/
|
||||
const handleFileChange = (file: any) => {
|
||||
// 文件大小校验
|
||||
const maxSize = 10 * 1024 * 1024; // 10MB
|
||||
@@ -114,6 +216,10 @@ const handleFileChange = (file: any) => {
|
||||
// 解析 Excel(追加模式,不清空原有数据)
|
||||
parseExcel(file.raw);
|
||||
};
|
||||
|
||||
/**
|
||||
* 解析 Excel 文件
|
||||
*/
|
||||
const parseExcel = (file: any) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
@@ -164,10 +270,9 @@ const parseExcel = (file: any) => {
|
||||
reader.readAsArrayBuffer(file);
|
||||
};
|
||||
|
||||
// 【新增】存储当前上传的文件信息,用于显示和移除
|
||||
const currentFile = ref<UploadUserFile | null>(null);
|
||||
|
||||
// 【新增】只清理表格数据(保留基本表单数据)
|
||||
/**
|
||||
* 只清理表格数据(保留基本表单数据)
|
||||
*/
|
||||
const clearTableData = () => {
|
||||
// 1. 清空表格数据
|
||||
if (form.value && Array.isArray(form.value[props.itemArrayName])) {
|
||||
@@ -175,14 +280,16 @@ const clearTableData = () => {
|
||||
}
|
||||
|
||||
// 2. 清空表单验证(仅表格相关)
|
||||
if (baseFormComponentRef.value) {
|
||||
baseFormComponentRef.value.clearValidate();
|
||||
if (baseFormRef.value) {
|
||||
baseFormRef.value.clearValidate();
|
||||
}
|
||||
|
||||
ElMessage.success("表格数据已清空");
|
||||
};
|
||||
|
||||
// 【新增】深度清理函数(清理表格+文件,保留基本表单数据)
|
||||
/**
|
||||
* 深度清理函数(清理表格+文件,保留基本表单数据)
|
||||
*/
|
||||
const resetFormData = () => {
|
||||
// 1. 清空表格数据
|
||||
if (form.value && Array.isArray(form.value[props.itemArrayName])) {
|
||||
@@ -194,12 +301,14 @@ const resetFormData = () => {
|
||||
uploadRef.value?.clearFiles();
|
||||
|
||||
// 3. 清空表单验证
|
||||
if (baseFormComponentRef.value) {
|
||||
baseFormComponentRef.value.clearValidate();
|
||||
if (baseFormRef.value) {
|
||||
baseFormRef.value.clearValidate();
|
||||
}
|
||||
};
|
||||
|
||||
// 【新增】增加一行
|
||||
/**
|
||||
* 增加一行
|
||||
*/
|
||||
const addRow = () => {
|
||||
if (!form.value) form.value = {};
|
||||
if (!Array.isArray(form.value[props.itemArrayName])) {
|
||||
@@ -208,39 +317,72 @@ const addRow = () => {
|
||||
// 创建一个空行,根据 mappingConfig 生成默认结构
|
||||
const newRow: any = {};
|
||||
for (const [key, config] of Object.entries(props.mappingConfig)) {
|
||||
const fieldConfig = config as ExcelDataMapping;
|
||||
const fieldConfig = config as ExcelColumnConfig;
|
||||
newRow[key] = fieldConfig.defaultValue !== undefined ? fieldConfig.defaultValue : "";
|
||||
}
|
||||
form.value[props.itemArrayName].push(newRow);
|
||||
};
|
||||
|
||||
// 【新增】删除指定行
|
||||
/**
|
||||
* 删除指定行
|
||||
*/
|
||||
const deleteRow = (index: number) => {
|
||||
if (form.value && Array.isArray(form.value[props.itemArrayName])) {
|
||||
form.value[props.itemArrayName].splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
// 【新增】移除文件
|
||||
/**
|
||||
* 移除文件
|
||||
*/
|
||||
const removeFile = () => {
|
||||
clearAllData();
|
||||
ElMessage.success("文件已移除,表格数据已清空");
|
||||
};
|
||||
|
||||
// 【新增】下载模板文件
|
||||
/**
|
||||
* 获取模板列配置
|
||||
* 根据 templateColumns 配置过滤 mappingConfig
|
||||
*/
|
||||
const getTemplateColumns = (): [string, ExcelColumnConfig][] => {
|
||||
const allEntries = Object.entries(props.mappingConfig) as [string, ExcelColumnConfig][];
|
||||
|
||||
if (!props.templateColumns) {
|
||||
return allEntries;
|
||||
}
|
||||
|
||||
const { include, exclude } = props.templateColumns;
|
||||
|
||||
if (include && include.length > 0) {
|
||||
// 只包含指定的列
|
||||
return allEntries.filter(([key]) => include.includes(key));
|
||||
}
|
||||
|
||||
if (exclude && exclude.length > 0) {
|
||||
// 排除指定的列
|
||||
return allEntries.filter(([key]) => !exclude.includes(key));
|
||||
}
|
||||
|
||||
return allEntries;
|
||||
};
|
||||
|
||||
/**
|
||||
* 下载模板文件
|
||||
*/
|
||||
const downloadTemplate = () => {
|
||||
// 1. 根据 mappingConfig 生成表头
|
||||
// 1. 根据模板列配置生成表头
|
||||
const headers: string[] = [];
|
||||
for (const [targetKey, config] of Object.entries(props.mappingConfig)) {
|
||||
const fieldConfig = config as ExcelDataMapping;
|
||||
// 使用 sourceKey 作为表头,如果是数组则使用第一个
|
||||
let header: string;
|
||||
if (Array.isArray(fieldConfig.sourceKey)) {
|
||||
header = fieldConfig.sourceKey[0];
|
||||
} else {
|
||||
header = fieldConfig.sourceKey;
|
||||
}
|
||||
const colWidths: { wch: number }[] = [];
|
||||
|
||||
const templateColumns = getTemplateColumns();
|
||||
|
||||
for (const [targetKey, config] of templateColumns) {
|
||||
// 使用 header 或 sourceKey 的第一个值作为表头
|
||||
const header = getHeaderText(config);
|
||||
headers.push(header);
|
||||
|
||||
// 使用配置的列宽,默认 20
|
||||
colWidths.push({ wch: config.width || 20 });
|
||||
}
|
||||
|
||||
// 2. 创建工作簿
|
||||
@@ -250,7 +392,6 @@ const downloadTemplate = () => {
|
||||
const ws = XLSX.utils.aoa_to_sheet([headers]);
|
||||
|
||||
// 4. 设置列宽
|
||||
const colWidths = headers.map(() => ({ wch: 20 }));
|
||||
ws["!cols"] = colWidths;
|
||||
|
||||
// 5. 将工作表添加到工作簿
|
||||
@@ -263,21 +404,55 @@ const downloadTemplate = () => {
|
||||
ElMessage.success("模板文件下载成功");
|
||||
};
|
||||
|
||||
// 监听 BaseForm 发出的 reset 事件
|
||||
/**
|
||||
* 计算属性:确保表格数据始终是一个数组
|
||||
*/
|
||||
const tableData = computed(() => {
|
||||
if (!form.value) return [];
|
||||
const data = form.value[props.itemArrayName];
|
||||
return Array.isArray(data) ? data : [];
|
||||
});
|
||||
|
||||
/**
|
||||
* 监听 form 变化,确保 itemArrayName 对应的属性是数组
|
||||
*/
|
||||
watch(
|
||||
() => form.value,
|
||||
newVal => {
|
||||
if (newVal && !Array.isArray(newVal[props.itemArrayName])) {
|
||||
newVal[props.itemArrayName] = [];
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
/**
|
||||
* 监听 BaseForm 发出的 reset 事件
|
||||
*/
|
||||
const handleBaseFormReset = () => {
|
||||
resetFormData();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
innerFormRef: computed(() => baseFormComponentRef.value?.formRef),
|
||||
innerFormRef: computed(() => baseFormRef.value?.formRef),
|
||||
baseFormRef: computed(() => baseFormRef.value?.formRef),
|
||||
resetFormData,
|
||||
addRow,
|
||||
deleteRow,
|
||||
clearTableData,
|
||||
downloadTemplate,
|
||||
loadDetailData,
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<BaseForm ref="baseFormRef" v-bind="$attrs" class="form-dialog" v-model:form="form" @reset="handleBaseFormReset">
|
||||
<BaseForm
|
||||
ref="baseFormRef"
|
||||
v-bind="$attrs"
|
||||
class="form-dialog"
|
||||
v-model:form="form"
|
||||
v-model:visible="visible"
|
||||
@reset="handleBaseFormReset"
|
||||
>
|
||||
<template #form-items>
|
||||
<el-divider content-position="left" v-if="props.baseTitle">
|
||||
<el-icon><Document /></el-icon>
|
||||
@@ -301,15 +476,11 @@ defineExpose({
|
||||
:on-change="handleFileChange"
|
||||
:show-file-list="false"
|
||||
>
|
||||
<el-button type="primary">
|
||||
<el-button type="primary" :loading="loading">
|
||||
<el-icon><Upload /></el-icon>
|
||||
选择 Excel 文件
|
||||
</el-button>
|
||||
<span class="upload-tip-side">
|
||||
只能上传 xlsx/xls/csv 文件,且不超过 10MB
|
||||
<br v-if="props.uploadDesc" />
|
||||
<span class="tip-desc" v-if="props.uploadDesc">{{ uploadDesc }}</span>
|
||||
</span>
|
||||
<span class="upload-tip-side">{{ $t("_component.baseFormWithTable.uploadTip") }}</span>
|
||||
</el-upload>
|
||||
<el-button type="success" @click="downloadTemplate">
|
||||
<el-icon><Download /></el-icon>
|
||||
@@ -318,10 +489,17 @@ defineExpose({
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-table :data="form[itemArrayName]" border style="width: 100%" max-height="400" class="form-item-table">
|
||||
<el-table
|
||||
:data="tableData"
|
||||
border
|
||||
style="width: 100%"
|
||||
max-height="400"
|
||||
class="form-item-table"
|
||||
v-loading="loading"
|
||||
>
|
||||
<slot name="form-table-columns"></slot>
|
||||
|
||||
<!-- 【新增】固定操作列:删除行 -->
|
||||
<!-- 固定操作列:删除行 -->
|
||||
<el-table-column label="操作" width="80" align="center">
|
||||
<template #default="{ $index }">
|
||||
<el-button type="danger" link size="small" @click="deleteRow($index)">
|
||||
@@ -331,7 +509,7 @@ defineExpose({
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 【新增】表格下方操作栏 -->
|
||||
<!-- 表格下方操作栏 -->
|
||||
<div class="table-actions">
|
||||
<el-button type="primary" link @click="addRow">
|
||||
<el-icon><Plus /></el-icon>
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
/**
|
||||
* 用于映射 Excel 中的数据
|
||||
* Excel 列配置
|
||||
* 用于映射 Excel 中的数据以及生成模板文件
|
||||
*/
|
||||
export interface ExcelDataMapping {
|
||||
export interface ExcelColumnConfig {
|
||||
/**
|
||||
* Excel 中的列名
|
||||
* Excel 中的列名(支持多个,按顺序匹配)
|
||||
* 例如:["物料编号", "partNumber", "商品编号"]
|
||||
*/
|
||||
sourceKey: string | string[];
|
||||
|
||||
/**
|
||||
* 模板表头显示文本
|
||||
* 如果不设置,默认使用 sourceKey 的第一个值
|
||||
*/
|
||||
header?: string;
|
||||
|
||||
/**
|
||||
* 模板列宽(字符数)
|
||||
* 默认 20
|
||||
*/
|
||||
width?: number;
|
||||
|
||||
/**
|
||||
* 默认值
|
||||
*/
|
||||
@@ -23,4 +37,25 @@ export interface ExcelDataMapping {
|
||||
/**
|
||||
* 映射配置表 目标 key -> 规则
|
||||
*/
|
||||
export type FieldMappingConfig = Record<string, ExcelDataMapping>;
|
||||
export type FieldMappingConfig = Record<string, ExcelColumnConfig>;
|
||||
|
||||
/**
|
||||
* 明细数据加载配置
|
||||
*/
|
||||
export interface DetailLoadConfig {
|
||||
/**
|
||||
* 加载明细的接口地址
|
||||
*/
|
||||
url: string;
|
||||
|
||||
/**
|
||||
* 参数名,如 'orderId'、'id'
|
||||
*/
|
||||
paramName: string;
|
||||
|
||||
/**
|
||||
* 响应数据路径,如 'data'、'data.items'
|
||||
* 默认 'data'
|
||||
*/
|
||||
dataPath?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user