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'])
+ })
+})