- 添加全局 API Token 认证支持 (环境变量 API_TOKEN) - Team 页面添加直接邀请按钮 - Team 页面添加随机邀请按钮 - 修复已邀请用户列表字段名不匹配问题 - 修复数据库为空时错误显示 toast 的问题
567 lines
18 KiB
Vue
567 lines
18 KiB
Vue
<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>
|