Merge pull request '🌶 feat(Web): 新增下拉表格选择器组件, 支持远程搜索、分页、自定义查询条件表单等功能' (#402) from jasondom/Admin.NET.Pro:v2-1 into v2

Reviewed-on: https://code.adminnet.top/Admin.NET/Admin.NET.Pro/pulls/402
This commit is contained in:
zuohuaijun 2025-08-17 17:01:36 +08:00
commit 3dbb59b4db

View File

@ -0,0 +1,402 @@
<!-- 下拉选择组件支持远程搜索分页自定义查询表单等功能 -->
<script lang="ts" setup>
import { onMounted, onUnmounted, reactive, ref, watch } from 'vue';
import { debounce } from 'xe-utils';
const props = defineProps({
/**
* @example
* <pulldown-selecter
* v-model="state.ruleForm.userId"
* :defaultOptions="[ id: state.ruleForm.userId, realName: state.ruleForm.realName ]"
* :fetch-options="handleSysUserTable"
* :queryParams="state.queryParams"
* :dropdown-width="dropdownWidth"
* :placeholder="placeholder"
* :label-prop="realName"
* :value-prop="id"
* @@change="handelChange"
* allow-create
* filterable
* clearable
* class="w100"
* >
* <-- 查询条件表单插槽 -->
* <template #queryForm="{ query, handleQuery }">
* <el-col class="mb5" :xs="24" :sm="12" :md="8" :lg="6" :xl="6">
* <el-form-item label="姓名" prop="realName">
* <el-input v-model="query.realName" placeholder="请输入姓名" class="w100" clearable @keydown.enter.native="handleQuery()" />
* </el-form-item>
* </el-col>
* <el-col class="mb5" :xs="24" :sm="12" :md="8" :lg="6" :xl="6">
* <el-form-item label="电话" prop="phone">
* <el-input v-model="query.phone" placeholder="请输入电话" class="w100" clearable @keydown.enter.native="handleQuery()" />
* </el-form-item>
* </el-col>
* </template>
* <-- 表格列插槽 -->
* <template #columns>
* <el-table-column prop="realName" label="姓名" />
* <el-table-column prop="account" label="账号" width="160"/ >
* <el-table-column prop="idCardNo" label="身份证号" width="140" />
* <el-table-column prop="cardNo" label="卡号" width="140" />
* <el-table-column prop="gender" label="性别" width="85">
* <template #default="{ row }">
* <g-sys-dict v-model="row.gender" code="GenderEnum" />
* </template>
* </el-table-column>
* <el-table-column prop="birthday" label="生日">
* <template #default="{ row, $index }">
* {{ commonFun.dateFormatYMD(row, $index, row.birthday) }}
* </template>
* </el-table-column>
* <el-table-column prop="phone" label="联系电话" width="100" />
* </template>
* </pulldown-selecter>
*/
modelValue: [String, Number, null],
/**
* 获取表数据的异步方法
* @example
* //
* const handleSysUserTable = (params: any) => {
* return getAPI(SysUserApi).apiSysUserPagePost(params);
* };
*/
fetchOptions: {
type: Function,
required: true,
},
/**
* 选中记录后绑定值属性名
*/
valueProp: {
type: String,
default: 'id',
},
/**
* 选中记录后绑定值回显属性名
*/
labelProp: {
type: String,
default: 'name',
},
/**
* 显示值格式化方法
* @example
* <pulldown-selecter
* v-model="state.ruleForm.userId"
* :labelFormat="(item: any) => `${item.realName}(${item.account})`"
* ....
*/
labelFormat: {
type: Function,
default: (item: any) => {},
},
/**
* 默认查询条件值绑定的属性名
*/
keywordProp: {
type: String,
default: 'keyword',
},
/**
* 下拉框宽度
*/
dropdownWidth: {
type: String,
default: '100%',
},
/**
* 下拉框高度
*/
dropdownHeight: {
type: String,
default: '400px',
},
/**
* 占位符
*/
placeholder: {
type: String,
default: '请输入关键词',
},
/**
* 默认选项用于回显
*/
defaultOptions: {
type: Array<any>,
default: [],
},
/**
* 查询参数
*/
queryParams: {
type: Object,
default: () => {
return {
page: 1,
pageSize: 20,
};
},
},
/**
* 是否显示分页
*/
pagination: {
type: Boolean,
default: true,
},
/**
* 查询字符输入字符醒目展示
*/
allowCreate: Boolean,
/**
* 是否禁用
*/
disabled: Boolean,
/**
* 是否多选
*/
multiple: Boolean,
/**
* 是否可清空
*/
clearable: Boolean,
});
const tableRef = ref();
const selectRef = ref();
const emit = defineEmits(['update:modelValue', 'change']);
const state = reactive({
selectedValues: '' as string | string[],
tableQuery: {
[props.keywordProp]: '',
page: 1,
pageSize: props.queryParams?.pageSize ?? 10,
},
tableData: {
items: [] as any[],
total: 0,
},
defaultOptions: props.defaultOptions,
loading: false,
});
//
const currentRowIndex = ref(-1);
const handleKeydown = (e: KeyboardEvent) => {
const tableData = state.tableData?.items || [];
if (!tableData.length) return;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
const direction = e.key === 'ArrowDown' ? 1 : -1;
const newIndex = Math.max(0, Math.min(currentRowIndex.value + direction, tableData.length - 1));
if (newIndex !== currentRowIndex.value) {
currentRowIndex.value = newIndex;
const row = tableData[newIndex];
tableRef.value?.setCurrentRow(row);
}
} else if (e.key === 'Enter') {
const row = tableData[currentRowIndex.value];
handleChange(row); //
}
};
const handleSizeChange = (pageSize: number) => {
state.tableQuery.pageSize = pageSize;
handleQuery();
};
const handleCurrentChange = (page: number) => {
state.tableQuery.page = page;
handleQuery();
};
onMounted(() => {
selectRef.value?.inputRef?.addEventListener('keydown', handleKeydown);
});
onUnmounted(() => {
selectRef.value?.inputRef?.removeEventListener('keydown', handleKeydown);
});
//
const resetQuery = () => {
state.tableQuery = Object.assign({}, props.queryParams) as any;
state.tableQuery.page ??= 1;
handleQuery();
};
//
const remoteMethod = debounce((query) => {
if (query) {
state.loading = true;
if (typeof query === 'string') {
state.tableQuery[props.keywordProp] = query.trim();
} else {
state.tableQuery = query;
}
props.fetchOptions(Object.assign({}, props.queryParams, state.tableQuery)).then((res: any) => {
const result = res.data?.result;
state.tableData.items = result?.items ?? [];
state.tableData.total = result.total;
state.loading = false;
});
} else {
state.tableData.items = [];
state.tableData.total = 0;
}
}, 300);
const handleQuery = () => {
remoteMethod(state.tableQuery);
};
//
const handleChange = (row: any) => {
state.tableQuery = Object.assign(state.tableQuery, props.queryParams);
state.defaultOptions = props.defaultOptions ?? [];
state.defaultOptions.push(row);
if (props.multiple && !state.selectedValues) state.selectedValues = [];
if (typeof row[props.valueProp] === 'string') row[props.valueProp] = row[props.valueProp]?.trim();
state.selectedValues = props.multiple ? Array.from(new Set([...state.selectedValues, row[props.valueProp]])) : row[props.valueProp];
emit('update:modelValue', state.selectedValues);
emit('change', state.selectedValues, row);
if (!props.multiple || state.selectedValues) selectRef.value?.blur();
tableRef.value?.setCurrentRow(row);
};
watch(
() => props.modelValue,
(val: any) => {
state.selectedValues = val;
},
{ immediate: true }
);
watch(
() => props.defaultOptions,
(val: any) => {
state.defaultOptions = val;
},
{ immediate: true }
);
//
const setDefaultOptions = (options: any[]) => {
state.defaultOptions = options;
};
//
const setQueryParams = (query: any, append: boolean = true) => {
if (!pagination) { //
query = Object.assign(query, {
page: 1,
pageSize: 99999,
});
}
query = Object.assign({}, props.queryParams, query ?? {});
state.tableQuery = append ? Object.assign(state.tableQuery, query) : query;
};
//
const setValue = (option: any | any[], row: any) => {
option = Array.isArray(option) ? option : [option];
state.tableData.total = option.length;
state.tableData.items = option;
state.selectedValues = props.multiple ? option.map((item: any) => item[props.valueProp]) : option[0][props.valueProp];
emit('update:modelValue', state.selectedValues);
emit('change', state.selectedValues, row);
};
defineExpose({
setValue,
handleQuery,
setQueryParams,
setDefaultOptions,
});
</script>
<template>
<el-select
v-model="state.selectedValues"
:clearable="clearable"
:multiple="multiple"
:disabled="disabled"
:placeholder="placeholder"
:allow-create="allowCreate"
:remote-method="remoteMethod"
:default-first-option="allowCreate"
@visible-change="(val: boolean) => (val ? handleQuery() : null)"
popper-class="popper-class"
ref="selectRef"
remote-show-suffix
filterable
remote
>
<!-- 隐藏的选项用于占位 -->
<el-option style="width: 0; height: 0" />
<!-- 默认选项用于回显数据 -->
<el-option v-for="item in (state.defaultOptions ?? []).filter((e) => !state.tableData?.items?.find((e2) => e2[valueProp] === e[valueProp]))" :key="item[valueProp]" :label="labelFormat(item) || item[labelProp]" :value="item[valueProp]" style="width: 0; height: 0" />
<!-- 下拉框内容区域 -->
<div class="w100" v-loading="state.loading">
<el-form :model="state.tableQuery" v-if="$slots.queryForm" class="mg5 query-form" @click.stop>
<el-row :gutter="10">
<!-- 查询表单插槽内容 -->
<slot name="queryForm" :query="state.tableQuery" :handleQuery="handleQuery"></slot>
<!-- 查询和重置按钮 -->
<el-button-group style="position: absolute; right: 10px;">
<el-button type="primary" icon="ele-Search" @click="remoteMethod(state.tableQuery)"> 查询 </el-button>
<el-button icon="ele-Refresh" @click="resetQuery"> 重置 </el-button>
</el-button-group>
</el-row>
</el-form>
<!-- 数据表格 -->
<el-table
ref="tableRef"
@row-click="handleChange"
:data="state.tableData?.items ?? []"
:height="`calc(${dropdownHeight} - 175px${$slots.queryForm ? ' - 35px' : ''})`"
:style="{ width: dropdownWidth }"
highlight-current-row
>
<template #empty><el-empty :image-size="25" /></template>
<slot name="columns"></slot>
</el-table>
<!-- 分页组件 -->
<el-pagination
v-if="props.showPagination"
:disabled="state.loading"
:currentPage="state.tableQuery.page"
:page-size="state.tableQuery.pageSize"
:total="state.tableData.total"
:pager-count="4"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
layout="prev, pager, next"
size="small"
background
/>
</div>
</el-select>
</template>
<style scoped>
.query-form {
z-index: 9999;
}
:deep(.el-select-dropdown) {
.el-scrollbar > .el-scrollbar__bar {
display: none !important;
}
}
.popper-class {
min-width: 400px !important;
}
</style>