From f6eeed3d5a92ed3d837ecd66fb6ccce07fb2b2ca Mon Sep 17 00:00:00 2001 From: feeeei Date: Sat, 19 Sep 2026 18:13:26 +0800 Subject: [PATCH] =?UTF-8?q?ui(users):=20=E7=A6=81=E7=94=A8/=E5=90=AF?= =?UTF-8?q?=E7=94=A8=E7=94=A8=E6=88=B7=E5=90=8E=E5=8E=9F=E5=9C=B0=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E8=AF=A5=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 管理端用户列表点「禁用/启用」后会重新拉取整个列表:表格先换成骨架屏, 再重新请求用户列表与用量、属性、平台限额三份附属数据,滚动位置随之丢失, 连续处理多个用户时每点一次都像整页刷新。 - 请求成功后按 id 找到当前列表中的那一行,只回写 status 与 updated_at; 更新接口的响应不含 current_concurrency、subscriptions 等列表专属字段, 不能整行替换 - 更新返回时若有列表请求在途,该请求可能读到更新前的状态,此时重新拉取列表 - 按状态筛选时,被切换的行留在原地并显示新状态,可当场撤回误操作; 总数在下次刷新前不变 - 新增三条用例:原地更新且不重载列表、请求失败时该行不变、并发列表请求时回退为重载 --- frontend/src/views/admin/UsersView.vue | 14 +++- .../views/admin/__tests__/UsersView.spec.ts | 80 ++++++++++++++++++- 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/frontend/src/views/admin/UsersView.vue b/frontend/src/views/admin/UsersView.vue index 2a11651fe7..6c4ce82607 100644 --- a/frontend/src/views/admin/UsersView.vue +++ b/frontend/src/views/admin/UsersView.vue @@ -1745,11 +1745,21 @@ const closeEditModal = () => { const handleToggleStatus = async (user: AdminUser) => { const newStatus = user.status === 'active' ? 'disabled' : 'active' try { - await adminAPI.users.toggleStatus(user.id, newStatus) + const updated = await adminAPI.users.toggleStatus(user.id, newStatus) appStore.showSuccess( newStatus === 'active' ? t('admin.users.userEnabled') : t('admin.users.userDisabled') ) - loadUsers() + if (loading.value) { + // 与本次更新并发的列表请求可能读到更新前的状态,重新拉取保证一致。 + loadUsers() + return + } + // 更新接口的响应不含 current_concurrency、subscriptions 等列表专属字段,只回写状态相关字段。 + const row = users.value.find((u) => u.id === user.id) + if (row) { + row.status = updated.status + row.updated_at = updated.updated_at + } } catch (error: any) { appStore.showError(error.response?.data?.detail || t('admin.users.failedToToggle')) console.error('Error toggling user status:', error) diff --git a/frontend/src/views/admin/__tests__/UsersView.spec.ts b/frontend/src/views/admin/__tests__/UsersView.spec.ts index 7210256351..c59e1ce293 100644 --- a/frontend/src/views/admin/__tests__/UsersView.spec.ts +++ b/frontend/src/views/admin/__tests__/UsersView.spec.ts @@ -6,6 +6,7 @@ import UsersView from '../UsersView.vue' const { listUsers, + toggleStatus, deleteUser, showError, showSuccess, @@ -15,6 +16,7 @@ const { getBatchUserAttributes } = vi.hoisted(() => ({ listUsers: vi.fn(), + toggleStatus: vi.fn(), deleteUser: vi.fn(), showError: vi.fn(), showSuccess: vi.fn(), @@ -28,7 +30,7 @@ vi.mock('@/api/admin', () => ({ adminAPI: { users: { list: listUsers, - toggleStatus: vi.fn(), + toggleStatus, delete: deleteUser }, groups: { @@ -90,6 +92,7 @@ const DataTableStub = {
{{ columns.map(col => col.key).join(',') }}
{{ data.map(row => row.email).join(',') }}
+
{{ data.map(row => [row.id, row.status, row.current_concurrency].join(':')).join(',') }}
{{ (selectedKeys || []).join(',') }}
` @@ -164,12 +168,22 @@ const mountBulkDeleteView = () => mount(UsersView, { } }) +const getToggleStatusButton = (wrapper: ReturnType, id: number) => { + const button = wrapper + .get(`[data-test="actions-${id}"]`) + .findAll('button') + .find((candidate) => /admin\.users\.(disable|enable)$/.test(candidate.text())) + if (!button) throw new Error(`toggle status button not found for user ${id}`) + return button +} + describe('admin UsersView', () => { beforeEach(() => { vi.useRealTimers() localStorage.clear() listUsers.mockReset() + toggleStatus.mockReset() deleteUser.mockReset() showError.mockReset() showSuccess.mockReset() @@ -272,6 +286,70 @@ describe('admin UsersView', () => { wrapper.unmount() }) + it('updates only the toggled row in place without reloading the list', async () => { + listUsers.mockResolvedValue({ + items: [createAdminUser({ id: 42, current_concurrency: 3 }), createAdminUser({ id: 43 })], + total: 2, page: 1, page_size: 20, pages: 1 + }) + toggleStatus.mockResolvedValue( + createAdminUser({ id: 42, status: 'disabled', current_concurrency: undefined }) + ) + const wrapper = mountBulkDeleteView() + await flushPromises() + expect(wrapper.get('[data-test="row-state"]').text()).toBe('42:active:3,43:active:0') + + await getToggleStatusButton(wrapper, 42).trigger('click') + await flushPromises() + + expect(toggleStatus.mock.calls).toEqual([[42, 'disabled']]) + expect(listUsers).toHaveBeenCalledTimes(1) + expect(wrapper.get('[data-test="row-state"]').text()).toBe('42:disabled:3,43:active:0') + expect(getToggleStatusButton(wrapper, 42).text()).toBe('admin.users.enable') + expect(showSuccess).toHaveBeenCalledWith('admin.users.userDisabled') + wrapper.unmount() + }) + + it('keeps the row untouched when toggling status fails', async () => { + toggleStatus.mockRejectedValue(new Error('boom')) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const wrapper = mountBulkDeleteView() + await flushPromises() + + await getToggleStatusButton(wrapper, 42).trigger('click') + await flushPromises() + + expect(listUsers).toHaveBeenCalledTimes(1) + expect(wrapper.get('[data-test="row-state"]').text()).toBe('42:active:0') + expect(showError).toHaveBeenCalledWith('admin.users.failedToToggle') + expect(showSuccess).not.toHaveBeenCalled() + errorSpy.mockRestore() + wrapper.unmount() + }) + + it('reloads the list when a list request is in flight as the toggle resolves', async () => { + let finishToggle!: (user: AdminUser) => void + toggleStatus.mockImplementation(() => new Promise(resolve => { finishToggle = resolve })) + const wrapper = mountBulkDeleteView() + await flushPromises() + + await getToggleStatusButton(wrapper, 42).trigger('click') + listUsers.mockImplementationOnce(() => new Promise(() => {})) + await wrapper.get('[data-test="sort-last-used"]').trigger('click') + await flushPromises() + expect(listUsers).toHaveBeenCalledTimes(2) + + listUsers.mockResolvedValue({ + items: [createAdminUser({ status: 'disabled' })], + total: 1, page: 1, page_size: 20, pages: 1 + }) + finishToggle(createAdminUser({ status: 'disabled' })) + await flushPromises() + + expect(listUsers).toHaveBeenCalledTimes(3) + expect(wrapper.get('[data-test="row-state"]').text()).toBe('42:disabled:0') + wrapper.unmount() + }) + it('shows active, used, and created activity columns in order and requests last_used_at sort', async () => { const wrapper = mount(UsersView, { global: {