mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat(users): wire batch actions into users page
This commit is contained in:
@@ -172,11 +172,62 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 border-b border-border/60 bg-muted/20 px-4 py-2.5 text-xs sm:flex-row sm:items-center sm:justify-between sm:px-6">
|
||||||
|
<div class="flex flex-wrap items-center gap-2 text-muted-foreground">
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
:checked="isAllFilteredSelected"
|
||||||
|
:indeterminate="isPartiallyFilteredSelected"
|
||||||
|
:disabled="filteredUsers.length === 0 || usersStore.loading"
|
||||||
|
@update:checked="toggleSelectFiltered"
|
||||||
|
/>
|
||||||
|
<span>全选筛选结果</span>
|
||||||
|
</label>
|
||||||
|
<span>匹配 {{ filteredUsers.length }} 个,当前页 {{ paginatedUsers.length }} 个,已选 {{ selectedCount }} 个</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-1.5">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-7 px-2 text-[11px]"
|
||||||
|
:disabled="paginatedUsers.length === 0 || selectAllFiltered || usersStore.loading"
|
||||||
|
@click="toggleSelectCurrentPage"
|
||||||
|
>
|
||||||
|
{{ isCurrentPageFullySelected ? '取消本页全选' : '本页全选' }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-7 px-2 text-[11px]"
|
||||||
|
:disabled="!canClearSelection || usersStore.loading"
|
||||||
|
@click="clearSelection"
|
||||||
|
>
|
||||||
|
清空选择
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
class="h-7 px-3 text-[11px]"
|
||||||
|
:disabled="selectedCount === 0 || usersStore.loading"
|
||||||
|
@click="openUserBatchDialog"
|
||||||
|
>
|
||||||
|
批量操作
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 桌面端表格 -->
|
<!-- 桌面端表格 -->
|
||||||
<div class="hidden xl:block overflow-x-auto">
|
<div class="hidden xl:block overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow class="border-b border-border/60 hover:bg-transparent">
|
<TableRow class="border-b border-border/60 hover:bg-transparent">
|
||||||
|
<TableHead class="w-[44px] h-12">
|
||||||
|
<Checkbox
|
||||||
|
:checked="isCurrentPageFullySelected || isAllFilteredSelected"
|
||||||
|
:indeterminate="isPartiallyFilteredSelected && !isCurrentPageFullySelected"
|
||||||
|
:disabled="paginatedUsers.length === 0 || selectAllFiltered || usersStore.loading"
|
||||||
|
@update:checked="toggleSelectCurrentPage"
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
<SortableTableHead
|
<SortableTableHead
|
||||||
class="w-[260px] h-12 font-semibold"
|
class="w-[260px] h-12 font-semibold"
|
||||||
column-key="role"
|
column-key="role"
|
||||||
@@ -231,6 +282,13 @@
|
|||||||
:key="user.id"
|
:key="user.id"
|
||||||
class="border-b border-border/40 hover:bg-muted/30 transition-colors"
|
class="border-b border-border/40 hover:bg-muted/30 transition-colors"
|
||||||
>
|
>
|
||||||
|
<TableCell class="py-4">
|
||||||
|
<Checkbox
|
||||||
|
:checked="selectAllFiltered || selectedIdSet.has(user.id)"
|
||||||
|
:disabled="selectAllFiltered || usersStore.loading"
|
||||||
|
@update:checked="(checked) => toggleOne(user.id, checked === true)"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
<TableCell class="py-4">
|
<TableCell class="py-4">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<Avatar class="h-10 w-10 ring-2 ring-background shadow-md">
|
<Avatar class="h-10 w-10 ring-2 ring-background shadow-md">
|
||||||
@@ -440,6 +498,12 @@
|
|||||||
>
|
>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
|
<Checkbox
|
||||||
|
class="mt-2 shrink-0"
|
||||||
|
:checked="selectAllFiltered || selectedIdSet.has(user.id)"
|
||||||
|
:disabled="selectAllFiltered || usersStore.loading"
|
||||||
|
@update:checked="(checked) => toggleOne(user.id, checked === true)"
|
||||||
|
/>
|
||||||
<Avatar class="h-10 w-10 ring-2 ring-background shadow-md flex-shrink-0">
|
<Avatar class="h-10 w-10 ring-2 ring-background shadow-md flex-shrink-0">
|
||||||
<AvatarFallback class="bg-primary text-sm font-bold text-white">
|
<AvatarFallback class="bg-primary text-sm font-bold text-white">
|
||||||
{{ user.username.charAt(0).toUpperCase() }}
|
{{ user.username.charAt(0).toUpperCase() }}
|
||||||
@@ -637,6 +701,16 @@
|
|||||||
@submit="handleUserFormSubmit"
|
@submit="handleUserFormSubmit"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<UserBatchActionDialog
|
||||||
|
:open="showUserBatchDialog"
|
||||||
|
:selected-ids="selectedIds"
|
||||||
|
:select-all-filtered="selectAllFiltered"
|
||||||
|
:selected-count="selectedCount"
|
||||||
|
:filters="batchSelectionFilters"
|
||||||
|
@close="showUserBatchDialog = false"
|
||||||
|
@completed="handleUserBatchCompleted"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- API Keys 管理对话框 -->
|
<!-- API Keys 管理对话框 -->
|
||||||
<Dialog
|
<Dialog
|
||||||
v-model="showApiKeysDialog"
|
v-model="showApiKeysDialog"
|
||||||
@@ -1060,7 +1134,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { useUsersStore } from '@/stores/users'
|
import { useUsersStore } from '@/stores/users'
|
||||||
import type { User, ApiKey, UserSession } from '@/api/users'
|
import type { User, ApiKey, UserSession, UserBatchActionResponse, UserBatchSelectionFilters } from '@/api/users'
|
||||||
import { formatSessionMeta } from '@/types/session'
|
import { formatSessionMeta } from '@/types/session'
|
||||||
import { adminWalletApi, type AdminWallet } from '@/api/admin-wallets'
|
import { adminWalletApi, type AdminWallet } from '@/api/admin-wallets'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
@@ -1093,7 +1167,8 @@ import {
|
|||||||
Avatar,
|
Avatar,
|
||||||
AvatarFallback,
|
AvatarFallback,
|
||||||
Pagination,
|
Pagination,
|
||||||
RefreshButton
|
RefreshButton,
|
||||||
|
Checkbox
|
||||||
} from '@/components/ui'
|
} from '@/components/ui'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -1114,11 +1189,13 @@ import {
|
|||||||
|
|
||||||
// 功能组件
|
// 功能组件
|
||||||
import UserFormDialog, { type UserFormData } from '@/features/users/components/UserFormDialog.vue'
|
import UserFormDialog, { type UserFormData } from '@/features/users/components/UserFormDialog.vue'
|
||||||
|
import UserBatchActionDialog from '@/features/users/components/UserBatchActionDialog.vue'
|
||||||
import WalletOpsDrawer from '@/features/wallet/components/WalletOpsDrawer.vue'
|
import WalletOpsDrawer from '@/features/wallet/components/WalletOpsDrawer.vue'
|
||||||
import { parseApiError } from '@/utils/errorParser'
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
import { formatTokens, formatRateLimitInheritable, formatRateLimitSimple, isRateLimitInherited, isRateLimitUnlimited } from '@/utils/format'
|
import { formatTokens, formatRateLimitInheritable, formatRateLimitSimple, isRateLimitInherited, isRateLimitUnlimited } from '@/utils/format'
|
||||||
import { parseNumberInput } from '@/utils/form'
|
import { parseNumberInput } from '@/utils/form'
|
||||||
import { log } from '@/utils/logger'
|
import { log } from '@/utils/logger'
|
||||||
|
import { useBatchSelection } from '@/composables/useBatchSelection'
|
||||||
|
|
||||||
const { success, error } = useToast()
|
const { success, error } = useToast()
|
||||||
const { confirmDanger } = useConfirm()
|
const { confirmDanger } = useConfirm()
|
||||||
@@ -1155,6 +1232,7 @@ const userWalletMap = ref<Record<string, AdminWallet>>({})
|
|||||||
|
|
||||||
const showWalletActionDialogState = ref(false)
|
const showWalletActionDialogState = ref(false)
|
||||||
const walletActionTarget = ref<{ user: User; wallet: AdminWallet } | null>(null)
|
const walletActionTarget = ref<{ user: User; wallet: AdminWallet } | null>(null)
|
||||||
|
const showUserBatchDialog = ref(false)
|
||||||
|
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const filterRole = ref('all')
|
const filterRole = ref('all')
|
||||||
@@ -1215,11 +1293,46 @@ const paginatedUsers = computed(() => {
|
|||||||
return filteredUsers.value.slice(start, start + pageSize.value)
|
return filteredUsers.value.slice(start, start + pageSize.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const filteredUserCount = computed(() => filteredUsers.value.length)
|
||||||
|
const {
|
||||||
|
selectedIds,
|
||||||
|
selectAllFiltered,
|
||||||
|
selectedIdSet,
|
||||||
|
selectedCount,
|
||||||
|
isAllFilteredSelected,
|
||||||
|
isPartiallyFilteredSelected,
|
||||||
|
isCurrentPageFullySelected,
|
||||||
|
canClearSelection,
|
||||||
|
rememberItems: rememberBatchPageUsers,
|
||||||
|
resetSelection: resetBatchSelection,
|
||||||
|
toggleOne,
|
||||||
|
toggleSelectFiltered,
|
||||||
|
toggleSelectCurrentPage,
|
||||||
|
clearSelection,
|
||||||
|
} = useBatchSelection<User>({
|
||||||
|
pageItems: paginatedUsers,
|
||||||
|
filteredTotal: filteredUserCount,
|
||||||
|
getItemId: (user) => user.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
const batchSelectionFilters = computed<UserBatchSelectionFilters>(() => {
|
||||||
|
const filters: UserBatchSelectionFilters = {}
|
||||||
|
const search = searchQuery.value.trim()
|
||||||
|
if (search) filters.search = search
|
||||||
|
if (filterRole.value === 'admin' || filterRole.value === 'user') filters.role = filterRole.value
|
||||||
|
if (filterStatus.value === 'active') filters.is_active = true
|
||||||
|
if (filterStatus.value === 'inactive') filters.is_active = false
|
||||||
|
return filters
|
||||||
|
})
|
||||||
|
|
||||||
// Watch filter changes and reset to first page
|
// Watch filter changes and reset to first page
|
||||||
watch([searchQuery, filterRole, filterStatus], () => {
|
watch([searchQuery, filterRole, filterStatus], () => {
|
||||||
currentPage.value = 1
|
currentPage.value = 1
|
||||||
|
resetBatchSelection()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(paginatedUsers, (users) => rememberBatchPageUsers(users), { immediate: true })
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void refreshUsers({ preferCache: true })
|
void refreshUsers({ preferCache: true })
|
||||||
})
|
})
|
||||||
@@ -1232,6 +1345,16 @@ async function refreshUsers(options: { preferCache?: boolean } = {}) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openUserBatchDialog(): void {
|
||||||
|
if (selectedCount.value === 0) return
|
||||||
|
showUserBatchDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUserBatchCompleted(_result: UserBatchActionResponse): Promise<void> {
|
||||||
|
await refreshUsers()
|
||||||
|
resetBatchSelection(true)
|
||||||
|
}
|
||||||
|
|
||||||
function formatDate(dateString: string) {
|
function formatDate(dateString: string) {
|
||||||
return new Date(dateString).toLocaleDateString('zh-CN')
|
return new Date(dateString).toLocaleDateString('zh-CN')
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user