效果图:
1.声明 Props类型
export type comboGridPropType = {
modelValue: any;
url: string;
keyField?: string;
labelField?: string;
filterOptions?: Array<ISearchOption>;
tableColumns?: Array<TableColumns>;
enableField?: string;
multiple?: boolean;
width?: number | string;
panelWidth?: number;
defaultPageSize?: number;
autoSelectFirst?: boolean; //自动选择第一项
sort?: string;
order?: "desc" | "asc";
queryOp: "Contains" | "BeginsWith" | "Equal";
disabled?: boolean; //禁用
searchable?: boolean; // 搜索
isTreeData?: boolean; // 树结构数据
treeProps?: { hasChildren?: string; children?: string };
};
2.后端数据结构
后端返回的数据结构:
using System;
using System.Collections.Generic;
using System.Text;namespace WebiMes.Models.ViewModels
{/// <summary>/// 翻页返回集合/// </summary>public sealed class PagenationResult{public PagenationResult(){}/// <summary>/// 当前页/// </summary>public int pageIndex { get; set; }/// <summary>/// 每页显示/// </summary>public int pageSize { get; set; }/// <summary>/// 页码token(弃用)/// </summary>public string PageNationToken { get; set; }/// <summary>/// 数据集/// </summary>public IEnumerable<Object> List { get; set; }/// <summary>/// 行数/// </summary>public long Total { get; set; }/// <summary>/// 额外数据/// </summary>public Object? ExtenalData { get; set; }}
}
3.注意事项
1.如果不指定 keyfield和labelfield,默认将返回数据的第一列作为 keyfield和labelfield
2.filterOptions搜索字段类型
id: number;
label: string;
value: string;
type: "string" | "boolean" | "date" | "number";
3.tableColumns表格列数据
{/** 是否隐藏 */hide?: boolean | CallableFunction;/** 自定义列的内容插槽 */slot?: string;/** 自定义表头的内容插槽 */headerSlot?: string;/** 多级表头,内部实现原理:嵌套 `el-table-column` */children?: Array<TableColumns>;/** 自定义单元格渲染器(`jsx`语法) */cellRenderer?: (data: TableColumnRenderer) => VNode;/** 自定义头部渲染器(`jsx`语法) */headerRenderer?: (data: TableColumnRenderer) => VNode;/** 显示的标题 */label?: string;/** 字段名称,对应列内容的字段名,也可以使用 `property` 属性 */prop?: string | ((index: number) => string);/** 对应列的类型,如果设置了 `selection` 则显示多选框;如果设置了 `index` 则显示该行的索引(从 `1` 开始计算);如果设置了 `expand` 则显示为一个可展开的按钮 */type?: TableColumnType;/** 如果设置了 `type=index`,可以通过传递 `index` 属性来自定义索引 */index?: number | ((index: number) => number);/** `column` 的 `key`, 如果需要使用 `filter-change` 事件,则需要此属性标识是哪个 `column` 的筛选条件 */columnKey?: string;/** 对应列的宽度 */width?: string | number;/** 对应列的最小宽度,对应列的最小宽度,与 `width` 的区别是 `width` 是固定的,`min-width` 会把剩余宽度按比例分配给设置了 `min-width` 的列 */minWidth?: string | number;/** 列是否固定在左侧或者右侧。`true` 表示固定在左侧 */fixed?: TableColumnFixed;/** 列标题 `Label` 区域渲染使用的 `Function` */renderHeader?: (data: RH) => VNode;/** 对应列是否可以排序, 如果设置为 `'custom'`,则代表用户希望远程排序,需要监听 `Table` 的 `sort-change `事件,默认值为 `false` */sortable?: TableColumnSortable;/** 指定数据按照哪个属性进行排序,仅当 `sortable` 设置为 `true` 的时候有效。应该如同 `Array.sort` 那样返回一个 `Number` */sortMethod?: (a: any, b: any) => number;/** 指定数据按照哪个属性进行排序,仅当 `sortable` 设置为 `true` 且没有设置 `sort-method` 的时候有效。如果 `sort-by` 为数组,则先按照第 `1` 个属性排序,如果第 `1` 个相等,再按照第 `2` 个排序,以此类推 */sortBy?: string | ((row: any, index: number) => string) | string[];/** 数据在排序时所使用排序策略的轮转顺序,仅当 `sortable` 为 `true` 时有效。需传入一个数组,随着用户点击表头,该列依次按照数组中元素的顺序进行排序,默认值为 `['ascending', 'descending', null]` */sortOrders?: Array<TableColumnSortOrders>;/** 对应列是否可以通过拖动改变宽度(需要在 `el-table` 上设置 `border` 属性为真),默认值为 `true` */resizable?: boolean;/** 用来格式化内容 */formatter?: (row: any, column: TableColumnCtx<any>, cellValue: any, index: number) => VNode | string;/** 当内容过长被隐藏时显示 `tooltip`,默认值为 `false` */showOverflowTooltip?: boolean;/** 对齐方式,默认值为 `left` */align?: Align;/** 表头对齐方式,若不设置该项,则使用表格的对齐方式 */headerAlign?: Align;/** 列的 `className` */className?: string;/** 当前列标题的自定义类名 */labelClassName?: string;/** 仅对 `type=selection` 的列有效,类型为 `Function`,`Function` 的返回值用来决定这一行的 `CheckBox` 是否可以勾选 */selectable?: (row: any, index: number) => boolean;/** 仅对 `type=selection` 的列有效,请注意,需指定 `row-key` 来让这个功能生效,默认值为 `false` */reserveSelection?: boolean;/** 数据过滤的选项,数组格式,数组中的元素需要有 `text` 和 `value` 属性。数组中的每个元素都需要有 `text` 和 `value` 属性 */filters?: Array<{text: string;value: string;}>;/** 过滤弹出框的定位 */filterPlacement?: TableColumnFilterPlacement;/** 数据过滤的选项是否多选,默认值为 `true` */filterMultiple?: boolean;/** 数据过滤使用的方法,如果是多选的筛选项,对每一条数据会执行多次,任意一次返回 `true` 就会显示 */filterMethod?: FilterMethods;/** 选中的数据过滤项,如果需要自定义表头过滤的渲染方式,可能会需要此属性 */filteredValue?: Array<any>;
}
4.完整代码
combogrid.vue
<script setup lang="ts">
import { ref, onMounted, watch, toRefs } from "vue";
import iSearch from "@iconify-icons/ep/search";
import dayjs from "dayjs";
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
import _ from "lodash";
import { http } from "@/utils/http";
import {IResultTable,IFilterRule,FilterOp,FilterValueType
} from "@/api/baseTypes";
import { message } from "@/utils/message";
import { comboGridPropType, ISearchOption } from "./types";
// import { useUserStoreHook } from "@/store/modules/user";defineOptions({name: "ComboGrid"
});
// statting in Vue 3.4
// const modelValue = defineModel<any>({ default: "" });
/** 配置默认值 */
const props = withDefaults(defineProps<comboGridPropType>(), {keyField: "", //主键IdlabelField: "", //主键IdtableColumns: () => [],filterOptions: () => [],defaultPageSize: 10, //默认每页展示数量multiple: false, //多选width: 180, //宽度panelWidth: 700, //高度autoSelectFirst: false, //自动选中第一条sort: "",order: "asc",queryOp: "Contains",disabled: false,searchable: true,isTreeData: false,treeProps: () => ({ hasChildren: "hasChildren", children: "children" })
});
/** emits */
const emits = defineEmits<{(e: "update:modelValue", value: any): void;(e: "change", value: any): void;
}>();
/** 已初始化 */
const inited = ref(false);
//解构出来 ref
const {url, //搜索数据链接keyField: _keyField, //主键IdlabelField: _labelField, //选中展示LabelenableField,tableColumns,defaultPageSize, //默认每页展示数量filterOptions: _filterOptions, //搜索字段 数据集queryOp,disabled
} = toRefs(props);const keyField = ref(""); //主键Id2
keyField.value = _keyField.value;
const labelField = ref(""); //选中展示Label
labelField.value = _labelField.value;
const filterOptions = ref<ISearchOption[]>([]); //搜索字段 数据集
filterOptions.value = _filterOptions.value;/**初始化字段 */
const isInitColumn = ref(false);
isInitColumn.value = tableColumns.value?.length <= 0;
if (!tableColumns.value?.some(x => x.type === "index")) {tableColumns.value.splice(0, 0, {type: "index",label: "#",align: "center",index: (index: number) => {return (currentPage.value - 1) * defaultPageSize.value + index + 1;}});
}const loadingList = ref(false); //加载数据列表
const tableData = ref([]); //表格数据
const selectRef = ref(); //选择框Ref
const tabRef = ref(); //数据展示Ref
const fieldSelectRef = ref(); //筛选字段Ref
//搜索字段
const filterField = ref<ISearchOption>();
/**初始化搜索字段 */
const isInitfilterOptions = ref(false);
//搜索字段 数组
if (_.isArray(filterOptions.value) && filterOptions.value.length > 0) {filterField.value = filterOptions.value[0];
} else isInitfilterOptions.value = true;
// 当前账户
// const useUserStore = useUserStoreHook();
// 已选数据的Label
const selectLabel = ref<string[]>([]);
// 搜索值
const searchQuery = ref();
/** 搜索 */
function onSearch() {let searchKey;let op: FilterOp = FilterOp.BeginsWith;if (queryOp.value === "Contains" || queryOp.value === "Equal") {const FilterOpStr: keyof typeof FilterOp = queryOp.value;op = FilterOp[FilterOpStr];}let valType: FilterValueType = FilterValueType.String;if (filterField.value?.type === "boolean") {const lowerVal = searchQuery.value.toLowerCase();if (lowerVal === "true") searchKey = true;if (lowerVal === "false") searchKey = false;if (_.isNumber(lowerVal)) searchKey = Number(lowerVal) > 0;if (_.isEmpty(searchKey)) {message("搜索数据类型不匹配" + filterField.value.type, {type: "warning",showClose: true,duration: 3000});return false;}op = FilterOp.Equal;valType = FilterValueType.Boolean;} else if (filterField.value?.type === "date") {const dateVal = new Date(searchQuery.value);if (!_.isDate(dateVal)) {message("搜索数据类型不匹配" + filterField.value.type, {type: "warning",showClose: true,duration: 3000});return false;} else {searchKey = dateVal;op = FilterOp.GreaterThanOrEqual;valType = FilterValueType.Datetime;}} else {searchKey = searchQuery.value;}if (_searhFilters.length > 1)_searhFilters.splice(1, _searhFilters.length - 1);if (_.isEmpty(searchKey) || _.isEmpty(filterField.value)) {// message(`搜索 类型或值,为空,将搜索所有数据`, {// type: "warning",// showClose: true,// duration: 3000// });const sfilter = _searhFilters.find(x => x.field == filterField.value?.value);if (_.isEmpty(sfilter)) {_searhFilters.push({field: filterField.value?.value, // 字段op: op, // 比较符号value: searchKey, // 值valType: valType // 值类型});} else {_searhFilters[0].op = op;_searhFilters[0].valType = valType;_searhFilters[0].value = "";}} else {const sfilter = _searhFilters.find(x => x.field == filterField.value.value);if (_.isEmpty(sfilter)) {_searhFilters.push({field: filterField.value.value, // 字段op: op, // 比较符号value: searchKey, // 值valType: valType // 值类型});} else {_searhFilters[0].op = op;_searhFilters[0].valType = valType;_searhFilters[0].value = searchKey;}}currentPage.value = 1;getTabelData();
}
/** 选中的 行 */
const selectedRows = ref([]);
/** 更新v-Model */
function updateVModel() {if (!props.multiple) {if (!_.isEmpty(selectedRows.value)) {const newValue = selectedRows.value[0][keyField.value];emits("update:modelValue", newValue);emits("change", selectedRows.value[0]);} else {emits("update:modelValue", null);emits("change", null);}} else {const newValues = selectedRows.value.map(x => x[keyField.value]);emits("update:modelValue", newValues);emits("change", selectedRows.value);}
}
/** 行选中 */
function handleRowClick(currentRow, _column, _event) {if (!_.isEmpty(currentRow)) {if (!_.isEmpty(enableField.value) &&!_.isEmpty(currentRow.value) &&_.isBoolean(currentRow[enableField.value]) &&!currentRow[enableField.value]) {message("没有权限选择本条数据", { type: "warning" });return false;}let removeIndex = -1;let some = false;selectedRows.value.forEach((x, index) => {if (x[keyField.value] === currentRow[keyField.value]) {some = true;removeIndex = index;return false;}});const _label = currentRow[labelField.value];if (!props.multiple && selectedRows.value.length > 0) {//清空selectedRows.value.splice(0, selectedRows.value.length);selectLabel.value.splice(0, selectLabel.value.length);}if (!some) {selectedRows.value.push(currentRow);} else {if (removeIndex >= 0) selectedRows.value.splice(removeIndex, 1);}selectLabel.value.forEach((x, index) => {if (x === _label) {some = true;removeIndex = index;return false;}});if (!some) {selectLabel.value.push(_label);} else {if (removeIndex >= 0) selectLabel.value.splice(removeIndex, 1);}/** 更新v-Model */updateVModel();//单选自动关闭if (!props.multiple) selectRef.value.blur();set_ElSelectTagsText();}
}
/** 暂存搜索数据 */
const _searhFilters: IFilterRule[] = [];
if (!_.isEmpty(filterField.value?.value)) {_searhFilters.push({field: filterField.value?.value, // 字段op: FilterOp.Equal, // 比较符号value: "", // 值valType: FilterValueType.String // 值类型});
}
/**翻页配置 */
const currentPage = ref(1);
// const defaultPageSize = ref(10);
const totalRecord = ref(0);
/** 翻页 */
async function handlePageChange(_currentPage) {currentPage.value = _currentPage;await getTabelData();
}
/** 获取数据信息 */
async function getTabelData(init = false) {try {loadingList.value = true;// // 数据为空时,强制设定头// if (!init) init = !_.isEmpty(tableData.value);let res: IResultTable;if (!_.isEmpty(url.value)) {res = await http.request<IResultTable>("get", url.value, {params: {searhFilters: JSON.stringify(_searhFilters.filter(x => !_.isEmpty(x))),page: currentPage.value,limit: defaultPageSize.value,sort: props.sort,order: props.order}});}if (res && res.isSuccess) {tableData.value = res.data.list;totalRecord.value = res.data.total;if (init || !inited.value) {if (!_.isEmpty(tableData.value)) {const top = tableData.value[0];const arrEntry = Object.entries(top);let i = 0;// tableColumns没有配置的字段,新增进去for (const [key, val] of arrEntry) {i++;if (i == 1) {// 配置值字段,和显示字段if (_.isEmpty(keyField.value)) {keyField.value = _.cloneDeep(key);}if (_.isEmpty(labelField.value)) {labelField.value = keyField.value;}}if (isInitColumn.value) {if (!tableColumns.value.some(x => x.prop == key))tableColumns.value.push({ label: key, prop: key });}if (isInitfilterOptions.value) {if (!filterOptions.value?.some(x => x.label == key)) {let _type: "date" | "boolean" | "string" = "string";const dateObj = dayjs(val?.toString());if (dateObj.isValid() && dateObj > dayjs("2020-01-01")) {_type = "date";}if (_.isBoolean(val)) {_type = "boolean";}const filterOption: ISearchOption = {id: i,label: key,value: key,type: _type};filterOptions.value.push(filterOption);}}}//选中第一行if (props.autoSelectFirst && _.isEmpty(props.modelValue)) {const firstRow = tableData.value[0];selectLabel.value.push(firstRow[labelField.value]);selectedRows.value.push(firstRow);} else {if (!_.isEmpty(props.modelValue)) {// 首次加载选中项if (!props.multiple) {// if (!_.isArray(props.modelValue)) {// const seltRows = tableData.value.filter(// x => x[keyField.value] == props.modelValue// );// if (!_.isEmpty(seltRows)) selectedRows.value = [seltRows[0]];// }} else {selectedRows.value = tableData.value.filter(x =>props.modelValue.some(n => n == x[keyField.value]));}selectedRows.value.forEach(item => {selectLabel.value.push(item[labelField.value]);});}}// 已初始化inited.value = true;}} else {//清空_searhFilters.splice(0, _searhFilters.length);}} else {if (!_.isEmpty(url.value))message(`获取数据出错:${res.errMessage}`, { type: "error" });}} catch (error) {message(`获取数据出错:${error.message}`, { type: "error" });} finally {loadingList.value = false;}
}
/**行class */
const tableRowClassName = ({row,_rowIndex
}: {row: any;_rowIndex: number;
}) => {if (selectedRows.value.some(x => x[keyField.value] === row[keyField.value])) {return "success-row";}if (_.isBoolean(row?._Enable) && !row._Enable) return "warning-row";return "";
};
/** 设置el-select_Tags-text Title */
function set_ElSelectTagsText() {if (!_.isEmpty(selectedRows.value)) {const maxTimes = 100;let timeNUm = 0;const _$el = selectRef.value.$el;const len = selectedRows.value.length;const interval = setInterval(() => {timeNUm++;if (timeNUm > maxTimes) {clearInterval(interval);}const elems = _$el.querySelectorAll("span.el-select__tags-text");if (elems.length === len) {console.log("selectRef",selectRef.value,_$el.querySelectorAll("span.el-select__tags-text"));elems.forEach(elem => {if (elem.innerText.indexOf("+ ") < 0) {elem.setAttribute("title", elem.innerText);}});clearInterval(interval);}}, 300);}
}
/**设置主Select-panel不关闭 */
function visibleChange(_visible) {if (!_visible) {selectRef.value.visible = true;selectRef.value.expanded = true;// selectRef.value.focus();// setTimeout(() => {// }, 100);console.log("_visible, selectRef.value");}console.log(_visible, selectRef.value);
}
/**设置主Select-panel不关闭 */
function mainVisibleChange(_visible) {selectRef.value.visible = !_visible;console.log(selectRef.value);
}
/** 监听 选中数据的修改 */
watch(selectLabel, (newval, oldval) => {if (newval.length > 0) {//清除未选择的数据oldval.forEach((x, removeIndex) => {if (!newval.some(n => n === x)) {selectedRows.value.splice(removeIndex, 1);}});} else {//清空选择的数据selectedRows.value.splice(0, selectedRows.value.length);}/** 更新v-Model */updateVModel();// if (!props.multiple) {// if (!_.isEmpty(selectedRows.value)) {// const newValue = selectedRows.value[0][keyField.value];// emits("update:modelValue", newValue);// emits("change", selectedRows.value[0]);// } else {// emits("update:modelValue", null);// emits("change", null);// }// } else {// const newValues = selectedRows.value.map(x => x[keyField.value]);// emits("update:modelValue", newValues);// emits("change", selectedRows.value);// }
});
watch(() => props.modelValue,newval => {console.log("watch-props", newval, typeof newval);/** _.isEmpty 如果是int boolean,将返回true */if ((_.isNumber(newval) && (_.isNaN(newval) || newval <= 0)) ||(!_.isNumber(newval) && _.isEmpty(newval))) {selectLabel.value = [];}}
);
watch(url, (newval, oldval) => {//清空_searhFilters.splice(0, _searhFilters.length);if (_.isEmpty(newval)) {tableData.value = [];emits("update:modelValue", null);// modelValue.value = null;emits("change", null);} else if (newval != oldval) {tableData.value = [];emits("update:modelValue", null);// modelValue.value = null;emits("change", null);//链接修改重新初始化getTabelData(true).then(() => {set_ElSelectTagsText();});}
});
onMounted(async () => {await getTabelData(true);if (!_.isEmpty(props.modelValue)) {let filter = _searhFilters.find(x => x.field == keyField.value);let isNew = false;if (_.isEmpty(filter)) {isNew = true;filter = {field: keyField.value, // 字段op: props.multiple ? FilterOp.In : FilterOp.BeginsWith, // 比较符号value: "", // 值valType: FilterValueType.String // 值类型};}let filterValStr = props.modelValue;if (_.isArray(props.modelValue)) filterValStr = props.modelValue.join(",");Object.assign(filter, {field: keyField.value, // 字段op: props.multiple ? FilterOp.In : FilterOp.BeginsWith, // 比较符号value: filterValStr, // 值valType: FilterValueType.String // 值类型});if (isNew) _searhFilters.push(filter);// 没有filter 无需重新搜索if (!_.isEmpty(_searhFilters)) {// getTabelData().then(() => {// if (_.isEmpty(selectedRows.value)) {// const seletData = tableData.value.find(// x => x[keyField.value] == filterValStr// );// if (!_.isEmpty(seletData)) {// selectedRows.value.push(seletData);// selectLabel.value.push(seletData[labelField.value]);// }// }// set_ElSelectTagsText();// });await getTabelData();if (_.isEmpty(selectedRows.value)) {const seletData = tableData.value.find(x => x[keyField.value] == filterValStr);if (!_.isEmpty(seletData)) {selectedRows.value.push(seletData);selectLabel.value.push(seletData[labelField.value]);emits("change", selectedRows.value[0]);}}}}console.log("ComboGrid - onMounted", keyField, labelField);set_ElSelectTagsText();
});
</script>
<template><el-selectref="selectRef"clearablecollapse-tagscollapse-tags-tooltipplaceholder="Please select Data"value-key="_Id"v-model="selectLabel":multiple="true":loading="loadingList":style="{width: _.isNumber(props.width || 180)? parseInt(props.width || 180) > 1000? '100%': `${props.width || 180}px`: props.width}"@visible-change="mainVisibleChange":disabled="disabled"class="combo-grid"><template #empty><div :class="`w-[${props.panelWidth}px] m-4`"><el-card height="500px"><template #header v-if="props.searchable"><el-row :gutter="1"><el-col :span="9" :sm="7" :md="8" :lg="9" :xl="10"><el-selectref="fieldSelectRef"filterablev-model="filterField"placeholder="Select"style="width: 100%"value-key="id"@visibleChange="visibleChange"><el-optionv-for="item in filterOptions":key="item.id":label="item.label":value="item"/></el-select></el-col><el-col :span="15" :sm="17" :md="16" :lg="15" :xl="14"><el-inputv-model="searchQuery"clearable@keyup.enter="onSearch"><template #append><el-buttontype="primary":icon="useRenderIcon(iSearch)":loading="loadingList"v-auth="'view'"@click="onSearch"/></template></el-input></el-col></el-row></template><el-row><el-col :span="24"><pure-tableref="tabRef"style="width: 100%"size="small"borderfitshow-overflow-tooltip:max-height="350":data="tableData":row-key="keyField":columns="tableColumns":loading="loadingList":row-class-name="tableRowClassName"@row-click="handleRowClick":tree-props="(props.isTreeData ? props.treeProps : {}) as any":default-expand-all="props.isTreeData"/><el-paginationv-if="!props.isTreeData"size="small"layout="prev, pager, next, jumper":default-page-size="defaultPageSize":total="totalRecord"@current-change="handlePageChange"/></el-col></el-row></el-card></div></template></el-select>
</template>
<style lang="scss" scoped>
:deep(.el-card) {--el-card-padding: 6px;
}
:deep(.el-table__row) {cursor: pointer;
}
</style>
<style>
.el-table .warning-row {--el-table-tr-bg-color: var(--el-color-warning-light-9);
}
.el-table .success-row {--el-table-tr-bg-color: var(--el-color-success-light-9);
}
</style>
types.ts
import { TableColumns } from "@pureadmin/table";export type comboGridPropType = {modelValue: any;url: string;keyField?: string;labelField?: string;filterOptions?: Array<ISearchOption>;tableColumns?: Array<TableColumns>;enableField?: string;multiple?: boolean;width?: number | string;panelWidth?: number;defaultPageSize?: number;autoSelectFirst?: boolean; //自动选择第一项sort?: string;order?: "desc" | "asc";queryOp: "Contains" | "BeginsWith" | "Equal";disabled?: boolean; //禁用searchable?: boolean; // 搜索isTreeData?: boolean; // 树结构数据treeProps?: { hasChildren?: string; children?: string };
};export interface ISearchOption {id: number;label: string;value: string;type: "string" | "boolean" | "date" | "number";
}