Merge pull request #4671 from yan9651688/fix/issue-4551-usage-user-search-race

fix(frontend): ignore stale usage user search responses
This commit is contained in:
Wesley Liddick
2026-07-22 09:34:40 +08:00
committed by GitHub
2 changed files with 91 additions and 8 deletions
@@ -238,6 +238,7 @@ const userKeyword = ref('')
const userResults = ref<SimpleUser[]>([])
const showUserDropdown = ref(false)
let userSearchTimeout: ReturnType<typeof setTimeout> | null = null
let userSearchSequence = 0
const apiKeyKeyword = ref('')
const apiKeyResults = ref<SimpleApiKey[]>([])
@@ -307,18 +308,34 @@ const billingModeOptions = ref<SelectOption[]>([
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
@@ -99,6 +99,17 @@ function mountFilters(filters = defaultFilters()) {
})
}
function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((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<Array<{ id: number; email: string; deleted: boolean }>>()
const secondSearch = deferred<Array<{ id: number; email: string; deleted: boolean }>>()
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<Array<{ id: number; email: string; deleted: boolean }>>()
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)', () => {