feat(admin): batch update user limits

This commit is contained in:
zhaozewu committed 2026-07-16 15:37:35 +08:00
1 parent 09c6c6d740
commit 7947619cc3
31 files changed
+1243 -9

No files matched your search

+46
View File
@@ -242,6 +242,16 @@
</button>
</div>
<button
v-if="selectedCount > 0"
class="btn btn-secondary flex-1 md:flex-initial"
data-test="bulk-edit-limits"
@click="showBulkEditModal = true"
>
<Icon name="users" size="md" class="mr-2" />
{{ t('admin.users.bulkLimits.action', { count: selectedCount }) }}
</button>
<!-- Create User Button (full width on mobile, auto width on desktop) -->
<button @click="showCreateModal = true" class="btn btn-primary flex-1 md:flex-initial">
<Icon name="plus" size="md" class="mr-2" />
@@ -257,12 +267,17 @@
:columns="columns"
:data="sortedUsers"
:loading="loading"
row-key="id"
selectable
:selected-keys="selectedIds"
:selection-label="getUserSelectionLabel"
:actions-count="7"
:server-side-sort="true"
default-sort-key="created_at"
default-sort-order="desc"
:sort-storage-key="USER_SORT_STORAGE_KEY"
@sort="handleSort"
@update:selected-keys="handleSelectedKeysUpdate"
>
<template #cell-email="{ value }">
<div class="flex items-center gap-2">
@@ -735,6 +750,12 @@
<ConfirmDialog :show="showDeleteDialog" :title="t('admin.users.deleteUser')" :message="t('admin.users.deleteConfirm', { email: deletingUser?.email })" :danger="true" @confirm="confirmDelete" @cancel="showDeleteDialog = false" />
<UserCreateModal :show="showCreateModal" @close="showCreateModal = false" @success="loadUsers" />
<UserEditModal :show="showEditModal" :user="editingUser" @close="closeEditModal" @success="loadUsers" />
<BulkEditUserModal
:show="showBulkEditModal"
:selected-ids="selectedIds"
@close="showBulkEditModal = false"
@success="handleBulkLimitsSuccess"
/>
<UserPlatformQuotaModal
:show="showPlatformQuotaModal"
:user="platformQuotaUser"
@@ -755,6 +776,7 @@ import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
import { useTableSelection } from '@/composables/useTableSelection'
import { formatDateTime } from '@/utils/format'
import Icon from '@/components/icons/Icon.vue'
@@ -781,6 +803,7 @@ import PlatformCostCell from '@/components/user/PlatformCostCell.vue'
import UserPlatformQuotaCell from '@/components/user/UserPlatformQuotaCell.vue'
import UserCreateModal from '@/components/admin/user/UserCreateModal.vue'
import UserEditModal from '@/components/admin/user/UserEditModal.vue'
import BulkEditUserModal from '@/components/admin/user/BulkEditUserModal.vue'
import UserPlatformQuotaModal from '@/components/admin/user/UserPlatformQuotaModal.vue'
import UserApiKeysModal from '@/components/admin/user/UserApiKeysModal.vue'
import UserAllowedGroupsModal from '@/components/admin/user/UserAllowedGroupsModal.vue'
@@ -1268,6 +1291,23 @@ const sortedUsers = computed(() => {
.map((x) => x.row)
})
const {
selectedIds,
selectedCount,
setSelectedIds,
clear: clearSelection
} = useTableSelection<AdminUser>({
rows: sortedUsers,
getId: (user) => user.id
})
const handleSelectedKeysUpdate = (keys: Array<string | number>) => {
setSelectedIds(keys.filter((key): key is number => typeof key === 'number'))
}
const getUserSelectionLabel = (user: AdminUser) =>
t('admin.users.bulkLimits.selectUser', { email: user.email })
// User attribute definitions and values
const attributeDefinitions = ref<UserAttributeDefinition[]>([])
const userAttributeValues = ref<Record<number, Record<number, string>>>({})
@@ -1280,6 +1320,7 @@ const pagination = reactive({
const showCreateModal = ref(false)
const showEditModal = ref(false)
const showBulkEditModal = ref(false)
const showDeleteDialog = ref(false)
const showApiKeysModal = ref(false)
const showAttributesModal = ref(false)
@@ -1584,6 +1625,11 @@ const loadUsers = async () => {
}
}
const handleBulkLimitsSuccess = async () => {
clearSelection()
await loadUsers()
}
let searchTimeout: ReturnType<typeof setTimeout>
const handleSearch = () => {
clearTimeout(searchTimeout)
@@ -77,13 +77,22 @@ const createAdminUser = (overrides: Partial<AdminUser> = {}): AdminUser => ({
})
const DataTableStub = {
props: ['columns', 'data'],
emits: ['sort'],
props: ['columns', 'data', 'selectedKeys'],
emits: ['sort', 'update:selectedKeys'],
template: `
<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="selected-keys">{{ (selectedKeys || []).join(',') }}</div>
<button data-test="sort-last-used" @click="$emit('sort', 'last_used_at', 'desc')">sort</button>
<button
v-for="row in data"
:key="'select-' + row.id"
:data-test="'select-' + row.id"
@click="$emit('update:selectedKeys', Array.from(new Set([...(selectedKeys || []), row.id])))"
>
select
</button>
<template v-for="col in columns" :key="col.key">
<slot :name="'header-' + col.key" :column="col" />
</template>
@@ -94,6 +103,22 @@ const DataTableStub = {
`
}
const PaginationStub = {
emits: ['update:page'],
template: '<button data-test="next-page" @click="$emit(\'update:page\', 2)">next</button>'
}
const BulkEditUserModalStub = {
props: ['show', 'selectedIds'],
emits: ['close', 'success'],
template: `
<div v-if="show" data-test="bulk-modal">
<span data-test="bulk-modal-ids">{{ selectedIds.join(',') }}</span>
<button data-test="bulk-success" @click="$emit('success', selectedIds.length)">success</button>
</div>
`
}
describe('admin UsersView', () => {
beforeEach(() => {
vi.useRealTimers()
@@ -140,6 +165,8 @@ describe('admin UsersView', () => {
UserConcurrencyCell: true,
UserCreateModal: true,
UserEditModal: true,
BulkEditUserModal: BulkEditUserModalStub,
UserPlatformQuotaModal: true,
UserApiKeysModal: true,
UserAllowedGroupsModal: true,
UserBalanceModal: true,
@@ -224,6 +251,8 @@ describe('admin UsersView', () => {
UserConcurrencyCell: true,
UserCreateModal: true,
UserEditModal: true,
BulkEditUserModal: BulkEditUserModalStub,
UserPlatformQuotaModal: true,
UserApiKeysModal: true,
UserAllowedGroupsModal: true,
UserBalanceModal: true,
@@ -264,4 +293,80 @@ describe('admin UsersView', () => {
expect.any(Object)
)
})
it('keeps selected user IDs across pages and clears them after a successful bulk update', async () => {
let refreshed = false
listUsers.mockImplementation(async (page: number) => {
const user = page === 2
? createAdminUser({
id: 43,
email: refreshed ? 'refreshed-page-two@example.com' : 'page-two@example.com'
})
: createAdminUser({ id: 42, email: 'page-one@example.com' })
return {
items: [user],
total: 2,
page,
page_size: 20,
pages: 2
}
})
const wrapper = mount(UsersView, {
global: {
stubs: {
AppLayout: { template: '<div><slot /></div>' },
TablePageLayout: {
template: '<div><slot name="filters" /><slot name="table" /><slot name="pagination" /></div>'
},
DataTable: DataTableStub,
Pagination: PaginationStub,
ConfirmDialog: true,
EmptyState: true,
GroupBadge: true,
Select: true,
UserAttributesConfigModal: true,
UserConcurrencyCell: true,
UserCreateModal: true,
UserEditModal: true,
BulkEditUserModal: BulkEditUserModalStub,
UserPlatformQuotaModal: true,
UserApiKeysModal: true,
UserAllowedGroupsModal: true,
UserBalanceModal: true,
UserBalanceHistoryModal: true,
GroupReplaceModal: true,
Icon: true,
Teleport: true
}
}
})
await flushPromises()
expect(wrapper.find('[data-test="bulk-edit-limits"]').exists()).toBe(false)
await wrapper.get('[data-test="select-42"]').trigger('click')
expect(wrapper.get('[data-test="selected-keys"]').text()).toBe('42')
expect(wrapper.find('[data-test="bulk-edit-limits"]').exists()).toBe(true)
await wrapper.get('[data-test="next-page"]').trigger('click')
await flushPromises()
expect(wrapper.get('[data-test="selected-keys"]').text()).toBe('42')
await wrapper.get('[data-test="select-43"]').trigger('click')
expect(wrapper.get('[data-test="selected-keys"]').text()).toBe('42,43')
await wrapper.get('[data-test="bulk-edit-limits"]').trigger('click')
expect(wrapper.get('[data-test="bulk-modal-ids"]').text()).toBe('42,43')
const callsBeforeSuccess = listUsers.mock.calls.length
refreshed = true
await wrapper.get('[data-test="bulk-success"]').trigger('click')
await flushPromises()
expect(listUsers.mock.calls.length).toBeGreaterThan(callsBeforeSuccess)
expect(wrapper.get('[data-test="row-order"]').text()).toBe('refreshed-page-two@example.com')
expect(wrapper.find('[data-test="bulk-edit-limits"]').exists()).toBe(false)
expect(wrapper.get('[data-test="selected-keys"]').text()).toBe('')
})
})