From 03c3f3b6fcf51e2c7c1dcc7a6c2fdbd4931bf67d Mon Sep 17 00:00:00 2001 From: Randark Date: Tue, 18 Aug 2026 09:14:31 +0000 Subject: [PATCH] =?UTF-8?q?feat(ui):=20Select=20=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=8F=AF=E9=80=89=E8=BF=9C=E7=A8=8B=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=EF=BC=88remote/loading=20props=20+=20search=20?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - remote 模式始终显示搜索框且不做本地过滤,输入 300ms 防抖后 emit('search') - 关闭/卸载时取消排队中的防抖定时器,避免尾随空 query - loading 时空态显示加载文案;未设 remote 时行为不变(回归用例保障) --- frontend/src/components/common/Select.vue | 39 ++++++- .../common/__tests__/Select.spec.ts | 104 ++++++++++++++++++ 2 files changed, 140 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/common/Select.vue b/frontend/src/components/common/Select.vue index 606e6300b..a534f5c24 100644 --- a/frontend/src/components/common/Select.vue +++ b/frontend/src/components/common/Select.vue @@ -111,7 +111,7 @@
- {{ emptyTextDisplay }} + {{ props.loading ? t('common.loading') : emptyTextDisplay }}
@@ -154,11 +154,16 @@ interface Props { id?: string ariaLabel?: string ariaDescribedby?: string + /** 远程搜索模式:输入不在本地过滤 options,而是防抖后 emit('search', query),由父组件请求数据更新 options */ + remote?: boolean + /** 远程搜索模式下的加载态:options 为空时下拉显示 loading 文案 */ + loading?: boolean } interface Emits { (e: 'update:modelValue', value: string | number | boolean | null): void (e: 'change', value: string | number | boolean | null, option: SelectOption | null): void + (e: 'search', query: string): void } const props = withDefaults(defineProps(), { @@ -169,7 +174,9 @@ const props = withDefaults(defineProps(), { creatablePrefix: '', clearable: false, valueKey: 'value', - labelKey: 'label' + labelKey: 'label', + remote: false, + loading: false }) const emit = defineEmits() @@ -192,7 +199,13 @@ const placeholderText = computed(() => props.placeholder ?? t('common.selectOpti const searchPlaceholderText = computed(() => props.searchPlaceholder ?? t('common.searchPlaceholder')) const emptyTextDisplay = computed(() => props.emptyText ?? t('common.noOptionsFound')) +// 远程搜索的防抖间隔(对齐 OpenAIFastPolicyUserSelector 的 300ms 惯例)。 +const REMOTE_SEARCH_DEBOUNCE_MS = 300 +let remoteSearchTimer: ReturnType | null = null + const isSearchable = computed(() => { + // 远程搜索模式始终显示搜索框(选项只是服务端结果的一页)。 + if (props.remote) return true if (props.searchable === 'auto') return props.options.length > 5 return props.searchable }) @@ -276,7 +289,8 @@ const hasValue = computed( const filteredOptions = computed(() => { let opts = props.options as any[] - if (isSearchable.value && searchQuery.value) { + // 远程搜索模式不在本地过滤(选项即服务端搜索结果的一页)。 + if (isSearchable.value && searchQuery.value && !props.remote) { const query = searchQuery.value.toLowerCase() opts = opts.filter((opt) => { // Match label @@ -377,11 +391,26 @@ watch(isOpen, (open) => { } else { searchQuery.value = '' focusedIndex.value = -1 + // 关闭时取消仍在排队的远程搜索(避免关闭后尾随 emit 一次 search(''))。 + if (remoteSearchTimer) { + clearTimeout(remoteSearchTimer) + remoteSearchTimer = null + } window.removeEventListener('scroll', updateTriggerRect, { capture: true }) window.removeEventListener('resize', calculateDropdownPosition) } }) +// 远程搜索:输入防抖后交给父组件请求(!isOpen 抑制关闭重置 searchQuery 触发的空 query)。 +watch(searchQuery, (query) => { + if (!props.remote || !isOpen.value) return + if (remoteSearchTimer) clearTimeout(remoteSearchTimer) + remoteSearchTimer = setTimeout(() => { + remoteSearchTimer = null + emit('search', query.trim()) + }, REMOTE_SEARCH_DEBOUNCE_MS) +}) + const selectOption = (option: any) => { const value = getOptionValue(option) ?? null emit('update:modelValue', value) @@ -467,6 +496,10 @@ onUnmounted(() => { document.removeEventListener('click', handleClickOutside) window.removeEventListener('scroll', updateTriggerRect, { capture: true }) window.removeEventListener('resize', calculateDropdownPosition) + if (remoteSearchTimer) { + clearTimeout(remoteSearchTimer) + remoteSearchTimer = null + } }) diff --git a/frontend/src/components/common/__tests__/Select.spec.ts b/frontend/src/components/common/__tests__/Select.spec.ts index 0817cad1a..6cd401015 100644 --- a/frontend/src/components/common/__tests__/Select.spec.ts +++ b/frontend/src/components/common/__tests__/Select.spec.ts @@ -61,6 +61,7 @@ afterEach(() => { unmountWrapper = undefined document.body.innerHTML = '' setViewportWidth(originalInnerWidth) + vi.useRealTimers() vi.restoreAllMocks() }) @@ -113,3 +114,106 @@ describe('Select dropdown viewport constraints', () => { expect(dropdown?.style.maxWidth).toBe('0px') }) }) + +describe('Select remote search', () => { + const mountRemoteSelect = (props: Record = {}) => { + const wrapper = mount(Select, { + props: { + modelValue: null, + remote: true, + options: [ + { value: 'alpha', label: 'Alpha account' }, + { value: 'beta', label: 'Beta account' }, + ], + ...props, + }, + }) + unmountWrapper = () => wrapper.unmount() + return wrapper + } + + const openDropdown = async () => { + const dropdown = document.body.querySelector('.select-dropdown-portal') + expect(dropdown).not.toBeNull() + return dropdown as HTMLElement + } + + const typeSearchQuery = async (query: string) => { + const dropdown = await openDropdown() + const input = dropdown.querySelector('.select-search-input') + expect(input).not.toBeNull() + input!.value = query + input!.dispatchEvent(new Event('input')) + await nextTick() + } + + it('emits debounced search events and skips local filtering in remote mode', async () => { + vi.useFakeTimers() + const wrapper = mountRemoteSelect() + await wrapper.get('button').trigger('click') + await nextTick() + + await typeSearchQuery('zzz') + + // 防抖窗口内不触发。 + expect(wrapper.emitted('search')).toBeUndefined() + await vi.advanceTimersByTimeAsync(300) + + expect(wrapper.emitted('search')).toEqual([['zzz']]) + // 远程模式不做本地过滤:无命中的 query 下选项仍完整展示(由父组件更新 options)。 + const dropdown = await openDropdown() + const labels = [...dropdown.querySelectorAll('.select-option-label')].map((el) => el.textContent) + expect(labels).toContain('Alpha account') + expect(labels).toContain('Beta account') + }) + + it('does not emit search when the dropdown closes and the query resets', async () => { + vi.useFakeTimers() + const wrapper = mountRemoteSelect() + await wrapper.get('button').trigger('click') + await nextTick() + + await typeSearchQuery('hidden') + + // 关闭下拉:排队中的防抖定时器应被取消,也不应因 query 重置而尾随 emit。 + await wrapper.get('button').trigger('click') + await nextTick() + await vi.advanceTimersByTimeAsync(300) + + expect(wrapper.emitted('search')).toBeUndefined() + }) + + it('shows the loading text instead of empty text while loading with no options', async () => { + const wrapper = mountRemoteSelect({ options: [], loading: true }) + await wrapper.get('button').trigger('click') + await nextTick() + + const dropdown = await openDropdown() + expect(dropdown.querySelector('.select-empty')?.textContent).toContain('common.loading') + }) + + it('keeps local filtering and emits nothing when remote is not set', async () => { + vi.useFakeTimers() + const wrapper = mount(Select, { + props: { + modelValue: null, + searchable: true, + options: [ + { value: 'alpha', label: 'Alpha account' }, + { value: 'beta', label: 'Beta account' }, + ], + }, + }) + unmountWrapper = () => wrapper.unmount() + await wrapper.get('button').trigger('click') + await nextTick() + + await typeSearchQuery('alpha') + await vi.advanceTimersByTimeAsync(300) + + expect(wrapper.emitted('search')).toBeUndefined() + const dropdown = await openDropdown() + const labels = [...dropdown.querySelectorAll('.select-option-label')].map((el) => el.textContent) + expect(labels).toEqual(['Alpha account']) + }) +})