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

609 lines
19 KiB
Vue

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
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 { Switch } from '@/components/ui/switch'
import { Checkbox } from '@/components/ui/checkbox'
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 {
getCardKeys,
createCardKey,
batchCreateCardKeys,
deleteCardKey,
batchDeleteCardKeys,
toggleCardKeyActive,
type CardKey,
} from '@/api/cardkeys'
import { saveAs } from 'file-saver'
import { Plus, Copy, KeyRound, Loader2, Layers, Trash2, Download } from 'lucide-vue-next'
const cardKeys = ref<CardKey[]>([])
const loading = ref(false)
const creating = ref(false)
const batchCreating = ref(false)
const total = ref(0)
// Selection - use array for reactivity
const selectedIds = ref<number[]>([])
const isAllSelected = computed(() => {
return cardKeys.value.length > 0 && selectedIds.value.length === cardKeys.value.length
})
const selectedCount = computed(() => selectedIds.value.length)
// Delete confirmation
const deleteDialogOpen = ref(false)
const pendingDeleteId = ref<number | null>(null)
const pendingBatchDelete = ref(false)
const deleting = ref(false)
// Pagination
const currentPage = ref(1)
const pageSize = ref(10)
const pageSizeOptions = [5, 10, 20, 50]
const totalPages = computed(() => Math.ceil(total.value / pageSize.value))
// Single create form
const singleDialogOpen = ref(false)
const singleForm = ref({
validity_days: '',
max_uses: '',
})
// Batch create form
const batchDialogOpen = ref(false)
const batchForm = ref({
count: '',
validity_days: '',
max_uses: '',
})
onMounted(() => {
loadCardKeys()
})
async function loadCardKeys() {
loading.value = true
try {
const response = await getCardKeys({ page: currentPage.value, page_size: pageSize.value })
if (response.data.success) {
cardKeys.value = response.data.keys || []
total.value = response.data.total || 0
} else {
toast.error(response.data.message || '获取卡密列表失败')
}
} catch (e: any) {
toast.error(e.response?.data?.message || '获取卡密列表失败')
} finally {
loading.value = false
selectedIds.value = []
}
}
async function handleCreateSingle() {
creating.value = true
try {
const response = await createCardKey({
validity_days: singleForm.value.validity_days ? Number(singleForm.value.validity_days) : undefined,
max_uses: singleForm.value.max_uses ? Number(singleForm.value.max_uses) : undefined,
})
if (response.data.success) {
toast.success('创建卡密成功')
singleDialogOpen.value = false
singleForm.value = { validity_days: '', max_uses: '' }
await loadCardKeys()
} else {
toast.error(response.data.message || '创建失败')
}
} catch (e: any) {
toast.error(e.response?.data?.message || '创建失败')
} finally {
creating.value = false
}
}
async function handleCreateBatch() {
if (!batchForm.value.count || Number(batchForm.value.count) < 1) {
toast.error('请输入有效的数量')
return
}
batchCreating.value = true
try {
const response = await batchCreateCardKeys({
count: Number(batchForm.value.count),
validity_days: batchForm.value.validity_days ? Number(batchForm.value.validity_days) : undefined,
max_uses: batchForm.value.max_uses ? Number(batchForm.value.max_uses) : undefined,
})
if (response.data.success) {
toast.success(`批量创建 ${batchForm.value.count} 个卡密成功`)
batchDialogOpen.value = false
batchForm.value = { count: '', validity_days: '', max_uses: '' }
await loadCardKeys()
} else {
toast.error(response.data.message || '创建失败')
}
} catch (e: any) {
toast.error(e.response?.data?.message || '创建失败')
} finally {
batchCreating.value = false
}
}
function confirmDelete(id: number) {
pendingDeleteId.value = id
pendingBatchDelete.value = false
deleteDialogOpen.value = true
}
function confirmBatchDelete() {
if (selectedIds.value.length === 0) {
toast.error('请先选择要删除的卡密')
return
}
pendingDeleteId.value = null
pendingBatchDelete.value = true
deleteDialogOpen.value = true
}
async function handleDelete() {
deleting.value = true
deleteDialogOpen.value = false
try {
if (pendingBatchDelete.value) {
const ids = [...selectedIds.value]
const response = await batchDeleteCardKeys(ids)
if (response.data.success) {
toast.success(`成功删除 ${ids.length} 个卡密`)
await loadCardKeys()
} else {
toast.error(response.data.message || '删除失败')
}
} else if (pendingDeleteId.value) {
const response = await deleteCardKey(pendingDeleteId.value)
if (response.data.success) {
toast.success('删除成功')
await loadCardKeys()
} else {
toast.error(response.data.message || '删除失败')
}
}
} catch (e: any) {
toast.error(e.response?.data?.message || '删除失败')
} finally {
deleting.value = false
pendingDeleteId.value = null
pendingBatchDelete.value = false
}
}
async function handleToggleActive(cardKey: CardKey, newStatus: boolean) {
try {
const response = await toggleCardKeyActive(cardKey.id, newStatus)
if (response.data.success) {
cardKey.is_active = newStatus
toast.success(newStatus ? '已启用' : '已禁用')
} else {
toast.error(response.data.message || '操作失败')
}
} catch (e: any) {
toast.error(e.response?.data?.message || '操作失败')
}
}
function handleSelectAll() {
if (isAllSelected.value) {
selectedIds.value = []
} else {
selectedIds.value = cardKeys.value.map(ck => ck.id)
}
}
function isSelected(id: number) {
return selectedIds.value.includes(id)
}
function handleRowSelect(id: number) {
const index = selectedIds.value.indexOf(id)
if (index > -1) {
selectedIds.value.splice(index, 1)
} else {
selectedIds.value.push(id)
}
}
function copyKey(key: string) {
navigator.clipboard.writeText(key)
toast.success('已复制到剪贴板')
}
function exportSelected() {
if (selectedIds.value.length === 0) {
toast.error('请先选择要导出的卡密')
return
}
try {
const selectedKeys = cardKeys.value.filter(ck => selectedIds.value.includes(ck.id))
const content = selectedKeys.map(ck => ck.key).join('\r\n')
const filename = `cardkeys_${new Date().toISOString().slice(0, 10)}.txt`
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
saveAs(blob, filename)
toast.success(`已导出 ${selectedKeys.length} 个卡密`)
} catch (e) {
toast.error('导出失败')
console.error('Export error:', e)
}
}
function formatDate(dateStr: string) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleDateString('zh-CN')
}
// 卡密状态判断
function getCardKeyStatus(cardKey: CardKey): { text: string; variant: 'default' | 'secondary' | 'destructive' } {
// 判断是否已用完(使用次数 >= 最大次数)
if (cardKey.max_uses > 0 && cardKey.used_count >= cardKey.max_uses) {
return { text: '已使用', variant: 'secondary' }
}
// 判断是否已失效(手动禁用)
if (!cardKey.is_active) {
return { text: '已失效', variant: 'destructive' }
}
// 其他情况为有效
return { text: '有效', variant: 'default' }
}
function goToPage(page: number) {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page
loadCardKeys()
}
}
function handlePageSizeChange(value: unknown) {
if (value) {
pageSize.value = Number(value)
currentPage.value = 1
loadCardKeys()
}
}
</script>
<template>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold">卡密管理</h1>
<div class="flex items-center gap-2">
<!-- Batch operations - always show when there's data -->
<template v-if="cardKeys.length > 0">
<Button
variant="outline"
@click="exportSelected"
:disabled="selectedCount === 0"
>
<Download class="h-4 w-4 mr-2" />
导出{{ selectedCount > 0 ? ` (${selectedCount})` : '' }}
</Button>
<Button
variant="destructive"
@click="confirmBatchDelete"
:disabled="selectedCount === 0"
>
<Trash2 class="h-4 w-4 mr-2" />
删除{{ selectedCount > 0 ? ` (${selectedCount})` : '' }}
</Button>
</template>
<!-- Single create -->
<Dialog v-model:open="singleDialogOpen">
<DialogTrigger as-child>
<Button variant="outline">
<Plus class="h-4 w-4 mr-2" />
创建卡密
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>创建卡密</DialogTitle>
<DialogDescription>创建单个卡密</DialogDescription>
</DialogHeader>
<form @submit.prevent="handleCreateSingle" class="space-y-4">
<div class="space-y-2">
<Label for="validity_days">有效天数(可选)</Label>
<Input
id="validity_days"
v-model="singleForm.validity_days"
type="number"
min="1"
placeholder="留空默认30天"
:disabled="creating"
/>
</div>
<div class="space-y-2">
<Label for="max_uses">最大使用次数(可选)</Label>
<Input
id="max_uses"
v-model="singleForm.max_uses"
type="number"
min="1"
placeholder="留空默认1次"
: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>
<!-- Batch create -->
<Dialog v-model:open="batchDialogOpen">
<DialogTrigger as-child>
<Button>
<Layers class="h-4 w-4 mr-2" />
批量创建
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>批量创建卡密</DialogTitle>
<DialogDescription>批量创建多个卡密</DialogDescription>
</DialogHeader>
<form @submit.prevent="handleCreateBatch" class="space-y-4">
<div class="space-y-2">
<Label for="batch_count">数量 *</Label>
<Input
id="batch_count"
v-model="batchForm.count"
type="number"
min="1"
max="100"
placeholder="1-100"
:disabled="batchCreating"
/>
</div>
<div class="space-y-2">
<Label for="batch_validity_days">有效天数(可选)</Label>
<Input
id="batch_validity_days"
v-model="batchForm.validity_days"
type="number"
min="1"
placeholder="留空默认30天"
:disabled="batchCreating"
/>
</div>
<div class="space-y-2">
<Label for="batch_max_uses">最大使用次数(可选)</Label>
<Input
id="batch_max_uses"
v-model="batchForm.max_uses"
type="number"
min="1"
placeholder="留空默认1次"
:disabled="batchCreating"
/>
</div>
<DialogFooter>
<Button type="submit" :disabled="batchCreating">
<Loader2 v-if="batchCreating" class="h-4 w-4 mr-2 animate-spin" />
{{ batchCreating ? '创建中...' : '批量创建' }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
</div>
<Card class="min-h-[600px] flex flex-col">
<CardHeader>
<CardTitle>卡密列表</CardTitle>
</CardHeader>
<CardContent class="flex-1 flex flex-col">
<!-- Loading state -->
<div v-if="loading" class="space-y-4">
<Skeleton v-for="i in 5" :key="i" class="h-12 w-full" />
</div>
<!-- Empty state -->
<div
v-else-if="cardKeys.length === 0"
class="flex-1 flex flex-col items-center justify-center text-muted-foreground"
>
<KeyRound class="h-12 w-12 mb-4 opacity-50" />
<p>暂无卡密</p>
<p class="text-sm">点击上方按钮创建卡密</p>
</div>
<!-- Table -->
<template v-else>
<div class="flex-1">
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-[50px]">
<Checkbox
:model-value="isAllSelected"
@update:model-value="handleSelectAll"
/>
</TableHead>
<TableHead>卡密</TableHead>
<TableHead>状态</TableHead>
<TableHead>使用次数</TableHead>
<TableHead>有效期</TableHead>
<TableHead>启用</TableHead>
<TableHead class="text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="cardKey in cardKeys" :key="cardKey.id">
<TableCell>
<Checkbox
:model-value="isSelected(cardKey.id)"
@update:model-value="() => handleRowSelect(cardKey.id)"
/>
</TableCell>
<TableCell class="font-mono text-sm">
{{ cardKey.key }}
</TableCell>
<TableCell>
<Badge :variant="getCardKeyStatus(cardKey).variant">
{{ getCardKeyStatus(cardKey).text }}
</Badge>
</TableCell>
<TableCell>
{{ cardKey.used_count }} / {{ cardKey.max_uses || '' }}
</TableCell>
<TableCell class="text-muted-foreground">
{{ formatDate(cardKey.expires_at) }}
</TableCell>
<TableCell>
<Switch
:model-value="cardKey.is_active"
@update:model-value="(val) => handleToggleActive(cardKey, val)"
/>
</TableCell>
<TableCell class="text-right space-x-2">
<Button variant="outline" size="sm" @click="copyKey(cardKey.key)">
<Copy class="h-4 w-4" />
</Button>
<Button variant="destructive" size="sm" @click="confirmDelete(cardKey.id)">
<Trash2 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">条,共 {{ total }} 条</span>
</div>
<Pagination v-if="totalPages > 1" :total="total" :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>
<template v-if="pendingBatchDelete">
确定要删除选中的 <strong>{{ selectedCount }}</strong> 个卡密吗?此操作不可撤销。
</template>
<template v-else>
确定要删除此卡密吗?此操作不可撤销。
</template>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction @click="handleDelete" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">
删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</template>