fix: 第一版错误修改前的存档。

This commit is contained in:
c
2026-03-16 14:17:03 +08:00
parent 48cd47dd72
commit 0c4e4679b3
32 changed files with 1123 additions and 288 deletions

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { Upload } from "@icon-park/vue-next";
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";
@@ -15,6 +15,7 @@ const props = defineProps({
uploadDesc: String,
itemArrayName: { type: String, required: true },
mappingConfig: { type: Object, required: true },
templateFileName: { type: String, default: "模板" },
});
const emit = defineEmits<Emits>();
const form = defineModel<any>("form");
@@ -104,16 +105,13 @@ const handleFileChange = (file: any) => {
const maxSize = 10 * 1024 * 1024; // 10MB
if (file.size > maxSize) {
ElMessage.error("文件大小不能超过 10MB");
clearAllData();
return;
}
clearAllData();
// 更新当前文件显示
currentFile.value = file;
// 解析 Excel
// 解析 Excel(追加模式,不清空原有数据)
parseExcel(file.raw);
};
const parseExcel = (file: any) => {
@@ -149,11 +147,15 @@ const parseExcel = (file: any) => {
form.value[props.itemArrayName] = [];
}
// 使用 splice 替换所有内容,这是最安全的触发数组更新的方式
form.value[props.itemArrayName].splice(0, form.value[props.itemArrayName].length, ...mappedData);
// 追加数据到现有数组
form.value[props.itemArrayName].push(...mappedData);
}
ElMessage.success(`解析成功,共 ${mappedData.length}物料数据`);
ElMessage.success(`解析成功,共追加 ${mappedData.length} 条数据`);
// 清空上传组件的文件列表,允许用户再次选择文件进行追加
uploadRef.value?.clearFiles();
currentFile.value = null;
} catch (error) {
console.error(error);
ElMessage.error("文件解析失败,请检查文件格式");
@@ -165,32 +167,36 @@ const parseExcel = (file: any) => {
// 【新增】存储当前上传的文件信息,用于显示和移除
const currentFile = ref<UploadUserFile | null>(null);
// 【新增】深度清理函数
const resetFormData = () => {
// 1. 清空表单验证
// 【新增】只清理表格数据(保留基本表单数据)
const clearTableData = () => {
// 1. 清空表格数据
if (form.value && Array.isArray(form.value[props.itemArrayName])) {
form.value[props.itemArrayName].splice(0, form.value[props.itemArrayName].length);
}
// 2. 清空表单验证(仅表格相关)
if (baseFormComponentRef.value) {
// 方式 A: 调用暴露的 clearValidate 方法
baseFormComponentRef.value.clearValidate();
// 方式 B: 或者访问内部 formRef (如果暴露了)
// baseFormComponentRef.value.formRef?.clearValidate();
}
// 2. 重置 form 对象 (保留非表格字段?通常重置是全部清空或设为初始值,这里设为空对象)
if (form.value) {
// 只清空表格数据,保留其他表单字段?还是全部重置?
// 根据需求“数据清理不干净”,通常指表格文件
// 这里我们清空表格数组和文件,其他字段由 BaseForm 的 reset 逻辑处理或手动置空
if (Array.isArray(form.value[props.itemArrayName])) {
form.value[props.itemArrayName].splice(0, form.value[props.itemArrayName].length);
}
ElMessage.success("表格数据已清空");
};
// 【新增】深度清理函数(清理表格+文件,保留基本表单数据)
const resetFormData = () => {
// 1. 清空表格数据
if (form.value && Array.isArray(form.value[props.itemArrayName])) {
form.value[props.itemArrayName].splice(0, form.value[props.itemArrayName].length);
}
// 3. 清空文件状态
// 2. 清空文件状态
currentFile.value = null;
uploadRef.value?.clearFiles();
// 4. 如果需要完全重置 form 为初始空对象,取消下面注释
form.value = {};
// 3. 清空表单验证
if (baseFormComponentRef.value) {
baseFormComponentRef.value.clearValidate();
}
};
// 【新增】增加一行
@@ -220,6 +226,43 @@ const removeFile = () => {
clearAllData();
ElMessage.success("文件已移除,表格数据已清空");
};
// 【新增】下载模板文件
const downloadTemplate = () => {
// 1. 根据 mappingConfig 生成表头
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;
}
headers.push(header);
}
// 2. 创建工作簿
const wb = XLSX.utils.book_new();
// 3. 创建工作表(只有表头,没有数据行)
const ws = XLSX.utils.aoa_to_sheet([headers]);
// 4. 设置列宽
const colWidths = headers.map(() => ({ wch: 20 }));
ws["!cols"] = colWidths;
// 5. 将工作表添加到工作簿
XLSX.utils.book_append_sheet(wb, ws, "模板");
// 6. 生成文件并下载
const fileName = `${props.templateFileName}.xlsx`;
XLSX.writeFile(wb, fileName);
ElMessage.success("模板文件下载成功");
};
// 监听 BaseForm 发出的 reset 事件
const handleBaseFormReset = () => {
resetFormData();
@@ -229,6 +272,8 @@ defineExpose({
resetFormData,
addRow,
deleteRow,
clearTableData,
downloadTemplate,
});
</script>
<template>
@@ -266,6 +311,10 @@ defineExpose({
<span class="tip-desc" v-if="props.uploadDesc">{{ uploadDesc }}</span>
</span>
</el-upload>
<el-button type="success" @click="downloadTemplate">
<el-icon><Download /></el-icon>
下载模板文件
</el-button>
</div>
</el-form-item>
@@ -288,7 +337,7 @@ defineExpose({
<el-icon><Plus /></el-icon>
增加一行
</el-button>
<el-button type="warning" link @click="resetFormData">
<el-button type="warning" link @click="clearTableData">
<el-icon><Delete /></el-icon>
清空表格
</el-button>

View File

@@ -0,0 +1,155 @@
<script lang="ts" setup>
import { get } from "@/common/http/request";
interface TreeNode {
id: number | string;
label: string;
children?: TreeNode[];
}
const props = defineProps({
url: String,
data: Array as () => TreeNode[],
placeholder: String,
nodeKey: { type: String, default: "id" },
labelKey: { type: String, default: "label" },
childrenKey: { type: String, default: "children" },
clearable: { type: Boolean, default: true },
disabled: { type: Boolean, default: false },
});
const modelValue = defineModel<number | string | undefined>({ required: true });
const treeData = ref<TreeNode[]>([]);
const treeSelectRef = ref();
const popoverVisible = ref(false);
const selectedLabel = ref("");
const defaultProps = {
children: props.childrenKey,
label: props.labelKey,
};
const loadData = async () => {
if (props.url) {
const rawData = await get(props.url).then(res => res.data);
treeData.value = rawData || [];
} else if (props.data) {
treeData.value = props.data;
}
};
const findNodeLabel = (nodes: TreeNode[], targetId: number | string): string => {
for (const node of nodes) {
if (node[props.nodeKey as keyof TreeNode] === targetId) {
return node[props.labelKey as keyof TreeNode] as string;
}
if (node.children && node.children.length > 0) {
const label = findNodeLabel(node.children, targetId);
if (label) return label;
}
}
return "";
};
const handleNodeClick = (node: TreeNode) => {
modelValue.value = node[props.nodeKey as keyof TreeNode] as number | string;
selectedLabel.value = node[props.labelKey as keyof TreeNode] as string;
popoverVisible.value = false;
};
const handleClear = () => {
modelValue.value = undefined;
selectedLabel.value = "";
};
watch(
() => modelValue.value,
newVal => {
if (newVal !== undefined && newVal !== null) {
selectedLabel.value = findNodeLabel(treeData.value, newVal);
} else {
selectedLabel.value = "";
}
},
{ immediate: true }
);
watch(
() => treeData.value,
() => {
if (modelValue.value !== undefined && modelValue.value !== null) {
selectedLabel.value = findNodeLabel(treeData.value, modelValue.value);
}
}
);
onMounted(loadData);
defineExpose({
reload: loadData,
});
</script>
<template>
<el-popover
ref="treeSelectRef"
v-model:visible="popoverVisible"
placement="bottom-start"
:width="200"
trigger="click"
:disabled="disabled"
popper-class="tree-select-popover"
>
<template #reference>
<el-input
v-model="selectedLabel"
readonly
:placeholder="placeholder"
:disabled="disabled"
:clearable="clearable"
@clear="handleClear"
class="tree-select-input"
>
<template #suffix>
<el-icon class="el-input__icon">
<arrow-down :class="{ 'is-reverse': popoverVisible }" />
</el-icon>
</template>
</el-input>
</template>
<div class="tree-select-dropdown">
<el-tree
:data="treeData"
:props="defaultProps"
:node-key="nodeKey"
default-expand-all
highlight-current
:current-node-key="modelValue"
@node-click="handleNodeClick"
/>
</div>
</el-popover>
</template>
<style scoped>
.tree-select-input {
width: 100%;
}
.tree-select-input :deep(.el-input__inner) {
cursor: pointer;
}
.tree-select-input :deep(.el-input__icon) {
transition: transform 0.3s;
}
.tree-select-input :deep(.el-input__icon.is-reverse) {
transform: rotate(180deg);
}
.tree-select-dropdown {
max-height: 300px;
overflow-y: auto;
}
</style>

View File

@@ -23,6 +23,13 @@ const loadData = async () => {
if (props.data !== undefined && treeData.value === undefined) {
treeData.value = props.data;
}
// 树数据加载完成后,如果有选中的值,使用 setCheckedKeys 设置选中状态
// 因为 default-checked-keys 只在组件初始化时生效
nextTick(() => {
if (treeRef.value && treeCheck.value !== undefined && treeCheck.value.length > 0) {
treeRef.value.setCheckedKeys(treeCheck.value);
}
});
};
const clearSelect = () => {
@@ -35,7 +42,8 @@ const defaultProps = {
};
const checkHandle = (checkedNode: any, treeStatus: any) => {
treeCheck.value = treeStatus.checkedKeys;
// 将字符串类型的 id 转换为数字类型
treeCheck.value = treeStatus.checkedKeys.map((id: string | number) => (typeof id === "string" ? Number(id) : id));
};
const clickHandle = (clickNode: any, nodeAttr: any, treeNode: any, event: any) => {
@@ -43,11 +51,13 @@ const clickHandle = (clickNode: any, nodeAttr: any, treeNode: any, event: any) =
};
const initSelect = (newV: number[]) => {
treeRef.value?.setCheckedKeys(newV);
if (treeRef.value && newV !== undefined && newV.length > 0) {
treeRef.value.setCheckedKeys(newV);
}
};
watch(treeCheck, (newV: number[] | undefined) => {
if (newV === undefined) return;
if (newV === undefined || newV.length === 0) return;
initSelect(newV);
});
@@ -64,11 +74,12 @@ defineExpose({
ref="treeRef"
:props="defaultProps"
:data="treeData"
v-model="treeCheck"
node-key="id"
default-expand-all
highlight-current
:show-checkbox="showCheckbox"
:default-checked-keys="treeCheck"
check-strictly
@check="checkHandle"
@node-click="clickHandle"
/>

View File

@@ -1,5 +1,6 @@
<script lang="ts" setup>
import { $t } from "@/common/languages";
import { computed } from "vue";
const props = defineProps({
authShowFunc: Function,
@@ -9,6 +10,16 @@ const emit = defineEmits<{ "operate-button-click": [eventName: string, row: any]
const buttonList = useRoute().meta.toolButtonAuth ?? [];
// 根据按钮数量计算列宽
// 中文2字按钮约40px4字按钮约60px
// 英文1词按钮约40px3词按钮约80px
// 间隙每个按钮间8px
const columnWidth = computed(() => {
const count = buttonList.length;
// 基础边距20px + 每个按钮平均45px + 间隙
return 20 + count * 45 + (count - 1) * 8;
});
const authShowFunc = (row: any, button: globalThis.ButtonProp) => {
if (props.authShowFunc !== undefined) {
return props.authShowFunc(row, button);
@@ -17,18 +28,49 @@ const authShowFunc = (row: any, button: globalThis.ButtonProp) => {
};
</script>
<template>
<el-table-column :label="$t('_prop.common.operate')" v-if="buttonList?.length !== 0">
<el-table-column
:label="$t('_prop.common.operate')"
v-if="buttonList?.length !== 0"
:min-width="columnWidth"
align="center"
fixed="right"
class-name="operate-column"
>
<template #default="{ row }">
<template v-for="button of buttonList" :key="button.buttonName">
<el-button
v-if="authShowFunc(row, button)"
:type="button.colorType || 'primary'"
@click="emit('operate-button-click', button.eventName, row)"
link
>
{{ $t("_button." + button.eventName) }}
</el-button>
</template>
<div class="operate-buttons">
<template v-for="button of buttonList" :key="button.buttonName">
<el-button
v-if="authShowFunc(row, button)"
:type="button.colorType || 'primary'"
@click="emit('operate-button-click', button.eventName, row)"
link
size="small"
class="operate-btn"
>
{{ $t("_button." + button.eventName) }}
</el-button>
</template>
</div>
</template>
</el-table-column>
</template>
<style scoped>
.operate-buttons {
display: flex;
flex-wrap: nowrap;
gap: 4px;
align-items: center;
justify-content: center;
}
.operate-btn {
padding: 2px 4px;
font-size: 13px;
white-space: nowrap;
}
.operate-btn:hover {
transform: translateY(-1px);
transition: transform 0.2s ease;
}
</style>

View File

@@ -48,7 +48,7 @@ const getTagType = (code: number | null): string => {
};
</script>
<template>
<el-table-column :label="$t('_prop.common.status')" :prop="statusParamName">
<el-table-column :label="$t('_prop.common.status')" :prop="statusParamName" min-width="100" align="center">
<template #default="scope">
<!-- 没有权限按钮时显示带框标签 -->
<span v-if="buttonList.length === 0">

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { ref, reactive, nextTick } from "vue";
import { ref, reactive, nextTick, computed } from "vue";
import type { PropType } from "vue";
import BasePageableTable from "../base-pageable-table/BasePageableTable.vue";
import type { SearcherProp } from "../search-bar/SearchBarType";
@@ -19,6 +19,14 @@ const props = defineProps({
itemIdName: String,
itemFieldName: String,
rowKey: { type: String, default: "id" },
useAuth: { type: Boolean, default: true },
});
const buttonList = useRoute().meta.toolButtonAuth ?? [];
const hasShowItemAuth = computed(() => {
if (!props.useAuth) return true;
return buttonList.some(item => item.eventName === "showItem");
});
interface Emits {
@@ -209,7 +217,7 @@ defineExpose({
</template>
<template #columns>
<el-table-column v-if="itemUrl || itemFieldName" type="expand" fixed="left" width="50">
<el-table-column v-if="(itemUrl || itemFieldName) && hasShowItemAuth" type="expand" fixed="left" width="50">
<template #default="{ row, expanded }">
<div :class="['item-container', { 'is-expanded': expanded }]">
<div v-if="loadingItems[getRowKey(row)]" class="loading-state">

View File

@@ -19,6 +19,7 @@ const props = defineProps({
treeSideNodeName: String,
treeSideTitle: String,
});
const emit = defineEmits(["update:current-node"]);
const page = ref(1);
const pageSize = ref(20);
const total = ref(0);
@@ -32,6 +33,10 @@ const resetClick = () => {
loadData();
};
watch(treeSelect, newVal => {
emit("update:current-node", newVal);
});
const loadData = async () => {
if (props.data !== undefined) {
total.value = props.data.length;