完成了 BOM 管理和生产管理,完成部分发料单、采购计划和调拨单。
This commit is contained in:
349
src/components/base/base-form-with-table/BaseFormWithTable.vue
Normal file
349
src/components/base/base-form-with-table/BaseFormWithTable.vue
Normal 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>
|
||||
26
src/components/base/base-form-with-table/type.ts
Normal file
26
src/components/base/base-form-with-table/type.ts
Normal 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>;
|
||||
68
src/components/base/base-form/BaseForm.vue
Normal file
68
src/components/base/base-form/BaseForm.vue
Normal 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>
|
||||
79
src/components/base/base-item-dialog/BaseItemDialog.vue
Normal file
79
src/components/base/base-item-dialog/BaseItemDialog.vue
Normal 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>
|
||||
101
src/components/base/base-pageable-table/BasePageableTable.vue
Normal file
101
src/components/base/base-pageable-table/BasePageableTable.vue
Normal 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>
|
||||
38
src/components/base/base-pageable-table/TableHeader.vue
Normal file
38
src/components/base/base-pageable-table/TableHeader.vue
Normal 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>
|
||||
79
src/components/base/base-pageable-table/TableMain.vue
Normal file
79
src/components/base/base-pageable-table/TableMain.vue
Normal 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>
|
||||
21
src/components/base/base-pageable-table/TableType.ts
Normal file
21
src/components/base/base-pageable-table/TableType.ts
Normal 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;
|
||||
}
|
||||
46
src/components/base/base-select/BaseSelect.vue
Normal file
46
src/components/base/base-select/BaseSelect.vue
Normal 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>
|
||||
21
src/components/base/base-switch/StatusSwitch.vue
Normal file
21
src/components/base/base-switch/StatusSwitch.vue
Normal 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>
|
||||
229
src/components/base/base-table-form/BaseTableForm.vue
Normal file
229
src/components/base/base-table-form/BaseTableForm.vue
Normal 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>
|
||||
76
src/components/base/base-tree/BaseTree.vue
Normal file
76
src/components/base/base-tree/BaseTree.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
69
src/components/base/search-bar/SearchBar.vue
Normal file
69
src/components/base/search-bar/SearchBar.vue
Normal 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>
|
||||
26
src/components/base/search-bar/SearchBarType.ts
Normal file
26
src/components/base/search-bar/SearchBarType.ts
Normal 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>;
|
||||
}
|
||||
34
src/components/base/table-tool-bar/TableToolBar.vue
Normal file
34
src/components/base/table-tool-bar/TableToolBar.vue
Normal 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>
|
||||
22
src/components/base/table-tool-bar/TableToolBarType.ts
Normal file
22
src/components/base/table-tool-bar/TableToolBarType.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user