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:
sar
2026-01-16 11:53:04 +08:00
parent 59f5a87275
commit 474f592dcd
32 changed files with 405 additions and 67 deletions

View File

@@ -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 })
}

View File

@@ -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

View File

@@ -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>

View File

@@ -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>