diff --git a/src/components/EntityPicker.vue b/src/components/EntityPicker.vue index bad75d3..b262ea1 100644 --- a/src/components/EntityPicker.vue +++ b/src/components/EntityPicker.vue @@ -5,6 +5,13 @@ * 基于 el-select 的远程搜索选择器,替代原生 entityPicker 控件。 * 配合 entityPickerPresets 预设或自定义 EntityPickerConfig 使用。 * + * 交互: + * 1. 主输入框(el-select remote):输入关键字远程搜索选择。 + * 2. 旁边"浏览"按钮:点击弹出独立面板(el-dialog),面板内: + * - 顶部搜索框(可选输入,不输入即列出全部) + * - 列表展示当前角色可见范围内的所有实体(分页,底部"加载更多") + * - 点击行选中,回填到主输入框 + * * 用法: * */ @@ -48,7 +55,7 @@ const emit = defineEmits<{ (e: 'update:label', label: string): void }>() -/** 下拉选项列表 */ +/** 主下拉选项列表(搜索用) */ const options = ref([]) /** 搜索加载态 */ const loading = ref(false) @@ -57,6 +64,9 @@ const searched = ref(false) /** 回显标签 */ const displayLabel = ref('') +/** 是否启用分页"加载更多" */ +const paged = computed(() => props.config.paged !== false) + /** 占位符:props.placeholder > config.placeholder > 默认 */ const placeholderText = computed(() => props.placeholder || props.config.placeholder || '请选择') @@ -64,9 +74,41 @@ const placeholderText = computed(() => props.placeholder || props.config.placeho let debounceTimer: ReturnType | null = null /** - * 防抖触发远程搜索(300ms) - * 项目未引入 lodash-es,使用 setTimeout 自定义防抖 + * 统一请求 Search 接口(GET/POST 兼容),返回解析后的选项与 has_more + * @param keyword 搜索关键字(空串=全部) + * @param offset 分页偏移(加载更多用) */ +async function fetchOptions(keyword: string, offset: number): Promise<{ items: EntityOption[]; more: boolean }> { + const cfg = props.config + const searchParam = cfg.searchParam || 'keyword' + const method = cfg.method || 'GET' + const params: Record = { [searchParam]: keyword } + if (offset > 0) params.offset = offset + let res: unknown + if (method === 'POST') { + res = await post(cfg.api, params) + } else { + res = await get(cfg.api, params) + } + return { + items: parseOptions(res, cfg), + more: parseHasMore(res), + } +} + +/** 解析响应中的 has_more(兼容直接返回与包裹两种结构) */ +function parseHasMore(res: unknown): boolean { + if (res && typeof res === 'object') { + const obj = res as Record + if (typeof obj.has_more === 'boolean') return obj.has_more + if (typeof obj.hasMore === 'boolean') return obj.hasMore + // 兼容 total 字段:total > 当前已加载数即还有更多 + if (typeof obj.total === 'number') return obj.total > loadedCount.value + } + return false +} + +/** 防抖触发远程搜索(300ms) */ function debounceSearch(keyword: string): void { if (debounceTimer) clearTimeout(debounceTimer) debounceTimer = setTimeout(() => { @@ -74,22 +116,16 @@ function debounceSearch(keyword: string): void { }, 300) } -/** 执行远程搜索 */ +/** 执行远程搜索(重置分页状态) */ async function doSearch(keyword: string): Promise { - const cfg = props.config - // 搜索参数名默认 keyword - const searchParam = cfg.searchParam || 'keyword' - // HTTP 方法默认 GET - const method = cfg.method || 'GET' loading.value = true + loadedCount.value = 0 + hasMore.value = false try { - let res: unknown - if (method === 'POST') { - res = await post(cfg.api, { [searchParam]: keyword }) - } else { - res = await get(cfg.api, { [searchParam]: keyword }) - } - options.value = parseOptions(res, cfg) + const { items, more } = await fetchOptions(keyword, 0) + options.value = items + hasMore.value = more + loadedCount.value = items.length } catch { options.value = [] } finally { @@ -97,6 +133,15 @@ async function doSearch(keyword: string): Promise { } } +/** el-select remote-method 回调 */ +function remoteSearch(keyword: string): void { + // 关键字为空:不发起搜索(浏览列表由面板负责) + if (!keyword) return + searched.value = true + lastKeyword.value = keyword + debounceSearch(keyword) +} + /** * 解析响应为选项数组 * 响应拦截器已解包 data,res 通常直接是数组; @@ -120,18 +165,6 @@ function parseOptions(res: unknown, cfg: EntityPickerConfig): EntityOption[] { }) } -/** el-select remote-method 回调 */ -function remoteSearch(keyword: string): void { - // 关键字为空:重置为回显状态,不发起搜索 - if (!keyword) { - options.value = [] - searched.value = false - return - } - searched.value = true - debounceSearch(keyword) -} - /** 选中变化处理 */ function handleChange(value: string): void { emit('update:modelValue', value) @@ -162,6 +195,86 @@ function syncDisplayLabel(): void { watch(() => props.existingLabel, syncDisplayLabel, { immediate: true }) watch(() => props.modelValue, syncDisplayLabel) +// ===== 浏览面板(弹窗内列出全部可见实体,分页加载) ===== +/** 面板显示状态 */ +const browseVisible = ref(false) +/** 面板内搜索关键字 */ +const browseKeyword = ref('') +/** 面板内列表 */ +const browseList = ref([]) +/** 面板内加载态 */ +const browseLoading = ref(false) +/** 面板内加载更多态 */ +const browseLoadingMore = ref(false) +/** 面板内是否还有更多 */ +const browseHasMore = ref(false) +/** 面板内已加载条数 */ +const browseLoadedCount = ref(0) + +/** 最近一次搜索关键字(供加载更多沿用) */ +const lastKeyword = ref('') +/** 主下拉已加载条数(供 parseHasMore 的 total 兼容分支使用) */ +const loadedCount = ref(0) +/** 主下拉是否还有更多(加载更多按钮用) */ +const hasMore = ref(false) + +/** 打开浏览面板:重置状态并加载第一页(不输入关键字=列出全部可见) */ +function openBrowse(): void { + if (props.disabled) return + browseVisible.value = true + browseKeyword.value = '' + browseList.value = [] + browseLoadedCount.value = 0 + browseHasMore.value = false + void loadBrowsePage(0, '') +} + +/** 面板内搜索 */ +function browseSearch(): void { + browseList.value = [] + browseLoadedCount.value = 0 + browseHasMore.value = false + void loadBrowsePage(0, browseKeyword.value.trim()) +} + +/** 加载一页数据(offset=0 时重置,否则追加) */ +async function loadBrowsePage(offset: number, keyword: string): Promise { + const isFirst = offset === 0 + if (isFirst) browseLoading.value = true + else browseLoadingMore.value = true + try { + const { items, more } = await fetchOptions(keyword, offset) + if (isFirst) { + browseList.value = items + } else { + // 去重追加(防后端重复数据导致 key 冲突) + const existing = new Set(browseList.value.map((o) => o.value)) + browseList.value = [...browseList.value, ...items.filter((o) => !existing.has(o.value))] + } + browseHasMore.value = more + browseLoadedCount.value += items.length + } catch { + if (isFirst) browseList.value = [] + } finally { + browseLoading.value = false + browseLoadingMore.value = false + } +} + +/** 面板内加载更多 */ +function browseLoadMore(): void { + if (browseLoadingMore.value || !browseHasMore.value) return + void loadBrowsePage(browseLoadedCount.value, browseKeyword.value.trim()) +} + +/** 面板内选中:回填到主选择器并关闭面板 */ +function browseSelect(opt: EntityOption): void { + emit('update:modelValue', opt.value) + emit('update:label', opt.label) + displayLabel.value = opt.label + browseVisible.value = false +} + // 组件卸载前清理防抖定时器 onBeforeUnmount(() => { if (debounceTimer) clearTimeout(debounceTimer) @@ -169,44 +282,134 @@ onBeforeUnmount(() => { diff --git a/src/types/api.ts b/src/types/api.ts index 68abaa1..4919483 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -59,6 +59,16 @@ export interface EntityPickerConfig { searchParam?: string /** 响应数据路径,默认 data */ resultPath?: string + /** + * 是否在下拉打开时自动加载前 20 条(无需输入关键字直接选择),默认 true。 + * 公司/租户等数据量大的实体建议开启,配合 offset 分页逐页加载。 + */ + fetchOnOpen?: boolean + /** + * 是否启用"加载更多"分页按钮(下拉底部),默认 true。 + * 向后端 Search 接口传递 offset 参数,逐页追加 20 条。 + */ + paged?: boolean /** 关联码表名(可选,用于回显翻译) */ codeTable?: string }