From 4696ed7d0b72401fab2ea185ba611417892bd475 Mon Sep 17 00:00:00 2001 From: yan9651688 <174289968+yan9651688@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:52:47 +0800 Subject: [PATCH] Keep usage user search aligned with the latest query Debounced requests can finish out of order, allowing an earlier response to replace newer results or repopulate a cleared field. Track a request generation and gate state updates, with deterministic regression coverage. Constraint: Preserve the existing 300 ms debounce and admin usage API contract Rejected: Add AbortController plumbing | sequence invalidation matches repository patterns without changing API signatures Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep async filter results gated against superseded queries Tested: Focused Vitest (4 tests), vue-tsc, ESLint, and full frontend suite except two pre-existing rollback assertion failures Not-tested: Live browser against production-sized user data Related: https://github.com/Wei-Shaw/sub2api/issues/4551 --- .../components/admin/usage/UsageFilters.vue | 38 +++++++++--- .../usage/__tests__/UsageFilters.spec.ts | 61 +++++++++++++++++++ 2 files changed, 91 insertions(+), 8 deletions(-) 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)', () => {