feat: 实现前端卡密管理界面
- 卡密列表展示与分页功能 - 单个/批量创建卡密 - 卡密删除与批量删除 - 卡密导出功能 (file-saver) - 启用/禁用状态切换 - 状态判断 (有效/已使用/已失效) - Toast 通知系统 (vue-sonner) - 登录页面错误提示优化 - 后端登录错误消息中文化
This commit is contained in:
608
frontend/src/views/admin/CardKeysPage.vue
Normal file
608
frontend/src/views/admin/CardKeysPage.vue
Normal file
@@ -0,0 +1,608 @@
|
||||
<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 && response.data.keys) {
|
||||
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>
|
||||
98
frontend/src/views/admin/DashboardPage.vue
Normal file
98
frontend/src/views/admin/DashboardPage.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useAccountsStore } from '@/stores/accounts'
|
||||
import { Users, CheckCircle, XCircle, Armchair, RefreshCw } from 'lucide-vue-next'
|
||||
|
||||
const accountsStore = useAccountsStore()
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
await accountsStore.fetchAccounts()
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '加载数据失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold">Dashboard</h1>
|
||||
<Button variant="outline" size="sm" @click="loadData" :disabled="accountsStore.loading">
|
||||
<RefreshCw :class="['h-4 w-4 mr-2', accountsStore.loading && 'animate-spin']" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<!-- Total Teams -->
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium">Team 总数</CardTitle>
|
||||
<Users class="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton v-if="accountsStore.loading" class="h-8 w-16" />
|
||||
<div v-else class="text-2xl font-bold">{{ accountsStore.totalTeams }}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Valid Teams -->
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium">有效订阅</CardTitle>
|
||||
<CheckCircle class="h-4 w-4 text-green-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton v-if="accountsStore.loading" class="h-8 w-16" />
|
||||
<div v-else class="text-2xl font-bold text-green-600">{{ accountsStore.validTeams }}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Invalid Teams -->
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium">无效订阅</CardTitle>
|
||||
<XCircle class="h-4 w-4 text-red-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton v-if="accountsStore.loading" class="h-8 w-16" />
|
||||
<div v-else class="text-2xl font-bold text-red-600">{{ accountsStore.invalidTeams }}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Available Seats -->
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium">剩余席位</CardTitle>
|
||||
<Armchair class="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton v-if="accountsStore.loading" class="h-8 w-16" />
|
||||
<div v-else class="text-2xl font-bold">{{ accountsStore.totalAvailableSeats }}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<Card v-if="accountsStore.error" class="border-destructive">
|
||||
<CardContent class="pt-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-destructive">{{ accountsStore.error }}</p>
|
||||
<Button variant="outline" size="sm" @click="loadData">
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
85
frontend/src/views/admin/LoginPage.vue
Normal file
85
frontend/src/views/admin/LoginPage.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { Card, CardContent, CardDescription, 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 { useAuthStore } from '@/stores/auth'
|
||||
import { Loader2 } from 'lucide-vue-next'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
async function handleLogin() {
|
||||
if (!username.value.trim() || !password.value.trim()) {
|
||||
toast.error('请输入账号和密码')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
const result = await authStore.login({
|
||||
username: username.value.trim(),
|
||||
password: password.value,
|
||||
})
|
||||
|
||||
loading.value = false
|
||||
|
||||
if (result.success) {
|
||||
toast.success('登录成功')
|
||||
// Redirect to intended page or dashboard
|
||||
const redirect = route.query.redirect as string
|
||||
router.push(redirect || '/admin/dashboard')
|
||||
} else {
|
||||
toast.error(result.message || '登录失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="w-full max-w-md mx-4">
|
||||
<CardHeader class="text-center">
|
||||
<CardTitle class="text-2xl">管理后台登录</CardTitle>
|
||||
<CardDescription>请输入您的账号和密码</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form @submit.prevent="handleLogin" class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="username">账号</Label>
|
||||
<Input
|
||||
id="username"
|
||||
v-model="username"
|
||||
type="text"
|
||||
placeholder="请输入账号"
|
||||
:disabled="loading"
|
||||
autocomplete="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="password">密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
:disabled="loading"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" class="w-full" :disabled="loading">
|
||||
<Loader2 v-if="loading" class="mr-2 h-4 w-4 animate-spin" />
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
290
frontend/src/views/admin/TeamInvitesPage.vue
Normal file
290
frontend/src/views/admin/TeamInvitesPage.vue
Normal file
@@ -0,0 +1,290 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
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 { listInvitations, deleteInvite, type Invitation } from '@/api/invite'
|
||||
import { refreshAccount } from '@/api/accounts'
|
||||
import { useAccountsStore } from '@/stores/accounts'
|
||||
import { ArrowLeft, Trash2, Users, Loader2 } from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const accountsStore = useAccountsStore()
|
||||
|
||||
const accountId = computed(() => Number(route.params.id))
|
||||
const invitations = ref<Invitation[]>([])
|
||||
const loading = ref(false)
|
||||
const deleting = ref<number | null>(null)
|
||||
const deleteDialogOpen = ref(false)
|
||||
const pendingDelete = ref<Invitation | null>(null)
|
||||
|
||||
// Pagination
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const pageSizeOptions = [5, 10, 20, 50]
|
||||
|
||||
const totalPages = computed(() => Math.ceil(invitations.value.length / pageSize.value))
|
||||
const paginatedInvitations = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
const end = start + pageSize.value
|
||||
return invitations.value.slice(start, end)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadInvitations()
|
||||
})
|
||||
|
||||
async function loadInvitations() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await listInvitations(accountId.value)
|
||||
if (response.data.success && response.data.invitations) {
|
||||
invitations.value = response.data.invitations
|
||||
} else {
|
||||
toast.error(response.data.message || '获取邀请列表失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(e.response?.data?.message || '获取邀请列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTeamSeats() {
|
||||
try {
|
||||
const response = await refreshAccount(accountId.value)
|
||||
if (response.data.success && response.data.data) {
|
||||
accountsStore.updateAccount(response.data.data)
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent fail - just for updating seats
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(invitation: Invitation) {
|
||||
pendingDelete.value = invitation
|
||||
deleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!pendingDelete.value) return
|
||||
|
||||
const invitation = pendingDelete.value
|
||||
deleting.value = invitation.id
|
||||
deleteDialogOpen.value = false
|
||||
|
||||
try {
|
||||
const response = await deleteInvite({
|
||||
email: invitation.email,
|
||||
account_id: accountId.value,
|
||||
})
|
||||
if (response.data.success) {
|
||||
toast.success('删除成功')
|
||||
invitations.value = invitations.value.filter(i => i.id !== invitation.id)
|
||||
// Refresh team seats after deleting invitation
|
||||
await refreshTeamSeats()
|
||||
// Reset page if current page is empty
|
||||
if (paginatedInvitations.value.length === 0 && currentPage.value > 1) {
|
||||
currentPage.value--
|
||||
}
|
||||
} else {
|
||||
toast.error(response.data.message || '删除失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(e.response?.data?.message || '删除失败')
|
||||
} finally {
|
||||
deleting.value = null
|
||||
pendingDelete.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
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 gap-4">
|
||||
<Button variant="ghost" size="icon" @click="router.push('/admin/teams')">
|
||||
<ArrowLeft class="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 class="text-2xl font-bold">已邀请用户</h1>
|
||||
</div>
|
||||
|
||||
<Card class="min-h-[600px] flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle>用户列表</CardTitle>
|
||||
<CardDescription>Team ID: {{ accountId }}</CardDescription>
|
||||
</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="invitations.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>暂无已邀请用户</p>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<template v-else>
|
||||
<div class="flex-1">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>邮箱</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>邀请时间</TableHead>
|
||||
<TableHead class="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="invitation in paginatedInvitations" :key="invitation.id">
|
||||
<TableCell class="font-medium">
|
||||
{{ invitation.email }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
{{ invitation.status }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="text-muted-foreground">
|
||||
{{ formatDate(invitation.created_at) }}
|
||||
</TableCell>
|
||||
<TableCell class="text-right">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
@click="confirmDelete(invitation)"
|
||||
:disabled="deleting === invitation.id"
|
||||
>
|
||||
<Loader2 v-if="deleting === invitation.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">条,共 {{ invitations.length }} 条</span>
|
||||
</div>
|
||||
|
||||
<Pagination v-if="totalPages > 1" :total="invitations.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>
|
||||
确定要删除用户 <strong>{{ pendingDelete?.email }}</strong> 吗?此操作将从 Team 中移除该用户。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction @click="handleDelete" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</template>
|
||||
412
frontend/src/views/admin/TeamsPage.vue
Normal file
412
frontend/src/views/admin/TeamsPage.vue
Normal 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>
|
||||
Reference in New Issue
Block a user