diff --git a/frontend/src/components/admin/usage/UsageFilters.vue b/frontend/src/components/admin/usage/UsageFilters.vue index 6747303708..7ac50aa6b2 100644 --- a/frontend/src/components/admin/usage/UsageFilters.vue +++ b/frontend/src/components/admin/usage/UsageFilters.vue @@ -238,6 +238,7 @@ const userKeyword = ref('') const userResults = ref([]) const showUserDropdown = ref(false) let userSearchTimeout: ReturnType | null = null +let userSearchSequence = 0 const apiKeyKeyword = ref('') const apiKeyResults = ref([]) @@ -307,18 +308,34 @@ const billingModeOptions = ref([ const emitChange = () => emit('change') +const clearPendingUserSearch = () => { + if (userSearchTimeout) { + clearTimeout(userSearchTimeout) + userSearchTimeout = null + } + userSearchSequence += 1 +} + const debounceUserSearch = () => { - if (userSearchTimeout) clearTimeout(userSearchTimeout) + clearPendingUserSearch() + const query = userKeyword.value.trim() + if (!query) { + userResults.value = [] + return + } + + const sequence = userSearchSequence userSearchTimeout = setTimeout(async () => { - if (!userKeyword.value) { - userResults.value = [] - return - } + userSearchTimeout = null try { - const results = await adminAPI.usage.searchUsers(userKeyword.value) - userResults.value = results.sort((a, b) => Number(a.deleted) - Number(b.deleted)) + const results = await adminAPI.usage.searchUsers(query) + if (sequence === userSearchSequence) { + userResults.value = results.sort((a, b) => Number(a.deleted) - Number(b.deleted)) + } } catch { - userResults.value = [] + if (sequence === userSearchSequence) { + userResults.value = [] + } } }, 300) } @@ -338,6 +355,7 @@ const debounceApiKeySearch = () => { } const selectUser = async (u: SimpleUser) => { + clearPendingUserSearch() userKeyword.value = u.email showUserDropdown.value = false filters.value.user_id = u.id @@ -354,6 +372,7 @@ const selectUser = async (u: SimpleUser) => { } const clearUser = () => { + clearPendingUserSearch() userKeyword.value = '' userResults.value = [] showUserDropdown.value = false @@ -453,6 +472,7 @@ watch( () => filters.value.user_id, (userId) => { if (!userId) { + clearPendingUserSearch() userKeyword.value = '' userResults.value = [] } @@ -490,11 +510,13 @@ onMounted(async () => { }) onUnmounted(() => { + clearPendingUserSearch() document.removeEventListener('click', onDocumentClick) }) // 供外部(如用户排行下钻)在程序化设置 user_id 后回显选中的用户邮箱 const setUserKeyword = (email: string) => { + clearPendingUserSearch() userKeyword.value = email userResults.value = [] showUserDropdown.value = false diff --git a/frontend/src/components/admin/usage/__tests__/UsageFilters.spec.ts b/frontend/src/components/admin/usage/__tests__/UsageFilters.spec.ts index 3d52385391..491145d4b9 100644 --- a/frontend/src/components/admin/usage/__tests__/UsageFilters.spec.ts +++ b/frontend/src/components/admin/usage/__tests__/UsageFilters.spec.ts @@ -99,6 +99,17 @@ function mountFilters(filters = defaultFilters()) { }) } +function deferred() { + let resolve!: (value: T | PromiseLike) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve + reject = promiseReject + }) + + return { promise, resolve, reject } +} + describe('UsageFilters — user search dropdown', () => { beforeEach(() => { vi.useFakeTimers() @@ -163,6 +174,56 @@ describe('UsageFilters — user search dropdown', () => { // (the component uses toRef so modelValue is mutated in place and 'change' is emitted) expect(wrapper.props('modelValue').user_id).toBe(1) }) + + it('keeps results from the latest user search when responses arrive out of order', async () => { + const firstSearch = deferred>() + const secondSearch = deferred>() + mockSearchUsers + .mockImplementationOnce(() => firstSearch.promise) + .mockImplementationOnce(() => secondSearch.promise) + + const wrapper = mountFilters() + const input = wrapper.find('input[type="text"]') + await input.trigger('focus') + + await input.setValue('a') + vi.advanceTimersByTime(300) + await flushPromises() + + await input.setValue('ab') + vi.advanceTimersByTime(300) + await flushPromises() + + secondSearch.resolve([{ id: 2, email: 'ab@test.com', deleted: false }]) + await flushPromises() + expect(wrapper.text()).toContain('ab@test.com') + + firstSearch.resolve([{ id: 1, email: 'a@test.com', deleted: false }]) + await flushPromises() + expect(wrapper.text()).toContain('ab@test.com') + expect(wrapper.text()).not.toContain('a@test.com') + }) + + it('does not restore stale user results after the search is cleared', async () => { + const pendingSearch = deferred>() + mockSearchUsers.mockImplementationOnce(() => pendingSearch.promise) + + const wrapper = mountFilters() + const input = wrapper.find('input[type="text"]') + await input.trigger('focus') + + await input.setValue('stale') + vi.advanceTimersByTime(300) + await flushPromises() + + await input.setValue('') + vi.advanceTimersByTime(300) + await flushPromises() + + pendingSearch.resolve([{ id: 3, email: 'stale@test.com', deleted: false }]) + await flushPromises() + expect(wrapper.text()).not.toContain('stale@test.com') + }) }) describe('UsageFilters — model options come from prop (no dup request)', () => {