mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 15:28:03 +08:00
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:
@@ -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)', () => {
|
||||
|
||||
Reference in New Issue
Block a user