From 8811c125ecaa8d5bfa538d870c8c447d9b66aedc Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Wed, 7 Oct 2026 14:57:52 +0800 Subject: [PATCH] fix(groups): ignore stale rate multiplier loads --- .../admin/group/GroupRateMultipliersModal.vue | 14 ++- ...GroupRateMultipliersModal.requests.spec.ts | 99 +++++++++++++++++++ 2 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/admin/group/__tests__/GroupRateMultipliersModal.requests.spec.ts diff --git a/frontend/src/components/admin/group/GroupRateMultipliersModal.vue b/frontend/src/components/admin/group/GroupRateMultipliersModal.vue index ccfcf546e0..87d54815fe 100644 --- a/frontend/src/components/admin/group/GroupRateMultipliersModal.vue +++ b/frontend/src/components/admin/group/GroupRateMultipliersModal.vue @@ -279,6 +279,7 @@ const pageSize = ref(10) const batchFactor = ref(null) let searchTimeout: ReturnType +let loadVersion = 0 const platformColorClass = computed(() => { switch (props.group?.platform) { @@ -319,18 +320,23 @@ const cloneEntries = (entries: GroupRateMultiplierEntry[]): LocalEntry[] => { const loadEntries = async () => { if (!props.group) return + const version = loadVersion loading.value = true + serverEntries.value = [] + localEntries.value = [] try { const raw = await adminAPI.groups.getGroupRateMultipliers(props.group.id) + if (version !== loadVersion) return // 仅显示已设置 rate_multiplier 的条目;rpm_override 在另一个弹窗管理,保留不动 serverEntries.value = raw.filter(e => e.rate_multiplier != null) localEntries.value = cloneEntries(serverEntries.value) adjustPage() } catch (error) { + if (version !== loadVersion) return appStore.showError(t('admin.groups.failedToLoad')) console.error('Error loading group rate multipliers:', error) } finally { - loading.value = false + if (version === loadVersion) loading.value = false } } @@ -341,8 +347,9 @@ const adjustPage = () => { } } -watch(() => props.show, (val) => { - if (val && props.group) { +watch([() => props.show, () => props.group?.id], ([show]) => { + loadVersion++ + if (show && props.group) { currentPage.value = 1 batchFactor.value = null searchQuery.value = '' @@ -491,6 +498,7 @@ if (typeof document !== 'undefined') { document.addEventListener('click', handleClickOutside) } onUnmounted(() => { + loadVersion++ clearTimeout(searchTimeout) document.removeEventListener('click', handleClickOutside) }) diff --git a/frontend/src/components/admin/group/__tests__/GroupRateMultipliersModal.requests.spec.ts b/frontend/src/components/admin/group/__tests__/GroupRateMultipliersModal.requests.spec.ts new file mode 100644 index 0000000000..301ea0a568 --- /dev/null +++ b/frontend/src/components/admin/group/__tests__/GroupRateMultipliersModal.requests.spec.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import type { AdminGroup } from '@/types' +import GroupRateMultipliersModal from '../GroupRateMultipliersModal.vue' + +const mocks = vi.hoisted(() => ({ getGroupRateMultipliers: vi.fn(), batchSetGroupRateMultipliers: vi.fn(), showError: vi.fn() })) +vi.mock('@/api/admin', () => ({ adminAPI: { groups: mocks } })) +vi.mock('@/stores/app', () => ({ useAppStore: () => ({ showError: mocks.showError, showSuccess: vi.fn() }) })) +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })) +enableAutoUnmount(afterEach) +beforeEach(() => vi.resetAllMocks()) +afterEach(() => vi.restoreAllMocks()) + +function deferred() { + let resolve!: (value: unknown) => void + let reject!: (reason: unknown) => void + const promise = new Promise((res, rej) => { resolve = res; reject = rej }) + return { promise, resolve, reject } +} +const group = (id: number) => ({ id, name: `Group ${id}`, platform: 'openai', rate_multiplier: 1 } as AdminGroup) +const entries = (id: number) => [{ user_id: id, user_name: '', user_email: `user${id}@example.com`, user_status: 'active', rate_multiplier: 2, rpm_override: null }] + +async function openGroup() { + const wrapper = mount(GroupRateMultipliersModal, { + props: { show: false, group: group(1) }, + global: { stubs: { + BaseDialog: { props: ['show'], template: '
' }, + Icon: true, PlatformIcon: true, Pagination: true + } } + }) + await wrapper.setProps({ show: true }) + return wrapper +} + +describe('group rate multiplier request ownership', () => { + it('keeps the reopened group data when an older response finishes last', async () => { + const old = deferred() + mocks.getGroupRateMultipliers.mockReturnValueOnce(old.promise).mockResolvedValueOnce(entries(2)) + const wrapper = await openGroup() + await wrapper.setProps({ show: false }) + await wrapper.setProps({ show: true, group: group(2) }) + await flushPromises() + old.resolve(entries(1)) + await flushPromises() + expect(wrapper.get('tbody').text()).toContain('user2@example.com') + expect(wrapper.get('tbody').text()).not.toContain('user1@example.com') + await wrapper.get('tbody input[type="number"]').setValue('3') + await wrapper.findAll('button').find(button => button.text() === 'common.save')!.trigger('click') + expect(mocks.batchSetGroupRateMultipliers).toHaveBeenCalledWith(2, [{ user_id: 2, rate_multiplier: 3 }]) + }) + + it('does not end the new load or report an error when an old request fails', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const old = deferred() + const current = deferred() + mocks.getGroupRateMultipliers.mockReturnValueOnce(old.promise).mockReturnValueOnce(current.promise) + const wrapper = await openGroup() + await wrapper.setProps({ show: false }) + await wrapper.setProps({ show: true, group: group(2) }) + old.reject(new Error('old request failed')) + await flushPromises() + expect(mocks.showError).not.toHaveBeenCalled() + expect(wrapper.find('svg.animate-spin').exists()).toBe(true) + current.resolve(entries(2)) + await flushPromises() + expect(wrapper.get('tbody').text()).toContain('user2@example.com') + }) + + it('loads a different group while the dialog remains open', async () => { + mocks.getGroupRateMultipliers.mockResolvedValueOnce(entries(1)).mockResolvedValueOnce(entries(2)) + const wrapper = await openGroup() + await flushPromises() + await wrapper.setProps({ group: group(2) }) + await flushPromises() + expect(mocks.getGroupRateMultipliers).toHaveBeenLastCalledWith(2) + expect(wrapper.get('tbody').text()).toContain('user2@example.com') + }) + + it('discards the previous group draft before loading the next group', async () => { + mocks.getGroupRateMultipliers.mockResolvedValueOnce(entries(1)).mockReturnValueOnce(deferred().promise) + const wrapper = await openGroup() + await flushPromises() + await wrapper.get('tbody input[type="number"]').setValue('3') + await wrapper.setProps({ show: false }) + await wrapper.setProps({ show: true, group: group(2) }) + expect(wrapper.findAll('button').some(button => button.text() === 'common.save')).toBe(false) + }) + + it('ignores a request failure after unmounting', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const request = deferred() + mocks.getGroupRateMultipliers.mockReturnValue(request.promise) + const wrapper = await openGroup() + wrapper.unmount() + request.reject(new Error('offline')) + await flushPromises() + expect(mocks.showError).not.toHaveBeenCalled() + }) +})