feat: 实现前端卡密管理界面

- 卡密列表展示与分页功能

- 单个/批量创建卡密

- 卡密删除与批量删除

- 卡密导出功能 (file-saver)

- 启用/禁用状态切换

- 状态判断 (有效/已使用/已失效)

- Toast 通知系统 (vue-sonner)

- 登录页面错误提示优化

- 后端登录错误消息中文化
This commit is contained in:
sar
2026-01-13 21:34:56 +08:00
parent 42c423bd32
commit 8d60704eda
143 changed files with 6646 additions and 91 deletions

View File

@@ -0,0 +1,412 @@
<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 { Plus, RefreshCw, Users, Loader2, Eye, EyeOff, Trash2 } 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)
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 goToPage(page: number) {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page
}
}
function handlePageSizeChange(value: string) {
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>
<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>
</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="viewInvites(account)">
<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>
</div>
</template>