mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-20 08:19:58 +00:00
ui(users): 禁用/启用用户后原地更新该行
管理端用户列表点「禁用/启用」后会重新拉取整个列表:表格先换成骨架屏, 再重新请求用户列表与用量、属性、平台限额三份附属数据,滚动位置随之丢失, 连续处理多个用户时每点一次都像整页刷新。 - 请求成功后按 id 找到当前列表中的那一行,只回写 status 与 updated_at; 更新接口的响应不含 current_concurrency、subscriptions 等列表专属字段, 不能整行替换 - 更新返回时若有列表请求在途,该请求可能读到更新前的状态,此时重新拉取列表 - 按状态筛选时,被切换的行留在原地并显示新状态,可当场撤回误操作; 总数在下次刷新前不变 - 新增三条用例:原地更新且不重载列表、请求失败时该行不变、并发列表请求时回退为重载
This commit is contained in:
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user