ui(users): 禁用/启用用户后原地更新该行

管理端用户列表点「禁用/启用」后会重新拉取整个列表:表格先换成骨架屏,
再重新请求用户列表与用量、属性、平台限额三份附属数据,滚动位置随之丢失,
连续处理多个用户时每点一次都像整页刷新。

- 请求成功后按 id 找到当前列表中的那一行,只回写 status 与 updated_at;
  更新接口的响应不含 current_concurrency、subscriptions 等列表专属字段,
  不能整行替换
- 更新返回时若有列表请求在途,该请求可能读到更新前的状态,此时重新拉取列表
- 按状态筛选时,被切换的行留在原地并显示新状态,可当场撤回误操作;
  总数在下次刷新前不变
- 新增三条用例:原地更新且不重载列表、请求失败时该行不变、并发列表请求时回退为重载
This commit is contained in:
feeeei
2026-09-19 18:14:27 +08:00
parent 1a9d49e16f
commit f6eeed3d5a
2 changed files with 91 additions and 3 deletions
+12 -2
View File
@@ -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)
@@ -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 = {
<div>
<div data-test="columns">{{ columns.map(col => col.key).join(',') }}</div>
<div data-test="row-order">{{ data.map(row => row.email).join(',') }}</div>
<div data-test="row-state">{{ data.map(row => [row.id, row.status, row.current_concurrency].join(':')).join(',') }}</div>
<div data-test="selected-keys">{{ (selectedKeys || []).join(',') }}</div>
<button data-test="sort-last-used" @click="$emit('sort', 'last_used_at', 'desc')">sort</button>
<button
@@ -105,6 +108,7 @@ const DataTableStub = {
</template>
<div v-for="row in data" :key="row.id">
<slot name="cell-last_used_at" :value="row.last_used_at" :row="row" />
<div :data-test="'actions-' + row.id"><slot name="cell-actions" :row="row" /></div>
</div>
</div>
`
@@ -164,12 +168,22 @@ const mountBulkDeleteView = () => mount(UsersView, {
}
})
const getToggleStatusButton = (wrapper: ReturnType<typeof mountBulkDeleteView>, 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<AdminUser>(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: {