diff --git a/package.json b/package.json index 704b11f..20d2f29 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "teek-design-vue3-template", "version": "2.0.0", "private": true, - "description": "Teek Design Vue3 后台管理系统", + "description": "牛安后台管理系统", "author": "Teeker <2456019588@qq.com>", "license": "MIT", "type": "module", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dfca53e..242e9fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,9 +47,6 @@ importers: qs: specifier: ^6.14.0 version: 6.14.0 - remixicon: - specifier: ^4.9.1 - version: 4.9.1 sortablejs: specifier: ^1.15.6 version: 1.15.6 @@ -4260,9 +4257,6 @@ packages: resolution: {integrity: sha512-vqlC04+RQoFalODCbCumG2xIOvapzVMHwsyIGM/SIE8fRhFFsXeH8/QQ+s0T0kDAhKc4k30s73/0ydkHQz6HlQ==} engines: {node: '>= 0.4'} - remixicon@4.9.1: - resolution: {integrity: sha512-36gLSoujkabnCFZFDyP17VNh9piuBA/rsXUb4auSJWLGsHVXtmxLj/EM5FjaEAGnk8oIAj1Azob/DZ2N+90lAQ==} - repeat-element@1.1.4: resolution: {integrity: sha512-LFiNfRcSu7KK3evMyYOuCzv3L10TW7yC1G2/+StMjK8Y6Vqd2MG7r/Qjw4ghtuCOjFvlnms/iMmLqpvW/ES/WQ==} engines: {node: '>=0.10.0'} @@ -9675,8 +9669,6 @@ snapshots: es-errors: 1.3.0 set-function-name: 2.0.2 - remixicon@4.9.1: {} - repeat-element@1.1.4: {} repeat-string@1.6.1: {} diff --git a/src/common/languages/locales/en-US.ts b/src/common/languages/locales/en-US.ts index 0dbba35..1d2ade0 100644 --- a/src/common/languages/locales/en-US.ts +++ b/src/common/languages/locales/en-US.ts @@ -80,6 +80,11 @@ export default { yes: "Yes", no: "No", }, + _component: { + baseFormWithTable: { + uploadTip: "Only xlsx/xls/csv files are allowed, max 10MB", + }, + }, _prop: { common: { tel: "Tel", @@ -339,6 +344,8 @@ export default { formStatus: "Audit Status", customerName: "Customer Name", customerId: "Customer", + storeNo: "Warehouse", + storeName: "Warehouse", partNumber: "Part Number", productSpecs: "Product Specs", saleCount: "Sale Count", @@ -754,6 +761,27 @@ export default { }, sale: { saleorder: { + select_customerId: "Please select customer", + select_storeNo: "Please select warehouse", + input_formName: "Please enter form name", + input_formMark: "Please enter form remark", + input_formCode: "Please enter form code", + input_partNumber: "Please enter part number", + input_saleCount: "Please enter sale count", + input_price: "Please enter price", + input_saleMark: "Please enter remark", + approve_confirm: "Confirm approval", + approve_success: "Approval success", + approve_fail: "Approval failed", + reject_confirm: "Confirm reject", + reject_success: "Reject success", + reject_fail: "Reject failed", + already_approved: "Sale order already approved", + not_approved: "Sale order not approved", + no_sale_order_items: "Sale order has no items", + cannot_unapprove_with_shipped: "Sale order already shipped, cannot unapprove", + import_success: "Import success", + import_fail: "Import failed", delete_message: "Delete Order", }, }, @@ -830,14 +858,17 @@ export default { add: "Add Transfer", edit: "Edit Transfer", stock_check_failed: "Stock check failed", + templateFileName: "Transfer Order Template", }, warehousereceipt: { add: "Add Receipt", edit: "Edit Receipt", + templateFileName: "Warehouse Receipt Template", }, inventorycount: { add: "Add Count", edit: "Edit Count", + templateFileName: "Inventory Count Template", }, }, production: { @@ -847,6 +878,7 @@ export default { showItem: "BOM Items", baseTitle: "BOM Info", tableTitle: "BOM Details", + templateFileName: "BOM Template", }, production_plan: { add: "Add Plan", @@ -868,10 +900,12 @@ export default { baseTitle: "Receipt Basic Info", tableTitle: "Product Details", outstockDialog: "Product Outstock", + templateFileName: "Finished Product Receipt Template", }, finishedproductshipment: { add: "Add Shipment", edit: "Edit Shipment", + templateFileName: "Finished Product Shipment Template", }, }, purchase: { @@ -881,6 +915,7 @@ export default { showItem: "Plan Items", baseTitle: "Purchase Plan Basic Info", tableTitle: "Purchase Details", + templateFileName: "Purchase Plan Template", }, purchaseorder: { add: "Add Purchase Order", @@ -888,6 +923,7 @@ export default { showItem: "Order Items", baseTitle: "Purchase Order Basic Info", tableTitle: "Order Details", + templateFileName: "Purchase Order Template", }, }, sale: { @@ -897,6 +933,7 @@ export default { showItem: "Sale Details", baseTitle: "Sale Order Basic Info", tableTitle: "Sale Details", + templateFileName: "Sale Order Template", }, repairrecord: { add: "Add Repair Record", diff --git a/src/common/languages/locales/zh-CN.ts b/src/common/languages/locales/zh-CN.ts index 50d7fce..ad0bf9d 100644 --- a/src/common/languages/locales/zh-CN.ts +++ b/src/common/languages/locales/zh-CN.ts @@ -80,6 +80,11 @@ export default { yes: "是", no: "否", }, + _component: { + baseFormWithTable: { + uploadTip: "只能上传 xlsx/xls/csv 文件,且不超过 10MB", + }, + }, _prop: { common: { tel: "电话", @@ -339,6 +344,8 @@ export default { formStatus: "审核状态", customerName: "客户名称", customerId: "客户", + storeNo: "出货仓库", + storeName: "出货仓库", partNumber: "物料编号", productSpecs: "物料型号", saleCount: "销售数量", @@ -809,6 +816,7 @@ export default { sale: { saleorder: { select_customerId: "请选择客户", + select_storeNo: "请选择出货仓库", input_formName: "请输入单据名称", input_formMark: "请输入单据备注", input_formCode: "请输入单据编号", @@ -928,6 +936,7 @@ export default { baseTitle: "调拨单基本信息", tableTitle: "调拨明细", stock_check_failed: "库存检测失败", + templateFileName: "调拨单模板", }, warehousereceipt: { add: "添加入库单", @@ -935,6 +944,7 @@ export default { showItem: "入库明细", baseTitle: "入库单基本信息", tableTitle: "入库明细", + templateFileName: "入库单模板", }, inventorycount: { add: "添加盘点单", @@ -942,6 +952,7 @@ export default { showItem: "盘点明细", baseTitle: "盘点单基本信息", tableTitle: "盘点明细", + templateFileName: "盘点单模板", }, }, production: { @@ -951,6 +962,7 @@ export default { showItem: "BOM 明细", baseTitle: "BOM 基本信息", tableTitle: "BOM 明细", + templateFileName: "BOM模板", }, production_plan: { add: "新增生产计划", @@ -972,6 +984,7 @@ export default { baseTitle: "成品入库单基本信息", tableTitle: "成品明细", outstockDialog: "成品出货", + templateFileName: "成品入库单模板", }, finishedproductshipment: { add: "添加成品出货单", @@ -979,6 +992,7 @@ export default { showItem: "成品明细", baseTitle: "成品出货单基本信息", tableTitle: "成品明细", + templateFileName: "成品出货单模板", }, saleorder: { add: "添加销售订单", @@ -986,6 +1000,7 @@ export default { showItem: "销售明细", baseTitle: "销售订单基本信息", tableTitle: "销售明细", + templateFileName: "销售订单模板", }, }, purchase: { @@ -1001,6 +1016,7 @@ export default { unitPrice: "单价", totalPrice: "总价", purchaseStatus: "采购状态", + templateFileName: "采购计划模板", }, purchase_order: { add: "新建采购订单", @@ -1009,6 +1025,7 @@ export default { qrcode: "二维码打印", baseTitle: "采购订单基本信息", tableTitle: "采购明细", + templateFileName: "采购订单模板", }, }, sale: { @@ -1018,6 +1035,7 @@ export default { showItem: "销售明细", baseTitle: "销售订单基本信息", tableTitle: "销售明细", + templateFileName: "销售订单模板", }, repairrecord: { add: "添加维修记录", diff --git a/src/components/base/base-form-with-table/BaseFormWithTable.vue b/src/components/base/base-form-with-table/BaseFormWithTable.vue index 5bd12d5..d4f3d13 100644 --- a/src/components/base/base-form-with-table/BaseFormWithTable.vue +++ b/src/components/base/base-form-with-table/BaseFormWithTable.vue @@ -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(); const form = defineModel("form"); +const visible = defineModel("visible"); const uploadRef = ref(); -const baseFormComponentRef = ref | null>(null); +const baseFormRef = ref | 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(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(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, });