Files
Aether/frontend/src/views/admin/Users.vue
T

1755 lines
60 KiB
Vue
Raw Normal View History

2025-12-10 20:52:44 +08:00
<template>
<div class="space-y-6 pb-8">
<!-- 用户表格 -->
<Card
variant="default"
class="overflow-hidden"
>
2025-12-10 20:52:44 +08:00
<!-- 标题和筛选器 -->
<div class="px-4 sm:px-6 py-3.5 border-b border-border/60">
<!-- 移动端:标题行 + 筛选器行 -->
<div class="flex flex-col gap-3 sm:hidden">
<div class="flex items-center justify-between">
<h3 class="text-base font-semibold">
用户管理
</h3>
<div class="flex items-center gap-2">
<!-- 新增用户按钮 -->
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="新增用户"
@click="openCreateDialog"
>
<Plus class="w-3.5 h-3.5" />
</Button>
<!-- 刷新按钮 -->
<RefreshButton
:loading="usersStore.loading"
@click="refreshUsers"
/>
</div>
</div>
<!-- 筛选器 -->
<div class="flex items-center gap-2">
<div class="relative flex-1">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
<Input
id="users-search-mobile"
v-model="searchQuery"
type="text"
placeholder="搜索..."
class="w-full pl-8 pr-3 h-8 text-sm bg-background/50 border-border/60"
/>
</div>
<Select
v-model="filterRole"
>
<SelectTrigger class="w-24 h-8 text-xs border-border/60">
<SelectValue placeholder="角色" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
全部
</SelectItem>
<SelectItem value="admin">
管理员
</SelectItem>
<SelectItem value="user">
用户
</SelectItem>
</SelectContent>
</Select>
<Select
v-model="filterStatus"
>
<SelectTrigger class="w-20 h-8 text-xs border-border/60">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
全部
</SelectItem>
<SelectItem value="active">
活跃
</SelectItem>
<SelectItem value="inactive">
禁用
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<!-- 桌面端:单行布局 -->
<div class="hidden sm:flex items-center justify-between gap-4">
<h3 class="text-base font-semibold">
用户管理
</h3>
2025-12-10 20:52:44 +08:00
<!-- 筛选器和操作按钮 -->
<div class="flex items-center gap-2">
<!-- 搜索框 -->
<div class="relative">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
<Input
id="users-search"
v-model="searchQuery"
type="text"
placeholder="搜索用户名或邮箱..."
class="w-48 pl-8 pr-3 h-8 text-sm bg-background/50 border-border/60 focus:border-primary/40 transition-colors"
/>
</div>
<!-- 分隔线 -->
<div class="h-4 w-px bg-border" />
<!-- 角色筛选 -->
2026-05-01 02:14:49 +08:00
<div class="xl:hidden">
<Select
v-model="filterRole"
>
<SelectTrigger class="w-32 h-8 text-xs border-border/60">
<SelectValue placeholder="全部角色" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
全部角色
</SelectItem>
<SelectItem value="admin">
管理员
</SelectItem>
<SelectItem value="user">
普通用户
</SelectItem>
</SelectContent>
</Select>
</div>
2025-12-10 20:52:44 +08:00
<!-- 状态筛选 -->
2026-05-01 02:14:49 +08:00
<div class="xl:hidden">
<Select
v-model="filterStatus"
>
<SelectTrigger class="w-28 h-8 text-xs border-border/60">
<SelectValue placeholder="全部状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
全部状态
</SelectItem>
<SelectItem value="active">
活跃
</SelectItem>
<SelectItem value="inactive">
禁用
</SelectItem>
</SelectContent>
</Select>
</div>
2025-12-10 20:52:44 +08:00
<!-- 分隔线 -->
<div class="h-4 w-px bg-border" />
<!-- 新增用户按钮 -->
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="新增用户"
@click="openCreateDialog"
2025-12-10 20:52:44 +08:00
>
<Plus class="w-3.5 h-3.5" />
</Button>
<!-- 刷新按钮 -->
<RefreshButton
:loading="usersStore.loading"
@click="refreshUsers"
/>
2025-12-10 20:52:44 +08:00
</div>
</div>
</div>
2026-05-07 22:22:55 +08:00
<div class="flex flex-col gap-2 border-b border-border/60 bg-muted/20 px-4 py-2.5 text-xs sm:flex-row sm:items-center sm:justify-between sm:px-6 xl:px-4">
<div class="flex flex-wrap items-center gap-2 text-muted-foreground">
<label class="flex items-center gap-2">
<Checkbox
:checked="isAllFilteredSelected"
:indeterminate="isPartiallyFilteredSelected"
:disabled="filteredUsers.length === 0 || usersStore.loading"
@update:checked="toggleSelectFiltered"
/>
<span>全选筛选结果</span>
</label>
<span>匹配 {{ filteredUsers.length }} 个,当前页 {{ paginatedUsers.length }} 个,已选 {{ selectedCount }} 个</span>
</div>
<div class="flex flex-wrap items-center gap-1.5">
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-[11px]"
:disabled="paginatedUsers.length === 0 || selectAllFiltered || usersStore.loading"
@click="toggleSelectCurrentPage"
>
{{ isCurrentPageFullySelected ? '取消本页全选' : '本页全选' }}
</Button>
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-[11px]"
:disabled="!canClearSelection || usersStore.loading"
@click="clearSelection"
>
清空选择
</Button>
<Button
size="sm"
class="h-7 px-3 text-[11px]"
:disabled="selectedCount === 0 || usersStore.loading"
@click="openUserBatchDialog"
>
批量操作
</Button>
</div>
</div>
2025-12-10 20:52:44 +08:00
<!-- 桌面端表格 -->
<div class="hidden xl:block overflow-x-auto">
<Table>
<TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent">
2026-05-07 22:22:55 +08:00
<TableHead class="w-[44px] h-12 px-4">
<Checkbox
:checked="isCurrentPageFullySelected || isAllFilteredSelected"
:indeterminate="isPartiallyFilteredSelected && !isCurrentPageFullySelected"
:disabled="paginatedUsers.length === 0 || selectAllFiltered || usersStore.loading"
@update:checked="toggleSelectCurrentPage"
/>
</TableHead>
2026-05-01 02:14:49 +08:00
<SortableTableHead
class="w-[260px] h-12 font-semibold"
column-key="role"
:sortable="false"
:filter-active="filterRole !== 'all'"
filter-title="筛选角色"
filter-content-class="w-40 p-1 rounded-2xl border-border bg-card text-foreground shadow-2xl backdrop-blur-xl"
>
用户信息
2026-05-01 02:14:49 +08:00
<template #filter="{ close }">
<TableFilterMenu
v-model="filterRole"
:options="userRoleFilterOptions"
@select="close"
/>
</template>
</SortableTableHead>
<TableHead class="w-[240px] h-12 font-semibold">
钱包
</TableHead>
<TableHead class="w-[170px] h-12 font-semibold">
统计/限速
</TableHead>
<TableHead class="w-[110px] h-12 font-semibold">
创建时间
</TableHead>
2026-05-01 02:14:49 +08:00
<SortableTableHead
class="w-[180px] h-12 font-semibold"
column-key="status"
:sortable="false"
:filter-active="filterStatus !== 'all'"
filter-title="筛选状态"
filter-content-class="w-40 p-1 rounded-2xl border-border bg-card text-foreground shadow-2xl backdrop-blur-xl"
>
状态
2026-05-01 02:14:49 +08:00
<template #filter="{ close }">
<TableFilterMenu
v-model="filterStatus"
:options="userStatusFilterOptions"
@select="close"
/>
</template>
</SortableTableHead>
<TableHead class="w-[220px] h-12 font-semibold text-center">
操作
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="user in paginatedUsers"
:key="user.id"
class="border-b border-border/40 hover:bg-muted/30 transition-colors"
>
2026-05-07 22:22:55 +08:00
<TableCell class="w-[44px] px-4 py-4">
<Checkbox
:checked="selectAllFiltered || selectedIdSet.has(user.id)"
:disabled="selectAllFiltered || usersStore.loading"
@update:checked="(checked) => toggleOne(user.id, checked === true)"
/>
</TableCell>
<TableCell class="py-4">
<div class="flex items-center gap-3">
<Avatar class="h-10 w-10 ring-2 ring-background shadow-md">
<AvatarFallback class="bg-primary text-sm font-bold text-white">
{{ user.username.charAt(0).toUpperCase() }}
</AvatarFallback>
</Avatar>
<div class="flex-1 min-w-0">
<div class="mb-1 flex items-center gap-1.5">
<div
class="truncate text-sm font-semibold"
:title="user.username"
>
{{ user.username }}
</div>
<Badge
:variant="user.role === 'admin' ? 'default' : 'secondary'"
class="h-5 px-1.5 py-0 text-[10px] font-medium flex-shrink-0"
>
{{ user.role === 'admin' ? '管理员' : '普通用户' }}
</Badge>
</div>
<div
class="truncate text-xs text-muted-foreground"
:title="user.email || '-'"
>
{{ user.email || '-' }}
</div>
</div>
2025-12-10 20:52:44 +08:00
</div>
</TableCell>
<TableCell class="py-4">
<div class="space-y-1.5">
<div class="flex items-center gap-1 text-[11px] text-muted-foreground">
<span>余额:</span>
<Badge
v-if="isUserUnlimited(user)"
variant="secondary"
class="h-5 px-1.5 py-0 text-[10px] font-medium"
>
无限额度
</Badge>
<span
v-else
class="text-sm font-semibold tabular-nums"
:class="isNegativeWalletValue(getUserWalletTotalBalance(user)) ? 'text-rose-600' : 'text-foreground'"
>
{{ formatCurrencyValue(getUserWalletTotalBalance(user), '-') }}
</span>
</div>
<div class="flex items-center gap-2 text-[11px] text-muted-foreground flex-wrap">
<span>
已消费:
<span class="font-medium tabular-nums text-foreground">${{ getUserWalletConsumed(user).toFixed(2) }}</span>
</span>
</div>
</div>
</TableCell>
<TableCell class="py-4">
<div class="space-y-1 text-xs">
<div class="flex items-center text-muted-foreground">
<span class="w-14">请求:</span>
<span class="font-medium text-foreground">{{ formatNumber(user.request_count) }}</span>
</div>
<div class="flex items-center text-muted-foreground">
<span class="w-14">Tokens:</span>
<span class="font-medium text-foreground">{{ formatTokens(user.total_tokens ?? 0) }}</span>
</div>
<div class="flex items-center text-muted-foreground">
<span class="w-14">限速:</span>
<Badge
v-if="isRateLimitInherited(user.rate_limit) || isRateLimitUnlimited(user.rate_limit)"
variant="secondary"
class="h-5 px-1.5 py-0 text-[10px] font-medium"
>
{{ formatRateLimitInheritable(user.rate_limit) }}
</Badge>
<span
v-else
class="font-medium text-foreground"
>
{{ formatRateLimitInheritable(user.rate_limit) }}
</span>
</div>
</div>
</TableCell>
<TableCell class="py-4 text-xs text-muted-foreground">
{{ formatDate(user.created_at) }}
</TableCell>
<TableCell class="py-4">
<div class="flex flex-col items-start gap-1.5">
<Badge
:variant="user.is_active ? 'success' : 'destructive'"
class="h-5 px-1.5 py-0 text-[10px] font-medium"
>
{{ user.is_active ? '活跃' : '禁用' }}
</Badge>
<Badge
v-if="getUserWallet(user.id)"
:variant="walletStatusBadge(getUserWalletStatus(user.id))"
class="h-5 px-1.5 py-0 text-[10px] font-medium"
>
{{ walletStatusLabel(getUserWalletStatus(user.id)) }}
</Badge>
</div>
</TableCell>
<TableCell class="py-4">
<div class="flex justify-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="编辑用户"
@click="editUser(user)"
>
<SquarePen class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="资金操作"
@click="openWalletActionDialog(user)"
>
<DollarSign class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="API Keys"
@click="manageApiKeys(user)"
>
<Key class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="登录设备"
@click="manageUserSessions(user)"
>
<MonitorSmartphone class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:title="user.is_active ? '禁用用户' : '启用用户'"
@click="toggleUserStatus(user)"
>
<PauseCircle
v-if="user.is_active"
class="h-4 w-4"
/>
<PlayCircle
v-else
class="h-4 w-4"
/>
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="删除用户"
@click="deleteUser(user)"
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
2025-12-10 20:52:44 +08:00
</div>
<!-- 移动端卡片列表 -->
<div class="xl:hidden bg-muted/[0.14] p-3 sm:p-4">
<div
v-if="paginatedUsers.length === 0"
class="rounded-2xl border border-dashed border-border/60 bg-card/70 px-6 py-10 text-center"
>
<Avatar class="mx-auto mb-3 h-12 w-12">
<AvatarFallback class="bg-muted text-base font-semibold text-muted-foreground">
U
</AvatarFallback>
</Avatar>
<p class="text-sm font-medium text-foreground">
{{ searchQuery || filterRole !== 'all' || filterStatus !== 'all' ? '未找到匹配的用户' : '暂无用户' }}
</p>
<p
v-if="searchQuery || filterRole !== 'all' || filterStatus !== 'all'"
class="mt-1 text-xs text-muted-foreground"
>
尝试调整筛选条件
</p>
</div>
<div
v-else
class="space-y-3.5"
>
<div
v-for="user in paginatedUsers"
:key="user.id"
class="rounded-2xl border border-border/60 bg-card/95 p-4 shadow-[0_10px_26px_-22px_hsl(var(--foreground))]"
>
<div class="space-y-4">
<div class="flex items-start gap-3">
<Checkbox
class="mt-2 shrink-0"
:checked="selectAllFiltered || selectedIdSet.has(user.id)"
:disabled="selectAllFiltered || usersStore.loading"
@update:checked="(checked) => toggleOne(user.id, checked === true)"
/>
<Avatar class="h-10 w-10 ring-2 ring-background shadow-md flex-shrink-0">
<AvatarFallback class="bg-primary text-sm font-bold text-white">
{{ user.username.charAt(0).toUpperCase() }}
</AvatarFallback>
</Avatar>
<div class="min-w-0 flex-1 space-y-1.5">
<div class="flex items-center gap-1.5">
<div
class="truncate text-sm font-semibold text-foreground"
:title="user.username"
>
{{ user.username }}
</div>
<Badge
:variant="user.role === 'admin' ? 'default' : 'secondary'"
class="h-5 px-1.5 py-0 text-[10px] font-medium flex-shrink-0"
>
{{ user.role === 'admin' ? '管理员' : '普通用户' }}
</Badge>
</div>
<div
class="truncate text-[11px] text-muted-foreground"
:title="user.email || '-'"
>
{{ user.email || '-' }}
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-1.5">
<Badge
:variant="user.is_active ? 'success' : 'destructive'"
class="h-5 px-1.5 py-0 text-[10px] font-medium"
>
{{ user.is_active ? '活跃' : '禁用' }}
</Badge>
<Badge
v-if="getUserWallet(user.id)"
:variant="walletStatusBadge(getUserWalletStatus(user.id))"
class="h-5 px-1.5 py-0 text-[10px] font-medium"
>
{{ walletStatusLabel(getUserWalletStatus(user.id)) }}
2025-12-10 20:52:44 +08:00
</Badge>
<Badge
variant="secondary"
class="h-5 px-1.5 py-0 text-[10px] font-medium"
>
{{ formatRateLimitInheritable(user.rate_limit) }}
</Badge>
2025-12-10 20:52:44 +08:00
</div>
<div class="rounded-xl border border-border/60 bg-muted/40 p-3.5">
<div class="flex items-start justify-between gap-3">
<div class="space-y-1">
<p class="text-[11px] text-muted-foreground">
余额:
</p>
<Badge
v-if="isUserUnlimited(user)"
variant="secondary"
class="h-5 px-1.5 py-0 text-[10px] font-medium"
>
无限额度
</Badge>
<p
v-else
class="text-base font-semibold tabular-nums leading-none"
:class="isNegativeWalletValue(getUserWalletTotalBalance(user)) ? 'text-rose-600' : 'text-foreground'"
>
{{ formatCurrencyValue(getUserWalletTotalBalance(user), '-') }}
</p>
</div>
<div class="text-right">
<p class="text-[11px] text-muted-foreground">
已消费:
</p>
<p class="text-sm font-medium tabular-nums text-foreground">
${{ getUserWalletConsumed(user).toFixed(2) }}
</p>
</div>
</div>
2025-12-10 20:52:44 +08:00
</div>
<div class="grid grid-cols-2 gap-2.5 text-xs">
<div class="rounded-lg border border-border/50 bg-background/70 p-2.5">
<div class="mb-1 text-muted-foreground">
请求次数
</div>
<div class="font-semibold text-foreground">
{{ formatNumber(user.request_count) }}
</div>
</div>
<div class="rounded-lg border border-border/50 bg-background/70 p-2.5">
<div class="mb-1 text-muted-foreground">
Tokens
</div>
<div class="font-semibold text-foreground">
{{ formatTokens(user.total_tokens ?? 0) }}
</div>
</div>
2025-12-10 20:52:44 +08:00
</div>
<div class="rounded-lg bg-muted/35 p-2.5 text-[11px] text-muted-foreground">
<div class="flex items-center justify-between gap-2">
<span>创建时间</span>
<span class="font-medium text-foreground">{{ formatDate(user.created_at) }}</span>
</div>
2025-12-10 20:52:44 +08:00
</div>
<div class="grid grid-cols-2 gap-2 pt-0.5">
<Button
variant="outline"
size="sm"
class="h-8 text-xs"
@click="editUser(user)"
>
<SquarePen class="mr-1.5 h-3.5 w-3.5" />
编辑
</Button>
<Button
variant="outline"
size="sm"
class="h-8 text-xs"
@click="openWalletActionDialog(user)"
>
<DollarSign class="mr-1.5 h-3.5 w-3.5" />
资金
</Button>
<Button
variant="outline"
size="sm"
class="h-8 text-xs"
@click="manageApiKeys(user)"
>
<Key class="mr-1.5 h-3.5 w-3.5" />
API Keys
</Button>
<Button
variant="outline"
size="sm"
class="h-8 text-xs"
@click="manageUserSessions(user)"
>
<MonitorSmartphone class="mr-1.5 h-3.5 w-3.5" />
设备
</Button>
<Button
variant="outline"
size="sm"
class="h-8 text-xs"
@click="toggleUserStatus(user)"
>
<PauseCircle
v-if="user.is_active"
class="mr-1.5 h-3.5 w-3.5"
/>
<PlayCircle
v-else
class="mr-1.5 h-3.5 w-3.5"
/>
{{ user.is_active ? '禁用' : '启用' }}
</Button>
<Button
variant="outline"
size="sm"
class="col-span-2 h-8 border-rose-200 text-xs text-rose-600 hover:bg-rose-50 dark:border-rose-900/60 dark:hover:bg-rose-950/40"
@click="deleteUser(user)"
>
<Trash2 class="mr-1.5 h-3.5 w-3.5" />
删除
</Button>
2025-12-10 20:52:44 +08:00
</div>
</div>
</div>
</div>
</div>
<!-- 分页控件 -->
<Pagination
:current="currentPage"
:total="filteredUsers.length"
:page-size="pageSize"
cache-key="users-page-size"
2025-12-10 20:52:44 +08:00
@update:current="currentPage = $event"
@update:page-size="pageSize = $event"
/>
</Card>
<!-- 用户表单对话框(创建/编辑共用) -->
<UserFormDialog
ref="userFormDialogRef"
2025-12-10 20:52:44 +08:00
:open="showUserFormDialog"
:user="editingUser"
@close="closeUserFormDialog"
@submit="handleUserFormSubmit"
/>
<UserBatchActionDialog
:open="showUserBatchDialog"
:selected-ids="selectedIds"
:select-all-filtered="selectAllFiltered"
:selected-count="selectedCount"
:filters="batchSelectionFilters"
@close="showUserBatchDialog = false"
@completed="handleUserBatchCompleted"
/>
2025-12-10 20:52:44 +08:00
<!-- API Keys 管理对话框 -->
<Dialog
v-model="showApiKeysDialog"
size="xl"
>
2025-12-10 20:52:44 +08:00
<template #header>
<div class="border-b border-border px-6 py-4">
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-kraft/10 flex-shrink-0">
<Key class="h-5 w-5 text-kraft" />
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-foreground leading-tight">
管理 API Keys
</h3>
<p class="text-xs text-muted-foreground">
查看和管理用户的 API 密钥
</p>
2025-12-10 20:52:44 +08:00
</div>
</div>
</div>
</template>
<div class="max-h-[60vh] overflow-y-auto space-y-3">
<template v-if="userApiKeys.length > 0">
<div
v-for="apiKey in userApiKeys"
:key="apiKey.id"
class="rounded-lg border border-border bg-card p-4 hover:border-primary/30 transition-colors"
>
<div class="flex items-center justify-between gap-3">
<!-- 左侧信息 -->
<div class="flex items-center gap-3 min-w-0 flex-1">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-semibold text-foreground">
{{ apiKey.name || '未命名 API Key' }}
</span>
<Badge
:variant="apiKey.is_active ? 'success' : 'secondary'"
class="text-xs"
>
{{ apiKey.is_active ? '活跃' : '禁用' }}
2025-12-10 20:52:44 +08:00
</Badge>
<Badge
v-if="apiKey.is_locked"
variant="secondary"
class="text-xs"
>
已锁定
</Badge>
2025-12-10 20:52:44 +08:00
<Badge
v-if="apiKey.is_standalone"
variant="default"
class="text-xs bg-purple-500"
>
独立余额
</Badge>
<Badge
variant="secondary"
class="text-xs"
>
{{ formatRateLimitSimple(apiKey.rate_limit) }}
</Badge>
2026-04-17 14:21:43 +08:00
<Badge
variant="secondary"
class="text-xs"
>
{{ formatConcurrentLimitSimple(apiKey.concurrent_limit) }}
</Badge>
2025-12-10 20:52:44 +08:00
</div>
<div class="flex items-center gap-1 mt-0.5">
<code class="text-xs font-mono text-muted-foreground">
{{ apiKey.key_display || '****' }}
2025-12-10 20:52:44 +08:00
</code>
<button
class="p-0.5 hover:bg-muted rounded transition-colors"
title="复制完整密钥"
@click="copyFullKey(apiKey)"
2025-12-10 20:52:44 +08:00
>
<Copy class="w-3 h-3 text-muted-foreground" />
</button>
</div>
</div>
</div>
<!-- 右侧统计和操作 -->
<div class="flex items-center gap-4 flex-shrink-0">
<div class="text-right text-sm">
<div class="text-muted-foreground">
{{ (apiKey.total_requests || 0).toLocaleString() }} 次
</div>
<div class="font-semibold text-rose-600">
${{ (apiKey.total_cost_usd || 0).toFixed(4) }}
</div>
</div>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="编辑"
@click="openEditUserApiKeyDialog(apiKey)"
>
<SquarePen class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:title="apiKey.is_locked ? '解锁' : '锁定'"
@click="toggleLockApiKey(apiKey)"
>
<Lock
v-if="apiKey.is_locked"
class="h-4 w-4"
/>
<LockOpen
v-else
class="h-4 w-4"
/>
</Button>
2025-12-10 20:52:44 +08:00
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="删除"
@click="deleteApiKey(apiKey)"
2025-12-10 20:52:44 +08:00
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
</div>
</div>
</template>
<div
v-else
class="rounded-lg border-2 border-dashed border-muted-foreground/20 bg-muted/20 px-4 py-12 text-center"
>
<div class="flex flex-col items-center gap-3">
<div class="flex h-14 w-14 items-center justify-center rounded-full bg-muted">
<Key class="h-6 w-6 text-muted-foreground/50" />
</div>
<div>
<p class="mb-1 text-base font-semibold text-foreground">
暂无 API Keys
</p>
<p class="text-sm text-muted-foreground">
点击下方按钮创建
</p>
2025-12-10 20:52:44 +08:00
</div>
</div>
</div>
</div>
<template #footer>
<Button
variant="outline"
class="h-10 px-5"
@click="showApiKeysDialog = false"
>
取消
</Button>
<Button
class="h-10 px-5"
:disabled="creatingApiKey"
@click="openCreateUserApiKeyDialog"
>
2025-12-10 20:52:44 +08:00
{{ creatingApiKey ? '创建中...' : '创建' }}
</Button>
</template>
</Dialog>
<Dialog
v-model="showUserApiKeyFormDialog"
size="lg"
>
<template #header>
<div class="border-b border-border px-6 py-4">
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-kraft/10 flex-shrink-0">
<Key class="h-5 w-5 text-kraft" />
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-foreground leading-tight">
{{ editingUserApiKey ? '编辑 API Key' : '创建 API Key' }}
</h3>
<p class="text-xs text-muted-foreground">
2026-04-17 14:21:43 +08:00
{{ editingUserApiKey ? '更新用户 API Key 的名称、速率限制和并发限制' : '为用户创建新的 API Key' }}
</p>
</div>
</div>
</div>
</template>
<div class="space-y-4">
<div class="space-y-2">
<Label
for="admin-user-key-name"
class="text-sm font-medium"
>密钥名称</Label>
<Input
id="admin-user-key-name"
v-model="userApiKeyForm.name"
class="h-10"
placeholder="例如:生产环境 Key"
/>
</div>
<div class="space-y-2">
<Label
for="admin-user-key-rate-limit"
class="text-sm font-medium"
>速率限制 (请求/分钟)</Label>
<Input
id="admin-user-key-rate-limit"
:model-value="userApiKeyForm.rate_limit ?? ''"
type="number"
min="0"
max="10000"
class="h-10"
placeholder="留空不限"
@update:model-value="(v) => userApiKeyForm.rate_limit = parseNumberInput(v, { min: 0, max: 10000 })"
/>
<p class="text-xs text-muted-foreground">
留空表示不限制
</p>
</div>
2026-04-17 14:21:43 +08:00
<div class="space-y-2">
<Label
for="admin-user-key-concurrent-limit"
class="text-sm font-medium"
>并发限制</Label>
<Input
id="admin-user-key-concurrent-limit"
:model-value="userApiKeyForm.concurrent_limit ?? ''"
type="number"
min="0"
max="10000"
class="h-10"
placeholder="0 = 不限并发"
@update:model-value="(v) => userApiKeyForm.concurrent_limit = parseNumberInput(v, { min: 0, max: 10000 })"
/>
<p class="text-xs text-muted-foreground">
{{ editingUserApiKey ? '留空表示保持当前值,填 0 表示不限并发' : '留空表示不限并发,填 0 也表示不限并发' }}
</p>
</div>
</div>
<template #footer>
<Button
variant="outline"
class="h-10 px-5"
@click="closeUserApiKeyFormDialog"
>
取消
</Button>
<Button
class="h-10 px-5"
:disabled="creatingApiKey"
@click="submitUserApiKeyForm"
>
{{ creatingApiKey ? (editingUserApiKey ? '保存中...' : '创建中...') : (editingUserApiKey ? '保存' : '创建') }}
</Button>
</template>
</Dialog>
<Dialog
v-model="showUserSessionsDialog"
size="xl"
>
<template #header>
<div class="border-b border-border px-6 py-4">
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0">
<MonitorSmartphone class="h-5 w-5 text-primary" />
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-foreground leading-tight">
登录设备
</h3>
<p class="text-xs text-muted-foreground">
查看并强制下线该用户的设备会话
</p>
</div>
</div>
</div>
</template>
<div class="max-h-[60vh] overflow-y-auto space-y-3">
<div
v-if="loadingUserSessions"
class="rounded-lg border border-dashed border-border/60 bg-muted/20 px-4 py-10 text-center text-sm text-muted-foreground"
>
正在加载设备会话...
</div>
<div
v-else-if="userSessions.length === 0"
class="rounded-lg border border-dashed border-border/60 bg-muted/20 px-4 py-10 text-center text-sm text-muted-foreground"
>
暂无在线设备
</div>
<div
v-else
class="space-y-3"
>
<div
v-for="session in userSessions"
:key="session.id"
class="rounded-lg border border-border bg-card p-4 hover:border-primary/30 transition-colors"
>
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="font-semibold text-foreground">
{{ session.device_label }}
</div>
<div class="mt-1 text-xs text-muted-foreground">
{{ formatSessionMeta(session) }}
</div>
<div class="mt-1 text-xs text-muted-foreground">
最近活跃 {{ formatDate(session.last_seen_at || session.created_at) }}
<span v-if="session.ip_address"> · IP {{ session.ip_address }}</span>
</div>
</div>
<Button
variant="outline"
size="sm"
:disabled="sessionDialogActionLoading === session.id"
@click="revokeSelectedUserSession(session.id)"
>
{{ sessionDialogActionLoading === session.id ? '处理中...' : '强制下线' }}
</Button>
</div>
</div>
</div>
</div>
<template #footer>
<Button
variant="outline"
class="h-10 px-5"
@click="showUserSessionsDialog = false"
>
关闭
</Button>
<Button
class="h-10 px-5"
:disabled="loadingUserSessions || userSessions.length === 0 || sessionDialogActionLoading === 'all'"
@click="revokeAllSelectedUserSessions"
>
{{ sessionDialogActionLoading === 'all' ? '处理中...' : '全部下线' }}
</Button>
</template>
</Dialog>
<WalletOpsDrawer
:open="showWalletActionDialogState"
:wallet="walletActionTarget?.wallet || null"
:owner-name="walletActionTarget?.user.username || ''"
:owner-subtitle="walletActionTarget?.user.email || '未设置邮箱'"
context-label="用户钱包"
accent="emerald"
@close="closeWalletActionDrawer"
@changed="handleWalletDrawerChanged"
/>
2025-12-10 20:52:44 +08:00
<!-- 新 API Key 显示对话框 -->
<Dialog
v-model="showNewApiKeyDialog"
size="lg"
>
2025-12-10 20:52:44 +08:00
<template #header>
<div class="border-b border-border px-6 py-4">
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900/30 flex-shrink-0">
<CheckCircle class="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-foreground leading-tight">
创建成功
</h3>
<p class="text-xs text-muted-foreground">
请妥善保管, 切勿泄露给他人.
</p>
2025-12-10 20:52:44 +08:00
</div>
</div>
</div>
</template>
<div class="space-y-4">
<div class="space-y-2">
<Label class="text-sm font-medium">API Key</Label>
<div class="flex items-center gap-2">
<Input
ref="apiKeyInput"
2025-12-10 20:52:44 +08:00
type="text"
:value="newApiKey"
readonly
class="flex-1 font-mono text-sm bg-muted/50 h-11"
@click="selectApiKey"
/>
<Button
class="h-11"
@click="copyApiKey"
>
2025-12-10 20:52:44 +08:00
复制
</Button>
</div>
</div>
</div>
<template #footer>
<Button
class="h-10 px-5"
@click="closeNewApiKeyDialog"
>
确定
</Button>
2025-12-10 20:52:44 +08:00
</template>
</Dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useUsersStore } from '@/stores/users'
import type { User, ApiKey, UserSession, UserBatchActionResponse, UserBatchSelectionFilters } from '@/api/users'
import { formatSessionMeta } from '@/types/session'
import { adminWalletApi, type AdminWallet } from '@/api/admin-wallets'
2025-12-10 20:52:44 +08:00
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { useClipboard } from '@/composables/useClipboard'
2025-12-10 20:52:44 +08:00
import { adminApi } from '@/api/admin'
import { walletStatusBadge, walletStatusLabel } from '@/utils/walletDisplay'
2025-12-10 20:52:44 +08:00
// UI 组件
import {
Dialog,
Card,
Button,
Badge,
Input,
Label,
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
2026-05-01 02:14:49 +08:00
SortableTableHead,
TableFilterMenu,
2025-12-10 20:52:44 +08:00
TableCell,
Avatar,
AvatarFallback,
Pagination,
RefreshButton,
Checkbox
2025-12-10 20:52:44 +08:00
} from '@/components/ui'
import {
Plus,
SquarePen,
Key,
PauseCircle,
PlayCircle,
DollarSign,
2025-12-10 20:52:44 +08:00
Trash2,
Copy,
Search,
CheckCircle,
Lock,
LockOpen,
MonitorSmartphone
2025-12-10 20:52:44 +08:00
} from 'lucide-vue-next'
// 功能组件
import UserFormDialog, { type UserFormData } from '@/features/users/components/UserFormDialog.vue'
import UserBatchActionDialog from '@/features/users/components/UserBatchActionDialog.vue'
import WalletOpsDrawer from '@/features/wallet/components/WalletOpsDrawer.vue'
import { parseApiError } from '@/utils/errorParser'
import { formatTokens, formatRateLimitInheritable, formatRateLimitSimple, isRateLimitInherited, isRateLimitUnlimited } from '@/utils/format'
import { parseNumberInput } from '@/utils/form'
import { log } from '@/utils/logger'
import { useBatchSelection } from '@/composables/useBatchSelection'
2025-12-10 20:52:44 +08:00
const { success, error } = useToast()
const { confirmDanger } = useConfirm()
const { copyToClipboard } = useClipboard()
2025-12-10 20:52:44 +08:00
const usersStore = useUsersStore()
// 用户表单对话框状态
const showUserFormDialog = ref(false)
const editingUser = ref<UserFormData | null>(null)
const userFormDialogRef = ref<InstanceType<typeof UserFormDialog>>()
// API Keys 对话框状态
const showApiKeysDialog = ref(false)
const showUserSessionsDialog = ref(false)
2025-12-10 20:52:44 +08:00
const showNewApiKeyDialog = ref(false)
const showUserApiKeyFormDialog = ref(false)
const selectedUser = ref<User | null>(null)
const userApiKeys = ref<ApiKey[]>([])
const userSessions = ref<UserSession[]>([])
2025-12-10 20:52:44 +08:00
const newApiKey = ref('')
const creatingApiKey = ref(false)
const loadingUserSessions = ref(false)
const sessionDialogActionLoading = ref<string | null>(null)
2025-12-10 20:52:44 +08:00
const apiKeyInput = ref<HTMLInputElement>()
const editingUserApiKey = ref<ApiKey | null>(null)
const userApiKeyForm = ref({
name: '',
rate_limit: undefined as number | undefined,
2026-04-17 14:21:43 +08:00
concurrent_limit: undefined as number | undefined,
})
2025-12-10 20:52:44 +08:00
// 用户统计
const userWalletMap = ref<Record<string, AdminWallet>>({})
const showWalletActionDialogState = ref(false)
const walletActionTarget = ref<{ user: User; wallet: AdminWallet } | null>(null)
const showUserBatchDialog = ref(false)
2025-12-10 20:52:44 +08:00
const searchQuery = ref('')
const filterRole = ref('all')
const filterStatus = ref('all')
2026-05-01 02:14:49 +08:00
const userRoleFilterOptions = [
{ value: 'all', label: '全部角色' },
{ value: 'admin', label: '管理员' },
{ value: 'user', label: '普通用户' },
]
const userStatusFilterOptions = [
{ value: 'all', label: '全部状态' },
{ value: 'active', label: '活跃' },
{ value: 'inactive', label: '禁用' },
]
2025-12-10 20:52:44 +08:00
const currentPage = ref(1)
const pageSize = ref(20)
const USERS_PAGE_CACHE_TTL_MS = 10 * 1000
const USER_WALLETS_CACHE_TTL_MS = 10 * 1000
let userWalletsRequestId = 0
2025-12-10 20:52:44 +08:00
const filteredUsers = computed(() => {
let filtered = [...usersStore.users]
// 先排序:管理员优先,然后按创建时间倒序
filtered.sort((a, b) => {
// 管理员优先
if (a.role === 'admin' && b.role !== 'admin') return -1
if (a.role !== 'admin' && b.role === 'admin') return 1
// 同角色按创建时间倒序(新用户在前)
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
})
// 搜索(支持空格分隔的多关键词 AND 搜索)
2025-12-10 20:52:44 +08:00
if (searchQuery.value) {
const keywords = searchQuery.value.toLowerCase().split(/\s+/).filter(k => k.length > 0)
filtered = filtered.filter(u => {
const searchableText = `${u.username} ${u.email || ''}`.toLowerCase()
return keywords.every(keyword => searchableText.includes(keyword))
})
2025-12-10 20:52:44 +08:00
}
if (filterRole.value !== 'all') {
filtered = filtered.filter(u => u.role === filterRole.value)
}
if (filterStatus.value !== 'all') {
filtered = filtered.filter(u =>
filterStatus.value === 'active' ? u.is_active : !u.is_active
)
}
return filtered
})
const paginatedUsers = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredUsers.value.slice(start, start + pageSize.value)
})
const filteredUserCount = computed(() => filteredUsers.value.length)
const {
selectedIds,
selectAllFiltered,
selectedIdSet,
selectedCount,
isAllFilteredSelected,
isPartiallyFilteredSelected,
isCurrentPageFullySelected,
canClearSelection,
rememberItems: rememberBatchPageUsers,
resetSelection: resetBatchSelection,
toggleOne,
toggleSelectFiltered,
toggleSelectCurrentPage,
clearSelection,
} = useBatchSelection<User>({
pageItems: paginatedUsers,
filteredTotal: filteredUserCount,
getItemId: (user) => user.id,
})
const batchSelectionFilters = computed<UserBatchSelectionFilters>(() => {
const filters: UserBatchSelectionFilters = {}
const search = searchQuery.value.trim()
if (search) filters.search = search
if (filterRole.value === 'admin' || filterRole.value === 'user') filters.role = filterRole.value
if (filterStatus.value === 'active') filters.is_active = true
if (filterStatus.value === 'inactive') filters.is_active = false
return filters
})
2025-12-10 20:52:44 +08:00
// Watch filter changes and reset to first page
watch([searchQuery, filterRole, filterStatus], () => {
currentPage.value = 1
resetBatchSelection()
2025-12-10 20:52:44 +08:00
})
watch(paginatedUsers, (users) => rememberBatchPageUsers(users), { immediate: true })
onMounted(() => {
void refreshUsers({ preferCache: true })
2025-12-10 20:52:44 +08:00
})
async function refreshUsers(options: { preferCache?: boolean } = {}) {
const cacheTtlMs = options.preferCache ? USERS_PAGE_CACHE_TTL_MS : 0
await usersStore.fetchUsers({ cacheTtlMs })
void loadUserWallets({
cacheTtlMs: options.preferCache ? USER_WALLETS_CACHE_TTL_MS : 0,
})
2025-12-10 20:52:44 +08:00
}
function openUserBatchDialog(): void {
if (selectedCount.value === 0) return
showUserBatchDialog.value = true
}
async function handleUserBatchCompleted(_result: UserBatchActionResponse): Promise<void> {
await refreshUsers()
resetBatchSelection(true)
}
2025-12-10 20:52:44 +08:00
function formatDate(dateString: string) {
return new Date(dateString).toLocaleDateString('zh-CN')
}
async function loadUserWallets(options: { cacheTtlMs?: number } = {}) {
const requestId = ++userWalletsRequestId
try {
const wallets = await adminWalletApi.listAllWallets(
{ owner_type: 'user' },
{ cacheTtlMs: options.cacheTtlMs ?? 0 },
)
if (requestId !== userWalletsRequestId) return
userWalletMap.value = wallets
.filter((wallet) => !!wallet.user_id)
.reduce<Record<string, AdminWallet>>((acc, wallet) => {
acc[wallet.user_id as string] = wallet
return acc
}, {})
} catch (err) {
if (requestId !== userWalletsRequestId) return
log.error('加载用户钱包失败:', err)
}
}
2025-12-10 20:52:44 +08:00
function formatNumber(value?: number | null): string {
const numericValue = typeof value === 'number' && Number.isFinite(value) ? value : 0
return numericValue.toLocaleString()
}
function getUserWallet(userId: string): AdminWallet | null {
return userWalletMap.value[userId] || null
}
function isUserUnlimited(user: User): boolean {
const wallet = getUserWallet(user.id)
if (wallet?.limit_mode === 'unlimited' || wallet?.unlimited === true) {
return true
}
return Boolean(user.unlimited)
}
function getUserWalletTotalBalance(user: User): number | null {
if (isUserUnlimited(user)) {
return null
}
const wallet = getUserWallet(user.id)
if (!wallet) {
return null
}
return wallet.balance
}
function getUserWalletConsumed(user: User): number {
return getUserWallet(user.id)?.total_consumed ?? 0
}
function getUserWalletStatus(userId: string): string | null {
return getUserWallet(userId)?.status ?? null
}
function formatCurrencyValue(value: number | null, nullLabel = '-'): string {
if (value == null) {
return nullLabel
}
return `$${value.toFixed(2)}`
}
2026-04-17 14:21:43 +08:00
function formatConcurrentLimitSimple(concurrentLimit?: number | null): string {
if (concurrentLimit == null || concurrentLimit === 0) {
return '不限并发'
}
return `${concurrentLimit} 并发`
}
function isNegativeWalletValue(value: number | null): boolean {
return typeof value === 'number' && value < 0
}
async function toggleUserStatus(user: User) {
2025-12-10 20:52:44 +08:00
const action = user.is_active ? '禁用' : '启用'
const confirmed = await confirmDanger(
`确定要${action}用户 ${user.username} 吗?`,
`${action}用户`,
action
2025-12-10 20:52:44 +08:00
)
if (!confirmed) return
try {
await usersStore.updateUser(user.id, { is_active: !user.is_active })
success(`用户已${action}`)
} catch (err: unknown) {
error(parseApiError(err, '未知错误'), `${action}用户失败`)
2025-12-10 20:52:44 +08:00
}
}
// ========== 用户表单对话框方法 ==========
function openCreateDialog() {
editingUser.value = null
showUserFormDialog.value = true
}
function editUser(user: User) {
// 创建数组副本,避免与 store 数据共享引用
2025-12-10 20:52:44 +08:00
editingUser.value = {
id: user.id,
username: user.username,
email: user.email,
unlimited: user.unlimited,
2025-12-10 20:52:44 +08:00
role: user.role,
is_active: user.is_active,
allowed_providers: user.allowed_providers == null ? null : [...user.allowed_providers],
allowed_api_formats: user.allowed_api_formats == null ? null : [...user.allowed_api_formats],
allowed_models: user.allowed_models == null ? null : [...user.allowed_models],
rate_limit: user.rate_limit ?? null
2025-12-10 20:52:44 +08:00
}
showUserFormDialog.value = true
}
function closeUserFormDialog() {
showUserFormDialog.value = false
editingUser.value = null
}
async function handleUserFormSubmit(data: UserFormData & { password?: string; unlimited?: boolean }) {
2025-12-10 20:52:44 +08:00
userFormDialogRef.value?.setSaving(true)
try {
if (data.id) {
// 更新用户
const updateData: Record<string, unknown> = {
2025-12-10 20:52:44 +08:00
username: data.username,
email: data.email || undefined,
unlimited: data.unlimited,
2025-12-10 20:52:44 +08:00
role: data.role,
allowed_providers: data.allowed_providers,
2026-01-07 19:53:32 +08:00
allowed_api_formats: data.allowed_api_formats,
allowed_models: data.allowed_models,
rate_limit: data.rate_limit ?? null
2025-12-10 20:52:44 +08:00
}
if (data.password) {
updateData.password = data.password
}
await usersStore.updateUser(data.id, updateData)
await loadUserWallets()
2025-12-10 20:52:44 +08:00
success('用户信息已更新')
} else {
// 创建用户
const newUser = await usersStore.createUser({
username: data.username,
password: data.password ?? '',
2025-12-10 20:52:44 +08:00
email: data.email || undefined,
initial_gift_usd: data.initial_gift_usd,
unlimited: data.unlimited,
2025-12-10 20:52:44 +08:00
role: data.role,
allowed_providers: data.allowed_providers,
2026-01-07 19:53:32 +08:00
allowed_api_formats: data.allowed_api_formats,
allowed_models: data.allowed_models,
rate_limit: data.rate_limit ?? null
2025-12-10 20:52:44 +08:00
})
// 如果创建时指定为禁用,则更新状态
if (data.is_active === false && newUser) {
await usersStore.updateUser(newUser.id, { is_active: false })
}
await loadUserWallets()
2025-12-10 20:52:44 +08:00
success('用户创建成功')
}
closeUserFormDialog()
} catch (err: unknown) {
2025-12-10 20:52:44 +08:00
const title = data.id ? '更新用户失败' : '创建用户失败'
error(parseApiError(err, '未知错误'), title)
2025-12-10 20:52:44 +08:00
} finally {
userFormDialogRef.value?.setSaving(false)
}
}
async function manageApiKeys(user: User) {
2025-12-10 20:52:44 +08:00
selectedUser.value = user
showApiKeysDialog.value = true
await loadUserApiKeys(user.id)
}
async function manageUserSessions(user: User) {
selectedUser.value = user
showUserSessionsDialog.value = true
loadingUserSessions.value = true
try {
userSessions.value = await usersStore.getUserSessions(user.id)
} catch (err) {
error(parseApiError(err, '加载用户设备会话失败'))
} finally {
loadingUserSessions.value = false
}
}
2025-12-10 20:52:44 +08:00
async function loadUserApiKeys(userId: string) {
try {
userApiKeys.value = await usersStore.getUserApiKeys(userId)
} catch (err) {
log.error('加载API Keys失败:', err)
2025-12-10 20:52:44 +08:00
userApiKeys.value = []
}
}
function openCreateUserApiKeyDialog() {
userApiKeyForm.value = {
name: `Key-${new Date().toISOString().split('T')[0]}`,
rate_limit: undefined,
2026-04-17 14:21:43 +08:00
concurrent_limit: undefined,
}
editingUserApiKey.value = null
showUserApiKeyFormDialog.value = true
}
function openEditUserApiKeyDialog(apiKey: ApiKey) {
editingUserApiKey.value = apiKey
userApiKeyForm.value = {
name: apiKey.name || '',
rate_limit: apiKey.rate_limit ?? undefined,
2026-04-17 14:21:43 +08:00
concurrent_limit: apiKey.concurrent_limit ?? undefined,
}
showUserApiKeyFormDialog.value = true
}
function closeUserApiKeyFormDialog() {
showUserApiKeyFormDialog.value = false
editingUserApiKey.value = null
userApiKeyForm.value = {
name: '',
rate_limit: undefined,
2026-04-17 14:21:43 +08:00
concurrent_limit: undefined,
}
}
async function submitUserApiKeyForm() {
2025-12-10 20:52:44 +08:00
if (!selectedUser.value) return
if (!userApiKeyForm.value.name.trim()) {
error('请输入密钥名称', editingUserApiKey.value ? '更新 API Key 失败' : '创建 API Key 失败')
return
}
2025-12-10 20:52:44 +08:00
creatingApiKey.value = true
try {
if (editingUserApiKey.value) {
await usersStore.updateApiKey(selectedUser.value.id, editingUserApiKey.value.id, {
name: userApiKeyForm.value.name,
rate_limit: userApiKeyForm.value.rate_limit ?? 0,
2026-04-17 14:21:43 +08:00
concurrent_limit: userApiKeyForm.value.concurrent_limit,
})
success('API Key已更新')
} else {
const response = await usersStore.createApiKey(selectedUser.value.id, {
name: userApiKeyForm.value.name,
rate_limit: userApiKeyForm.value.rate_limit ?? 0,
2026-04-17 14:21:43 +08:00
concurrent_limit: userApiKeyForm.value.concurrent_limit,
})
newApiKey.value = response.key || ''
showNewApiKeyDialog.value = true
success('API Key创建成功')
}
2025-12-10 20:52:44 +08:00
await loadUserApiKeys(selectedUser.value.id)
closeUserApiKeyFormDialog()
} catch (err: unknown) {
error(parseApiError(err, '未知错误'), editingUserApiKey.value ? '更新 API Key 失败' : '创建 API Key 失败')
2025-12-10 20:52:44 +08:00
} finally {
creatingApiKey.value = false
}
}
async function revokeSelectedUserSession(sessionId: string) {
if (!selectedUser.value) return
sessionDialogActionLoading.value = sessionId
try {
await usersStore.revokeUserSession(selectedUser.value.id, sessionId)
userSessions.value = userSessions.value.filter((session) => session.id !== sessionId)
success('设备已强制下线')
} catch (err) {
error(parseApiError(err, '强制下线失败'))
} finally {
sessionDialogActionLoading.value = null
}
}
async function revokeAllSelectedUserSessions() {
if (!selectedUser.value) return
sessionDialogActionLoading.value = 'all'
try {
const result = await usersStore.revokeAllUserSessions(selectedUser.value.id)
userSessions.value = []
success(result.revoked_count > 0 ? `已强制下线 ${result.revoked_count} 个设备` : '没有可下线的设备')
} catch (err) {
error(parseApiError(err, '强制下线全部设备失败'))
} finally {
sessionDialogActionLoading.value = null
}
}
2025-12-10 20:52:44 +08:00
function selectApiKey() {
apiKeyInput.value?.select()
}
async function copyApiKey() {
await copyToClipboard(newApiKey.value)
2025-12-10 20:52:44 +08:00
}
async function closeNewApiKeyDialog() {
showNewApiKeyDialog.value = false
newApiKey.value = ''
}
async function deleteApiKey(apiKey: ApiKey) {
2025-12-10 20:52:44 +08:00
const confirmed = await confirmDanger(
`确定要删除这个API Key吗?\n\n${apiKey.key_display || '****'}\n\n此操作无法撤销。`,
2025-12-10 20:52:44 +08:00
'删除 API Key'
)
if (!confirmed) return
try {
await usersStore.deleteApiKey(selectedUser.value.id, apiKey.id)
await loadUserApiKeys(selectedUser.value.id)
success('API Key已删除')
} catch (err: unknown) {
error(parseApiError(err, '未知错误'), '删除 API Key 失败')
2025-12-10 20:52:44 +08:00
}
}
async function toggleLockApiKey(apiKey: ApiKey) {
if (!selectedUser.value) return
try {
const response = await adminApi.toggleUserApiKeyLock(selectedUser.value.id, apiKey.id)
// 更新本地状态
const index = userApiKeys.value.findIndex(k => k.id === apiKey.id)
if (index !== -1) {
userApiKeys.value[index].is_locked = response.is_locked
}
success(response.message)
} catch (err: unknown) {
log.error('切换密钥锁定状态失败:', err)
error(parseApiError(err, '操作失败'), '锁定/解锁失败')
}
}
async function copyFullKey(apiKey: ApiKey) {
if (!selectedUser.value) return
2025-12-10 20:52:44 +08:00
try {
const response = await usersStore.getFullApiKey(selectedUser.value.id, apiKey.id)
await copyToClipboard(response.key)
} catch (err: unknown) {
log.error('复制密钥失败:', err)
error(parseApiError(err, '未知错误'), '复制密钥失败')
2025-12-10 20:52:44 +08:00
}
}
function openWalletActionDialog(user: User) {
const wallet = getUserWallet(user.id)
if (!wallet) {
error('该用户的钱包尚未初始化,暂时无法进行资金操作')
return
}
2025-12-10 20:52:44 +08:00
walletActionTarget.value = {
user,
wallet,
}
showWalletActionDialogState.value = true
}
2025-12-10 20:52:44 +08:00
function closeWalletActionDrawer() {
showWalletActionDialogState.value = false
}
async function handleWalletDrawerChanged() {
await loadUserWallets()
if (!walletActionTarget.value) return
const latestWallet = getUserWallet(walletActionTarget.value.user.id)
if (latestWallet) {
walletActionTarget.value.wallet = latestWallet
2025-12-10 20:52:44 +08:00
}
}
async function deleteUser(user: User) {
2025-12-10 20:52:44 +08:00
const confirmed = await confirmDanger(
`确定要删除用户 ${user.username} 吗?\n\n此操作将删除:\n• 用户账户\n• 所有API密钥\n• 所有使用记录\n\n此操作无法撤销!`,
'删除用户'
)
if (!confirmed) return
try {
await usersStore.deleteUser(user.id)
success('用户已删除')
} catch (err: unknown) {
error(parseApiError(err, '未知错误'), '删除用户失败')
2025-12-10 20:52:44 +08:00
}
}
</script>