完成了 BOM 管理和生产管理,完成部分发料单、采购计划和调拨单。

This commit is contained in:
c
2026-02-28 18:18:01 +08:00
commit 219eef4729
399 changed files with 46113 additions and 0 deletions

View File

@@ -0,0 +1,349 @@
<script lang="ts" setup>
import { Upload } 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 { Delete } from "@element-plus/icons-vue";
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 },
});
const emit = defineEmits<Emits>();
const form = defineModel<any>("form");
const uploadRef = ref<UploadInstance>();
const baseFormComponentRef = ref<InstanceType<typeof BaseForm> | null>(null);
const getValue = (row: any, config: ExcelDataMapping) => {
const { sourceKey, 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;
}
}
}
// 2. 处理 sourceKey 是字符串的情况 (一对一)
else if (typeof sourceKey === "string") {
rawValue = row[sourceKey];
}
// 3. 如果没找到值,使用默认值
if (rawValue === undefined || rawValue === null) {
rawValue = defaultValue;
}
// 4. 执行转换函数 (如果有)
if (transform && typeof transform === "function") {
try {
return transform(rawValue);
} catch (e) {
console.warn(`Transform error for key ${Array.isArray(sourceKey) ? sourceKey.join("/") : sourceKey}:`, e);
return defaultValue;
}
}
return rawValue;
};
const convertData = (jsonData: any) => {
if (!Array.isArray(jsonData)) {
emit("error", "jsonData 必须是数组");
return [];
}
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;
newRow[targetKey] = getValue(row, fieldConfig);
}
return newRow;
});
emit("update:mappedData", result);
return result;
};
const clearAllData = () => {
// 1. 清空文件引用
currentFile.value = null;
// 2. 清空上传组件列表
uploadRef.value?.clearFiles();
// 3. 清空表格数据
if (form.value && Array.isArray(form.value[props.itemArrayName])) {
form.value[props.itemArrayName].splice(0, form.value[props.itemArrayName].length);
}
// 4. 清空验证
if (baseFormComponentRef.value) {
baseFormComponentRef.value.clearValidate();
}
};
const handleFileChange = (file: any) => {
// 文件大小校验
const maxSize = 10 * 1024 * 1024; // 10MB
if (file.size > maxSize) {
ElMessage.error("文件大小不能超过 10MB");
clearAllData();
return;
}
clearAllData();
// 更新当前文件显示
currentFile.value = file;
// 解析 Excel
parseExcel(file.raw);
};
const parseExcel = (file: any) => {
const reader = new FileReader();
reader.onload = e => {
if (!(e.target?.result instanceof ArrayBuffer)) {
return;
}
try {
const data = new Uint8Array(e.target.result);
const workbook = XLSX.read(data, { type: "array" });
// 获取第一个 sheet
const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName];
// 转换为 JSON保留原始表头映射
const jsonData = XLSX.utils.sheet_to_json(worksheet, { defval: "" });
if (jsonData.length === 0) {
ElMessage.warning("Excel 文件为空");
removeFile(); // 清空刚选的文件
return;
}
// 映射字段(支持多种列名)
const mappedData = convertData(jsonData);
if (form.value) {
// 如果 form.value[itemArrayName] 不存在,先初始化为空数组
if (!Array.isArray(form.value[props.itemArrayName])) {
form.value[props.itemArrayName] = [];
}
// 使用 splice 替换所有内容,这是最安全的触发数组更新的方式
form.value[props.itemArrayName].splice(0, form.value[props.itemArrayName].length, ...mappedData);
}
ElMessage.success(`解析成功,共 ${mappedData.length} 条物料数据`);
} catch (error) {
console.error(error);
ElMessage.error("文件解析失败,请检查文件格式");
}
};
reader.readAsArrayBuffer(file);
};
// 【新增】存储当前上传的文件信息,用于显示和移除
const currentFile = ref<UploadUserFile | null>(null);
// 【新增】深度清理函数
const resetFormData = () => {
// 1. 清空表单验证
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);
}
}
// 3. 清空文件状态
currentFile.value = null;
uploadRef.value?.clearFiles();
// 4. 如果需要完全重置 form 为初始空对象,取消下面注释
form.value = {};
};
// 【新增】增加一行
const addRow = () => {
if (!form.value) form.value = {};
if (!Array.isArray(form.value[props.itemArrayName])) {
form.value[props.itemArrayName] = [];
}
// 创建一个空行,根据 mappingConfig 生成默认结构
const newRow: any = {};
for (const [key, config] of Object.entries(props.mappingConfig)) {
const fieldConfig = config as ExcelDataMapping;
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("文件已移除,表格数据已清空");
};
// 监听 BaseForm 发出的 reset 事件
const handleBaseFormReset = () => {
resetFormData();
};
defineExpose({
innerFormRef: computed(() => baseFormComponentRef.value?.formRef),
resetFormData,
addRow,
deleteRow,
});
</script>
<template>
<BaseForm ref="baseFormRef" v-bind="$attrs" class="form-dialog" v-model:form="form" @reset="handleBaseFormReset">
<template #form-items>
<el-divider content-position="left" v-if="props.baseTitle">
<el-icon><Document /></el-icon>
{{ props.baseTitle }}
</el-divider>
<slot name="form-items"></slot>
<el-divider content-position="left" v-if="props.tableTitle">
<el-icon><Document /></el-icon>
{{ props.tableTitle }}
</el-divider>
<el-form-item label="上传 Excel" :prop="`${itemArrayName}`">
<div class="upload-row">
<el-upload
ref="uploadRef"
class="upload-excel"
:auto-upload="false"
:limit="1"
accept=".xlsx,.xls,.csv"
:on-change="handleFileChange"
:show-file-list="false"
>
<el-button type="primary">
<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>
</el-upload>
</div>
</el-form-item>
<el-table :data="form[itemArrayName]" border style="width: 100%" max-height="400" class="form-item-table">
<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)">
<el-icon><Delete /></el-icon>
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 新增表格下方操作栏 -->
<div class="table-actions">
<el-button type="primary" link @click="addRow">
<el-icon><Plus /></el-icon>
增加一行
</el-button>
<el-button type="warning" link @click="resetFormData">
<el-icon><Delete /></el-icon>
清空表格
</el-button>
</div>
</template>
</BaseForm>
</template>
<style>
.form-dialog {
--el-dialog-width: fit-content !important;
}
.form-dialog :deep(.el-dialog__body) {
padding: 10px 20px;
max-height: 70vh;
overflow-y: auto;
}
.form-item-table {
height: 40vh;
}
.upload-row {
display: flex;
align-items: center; /* 垂直居中对齐 */
gap: 12px; /* 按钮和文字的间距 */
width: 100%;
}
.upload-tip-side {
margin-left: 4px;
opacity: 0.8;
font-size: 12px; /* 字体调小 */
color: var(--el-text-color-secondary); /* 使用次要文字颜色 */
line-height: 1.4;
white-space: nowrap; /* 防止意外换行,如果空间不够可去掉 */
}
.form-dialog .el-dialog__header {
padding-bottom: 0px;
}
.form-dialog .el-dialog__body {
padding: 0 !important;
}
.el-dialog {
--el-dialog-margin-top: 5vh !important;
}
.table-actions {
margin-top: 10px;
text-align: right;
padding: 5px 0;
}
.ml-2 {
margin-left: 8px;
}
.el-icon {
vertical-align: middle;
}
</style>

View File

@@ -0,0 +1,26 @@
/**
* 用于映射 Excel 中的数据
*/
export interface ExcelDataMapping {
/**
* Excel 中的列名
*/
sourceKey: string | string[];
/**
* 默认值
*/
defaultValue?: any;
/**
* 转换方法
* @param val 原值
* @returns 转换后的值
*/
transform?: (val: any) => any;
}
/**
* 映射配置表 目标 key -> 规则
*/
export type FieldMappingConfig = Record<string, ExcelDataMapping>;

View File

@@ -0,0 +1,68 @@
<script lang="ts" setup>
import type { FormInstance, FormRules } from "element-plus";
defineProps({
title: String,
rules: {
type: Object as () => FormRules,
default: () => ({}),
},
});
const emit = defineEmits<{
submit: [form: any, formRef: FormInstance | undefined];
reset: [];
}>();
const visible = defineModel<boolean>("visible");
const form = defineModel<any>("form");
const formRef = ref<FormInstance>();
watch(visible, (newV, oldV) => {
if (!newV) {
formRef.value?.clearValidate();
}
});
defineExpose({
formRef,
clearValidate: () => formRef.value?.clearValidate(),
resetFields: () => formRef.value?.resetFields(),
});
const handleReset = () => {
// 1. 清除验证状态
formRef.value?.clearValidate();
// 2. 重置表单字段到初始值 (如果 form 是响应式对象,这通常有效)
// 注意resetFields 需要 form 初始值不为空,如果初始是 {},可能无效。
// 为了保险,我们发出事件让父组件处理复杂逻辑,自己只负责基础清理
// 3. 如果没有父组件监听,默认行为是清空 form (可选,视需求而定)
form.value = {};
emit("reset");
};
</script>
<template>
<el-dialog
:title="title"
v-model="visible"
:close-on-click-modal="false"
:close-on-press-escape="false"
:lock-scroll="false"
>
<el-form ref="formRef" label-position="left" label-width="auto" :model="form" :rules="rules || {}">
<slot name="form-items"></slot>
<el-form-item>
<div class="button-container">
<el-button type="primary" @click="emit('submit', form, formRef)">{{ $t("_button.submit") }}</el-button>
<el-button @click="handleReset">{{ $t("_button.reset") }}</el-button>
</div>
</el-form-item>
</el-form>
</el-dialog>
</template>
<style>
.button-container {
width: 100%;
/* 核心:让行内/行内块元素(按钮)水平居中 */
text-align: center;
/* 可选:增加上下间距,避免按钮贴边 */
margin: 20px 0;
}
</style>

View File

@@ -0,0 +1,79 @@
<script lang="ts" setup>
import { get } from "@/common/http/request";
const props = defineProps({
title: String,
url: String,
parentParamName: String,
// 新增:是否显示底部统计信息
showSummary: {
type: Boolean,
default: true,
},
// 新增:自定义统计文本前缀,默认 "共"
summaryText: {
type: String,
default: "共",
},
});
const parentParamValue = defineModel<number>("parentParamValue");
const visible = defineModel<boolean>("visible");
const itemData = ref<any[]>([]);
const adjust = () => {
nextTick(() => {});
};
const loadData = async () => {
if (props.url !== undefined) {
const params = new URLSearchParams();
if (props.parentParamName !== undefined && parentParamValue.value !== undefined)
params.append(props.parentParamName, String(parentParamValue.value));
const rowData = await get(props.url, params).then(res => res.data);
itemData.value = rowData;
nextTick(adjust);
}
};
watch(() => parentParamValue.value, loadData);
</script>
<template>
<el-dialog
:title="title"
v-model="visible"
:close-on-click-modal="false"
:close-on-press-escape="false"
:lock-scroll="false"
class="item-dialog"
>
<el-table :data="itemData" class="item-table">
<slot name="columns"></slot>
</el-table>
<template #footer>
<div class="dialog-footer-container">
<!-- 左侧统计信息 -->
<div class="footer-summary" v-if="showSummary">
<span class="total-count">{{ summaryText }} {{ itemData.length }} </span>
</div>
</div>
</template>
</el-dialog>
</template>
<style>
.dialog-footer-container {
display: flex;
justify-content: space-between; /* 左右两端对齐 */
align-items: center;
width: 100%;
}
.item-dialog {
--el-dialog-width: fit-content !important;
--el-dialog-padding-primary: 20px;
}
.item-dialog :deep(.el-dialog__body) {
padding: 10px 20px;
max-height: 70vh;
overflow-y: auto;
}
.item-table {
height: 60vh;
}
</style>

View File

@@ -0,0 +1,101 @@
<script lang="ts" setup>
import TableHeader from "./TableHeader.vue";
import TableMain from "./TableMain.vue";
import type { SearcherProp } from "../search-bar/SearchBarType";
import type { ToolButtonProp } from "../table-tool-bar/TableToolBarType";
import type { PropType } from "vue";
import { get } from "@/common/http/request";
interface Emits {
(e: "data-loaded", data: any[]): void;
(e: "expand-change", row: any, expandedRows: any[]): void;
}
const props = defineProps({
searchers: Array as PropType<SearcherProp[]>,
toolButtons: Array as PropType<ToolButtonProp[]>,
data: Array,
url: String,
parse: Function,
expandRowKeys: Array as PropType<(string | number)[]>,
rowKey: { type: String, default: "id" },
});
const emit = defineEmits<Emits>();
const tableMainRef = ref<InstanceType<typeof TableMain> | null>(null);
const page = ref(1);
const pageSize = ref(30);
const total = ref(0);
const searcherParams = ref({});
const tableData = ref<any[]>([]);
const updatePage = () => loadData();
const searchClick = () => loadData();
const loadData = async () => {
if (props.data !== undefined) {
total.value = props.data.length;
tableData.value = props.data.slice(
(page.value - 1) * pageSize.value,
Math.min(page.value * pageSize.value, props.data.length)
);
onDataLoaded(tableData.value);
}
if (props.url !== undefined) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(searcherParams.value)) {
params.append(key, String(value));
}
params.append("page", page.value.toString());
params.append("pageSize", pageSize.value.toString());
const urlRawData = await get(props.url, params).then(res => res.data);
const urlData = props.parse === undefined ? urlRawData : props.parse(urlRawData);
total.value = urlData.total;
tableData.value = urlData.records;
onDataLoaded(tableData.value);
}
};
// 数据处理完成后的回调
const onDataLoaded = (data: any[]) => {
emit("data-loaded", data);
};
const handleExpandChange = (row: any, expandedRows: any[]) => {
emit("expand-change", row, expandedRows);
};
defineExpose({
reload: loadData,
tableMainRef,
toggleRowExpansion: (row: any, expanded?: boolean) => {
tableMainRef.value?.toggleRowExpansion(row, expanded);
},
sort: (field: string, order: string) => {
console.log(tableMainRef.value?.tableRef);
tableMainRef.value?.tableRef?.sort(field, order);
},
});
onMounted(loadData);
</script>
<template>
<TableHeader
:searchers="searchers"
:tool-buttons="toolButtons"
v-model:searcher-params="searcherParams"
:search-click="searchClick"
>
<template #tool-button><slot name="tool-button"></slot></template>
</TableHeader>
<TableMain
ref="tableMainRef"
:data="tableData"
:total="total"
:update-page="updatePage"
v-model:page="page"
v-model:page-size="pageSize"
:expand-row-keys="expandRowKeys"
:row-key="rowKey"
@expand-change="handleExpandChange"
>
<template #columns><slot name="columns"></slot></template>
</TableMain>
</template>

View File

@@ -0,0 +1,38 @@
<script lang="ts" setup>
import type { SearcherProp } from "../search-bar/SearchBarType";
import type { ToolButtonProp } from "../table-tool-bar/TableToolBarType";
import SearchBar from "../search-bar/SearchBar.vue";
// import TableToolBar from "../table-tool-bar/TableToolBar.vue";
defineProps({
searchers: Array as PropType<SearcherProp[]>,
searchClick: Function,
toolButtons: Array as PropType<ToolButtonProp[]>,
resetClick: Function,
});
const searcherParams = defineModel<Record<string, string>>("searcherParams");
</script>
<template>
<div class="table-header">
<SearchBar
:searchers="searchers"
:search-click="searchClick"
v-model:searcher-params="searcherParams"
:reset-click="resetClick"
/>
<!-- <TableToolBar class="table-tool-bar" :tool-buttons="toolButtons" /> -->
<div>
<slot name="tool-button"></slot>
</div>
</div>
</template>
<style>
.table-header {
display: flex;
margin-bottom: 10px;
}
.table-tool-bar {
padding-left: 10px;
}
</style>

View File

@@ -0,0 +1,79 @@
<script lang="ts" setup>
import type { TableInstance } from "element-plus";
import type { PropType } from "vue";
interface Emits {
(e: "expand-change", row: any, expandedRows: any[]): void;
}
const emit = defineEmits<Emits>();
defineProps({
data: {
type: Array as PropType<any[]>,
},
total: {
type: Number,
required: true,
},
updatePage: {
type: Function,
required: true,
},
// 新增:透传给 el-table 的展开行 key 列表
expandRowKeys: {
type: Array as PropType<(string | number)[]>,
default: () => [],
},
// 新增:行数据的唯一标识字段名
rowKey: {
type: String,
default: "id",
},
});
const page = defineModel<number>("page");
const pageSize = defineModel<number>("pageSize");
// 获取 el-table 实例引用
const tableRef = ref<TableInstance>();
// 暴露方法和属性给父组件
defineExpose({
tableRef,
// 封装 toggleRowExpansion 方便调用
toggleRowExpansion: (row: any, expanded?: boolean) => {
if (tableRef.value) {
tableRef.value.toggleRowExpansion(row, expanded);
}
},
// 暴露 sort/change 等其他可能需要的方法
clearSort: () => tableRef.value?.clearSort(),
clearFilter: () => tableRef.value?.clearFilter(),
});
</script>
<template>
<div style="height: calc(100vh - 157px); display: flex; flex-direction: column; overflow: hidden">
<div style="flex: 1; overflow: hidden">
<el-table
ref="tableRef"
:data="data"
stripe
style="width: 100%"
height="100%"
:row-key="rowKey"
:expand-row-keys="expandRowKeys as any"
@expand-change="(row, expandedRows) => emit('expand-change', row, expandedRows)"
>
<slot name="columns"></slot>
</el-table>
</div>
<div style="padding-top: 10px">
<ElPagination
v-model:current-page="page"
v-model:page-size="pageSize"
background
layout="prev, pager, next, jumper, total, sizes"
:total="total"
@update:current-page="() => updatePage()"
@update:page-size="() => updatePage()"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,21 @@
import type { TableColumnCtx } from "element-plus";
import type { VNode } from "vue";
export interface ColumnProp {
/**
* 列 对应的变量名
*/
name: string;
/**
* 列名
*/
label: string;
/**
* 宽度
*/
width?: number | string;
/**
* 格式化方法,传入一列的数据
*/
formatter?: (row: any, column: TableColumnCtx<any>, cellValue: any, index: number) => VNode | string;
}

View File

@@ -0,0 +1,46 @@
<script lang="ts" setup>
import { get } from "@/common/http/request";
interface OptionData {
value: string | number;
label: string;
}
const props = defineProps({
name: String,
label: String,
url: String,
data: Array<Record<string, string | number>>,
});
const optionData = ref<OptionData[]>([]);
const model = defineModel<string | number>({ required: true });
function pushOptionData(data: any[]) {
for (const item of data) {
optionData.value.push({
value: props.name !== undefined ? item[props.name] : item["value"],
label: props.label !== undefined ? item[props.label] : item["label"],
});
}
}
const getData = async () => {
if (props.url !== undefined) {
pushOptionData(await get(props.url).then(res => res.data));
} else if (props.data !== undefined) {
pushOptionData(props.data);
}
};
onMounted(getData);
defineExpose({
getLabel: () => {
const option = optionData.value.find(item => item.value === model.value);
return option?.label;
},
});
</script>
<template>
<el-select :name="name || ''" v-model="model">
<el-option v-for="o in optionData" :key="o.value" :value="o.value" :label="o.label" />
</el-select>
</template>

View File

@@ -0,0 +1,21 @@
<script lang="ts" setup>
defineProps({
name: { type: String, default: "status" },
activeText: { type: String, default: "启用" },
inactiveText: { type: String, default: "禁用" },
});
const statusValue = defineModel<number>({ default: 1 });
const emit = defineEmits<{ change: [val: any] }>();
</script>
<template>
<el-switch
:name="name"
v-model="statusValue"
:active-text="activeText"
:inactive-text="inactiveText"
:active-value="1"
:inactive-value="0"
@change="val => emit('change', val)"
inline-prompt
/>
</template>

View File

@@ -0,0 +1,229 @@
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import { useRoute } from "vue-router";
import type { TableInstance } from "element-plus";
// Props 定义
const props = defineProps({
title: {
type: String,
default: "列表选择",
},
// 表格数据
tableData: {
type: Array as () => any[],
default: () => [],
},
// 是否显示加载状态
loading: {
type: Boolean,
default: false,
},
// 用于行勾选的唯一键,默认 'id'
rowKey: {
type: String,
default: "id",
},
// 是否默认全选(可选)
defaultSelectAll: {
type: Boolean,
default: false,
},
// 新增:是否显示底部统计信息
showSummary: {
type: Boolean,
default: true,
},
// 新增:自定义统计文本前缀,默认 "共"
summaryText: {
type: String,
default: "共",
},
// 【新增】是否禁用所有行的选中功能
// true: 所有行不可选 (复选框变灰)
// false: 所有行可选 (默认)
disabledSelection: {
type: Boolean,
default: false,
},
useAuth: {
type: Boolean,
default: true,
},
selectable: {
type: Boolean,
default: true,
},
});
const visible = defineModel<boolean>("visible");
// Emits 定义
const emit = defineEmits<{
"update:visible": [value: boolean];
"selection-change": [selection: any[]];
"dialog-button-click": [eventName: string, buttonConfig: globalThis.ButtonProp, selectedRows: any[]];
"filter-refresh": []; // 当父组件在 slot 中点击查询时,可触发此事件刷新数据
}>();
const route = useRoute();
const tableRef = ref<TableInstance>();
// 获取底部对话框按钮权限
// 假设权限数据结构与参考代码类似,但来源是 dialogButtonAuth
const dialogButtonList = computed<globalThis.ButtonProp[]>(() => {
if (!props.useAuth) return [];
const list = (route.meta.dialogButtonAuth as globalThis.ButtonProp[]) || [];
// 按 rank 排序rank 越小越靠前,没有 rank 的排后面
return list.sort((a, b) => (a.rank ?? 999) - (b.rank ?? 999));
});
// 内部状态
const selectedRows = ref<any[]>([]);
// 监听弹窗打开,如果需要默认全选
watch(
() => visible,
val => {
if (val && props.defaultSelectAll && tableRef.value) {
// 下一帧执行以确保 DOM 渲染完成
setTimeout(() => {
tableRef.value?.toggleAllSelection();
}, 0);
}
if (!val) {
// 关闭时清空选中
selectedRows.value = [];
}
}
);
// 处理表格勾选变化
const handleSelectionChange = (selection: any[]) => {
selectedRows.value = selection;
emit("selection-change", selection);
};
// 处理底部按钮点击
const handleDialogButtonClick = (btn: globalThis.ButtonProp) => {
emit("dialog-button-click", btn.eventName, btn, selectedRows.value);
};
// 暴露方法给父组件(例如手动刷新表格或清空选中)
const clearSelection = () => {
tableRef.value?.clearSelection();
};
const toggleRowSelection = (row: any, selected?: boolean) => {
tableRef.value?.toggleRowSelection(row, selected);
};
const checkPermission = (buttonEventName?: string) => {
if (!buttonEventName) return true;
return dialogButtonList.value.find(item => item.eventName === buttonEventName) !== undefined;
};
defineExpose({
clearSelection,
toggleRowSelection,
tableRef,
});
</script>
<template>
<el-dialog v-model="visible" :title="title" :close-on-click-modal="false" width="80%">
<!-- 1. 顶部筛选区域 (Slot) -->
<div class="dialog-filter-area">
<slot name="filter" :refresh="() => emit('filter-refresh')" :selected-rows="selectedRows">
<!-- 默认空内容由父组件填充 -->
</slot>
</div>
<!-- 2. 中部表格区域 -->
<div class="dialog-table-area">
<el-table
ref="tableRef"
:data="tableData"
:row-key="rowKey"
border
stripe
style="width: 100%"
:loading="loading"
max-height="50vh"
@selection-change="handleSelectionChange"
>
<!-- 勾选列 -->
<el-table-column type="selection" width="55" :reserve-selection="true" v-if="selectable" />
<!-- 父组件自定义列 -->
<slot name="table-columns"></slot>
<!-- 如果没有权限按钮且父组件也没传操作列可以留空或显示提示 -->
</el-table>
<!-- 提示信息当无数据时 -->
<el-empty v-if="!loading && tableData.length === 0" description="暂无数据" />
</div>
<!-- 3. 底部区域 (修改部分) -->
<template #footer>
<div class="dialog-footer-container">
<!-- 左侧统计信息 -->
<div class="footer-summary" v-if="showSummary">
<span class="total-count">{{ summaryText }} {{ tableData.length }} </span>
<span v-if="selectedRows.length > 0" class="selected-count">(已选 {{ selectedRows.length }} )</span>
</div>
<!-- 右侧操作按钮 -->
<div class="footer-actions">
<slot name="attachment" :selected-rows="selectedRows" :checkPermission="checkPermission">
<!-- 默认内容为空不影响原有布局 -->
</slot>
<el-button
v-for="btn in dialogButtonList"
:key="btn.buttonName"
:type="btn.colorType || 'primary'"
:disabled="selectedRows.length === 0 && !['cancel'].includes(btn.eventName)"
@click="handleDialogButtonClick(btn)"
>
{{ $t("_button." + btn.eventName) || btn.text }}
</el-button>
<el-button @click="visible = false">
{{ $t("_button.cancel") || "取消" }}
</el-button>
</div>
</div>
</template>
</el-dialog>
</template>
<style scoped>
.footer-actions {
display: flex;
align-items: center;
gap: 12px; /* 确保附加组件和按钮之间有间距 */
}
.dialog-footer-container {
display: flex;
justify-content: space-between; /* 左右两端对齐 */
align-items: center;
width: 100%;
}
.dialog-filter-area {
margin-bottom: 16px;
padding: 10px;
background-color: var(--el-fill-color-lighter);
border-radius: 4px;
}
.dialog-table-area {
min-height: 200px;
max-height: 60vh;
overflow-y: auto;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

View File

@@ -0,0 +1,76 @@
<script lang="ts" setup>
import { get } from "@/common/http/request";
import type { TreeInstance } from "element-plus";
const props = defineProps({
title: String,
data: Array,
url: String,
param: URLSearchParams,
selectUrl: String,
showCheckbox: { default: false },
});
const treeCheck = defineModel<number[]>();
const treeSelect = defineModel("current-node");
const treeData = ref();
const treeRef = ref<TreeInstance>();
const loadData = async () => {
if (props.url !== undefined) {
const rawUrlData = await get(props.url, props.param).then(res => res.data);
treeData.value = rawUrlData;
}
if (props.data !== undefined && treeData.value === undefined) {
treeData.value = props.data;
}
};
const clearSelect = () => {
treeRef.value?.setCheckedKeys([]);
};
const defaultProps = {
children: "children",
label: "label",
};
const checkHandle = (checkedNode: any, treeStatus: any) => {
treeCheck.value = treeStatus.checkedKeys;
};
const clickHandle = (clickNode: any, nodeAttr: any, treeNode: any, event: any) => {
treeSelect.value = clickNode;
};
const initSelect = (newV: number[]) => {
treeRef.value?.setCheckedKeys(newV);
};
watch(treeCheck, (newV: number[] | undefined) => {
if (newV === undefined) return;
initSelect(newV);
});
onMounted(loadData);
defineExpose({
clear: clearSelect,
});
</script>
<template>
<div>
<p v-if="title !== undefined">{{ title }}</p>
<el-tree
ref="treeRef"
:props="defaultProps"
:data="treeData"
v-model="treeCheck"
node-key="id"
default-expand-all
highlight-current
:show-checkbox="showCheckbox"
@check="checkHandle"
@node-click="clickHandle"
/>
</div>
</template>

View File

@@ -0,0 +1,22 @@
<script lang="ts" setup>
import { $t } from "@/common/languages";
const emit = defineEmits<{ "operate-button-click": [eventName: string, row: any] }>();
const buttonList = useRoute().meta.toolButtonAuth ?? [];
</script>
<template>
<el-table-column :label="$t('_prop.common.operate')" v-if="buttonList?.length !== 0">
<template #default="scope">
<el-button
v-for="button of buttonList"
:key="button.buttonName"
:type="button.colorType || 'primary'"
@click="emit('operate-button-click', button.eventName, scope.row)"
link
>
{{ $t("_button." + button.eventName) }}
</el-button>
</template>
</el-table-column>
</template>

View File

@@ -0,0 +1,57 @@
<script lang="ts" setup>
import { post } from "@/common/http/request";
import StatusSwitch from "../base-switch/StatusSwitch.vue";
import { ElMessage } from "element-plus";
import { $t } from "@/common/languages";
import { useStatus } from "@/common/languages/mapping/base-info-mapping";
const props = defineProps({
url: String,
tableEl: ref,
statusLabelMapping: Function,
statusParamName: {
type: String,
default: "status",
},
});
const { getCommonStatusLabel } = useStatus();
const buttonList = useRoute().meta.statusButtonAuth ?? [];
const change = (val: any, row: any) => {
if (props.url !== undefined)
post(props.url, { id: row.id, status: val })
.then(res => {
if (res.code === 0) {
ElMessage.success((val === 1 ? $t("_button.enable") : $t("_button.disable")) + "成功");
} else {
ElMessage.error((val === 1 ? $t("_button.enable") : $t("_button.disable")) + "失败,信息为:" + res.msg);
}
})
.catch(err => {
ElMessage.error((val === 1 ? $t("_button.enable") : $t("_button.disable")) + "失败,信息为:" + err);
});
};
const getLabel = (code: number | null) => {
if (props.statusLabelMapping === undefined) return getCommonStatusLabel(code);
return props.statusLabelMapping(code);
};
</script>
<template>
<el-table-column :label="$t('_prop.common.status')" :prop="statusParamName">
<template #default="scope">
<span v-if="buttonList.length === 0">
{{ getLabel(scope.row[statusParamName]) }}
</span>
<template v-for="button in buttonList" :key="button.buttonName">
<StatusSwitch
v-model="scope.row[statusParamName]"
@change="val => change(val, scope.row)"
v-if="button.buttonType === 'switch'"
/>
</template>
</template>
</el-table-column>
</template>

View File

@@ -0,0 +1,17 @@
<script lang="ts" setup>
const buttonList = useRoute().meta.buttonAuth || [];
const emit = defineEmits<{
"top-button-click": [eventName: string];
}>();
</script>
<template>
<el-button
v-for="button of buttonList"
:key="button.buttonName"
:type="button.colorType || 'primary'"
@click="emit('top-button-click', button.eventName)"
>
{{ $t("_button." + button.eventName) }}
</el-button>
</template>

View File

@@ -0,0 +1,253 @@
<script lang="ts" setup>
import { ref, reactive, nextTick } from "vue";
import type { PropType } from "vue";
import BasePageableTable from "../base-pageable-table/BasePageableTable.vue";
import type { SearcherProp } from "../search-bar/SearchBarType";
import type { ToolButtonProp } from "../table-tool-bar/TableToolBarType";
import { get } from "@/common/http/request";
import { Loading } from "@element-plus/icons-vue";
const props = defineProps({
searchers: Array as PropType<SearcherProp[]>,
toolButtons: Array as PropType<ToolButtonProp[]>,
data: Array,
url: String,
parse: Function,
itemUrl: String,
itemIdKey: String,
itemIdName: String,
itemFieldName: String,
rowKey: { type: String, default: "id" },
});
interface Emits {
(e: "expand-change", row: any, expandedRows: any[]): void;
}
const emit = defineEmits<Emits>();
type LocalSlots = {
columns(): any;
"tool-button"?: any;
"item-content": (props: { row: any; itemData: any }) => any;
};
defineSlots<LocalSlots>();
const baseTableRef = ref<InstanceType<typeof BasePageableTable>>();
const loadingItems = reactive<Record<string, boolean>>({});
const itemDataMap = reactive<Record<string, any>>({});
const currentExpandKeys = ref<(string | number)[]>([]);
const getRowKey = (row: any): string => {
return String(row[props.rowKey]);
};
const loadItemData = async (row: any) => {
if (props.itemIdKey === undefined) return;
const rowKey = getRowKey(row);
if (itemDataMap[rowKey] !== undefined) return;
if (loadingItems[rowKey]) return;
if (!props.itemUrl) {
itemDataMap[rowKey] = null;
return;
}
loadingItems[rowKey] = true;
try {
const params = new URLSearchParams();
params.append(props.itemIdName!, String(row[props.itemIdKey]));
const res = await get(props.itemUrl, params);
const resultData = res.data?.records || res.data?.list || res.data || [];
itemDataMap[rowKey] = resultData;
} catch (error) {
console.error(`Failed to load item for ${rowKey}:`, error);
itemDataMap[rowKey] = [];
} finally {
loadingItems[rowKey] = false;
}
};
/**
* 核心修改:处理数据加载完成后的清理与同步逻辑
*/
const handleDataLoaded = (newData: any[]) => {
if (!newData) return;
// 1. 构建新数据的 Key 映射表,方便快速查找
// Map<RowKey, RowObject>
const newRowMap = new Map<string | number, any>();
// 记录新数据中哪些 Key 是“有效且应展开”的(即拥有 itemFieldName 数据)
const validExpandKeysInNewData = new Set<string | number>();
newData.forEach(row => {
const key = getRowKey(row);
newRowMap.set(key, row);
// 如果配置了 itemFieldName 且该行有此数据
if (props.itemFieldName && row[props.itemFieldName]) {
validExpandKeysInNewData.add(key);
// 预加载/更新数据到缓存
itemDataMap[key] = row[props.itemFieldName];
}
});
// 2. 检查当前已展开的 Keys找出需要关闭的
const keysToClose: (string | number)[] = [];
currentExpandKeys.value.forEach(key => {
// 情况 A: 新数据中根本没有这一行 (行消失了)
// 情况 B: 新数据中有这一行,但它不再包含 itemFieldName (标签没了)
const existsInNewData = newRowMap.has(key);
const hasValidItem = validExpandKeysInNewData.has(key);
if (!existsInNewData || !hasValidItem) {
keysToClose.push(key);
// 清理缓存 (可选:如果想保留历史缓存可注释掉下一行,但通常建议清理以防数据陈旧)
delete itemDataMap[key];
}
});
// 3. 执行关闭操作
if (keysToClose.length > 0) {
// 3.1 更新响应式数组 (移除需要关闭的 key)
currentExpandKeys.value = currentExpandKeys.value.filter(k => !keysToClose.includes(k));
// 3.2 强制 UI 收缩
nextTick(() => {
if (!baseTableRef.value) return;
keysToClose.forEach(key => {
// 如果行还在新数据中(只是没了明细),我们可以直接拿到 row 对象关闭
// 如果行不在了toggleRowExpansion 可能找不到 row但 UI 上该行已消失,所以不影响
const rowToClose = newRowMap.get(key);
if (rowToClose) {
baseTableRef.value!.toggleRowExpansion(rowToClose, false);
}
// 注意:如果 rowToClose 为 undefined (行彻底消失)el-table 内部会在渲染时自动处理移除,
// 我们不需要(也无法)调用 toggleRowExpansion。
});
});
}
// 4. 处理自动展开 (针对新数据中首次出现明细的行)
// 只有当之前没展开过,现在有了明细,才自动展开
const keysToOpen: (string | number)[] = [];
validExpandKeysInNewData.forEach(key => {
if (!currentExpandKeys.value.includes(key)) {
keysToOpen.push(key);
}
});
if (keysToOpen.length > 0) {
currentExpandKeys.value = [...currentExpandKeys.value, ...keysToOpen];
nextTick(() => {
if (!baseTableRef.value) return;
keysToOpen.forEach(key => {
const rowToOpen = newRowMap.get(key);
if (rowToOpen) {
baseTableRef.value!.toggleRowExpansion(rowToOpen, true);
}
});
});
}
};
const handleExpandChange = (row: any, expandedRows: any[]) => {
if (!Array.isArray(expandedRows)) {
console.warn("Unexpected expand-change format");
return;
}
expandedRows.forEach((r: any) => {
const rowKey = getRowKey(r);
// 如果是手动展开且没有数据,则加载
// 注意:这里要排除掉由 itemFieldName 直接提供的数据情况,避免重复逻辑,不过 loadItemData 内部有判重,所以直接调也没事
if (itemDataMap[rowKey] === undefined && !loadingItems[rowKey]) {
loadItemData(r);
}
});
// 同步状态
currentExpandKeys.value = expandedRows.map((r: any) => getRowKey(r));
emit("expand-change", row, expandedRows);
};
const wrappedParse = (rawData: any) => {
let result = rawData;
if (props.parse) {
result = props.parse(rawData);
}
return result;
};
defineExpose({
reload: () => {
baseTableRef.value?.reload;
},
});
</script>
<template>
<BasePageableTable
ref="baseTableRef"
:searchers="searchers"
:tool-buttons="toolButtons"
:url="url"
:data="data"
:parse="wrappedParse"
:row-key="rowKey"
:expand-row-keys="currentExpandKeys as (string | number)[]"
@data-loaded="handleDataLoaded"
@expand-change="handleExpandChange"
>
<template #tool-button>
<slot name="tool-button"></slot>
</template>
<template #columns>
<el-table-column v-if="itemUrl || itemFieldName" type="expand" fixed="left" width="50">
<template #default="{ row }">
<div class="item-container">
<div v-if="loadingItems[getRowKey(row)]" class="loading-state">
<el-icon class="is-loading"><Loading /></el-icon>
<span>加载中...</span>
</div>
<div v-else-if="itemDataMap[getRowKey(row)] !== undefined">
<slot name="item-content" :row="row" :item-data="itemDataMap[getRowKey(row)]">
<el-empty v-if="!itemDataMap[getRowKey(row)]" description="无明细数据" :image-size="60" />
<pre v-else>{{ JSON.stringify(itemDataMap[getRowKey(row)], null, 2) }}</pre>
</slot>
</div>
<div v-else class="empty-state">点击展开加载明细</div>
</div>
</template>
</el-table-column>
<slot name="columns"></slot>
</template>
</BasePageableTable>
</template>
<style scoped>
.item-container {
padding: 16px;
background-color: #fafafa;
border-radius: 4px;
min-height: 50px;
}
.loading-state,
.empty-state {
display: flex;
align-items: center;
justify-content: center;
color: #909399;
font-size: 14px;
gap: 8px;
}
</style>

View File

@@ -0,0 +1,69 @@
<script lang="ts" setup>
import { post } from "@/common/http/request";
import type { SearcherProp } from "./SearchBarType";
import type { PropType } from "vue";
import { Search, Refresh } from "@element-plus/icons-vue";
const props = defineProps({
searchers: Array as PropType<SearcherProp[]>,
searchClick: Function,
resetClick: Function,
});
const searcherParams = defineModel<Record<string, string>>("searcherParams");
function getSelectData(searcher: SearcherProp) {
if (searcher.selectData !== undefined) {
return searcher.selectData;
}
if (searcher.selectDataUrl !== undefined && searcher.selectDataParse !== undefined) {
return searcher.selectDataParse(post(searcher.selectDataUrl));
}
return {};
}
function searchButtonClick() {
if (props.searchClick === undefined) return;
props.searchClick(searcherParams);
}
function reset() {
searcherParams.value = {};
if (props.resetClick === undefined) return;
props.resetClick();
}
</script>
<template>
<div class="search-bar" v-if="searcherParams !== undefined">
<div v-for="searcher of searchers" :key="searcher.name" class="searcher-box">
<el-input v-model="searcherParams[searcher.name]" v-if="searcher.type === 'text'" class="searcher">
<template #prepend>{{ searcher.placeholder }}</template>
</el-input>
<el-select v-model="searcherParams[searcher.name]" v-if="searcher.type === 'select'" class="searcher">
<template #prefix>{{ searcher.placeholder }}</template>
<el-option v-for="(value, key) of getSelectData(searcher)" :key="key" :label="value" :value="key"></el-option>
</el-select>
<el-date-picker
v-model="searcherParams[searcher.name]"
v-if="searcher.type === 'date-range-pick'"
type="daterange"
range-separator="-"
:start-placeholder="$t('_prop.common.startdate')"
:end-placeholder="$t('_prop.common.enddate')"
value-format="YYYY-MM-DD"
></el-date-picker>
</div>
<el-button v-if="searchClick !== undefined" :icon="Search" type="primary" @click="searchButtonClick">
{{ $t("_button.search") }}
</el-button>
<el-button v-if="searchClick !== undefined" :icon="Refresh" @click="reset">{{ $t("_button.reset") }}</el-button>
</div>
</template>
<style>
.search-bar {
display: flex;
padding-right: 20px;
}
.searcher-box {
padding-left: 5px;
padding-right: 5px;
}
.searcher {
width: 285px;
}
</style>

View File

@@ -0,0 +1,26 @@
export interface SearcherProp {
/**
* get时的ParamName
*/
name: string;
/**
* 搜索栏的类型
*/
type: "text" | "select" | "date-range-pick";
/**
* 占位文本
*/
placeholder?: string;
/**
* 下拉框的数据
*/
selectData?: Record<any, string>;
/**
* 下拉框 api
*/
selectDataUrl?: string;
/**
* 下拉框 api 得到的数据对应
*/
selectDataParse?: (data: any) => Record<any, string>;
}

View File

@@ -0,0 +1,34 @@
<script lang="ts" setup>
import { useUserStore } from "@/pinia";
import type { ToolButtonProp } from "./TableToolBarType";
defineProps({
toolButtons: Array as PropType<ToolButtonProp[]>,
});
const userStore = useUserStore();
function checkButtonAuth(toolButton: ToolButtonProp) {
if (toolButton.isAuth !== undefined && !toolButton.isAuth) return true;
if (toolButton.authCode in userStore.buttonAuth) {
return true;
}
return false;
}
function buttonClick(toolButtonProp: ToolButtonProp) {
if (toolButtonProp.click === undefined) return;
toolButtonProp.click();
}
</script>
<template>
<div>
<template v-for="toolButton of toolButtons || []" :key="toolButton.authCode">
<el-button
v-if="checkButtonAuth(toolButton)"
@click="buttonClick(toolButton)"
:type="toolButton.colorType || 'primary'"
>
{{ toolButton.text }}
</el-button>
</template>
</div>
</template>

View File

@@ -0,0 +1,22 @@
export interface ToolButtonProp {
/**
* 按钮文本
*/
text: string;
/**
* 权限码,用于管理权限
*/
authCode: string;
/**
* 点击事件
*/
click?: Function;
/**
* 设置按钮 el-button 颜色,默认 primary
*/
colorType?: "primary" | "success" | "info" | "warning" | "danger";
/**
* 是否需要开启权限控制,默认 true
*/
isAuth?: boolean;
}

View File

@@ -0,0 +1,113 @@
<script lang="ts" setup>
import TableHeader from "../base-pageable-table/TableHeader.vue";
import TableMain from "../base-pageable-table/TableMain.vue";
import type { SearcherProp } from "../search-bar/SearchBarType";
import type { ToolButtonProp } from "../table-tool-bar/TableToolBarType";
import type { PropType } from "vue";
import { get } from "@/common/http/request";
import BaseTree from "../base-tree/BaseTree.vue";
const props = defineProps({
searchers: Array as PropType<SearcherProp[]>,
toolButtons: Array as PropType<ToolButtonProp[]>,
data: Array,
url: String,
parse: Function,
treeSideData: Array,
treeSideUrl: String,
treeSideParamName: String,
treeSideNodeName: String,
treeSideTitle: String,
});
const page = ref(1);
const pageSize = ref(20);
const total = ref(0);
const searcherParams = ref({});
const tableData = ref<any[]>([]);
const treeSelect = ref();
const updatePage = () => loadData();
const searchClick = () => loadData();
const resetClick = () => {
treeSelect.value = undefined;
loadData();
};
const loadData = async () => {
if (props.data !== undefined) {
total.value = props.data.length;
tableData.value = props.data.slice(
(page.value - 1) * pageSize.value,
Math.min(page.value * pageSize.value, props.data.length)
);
}
if (props.url !== undefined) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(searcherParams.value)) {
params.append(key, String(value));
}
params.append("page", page.value.toString());
params.append("pageSize", pageSize.value.toString());
if (
props.treeSideParamName !== undefined &&
props.treeSideNodeName !== undefined &&
treeSelect.value !== undefined
) {
params.append(props.treeSideParamName, treeSelect.value[props.treeSideNodeName]);
}
const urlRawData = await get(props.url, params).then(res => res.data);
const urlData = props.parse === undefined ? urlRawData : props.parse(urlRawData);
total.value = urlData.total;
tableData.value = urlData.records;
}
};
defineExpose({
reload: loadData,
});
onMounted(loadData);
watch(treeSelect, loadData);
</script>
<template>
<div class="page-container">
<div class="tree-box">
<BaseTree :url="treeSideUrl" :data="treeSideData" v-model:current-node="treeSelect" :title="treeSideTitle" />
</div>
<div class="table-box">
<TableHeader
:searchers="searchers"
:tool-buttons="toolButtons"
v-model:searcher-params="searcherParams"
:search-click="searchClick"
:reset-click="resetClick"
>
<template #tool-button><slot name="tool-button"></slot></template>
</TableHeader>
<TableMain
:data="tableData"
:total="total"
:update-page="updatePage"
v-model:page="page"
v-model:page-size="pageSize"
>
<template #columns><slot name="columns"></slot></template>
</TableMain>
</div>
</div>
</template>
<style>
.page-container {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
}
.tree-box {
width: 17.5%;
height: calc(100vh - 120px);
overflow: auto;
}
.table-box {
width: 82%;
}
</style>