fix: 修复和完善 Excel 功能。

This commit is contained in:
c
2026-03-19 16:58:10 +08:00
parent bf83b5e3b6
commit d88701f4ee
15 changed files with 546 additions and 220 deletions

View File

@@ -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>

View File

@@ -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;
}