feat(teams): fix checkbox multi-select and improve batch operations UI
- Fix checkbox binding using :model-value instead of :checked - Change selectedIds from Set to reactive array for proper Vue reactivity - Move batch refresh/delete buttons to top bar (matching CardKeysPage layout) - Buttons show selection count like 'Refresh (2)' when items selected - Swap position of 'Add Team' and 'Random Invite' buttons - Remove unused isIndeterminate computed property
This commit is contained in:
@@ -7,6 +7,7 @@ export interface Account {
|
||||
is_active: boolean
|
||||
seats_in_use: number
|
||||
seats_entitled: number
|
||||
active_until?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -56,3 +57,18 @@ export function refreshAccount(id: number) {
|
||||
export function deleteAccount(id: number) {
|
||||
return request.delete(`/api/accounts/delete?id=${id}`)
|
||||
}
|
||||
|
||||
export interface BatchOperationResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
success_count?: number
|
||||
failed_count?: number
|
||||
}
|
||||
|
||||
export function batchDeleteAccounts(ids: number[]) {
|
||||
return request.delete<BatchOperationResponse>('/api/accounts/batch/delete', { data: { ids } })
|
||||
}
|
||||
|
||||
export function batchRefreshAccounts(ids: number[]) {
|
||||
return request.post<BatchOperationResponse>('/api/accounts/batch/refresh', { ids })
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface InviteByCardRequest {
|
||||
|
||||
export interface Invitation {
|
||||
id: number
|
||||
email: string
|
||||
invited_email: string
|
||||
account_id: number
|
||||
status: string
|
||||
created_at: string
|
||||
|
||||
@@ -114,7 +114,7 @@ async function handleDelete() {
|
||||
|
||||
try {
|
||||
const response = await deleteInvite({
|
||||
email: invitation.email,
|
||||
email: invitation.invited_email,
|
||||
account_id: accountId.value,
|
||||
})
|
||||
if (response.data.success) {
|
||||
@@ -199,7 +199,7 @@ function handlePageSizeChange(value: any) {
|
||||
<TableBody>
|
||||
<TableRow v-for="invitation in paginatedInvitations" :key="invitation.id">
|
||||
<TableCell class="font-medium">
|
||||
{{ invitation.email }}
|
||||
{{ invitation.invited_email }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
@@ -277,7 +277,7 @@ function handlePageSizeChange(value: any) {
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除用户 <strong>{{ pendingDelete?.email }}</strong> 吗?此操作将从 Team 中移除该用户。
|
||||
确定要删除用户 <strong>{{ pendingDelete?.invited_email }}</strong> 吗?此操作将从 Team 中移除该用户。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
|
||||
@@ -53,8 +53,9 @@ import {
|
||||
PaginationPrevious,
|
||||
} from '@/components/ui/pagination'
|
||||
import { useAccountsStore } from '@/stores/accounts'
|
||||
import { createAccount, refreshAccount, deleteAccount, type Account } from '@/api/accounts'
|
||||
import { createAccount, refreshAccount, deleteAccount, batchDeleteAccounts, batchRefreshAccounts, type Account } from '@/api/accounts'
|
||||
import { inviteByAdmin } from '@/api/invite'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Plus, RefreshCw, Users, Loader2, Eye, EyeOff, Trash2, UserPlus, Shuffle } from 'lucide-vue-next'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -87,6 +88,12 @@ const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const pageSizeOptions = [5, 10, 20, 50]
|
||||
|
||||
// Selection for batch operations
|
||||
const selectedIds = ref<number[]>([])
|
||||
const batchDeleting = ref(false)
|
||||
const batchRefreshing = ref(false)
|
||||
const batchDeleteDialogOpen = ref(false)
|
||||
|
||||
const totalPages = computed(() => Math.ceil(accountsStore.accounts.length / pageSize.value))
|
||||
const paginatedAccounts = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
@@ -278,6 +285,99 @@ function handlePageSizeChange(value: any) {
|
||||
currentPage.value = 1
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr?: string) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
const isAllSelected = computed(() => {
|
||||
if (paginatedAccounts.value.length === 0) return false
|
||||
return paginatedAccounts.value.every(a => selectedIds.value.includes(a.id))
|
||||
})
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (isAllSelected.value) {
|
||||
// Deselect all in current page
|
||||
const pageIds = paginatedAccounts.value.map(a => a.id)
|
||||
selectedIds.value = selectedIds.value.filter(id => !pageIds.includes(id))
|
||||
} else {
|
||||
// Select all in current page
|
||||
const pageIds = paginatedAccounts.value.map(a => a.id)
|
||||
const newIds = pageIds.filter(id => !selectedIds.value.includes(id))
|
||||
selectedIds.value = [...selectedIds.value, ...newIds]
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelect(id: number) {
|
||||
if (selectedIds.value.includes(id)) {
|
||||
selectedIds.value = selectedIds.value.filter(i => i !== id)
|
||||
} else {
|
||||
selectedIds.value = [...selectedIds.value, id]
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedIds.value = []
|
||||
}
|
||||
|
||||
function confirmBatchDelete() {
|
||||
if (selectedIds.value.length === 0) {
|
||||
toast.error('请先选择要删除的 Team')
|
||||
return
|
||||
}
|
||||
batchDeleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
if (selectedIds.value.length === 0) return
|
||||
|
||||
batchDeleting.value = true
|
||||
batchDeleteDialogOpen.value = false
|
||||
|
||||
try {
|
||||
const ids = [...selectedIds.value]
|
||||
const response = await batchDeleteAccounts(ids)
|
||||
if (response.data.success) {
|
||||
toast.success(`成功删除 ${response.data.success_count} 个 Team`)
|
||||
} else {
|
||||
toast.success(`删除完成: 成功 ${response.data.success_count} 个, 失败 ${response.data.failed_count} 个`)
|
||||
}
|
||||
clearSelection()
|
||||
await accountsStore.fetchAccounts()
|
||||
if (paginatedAccounts.value.length === 0 && currentPage.value > 1) {
|
||||
currentPage.value--
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(e.response?.data?.message || '批量删除失败')
|
||||
} finally {
|
||||
batchDeleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatchRefresh() {
|
||||
if (selectedIds.value.length === 0) {
|
||||
toast.error('请先选择要刷新的 Team')
|
||||
return
|
||||
}
|
||||
|
||||
batchRefreshing.value = true
|
||||
|
||||
try {
|
||||
const ids = [...selectedIds.value]
|
||||
const response = await batchRefreshAccounts(ids)
|
||||
if (response.data.success) {
|
||||
toast.success(`成功刷新 ${response.data.success_count} 个 Team`)
|
||||
} else {
|
||||
toast.success(`刷新完成: 成功 ${response.data.success_count} 个, 失败 ${response.data.failed_count} 个`)
|
||||
}
|
||||
await accountsStore.fetchAccounts()
|
||||
} catch (e: any) {
|
||||
toast.error(e.response?.data?.message || '批量刷新失败')
|
||||
} finally {
|
||||
batchRefreshing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -285,6 +385,64 @@ function handlePageSizeChange(value: any) {
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold">Team 管理</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Batch operations - always show when there's data -->
|
||||
<template v-if="accountsStore.accounts.length > 0">
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="handleBatchRefresh"
|
||||
:disabled="selectedIds.length === 0 || batchRefreshing"
|
||||
>
|
||||
<Loader2 v-if="batchRefreshing" class="h-4 w-4 mr-2 animate-spin" />
|
||||
<RefreshCw v-else class="h-4 w-4 mr-2" />
|
||||
刷新{{ selectedIds.length > 0 ? ` (${selectedIds.length})` : '' }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
@click="confirmBatchDelete"
|
||||
:disabled="selectedIds.length === 0 || batchDeleting"
|
||||
>
|
||||
<Loader2 v-if="batchDeleting" class="h-4 w-4 mr-2 animate-spin" />
|
||||
<Trash2 v-else class="h-4 w-4 mr-2" />
|
||||
删除{{ selectedIds.length > 0 ? ` (${selectedIds.length})` : '' }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<!-- Random Invite Button and Dialog -->
|
||||
<Dialog v-model:open="randomInviteDialogOpen">
|
||||
<DialogTrigger as-child>
|
||||
<Button variant="outline">
|
||||
<Shuffle class="h-4 w-4 mr-2" />
|
||||
随机邀请
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>随机邀请</DialogTitle>
|
||||
<DialogDescription>
|
||||
系统将自动选择有空位的 Team 发送邀请
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form @submit.prevent="handleRandomInvite" class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="random_invite_email">邮箱地址 *</Label>
|
||||
<Input
|
||||
id="random_invite_email"
|
||||
v-model="randomInviteEmail"
|
||||
type="email"
|
||||
placeholder="user@example.com"
|
||||
:disabled="randomInviting"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" :disabled="randomInviting">
|
||||
<Loader2 v-if="randomInviting" class="h-4 w-4 mr-2 animate-spin" />
|
||||
{{ randomInviting ? '邀请中...' : '发送邀请' }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:open="dialogOpen">
|
||||
<DialogTrigger as-child>
|
||||
<Button>
|
||||
@@ -350,42 +508,6 @@ function handlePageSizeChange(value: any) {
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- Random Invite Button and Dialog -->
|
||||
<Dialog v-model:open="randomInviteDialogOpen">
|
||||
<DialogTrigger as-child>
|
||||
<Button variant="outline">
|
||||
<Shuffle class="h-4 w-4 mr-2" />
|
||||
随机邀请
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>随机邀请</DialogTitle>
|
||||
<DialogDescription>
|
||||
系统将自动选择有空位的 Team 发送邀请
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form @submit.prevent="handleRandomInvite" class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="random_invite_email">邮箱地址 *</Label>
|
||||
<Input
|
||||
id="random_invite_email"
|
||||
v-model="randomInviteEmail"
|
||||
type="email"
|
||||
placeholder="user@example.com"
|
||||
:disabled="randomInviting"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" :disabled="randomInviting">
|
||||
<Loader2 v-if="randomInviting" class="h-4 w-4 mr-2 animate-spin" />
|
||||
{{ randomInviting ? '邀请中...' : '发送邀请' }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -415,14 +537,27 @@ function handlePageSizeChange(value: any) {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-12">
|
||||
<Checkbox
|
||||
:model-value="isAllSelected"
|
||||
@update:model-value="toggleSelectAll"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>订阅状态</TableHead>
|
||||
<TableHead>到期时间</TableHead>
|
||||
<TableHead class="text-right">剩余席位</TableHead>
|
||||
<TableHead class="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="account in paginatedAccounts" :key="account.id">
|
||||
<TableRow v-for="account in paginatedAccounts" :key="account.id" :class="{ 'bg-muted/50': selectedIds.includes(account.id) }">
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
:model-value="selectedIds.includes(account.id)"
|
||||
@update:model-value="() => toggleSelect(account.id)"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell class="font-medium">
|
||||
{{ account.name || account.team_account_id }}
|
||||
</TableCell>
|
||||
@@ -431,6 +566,9 @@ function handlePageSizeChange(value: any) {
|
||||
{{ account.is_active ? '有效' : '无效' }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="text-muted-foreground">
|
||||
{{ formatDate(account.active_until) }}
|
||||
</TableCell>
|
||||
<TableCell class="text-right">
|
||||
{{ (account.seats_entitled || 0) - (account.seats_in_use || 0) }}
|
||||
</TableCell>
|
||||
@@ -533,6 +671,24 @@ function handlePageSizeChange(value: any) {
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<!-- Batch Delete confirmation dialog -->
|
||||
<AlertDialog v-model:open="batchDeleteDialogOpen">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认批量删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除选中的 <strong>{{ selectedIds.length }}</strong> 个 Team 吗?此操作不可撤销,相关的邀请记录也会被删除。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction @click="handleBatchDelete" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<!-- Invite dialog -->
|
||||
<Dialog v-model:open="inviteDialogOpen">
|
||||
<DialogContent>
|
||||
|
||||
Reference in New Issue
Block a user