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: {