Files
GPT_Management/frontend/src/views/admin/TeamsPage.vue
sar 93aa31219d feat: 添加功能和修复问题
- 添加全局 API Token 认证支持 (环境变量 API_TOKEN)
- Team 页面添加直接邀请按钮
- Team 页面添加随机邀请按钮
- 修复已邀请用户列表字段名不匹配问题
- 修复数据库为空时错误显示 toast 的问题
2026-01-14 13:25:49 +08:00

567 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationFirst,
PaginationItem,
PaginationLast,
PaginationNext,
PaginationPrevious,
} from '@/components/ui/pagination'
import { useAccountsStore } from '@/stores/accounts'
import { createAccount, refreshAccount, deleteAccount, type Account } from '@/api/accounts'
import { inviteByAdmin } from '@/api/invite'
import { Plus, RefreshCw, Users, Loader2, Eye, EyeOff, Trash2, UserPlus, Shuffle } from 'lucide-vue-next'
const router = useRouter()
const accountsStore = useAccountsStore()
const dialogOpen = ref(false)
const refreshingId = ref<number | null>(null)
const deletingId = ref<number | null>(null)
const creating = ref(false)
const showToken = ref(false)
// Delete confirmation
const deleteDialogOpen = ref(false)
// Invite dialog
const inviteDialogOpen = ref(false)
const invitingAccountId = ref<number | null>(null)
const invitingAccountName = ref('')
const inviteEmail = ref('')
const inviting = ref(false)
// Random invite dialog
const randomInviteDialogOpen = ref(false)
const randomInviteEmail = ref('')
const randomInviting = ref(false)
const pendingDelete = ref<Account | null>(null)
// Pagination
const currentPage = ref(1)
const pageSize = ref(10)
const pageSizeOptions = [5, 10, 20, 50]
const totalPages = computed(() => Math.ceil(accountsStore.accounts.length / pageSize.value))
const paginatedAccounts = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return accountsStore.accounts.slice(start, end)
})
// Form
const form = ref({
team_account_id: '',
auth_token: '',
name: '',
})
onMounted(() => {
loadAccounts()
})
async function loadAccounts() {
try {
await accountsStore.fetchAccounts()
} catch (e: any) {
toast.error(e.message || '加载 Team 列表失败')
}
}
async function handleRefresh(account: Account) {
refreshingId.value = account.id
try {
const response = await refreshAccount(account.id)
if (response.data.success && response.data.data) {
accountsStore.updateAccount(response.data.data)
toast.success('刷新成功')
} else {
toast.error(response.data.message || '刷新失败')
}
await accountsStore.fetchAccounts()
} catch (e: any) {
toast.error(e.response?.data?.message || '刷新失败')
} finally {
refreshingId.value = null
}
}
function confirmDelete(account: Account) {
pendingDelete.value = account
deleteDialogOpen.value = true
}
async function handleDelete() {
if (!pendingDelete.value) return
const account = pendingDelete.value
deletingId.value = account.id
deleteDialogOpen.value = false
try {
const response = await deleteAccount(account.id)
if (response.data.success) {
toast.success('删除成功')
await accountsStore.fetchAccounts()
if (paginatedAccounts.value.length === 0 && currentPage.value > 1) {
currentPage.value--
}
} else {
toast.error(response.data.message || '删除失败')
}
} catch (e: any) {
toast.error(e.response?.data?.message || '删除失败')
} finally {
deletingId.value = null
pendingDelete.value = null
}
}
async function handleCreate() {
if (!form.value.team_account_id.trim()) {
toast.error('请输入 Team Account ID')
return
}
if (!form.value.auth_token.trim()) {
toast.error('请输入 Auth Token')
return
}
creating.value = true
try {
const response = await createAccount({
team_account_id: form.value.team_account_id.trim(),
auth_token: form.value.auth_token.trim(),
name: form.value.name.trim() || undefined,
})
if (response.data.success) {
toast.success('添加 Team 成功')
dialogOpen.value = false
form.value = { team_account_id: '', auth_token: '', name: '' }
await accountsStore.fetchAccounts()
} else {
toast.error(response.data.message || '添加失败')
}
} catch (e: any) {
toast.error(e.response?.data?.message || '添加失败')
} finally {
creating.value = false
}
}
function viewInvites(account: Account) {
router.push(`/admin/teams/${account.id}/invites`)
}
function openInviteDialog(account: Account) {
invitingAccountId.value = account.id
invitingAccountName.value = account.name || account.team_account_id
inviteEmail.value = ''
inviteDialogOpen.value = true
}
async function handleInvite() {
if (!inviteEmail.value.trim()) {
toast.error('请输入邮箱地址')
return
}
if (!invitingAccountId.value) return
inviting.value = true
try {
const response = await inviteByAdmin({
email: inviteEmail.value.trim(),
account_id: invitingAccountId.value,
})
if (response.data.success) {
toast.success('邀请发送成功')
inviteDialogOpen.value = false
inviteEmail.value = ''
// Refresh account to update seats
if (invitingAccountId.value) {
await handleRefresh(accountsStore.accounts.find(a => a.id === invitingAccountId.value)!)
}
} else {
toast.error(response.data.message || '邀请失败')
}
} catch (e: any) {
toast.error(e.response?.data?.message || '邀请失败')
} finally {
inviting.value = false
}
}
// Random invite - auto select available team
async function handleRandomInvite() {
if (!randomInviteEmail.value.trim()) {
toast.error('请输入邮箱地址')
return
}
randomInviting.value = true
try {
// Use account_id = 0 to let backend auto-select
const response = await inviteByAdmin({
email: randomInviteEmail.value.trim(),
account_id: 0,
})
if (response.data.success) {
toast.success(`邀请发送成功,已分配到: ${response.data.account_name || 'Team'}`)
randomInviteDialogOpen.value = false
randomInviteEmail.value = ''
// Refresh all accounts to update seats
await accountsStore.fetchAccounts()
} else {
toast.error(response.data.message || '邀请失败')
}
} catch (e: any) {
toast.error(e.response?.data?.message || '邀请失败')
} finally {
randomInviting.value = false
}
}
function goToPage(page: number) {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page
}
}
function handlePageSizeChange(value: any) {
if (value) {
pageSize.value = Number(value)
currentPage.value = 1
}
}
</script>
<template>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold">Team 管理</h1>
<div class="flex items-center gap-2">
<Dialog v-model:open="dialogOpen">
<DialogTrigger as-child>
<Button>
<Plus class="h-4 w-4 mr-2" />
添加 Team
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>添加 Team</DialogTitle>
<DialogDescription>
填写 Team 信息以添加新的 ChatGPT Team 账号
</DialogDescription>
</DialogHeader>
<form @submit.prevent="handleCreate" class="space-y-4">
<div class="space-y-2">
<Label for="team_account_id">Team Account ID *</Label>
<Input
id="team_account_id"
v-model="form.team_account_id"
placeholder="例如: org-xxxxx"
:disabled="creating"
/>
</div>
<div class="space-y-2">
<Label for="auth_token">Auth Token *</Label>
<div class="relative">
<Input
id="auth_token"
v-model="form.auth_token"
:type="showToken ? 'text' : 'password'"
placeholder="Bearer token"
:disabled="creating"
class="pr-10"
/>
<Button
type="button"
variant="ghost"
size="icon"
class="absolute right-0 top-0 h-full"
@click="showToken = !showToken"
>
<Eye v-if="!showToken" class="h-4 w-4" />
<EyeOff v-else class="h-4 w-4" />
</Button>
</div>
</div>
<div class="space-y-2">
<Label for="name">名称可选</Label>
<Input
id="name"
v-model="form.name"
placeholder="给这个 Team 起个名字"
:disabled="creating"
/>
</div>
<DialogFooter>
<Button type="submit" :disabled="creating">
<Loader2 v-if="creating" class="h-4 w-4 mr-2 animate-spin" />
{{ creating ? '添加中...' : '添加' }}
</Button>
</DialogFooter>
</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>
<Card class="min-h-[600px] flex flex-col">
<CardHeader>
<CardTitle>Team 列表</CardTitle>
</CardHeader>
<CardContent class="flex-1 flex flex-col">
<!-- Loading state -->
<div v-if="accountsStore.loading && accountsStore.accounts.length === 0" class="space-y-4">
<Skeleton v-for="i in 5" :key="i" class="h-12 w-full" />
</div>
<!-- Empty state -->
<div
v-else-if="accountsStore.accounts.length === 0"
class="flex-1 flex flex-col items-center justify-center text-muted-foreground"
>
<Users class="h-12 w-12 mb-4 opacity-50" />
<p>暂无 Team</p>
<p class="text-sm">点击上方按钮添加第一个 Team</p>
</div>
<!-- Table -->
<template v-else>
<div class="flex-1">
<Table>
<TableHeader>
<TableRow>
<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">
<TableCell class="font-medium">
{{ account.name || account.team_account_id }}
</TableCell>
<TableCell>
<Badge :variant="account.is_active ? 'default' : 'destructive'">
{{ account.is_active ? '有效' : '无效' }}
</Badge>
</TableCell>
<TableCell class="text-right">
{{ (account.seats_entitled || 0) - (account.seats_in_use || 0) }}
</TableCell>
<TableCell class="text-right space-x-2">
<Button
variant="outline"
size="sm"
@click="handleRefresh(account)"
:disabled="refreshingId === account.id"
>
<RefreshCw
:class="[
'h-4 w-4',
refreshingId === account.id && 'animate-spin'
]"
/>
</Button>
<Button variant="outline" size="sm" @click="openInviteDialog(account)" title="直接邀请">
<UserPlus class="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" @click="viewInvites(account)" title="查看已邀请用户">
<Users class="h-4 w-4" />
</Button>
<Button
variant="destructive"
size="sm"
@click="confirmDelete(account)"
:disabled="deletingId === account.id"
>
<Loader2 v-if="deletingId === account.id" class="h-4 w-4 animate-spin" />
<Trash2 v-else class="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<!-- Pagination -->
<div class="flex items-center justify-between mt-4 pt-4 border-t">
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">每页</span>
<Select :model-value="String(pageSize)" @update:model-value="handlePageSizeChange">
<SelectTrigger class="w-[70px] h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="size in pageSizeOptions" :key="size" :value="String(size)">
{{ size }}
</SelectItem>
</SelectContent>
</Select>
<span class="text-sm text-muted-foreground"> {{ accountsStore.accounts.length }} </span>
</div>
<Pagination v-if="totalPages > 1" :total="accountsStore.accounts.length" :items-per-page="pageSize" :default-page="1">
<PaginationContent class="flex items-center gap-1">
<PaginationFirst @click="goToPage(1)" />
<PaginationPrevious @click="goToPage(currentPage - 1)" />
<template v-for="page in totalPages" :key="page">
<PaginationItem
v-if="page === 1 || page === totalPages || (page >= currentPage - 1 && page <= currentPage + 1)"
:value="page"
@click="goToPage(page)"
>
<Button class="w-9 h-9 p-0" :variant="page === currentPage ? 'default' : 'outline'">
{{ page }}
</Button>
</PaginationItem>
<PaginationEllipsis
v-else-if="page === currentPage - 2 || page === currentPage + 2"
/>
</template>
<PaginationNext @click="goToPage(currentPage + 1)" />
<PaginationLast @click="goToPage(totalPages)" />
</PaginationContent>
</Pagination>
</div>
</template>
</CardContent>
</Card>
<!-- Delete confirmation dialog -->
<AlertDialog v-model:open="deleteDialogOpen">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除</AlertDialogTitle>
<AlertDialogDescription>
确定要删除 Team <strong>{{ pendingDelete?.name || pendingDelete?.team_account_id }}</strong> 此操作不可撤销
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction @click="handleDelete" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">
删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<!-- Invite dialog -->
<Dialog v-model:open="inviteDialogOpen">
<DialogContent>
<DialogHeader>
<DialogTitle>邀请用户</DialogTitle>
<DialogDescription>
邀请用户加入 Team: {{ invitingAccountName }}
</DialogDescription>
</DialogHeader>
<form @submit.prevent="handleInvite" class="space-y-4">
<div class="space-y-2">
<Label for="invite_email">邮箱地址 *</Label>
<Input
id="invite_email"
v-model="inviteEmail"
type="email"
placeholder="user@example.com"
:disabled="inviting"
/>
</div>
<DialogFooter>
<Button type="submit" :disabled="inviting">
<Loader2 v-if="inviting" class="h-4 w-4 mr-2 animate-spin" />
{{ inviting ? '邀请中...' : '发送邀请' }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
</template>