2025-12-10 20:52:44 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<div class="space-y-6 pb-8">
|
|
|
|
|
|
<!-- 用户表格 -->
|
2025-12-12 16:15:54 +08:00
|
|
|
|
<Card
|
|
|
|
|
|
variant="default"
|
|
|
|
|
|
class="overflow-hidden"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<!-- 标题和筛选器 -->
|
2025-12-13 22:26:47 +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">
|
|
|
|
|
|
<!-- 新增用户按钮 -->
|
2026-05-09 21:47:33 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8"
|
|
|
|
|
|
title="分组管理"
|
|
|
|
|
|
@click="showUserGroupsDialog = true"
|
|
|
|
|
|
>
|
|
|
|
|
|
<FolderKanban class="w-3.5 h-3.5" />
|
|
|
|
|
|
</Button>
|
2025-12-13 22:26:47 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8"
|
|
|
|
|
|
title="新增用户"
|
|
|
|
|
|
@click="openCreateDialog"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Plus class="w-3.5 h-3.5" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<!-- 刷新按钮 -->
|
|
|
|
|
|
<RefreshButton
|
2026-04-19 01:26:22 +08:00
|
|
|
|
:loading="usersStore.loading"
|
2025-12-13 22:26:47 +08:00
|
|
|
|
@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>
|
2026-05-13 18:12:46 +08:00
|
|
|
|
<SelectItem value="audit_admin">
|
|
|
|
|
|
审计管理员
|
|
|
|
|
|
</SelectItem>
|
2025-12-13 22:26:47 +08:00
|
|
|
|
<SelectItem value="user">
|
|
|
|
|
|
用户
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
2026-05-09 21:47:33 +08:00
|
|
|
|
<Select
|
|
|
|
|
|
v-model="filterGroup"
|
|
|
|
|
|
>
|
|
|
|
|
|
<SelectTrigger class="w-24 h-8 text-xs border-border/60">
|
|
|
|
|
|
<SelectValue placeholder="分组" />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectItem value="all">
|
|
|
|
|
|
全部
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
<SelectItem
|
|
|
|
|
|
v-for="group in userGroups"
|
|
|
|
|
|
:key="group.id"
|
|
|
|
|
|
:value="group.id"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ group.name }}
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
2025-12-13 22:26:47 +08:00
|
|
|
|
<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">
|
2025-12-12 16:15:54 +08:00
|
|
|
|
<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>
|
2026-05-13 18:12:46 +08:00
|
|
|
|
<SelectItem value="audit_admin">
|
|
|
|
|
|
审计管理员
|
|
|
|
|
|
</SelectItem>
|
2026-05-01 02:14:49 +08:00
|
|
|
|
<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
|
|
|
|
|
2026-05-09 21:47:33 +08:00
|
|
|
|
<Select v-model="filterGroup">
|
|
|
|
|
|
<SelectTrigger class="w-32 h-8 text-xs border-border/60">
|
|
|
|
|
|
<SelectValue placeholder="全部分组" />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectItem value="all">
|
|
|
|
|
|
全部分组
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
<SelectItem
|
|
|
|
|
|
v-for="group in userGroups"
|
|
|
|
|
|
:key="group.id"
|
|
|
|
|
|
:value="group.id"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ group.name }}
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<!-- 分隔线 -->
|
|
|
|
|
|
<div class="h-4 w-px bg-border" />
|
|
|
|
|
|
|
2026-05-09 21:47:33 +08:00
|
|
|
|
<!-- 新增用户按钮 -->
|
|
|
|
|
|
<Button
|
2026-05-13 18:12:46 +08:00
|
|
|
|
v-if="authStore.canOperateAdmin"
|
2026-05-09 21:47:33 +08:00
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8"
|
|
|
|
|
|
title="分组管理"
|
|
|
|
|
|
@click="showUserGroupsDialog = true"
|
|
|
|
|
|
>
|
|
|
|
|
|
<FolderKanban class="w-3.5 h-3.5" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<!-- 新增用户按钮 -->
|
|
|
|
|
|
<Button
|
2026-05-13 18:12:46 +08:00
|
|
|
|
v-if="authStore.canOperateAdmin"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8"
|
|
|
|
|
|
title="新增用户"
|
2025-12-12 16:15:54 +08:00
|
|
|
|
@click="openCreateDialog"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
>
|
|
|
|
|
|
<Plus class="w-3.5 h-3.5" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 刷新按钮 -->
|
2025-12-12 16:15:54 +08:00
|
|
|
|
<RefreshButton
|
2026-04-19 01:26:22 +08:00
|
|
|
|
:loading="usersStore.loading"
|
2025-12-12 16:15:54 +08:00
|
|
|
|
@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">
|
2026-05-07 19:16:49 +08:00
|
|
|
|
<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
|
2026-05-13 18:12:46 +08:00
|
|
|
|
v-if="authStore.canOperateAdmin"
|
2026-05-07 19:16:49 +08:00
|
|
|
|
size="sm"
|
|
|
|
|
|
class="h-7 px-3 text-[11px]"
|
2026-05-09 21:47:33 +08:00
|
|
|
|
:disabled="(selectedCount === 0 && userGroups.length === 0) || usersStore.loading"
|
2026-05-07 19:16:49 +08:00
|
|
|
|
@click="openUserBatchDialog"
|
|
|
|
|
|
>
|
|
|
|
|
|
批量操作
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<!-- 桌面端表格 -->
|
|
|
|
|
|
<div class="hidden xl:block overflow-x-auto">
|
|
|
|
|
|
<Table>
|
2025-12-12 16:15:54 +08:00
|
|
|
|
<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">
|
2026-05-07 19:16:49 +08:00
|
|
|
|
<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"
|
|
|
|
|
|
>
|
2025-12-12 16:15:54 +08:00
|
|
|
|
用户信息
|
2026-05-01 02:14:49 +08:00
|
|
|
|
<template #filter="{ close }">
|
|
|
|
|
|
<TableFilterMenu
|
|
|
|
|
|
v-model="filterRole"
|
|
|
|
|
|
:options="userRoleFilterOptions"
|
|
|
|
|
|
@select="close"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</SortableTableHead>
|
2026-03-08 00:05:48 +08:00
|
|
|
|
<TableHead class="w-[240px] h-12 font-semibold">
|
|
|
|
|
|
钱包
|
2025-12-12 16:15:54 +08:00
|
|
|
|
</TableHead>
|
2026-03-08 00:05:48 +08:00
|
|
|
|
<TableHead class="w-[170px] h-12 font-semibold">
|
2026-03-15 14:22:59 +08:00
|
|
|
|
统计/限速
|
2025-12-12 16:15:54 +08:00
|
|
|
|
</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"
|
|
|
|
|
|
>
|
2025-12-12 16:15:54 +08:00
|
|
|
|
状态
|
2026-05-01 02:14:49 +08:00
|
|
|
|
<template #filter="{ close }">
|
|
|
|
|
|
<TableFilterMenu
|
|
|
|
|
|
v-model="filterStatus"
|
|
|
|
|
|
:options="userStatusFilterOptions"
|
|
|
|
|
|
@select="close"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</SortableTableHead>
|
2026-05-13 01:18:38 +08:00
|
|
|
|
<TableHead class="w-[260px] h-12 font-semibold text-center">
|
2025-12-12 16:15:54 +08:00
|
|
|
|
操作
|
|
|
|
|
|
</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">
|
2026-05-07 19:16:49 +08:00
|
|
|
|
<Checkbox
|
|
|
|
|
|
:checked="selectAllFiltered || selectedIdSet.has(user.id)"
|
|
|
|
|
|
:disabled="selectAllFiltered || usersStore.loading"
|
|
|
|
|
|
@update:checked="(checked) => toggleOne(user.id, checked === true)"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</TableCell>
|
2025-12-12 16:15:54 +08:00
|
|
|
|
<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">
|
2026-03-08 00:05:48 +08:00
|
|
|
|
<div class="mb-1 flex items-center gap-1.5">
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="truncate text-sm font-semibold"
|
|
|
|
|
|
:title="user.username"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ user.username }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Badge
|
2026-05-13 18:12:46 +08:00
|
|
|
|
:variant="userRoleBadgeVariant(user.role)"
|
2026-03-08 00:05:48 +08:00
|
|
|
|
class="h-5 px-1.5 py-0 text-[10px] font-medium flex-shrink-0"
|
|
|
|
|
|
>
|
2026-05-13 18:12:46 +08:00
|
|
|
|
{{ formatUserRole(user.role) }}
|
2026-03-08 00:05:48 +08:00
|
|
|
|
</Badge>
|
2025-12-12 16:15:54 +08:00
|
|
|
|
</div>
|
2026-03-08 00:05:48 +08:00
|
|
|
|
<div
|
|
|
|
|
|
class="truncate text-xs text-muted-foreground"
|
|
|
|
|
|
:title="user.email || '-'"
|
2025-12-12 16:15:54 +08:00
|
|
|
|
>
|
2026-03-08 00:05:48 +08:00
|
|
|
|
{{ user.email || '-' }}
|
|
|
|
|
|
</div>
|
2026-05-09 21:47:33 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="user.groups?.length"
|
|
|
|
|
|
class="mt-1 flex flex-wrap gap-1"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Badge
|
|
|
|
|
|
v-for="group in user.groups"
|
|
|
|
|
|
:key="group.id"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
class="h-5 px-1.5 py-0 text-[10px]"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ group.name }}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</div>
|
2025-12-12 16:15:54 +08:00
|
|
|
|
</div>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
2025-12-12 16:15:54 +08:00
|
|
|
|
</TableCell>
|
|
|
|
|
|
<TableCell class="py-4">
|
2026-03-08 00:05:48 +08:00
|
|
|
|
<div class="space-y-1.5">
|
|
|
|
|
|
<div class="flex items-center gap-1 text-[11px] text-muted-foreground">
|
2026-05-13 01:18:38 +08:00
|
|
|
|
<span>总可用:</span>
|
2026-03-08 00:05:48 +08:00
|
|
|
|
<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>
|
2026-05-13 01:18:38 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="!isUserUnlimited(user) && getUserWallet(user.id)"
|
|
|
|
|
|
class="text-[11px] text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
套餐 {{ formatCurrencyValue(getUserPackageBalance(user), '$0.00') }}
|
|
|
|
|
|
· 钱包 {{ formatCurrencyValue(getUserWalletBalance(user), '$0.00') }}
|
|
|
|
|
|
</div>
|
2026-03-08 00:05:48 +08:00
|
|
|
|
<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>
|
2025-12-12 16:15:54 +08:00
|
|
|
|
</TableCell>
|
|
|
|
|
|
<TableCell class="py-4">
|
2026-03-15 14:22:59 +08:00
|
|
|
|
<div class="space-y-1 text-xs">
|
2026-04-19 01:26:22 +08:00
|
|
|
|
<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>
|
2025-12-12 16:15:54 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="flex items-center text-muted-foreground">
|
2026-03-15 14:22:59 +08:00
|
|
|
|
<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>
|
2025-12-12 16:15:54 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
<TableCell class="py-4 text-xs text-muted-foreground">
|
|
|
|
|
|
{{ formatDate(user.created_at) }}
|
|
|
|
|
|
</TableCell>
|
2026-03-08 00:05:48 +08:00
|
|
|
|
<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>
|
2025-12-12 16:15:54 +08:00
|
|
|
|
</TableCell>
|
|
|
|
|
|
<TableCell class="py-4">
|
|
|
|
|
|
<div class="flex justify-center gap-1">
|
|
|
|
|
|
<Button
|
2026-05-13 18:12:46 +08:00
|
|
|
|
v-if="authStore.canOperateAdmin"
|
2025-12-12 16:15:54 +08:00
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8"
|
|
|
|
|
|
title="编辑用户"
|
|
|
|
|
|
@click="editUser(user)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<SquarePen class="h-4 w-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
2026-05-13 18:12:46 +08:00
|
|
|
|
v-if="authStore.canOperateAdmin"
|
2025-12-12 16:15:54 +08:00
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8"
|
2026-03-08 00:05:48 +08:00
|
|
|
|
title="资金操作"
|
|
|
|
|
|
@click="openWalletActionDialog(user)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<DollarSign class="h-4 w-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
2026-05-13 18:12:46 +08:00
|
|
|
|
v-if="authStore.canOperateAdmin"
|
2026-05-13 01:18:38 +08:00
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8"
|
|
|
|
|
|
title="套餐"
|
|
|
|
|
|
@click="manageUserPlans(user)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<PackageCheck class="h-4 w-4" />
|
|
|
|
|
|
</Button>
|
2026-03-08 00:05:48 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8"
|
|
|
|
|
|
title="API Keys"
|
2025-12-12 16:15:54 +08:00
|
|
|
|
@click="manageApiKeys(user)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Key class="h-4 w-4" />
|
|
|
|
|
|
</Button>
|
2026-03-17 16:34:09 +08:00
|
|
|
|
<Button
|
2026-05-13 18:12:46 +08:00
|
|
|
|
v-if="authStore.canOperateAdmin"
|
2026-03-17 16:34:09 +08:00
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8"
|
|
|
|
|
|
title="登录设备"
|
|
|
|
|
|
@click="manageUserSessions(user)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<MonitorSmartphone class="h-4 w-4" />
|
|
|
|
|
|
</Button>
|
2025-12-12 16:15:54 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 移动端卡片列表 -->
|
2026-03-08 00:05:48 +08:00
|
|
|
|
<div class="xl:hidden bg-muted/[0.14] p-3 sm:p-4">
|
2025-12-12 16:15:54 +08:00
|
|
|
|
<div
|
2026-03-08 00:05:48 +08:00
|
|
|
|
v-if="paginatedUsers.length === 0"
|
|
|
|
|
|
class="rounded-2xl border border-dashed border-border/60 bg-card/70 px-6 py-10 text-center"
|
2025-12-12 16:15:54 +08:00
|
|
|
|
>
|
2026-03-08 00:05:48 +08:00
|
|
|
|
<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">
|
2026-05-07 19:16:49 +08:00
|
|
|
|
<Checkbox
|
|
|
|
|
|
class="mt-2 shrink-0"
|
|
|
|
|
|
:checked="selectAllFiltered || selectedIdSet.has(user.id)"
|
|
|
|
|
|
:disabled="selectAllFiltered || usersStore.loading"
|
|
|
|
|
|
@update:checked="(checked) => toggleOne(user.id, checked === true)"
|
|
|
|
|
|
/>
|
2026-03-08 00:05:48 +08:00
|
|
|
|
<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
|
2026-05-13 18:12:46 +08:00
|
|
|
|
:variant="userRoleBadgeVariant(user.role)"
|
2026-03-08 00:05:48 +08:00
|
|
|
|
class="h-5 px-1.5 py-0 text-[10px] font-medium flex-shrink-0"
|
|
|
|
|
|
>
|
2026-05-13 18:12:46 +08:00
|
|
|
|
{{ formatUserRole(user.role) }}
|
2026-03-08 00:05:48 +08:00
|
|
|
|
</Badge>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="truncate text-[11px] text-muted-foreground"
|
|
|
|
|
|
:title="user.email || '-'"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ user.email || '-' }}
|
|
|
|
|
|
</div>
|
2025-12-12 16:15:54 +08:00
|
|
|
|
</div>
|
2026-03-08 00:05:48 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="flex flex-wrap items-center gap-1.5">
|
2025-12-12 16:15:54 +08:00
|
|
|
|
<Badge
|
2026-03-08 00:05:48 +08:00
|
|
|
|
:variant="user.is_active ? 'success' : 'destructive'"
|
|
|
|
|
|
class="h-5 px-1.5 py-0 text-[10px] font-medium"
|
2025-12-12 16:15:54 +08:00
|
|
|
|
>
|
2026-03-08 00:05:48 +08:00
|
|
|
|
{{ 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>
|
2026-03-15 14:22:59 +08:00
|
|
|
|
<Badge
|
|
|
|
|
|
variant="secondary"
|
|
|
|
|
|
class="h-5 px-1.5 py-0 text-[10px] font-medium"
|
2026-05-09 21:47:33 +08:00
|
|
|
|
:title="formatUserEffectiveRateLimitSource(user)"
|
2026-03-15 14:22:59 +08:00
|
|
|
|
>
|
|
|
|
|
|
{{ formatRateLimitInheritable(user.rate_limit) }}
|
|
|
|
|
|
</Badge>
|
2026-05-09 21:47:33 +08:00
|
|
|
|
<Badge
|
|
|
|
|
|
v-for="group in user.groups || []"
|
|
|
|
|
|
:key="group.id"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
class="h-5 px-1.5 py-0 text-[10px] font-medium"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ group.name }}
|
|
|
|
|
|
</Badge>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-08 00:05:48 +08:00
|
|
|
|
<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">
|
2026-05-13 01:18:38 +08:00
|
|
|
|
总可用:
|
2026-03-08 00:05:48 +08:00
|
|
|
|
</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>
|
2026-05-13 01:18:38 +08:00
|
|
|
|
<p
|
|
|
|
|
|
v-if="!isUserUnlimited(user) && getUserWallet(user.id)"
|
|
|
|
|
|
class="text-[11px] text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
套餐 {{ formatCurrencyValue(getUserPackageBalance(user), '$0.00') }}
|
|
|
|
|
|
· 钱包 {{ formatCurrencyValue(getUserWalletBalance(user), '$0.00') }}
|
|
|
|
|
|
</p>
|
2026-03-08 00:05:48 +08:00
|
|
|
|
</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>
|
2025-12-12 16:15:54 +08:00
|
|
|
|
</div>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
2026-03-08 00:05:48 +08:00
|
|
|
|
|
|
|
|
|
|
<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">
|
2026-04-19 01:26:22 +08:00
|
|
|
|
{{ formatNumber(user.request_count) }}
|
2026-03-08 00:05:48 +08:00
|
|
|
|
</div>
|
2025-12-12 16:15:54 +08:00
|
|
|
|
</div>
|
2026-03-08 00:05:48 +08:00
|
|
|
|
<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">
|
2026-04-19 01:26:22 +08:00
|
|
|
|
{{ formatTokens(user.total_tokens ?? 0) }}
|
2026-03-08 00:05:48 +08:00
|
|
|
|
</div>
|
2025-12-12 16:15:54 +08:00
|
|
|
|
</div>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-08 00:05:48 +08:00
|
|
|
|
<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>
|
2026-03-08 00:05:48 +08:00
|
|
|
|
|
|
|
|
|
|
<div class="grid grid-cols-2 gap-2 pt-0.5">
|
|
|
|
|
|
<Button
|
2026-05-13 18:12:46 +08:00
|
|
|
|
v-if="authStore.canOperateAdmin"
|
2026-03-08 00:05:48 +08:00
|
|
|
|
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
|
2026-05-13 18:12:46 +08:00
|
|
|
|
v-if="authStore.canOperateAdmin"
|
2026-03-08 00:05:48 +08:00
|
|
|
|
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
|
2026-05-13 18:12:46 +08:00
|
|
|
|
v-if="authStore.canOperateAdmin"
|
2026-05-13 01:18:38 +08:00
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
class="h-8 text-xs"
|
|
|
|
|
|
@click="manageUserPlans(user)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<PackageCheck class="mr-1.5 h-3.5 w-3.5" />
|
|
|
|
|
|
套餐
|
|
|
|
|
|
</Button>
|
2026-03-08 00:05:48 +08:00
|
|
|
|
<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>
|
2026-03-17 16:34:09 +08:00
|
|
|
|
<Button
|
2026-05-13 18:12:46 +08:00
|
|
|
|
v-if="authStore.canOperateAdmin"
|
2026-03-17 16:34:09 +08:00
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
class="h-8 text-xs"
|
|
|
|
|
|
@click="manageUserSessions(user)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<MonitorSmartphone class="mr-1.5 h-3.5 w-3.5" />
|
|
|
|
|
|
设备
|
|
|
|
|
|
</Button>
|
2026-03-08 00:05:48 +08:00
|
|
|
|
<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"
|
2026-01-17 20:21:03 +08:00
|
|
|
|
cache-key="users-page-size"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
@update:current="currentPage = $event"
|
|
|
|
|
|
@update:page-size="pageSize = $event"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 用户表单对话框(创建/编辑共用) -->
|
|
|
|
|
|
<UserFormDialog
|
2025-12-12 16:15:54 +08:00
|
|
|
|
ref="userFormDialogRef"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
:open="showUserFormDialog"
|
|
|
|
|
|
:user="editingUser"
|
2026-05-09 21:47:33 +08:00
|
|
|
|
:groups="userGroups"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
@close="closeUserFormDialog"
|
|
|
|
|
|
@submit="handleUserFormSubmit"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
2026-05-07 19:16:49 +08:00
|
|
|
|
<UserBatchActionDialog
|
|
|
|
|
|
:open="showUserBatchDialog"
|
|
|
|
|
|
:selected-ids="selectedIds"
|
|
|
|
|
|
:select-all-filtered="selectAllFiltered"
|
|
|
|
|
|
:selected-count="selectedCount"
|
|
|
|
|
|
:filters="batchSelectionFilters"
|
2026-05-09 21:47:33 +08:00
|
|
|
|
:groups="userGroups"
|
2026-05-07 19:16:49 +08:00
|
|
|
|
@close="showUserBatchDialog = false"
|
|
|
|
|
|
@completed="handleUserBatchCompleted"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
2026-05-09 21:47:33 +08:00
|
|
|
|
<UserGroupsDialog
|
|
|
|
|
|
:open="showUserGroupsDialog"
|
|
|
|
|
|
:users="usersStore.users"
|
|
|
|
|
|
@close="showUserGroupsDialog = false"
|
|
|
|
|
|
@changed="handleUserGroupsChanged"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
2026-05-13 01:18:38 +08:00
|
|
|
|
<Dialog
|
|
|
|
|
|
v-model="showUserPlansDialog"
|
|
|
|
|
|
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 flex-shrink-0 items-center justify-center rounded-lg bg-kraft/10">
|
|
|
|
|
|
<PackageCheck class="h-5 w-5 text-kraft" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="min-w-0 flex-1">
|
|
|
|
|
|
<h3 class="text-lg font-semibold leading-tight text-foreground">
|
|
|
|
|
|
用户套餐
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
<p class="text-xs text-muted-foreground">
|
|
|
|
|
|
{{ selectedUser?.username || '-' }} · 查看当前套餐并手动发放
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="max-h-[64vh] space-y-4 overflow-y-auto">
|
|
|
|
|
|
<div class="rounded-lg border border-amber-500/20 bg-amber-500/10 px-3 py-2.5 text-xs text-amber-100/90">
|
|
|
|
|
|
后台发放会立即生效;如果新套餐包含每日额度或会员权益,用户已有的同类旧套餐会自动失效。
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<section class="space-y-2.5">
|
|
|
|
|
|
<div class="flex items-center justify-between gap-3">
|
|
|
|
|
|
<h4 class="text-sm font-semibold text-foreground">
|
|
|
|
|
|
当前有效套餐
|
|
|
|
|
|
</h4>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
class="h-7 px-2 text-[11px]"
|
|
|
|
|
|
:disabled="loadingUserPlans || !selectedUser"
|
|
|
|
|
|
@click="selectedUser && loadUserPlanEntitlements(selectedUser.id)"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ loadingUserPlans ? '加载中...' : '刷新' }}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="loadingUserPlans"
|
|
|
|
|
|
class="rounded-lg border border-dashed border-border/60 bg-muted/20 px-4 py-8 text-center text-sm text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
正在加载用户套餐...
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-else-if="userPlanEntitlements.length === 0"
|
|
|
|
|
|
class="rounded-lg border border-dashed border-border/60 bg-muted/20 px-4 py-8 text-center text-sm text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
当前没有有效套餐
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-else
|
|
|
|
|
|
class="space-y-2.5"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-for="item in userPlanEntitlements"
|
|
|
|
|
|
:key="item.id"
|
|
|
|
|
|
class="rounded-lg border border-border bg-card/80 p-3"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
|
|
|
|
<div class="min-w-0 flex-1">
|
|
|
|
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
|
|
|
|
<span class="font-medium text-foreground">
|
|
|
|
|
|
{{ item.plan_title || item.plan?.title || item.plan_id }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<Badge
|
|
|
|
|
|
:variant="item.active ? 'success' : 'secondary'"
|
|
|
|
|
|
class="h-5 px-1.5 py-0 text-[10px]"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ item.active ? '生效中' : item.status }}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="mt-2 flex flex-wrap gap-1.5">
|
|
|
|
|
|
<Badge
|
|
|
|
|
|
v-for="label in entitlementLabels(item.entitlements)"
|
|
|
|
|
|
:key="label"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
class="h-5 px-1.5 py-0 text-[10px]"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ label }}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="text-left text-[11px] text-muted-foreground sm:text-right">
|
|
|
|
|
|
<div>开始:{{ formatDateTime(item.starts_at) }}</div>
|
|
|
|
|
|
<div>到期:{{ formatDateTime(item.expires_at) }}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<section class="space-y-3 rounded-lg border border-border bg-card/70 p-4">
|
|
|
|
|
|
<div class="space-y-1">
|
|
|
|
|
|
<h4 class="text-sm font-semibold text-foreground">
|
|
|
|
|
|
发放套餐
|
|
|
|
|
|
</h4>
|
|
|
|
|
|
<p class="text-xs text-muted-foreground">
|
|
|
|
|
|
仅发放套餐权益,不产生用户付款;同类旧套餐会按现有规则自动替换。
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<Select v-model="selectedGrantPlanId">
|
|
|
|
|
|
<SelectTrigger
|
|
|
|
|
|
class="h-9 rounded-md bg-muted/50 px-3"
|
|
|
|
|
|
:disabled="loadingBillingPlans || grantableBillingPlans.length === 0"
|
|
|
|
|
|
>
|
|
|
|
|
|
<SelectValue :placeholder="loadingBillingPlans ? '加载套餐中...' : '选择要发放的套餐'" />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectItem
|
|
|
|
|
|
v-for="plan in grantableBillingPlans"
|
|
|
|
|
|
:key="plan.id"
|
|
|
|
|
|
:value="plan.id"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="flex min-w-0 items-center gap-2">
|
|
|
|
|
|
<span class="truncate">{{ plan.title }}</span>
|
|
|
|
|
|
<span class="shrink-0 text-xs text-muted-foreground">
|
|
|
|
|
|
{{ formatPlanPrice(plan) }} · {{ formatPlanDuration(plan) }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-if="!plan.enabled"
|
|
|
|
|
|
class="shrink-0 text-[10px] text-amber-400"
|
|
|
|
|
|
>
|
|
|
|
|
|
已下架
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
|
|
|
|
|
|
<Textarea
|
|
|
|
|
|
v-model="grantReason"
|
|
|
|
|
|
class="min-h-[60px] resize-y rounded-md bg-muted/50 text-sm"
|
|
|
|
|
|
maxlength="512"
|
|
|
|
|
|
placeholder="备注(可选,例如:人工补偿、活动赠送)"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="flex justify-end">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
:disabled="grantingUserPlan || !selectedUser || !selectedGrantPlanId"
|
|
|
|
|
|
@click="grantPlanToSelectedUser"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ grantingUserPlan ? '发放中...' : '发放套餐' }}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<template #footer>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
class="h-10 px-5"
|
|
|
|
|
|
@click="showUserPlansDialog = false"
|
|
|
|
|
|
>
|
|
|
|
|
|
关闭
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<!-- API Keys 管理对话框 -->
|
2025-12-12 16:15:54 +08:00
|
|
|
|
<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">
|
2025-12-12 16:15:54 +08:00
|
|
|
|
<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"
|
|
|
|
|
|
>
|
2026-03-03 11:32:41 +08:00
|
|
|
|
{{ apiKey.is_active ? '活跃' : '禁用' }}
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</Badge>
|
2026-01-16 01:18:54 +08:00
|
|
|
|
<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>
|
2026-03-15 14:22:59 +08:00
|
|
|
|
<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">
|
2026-04-11 17:39:02 +08:00
|
|
|
|
{{ apiKey.key_display || '****' }}
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</code>
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="p-0.5 hover:bg-muted rounded transition-colors"
|
|
|
|
|
|
title="复制完整密钥"
|
2025-12-12 16:15:54 +08:00
|
|
|
|
@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>
|
2026-03-15 14:22:59 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8"
|
|
|
|
|
|
title="编辑"
|
|
|
|
|
|
@click="openEditUserApiKeyDialog(apiKey)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<SquarePen class="h-4 w-4" />
|
|
|
|
|
|
</Button>
|
2026-01-16 01:18:54 +08:00
|
|
|
|
<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="删除"
|
2025-12-12 16:15:54 +08:00
|
|
|
|
@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>
|
2025-12-12 16:15:54 +08:00
|
|
|
|
<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>
|
2025-12-12 16:15:54 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
class="h-10 px-5"
|
|
|
|
|
|
@click="showApiKeysDialog = false"
|
|
|
|
|
|
>
|
|
|
|
|
|
取消
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
class="h-10 px-5"
|
|
|
|
|
|
:disabled="creatingApiKey"
|
2026-03-15 14:22:59 +08:00
|
|
|
|
@click="openCreateUserApiKeyDialog"
|
2025-12-12 16:15:54 +08:00
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
{{ creatingApiKey ? '创建中...' : '创建' }}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
2026-03-15 14:22:59 +08:00
|
|
|
|
<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' }}
|
2026-03-15 14:22:59 +08:00
|
|
|
|
</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>
|
2026-05-14 11:14:20 +08:00
|
|
|
|
|
|
|
|
|
|
<div class="rounded-lg border border-border bg-muted/30 p-3 space-y-3">
|
|
|
|
|
|
<div class="flex items-center justify-between gap-3">
|
|
|
|
|
|
<Label class="text-sm font-medium">敏感信息保护</Label>
|
|
|
|
|
|
<Switch v-model="userApiKeyForm.chat_pii_redaction_enabled" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="flex items-center justify-between gap-3">
|
|
|
|
|
|
<Label class="text-sm font-medium">占位符说明</Label>
|
|
|
|
|
|
<Switch
|
|
|
|
|
|
v-model="userApiKeyForm.chat_pii_redaction_placeholder_notice"
|
|
|
|
|
|
:disabled="!userApiKeyForm.chat_pii_redaction_enabled"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-15 14:22:59 +08:00
|
|
|
|
</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>
|
|
|
|
|
|
|
2026-03-17 16:34:09 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-03-08 00:05:48 +08:00
|
|
|
|
<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 显示对话框 -->
|
2025-12-12 16:15:54 +08:00
|
|
|
|
<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">
|
2025-12-12 16:15:54 +08:00
|
|
|
|
<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
|
2025-12-12 16:15:54 +08:00
|
|
|
|
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"
|
|
|
|
|
|
/>
|
2025-12-12 16:15:54 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
class="h-11"
|
|
|
|
|
|
@click="copyApiKey"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
复制
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<template #footer>
|
2025-12-12 16:15:54 +08:00
|
|
|
|
<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'
|
2026-05-13 18:12:46 +08:00
|
|
|
|
import { useAuthStore } from '@/stores/auth'
|
2026-05-13 01:18:38 +08:00
|
|
|
|
import { usersApi, type User, type ApiKey, type UserSession, type UserBatchActionResponse, type UserBatchSelectionFilters, type UserGroup, type AdminUserPlanEntitlement } from '@/api/users'
|
2026-03-17 16:34:09 +08:00
|
|
|
|
import { formatSessionMeta } from '@/types/session'
|
2026-03-08 00:05:48 +08:00
|
|
|
|
import { adminWalletApi, type AdminWallet } from '@/api/admin-wallets'
|
2026-05-13 01:18:38 +08:00
|
|
|
|
import { adminBillingPlansApi, type BillingEntitlement, type BillingPlan } from '@/api/billing'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
import { useToast } from '@/composables/useToast'
|
|
|
|
|
|
import { useConfirm } from '@/composables/useConfirm'
|
2025-12-28 20:41:52 +08:00
|
|
|
|
import { useClipboard } from '@/composables/useClipboard'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
import { adminApi } from '@/api/admin'
|
2026-03-08 00:05:48 +08:00
|
|
|
|
import { walletStatusBadge, walletStatusLabel } from '@/utils/walletDisplay'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
// UI 组件
|
|
|
|
|
|
import {
|
|
|
|
|
|
Dialog,
|
|
|
|
|
|
Card,
|
|
|
|
|
|
Button,
|
|
|
|
|
|
Badge,
|
|
|
|
|
|
Input,
|
|
|
|
|
|
Label,
|
2026-05-13 01:18:38 +08:00
|
|
|
|
Textarea,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
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,
|
2026-05-07 19:16:49 +08:00
|
|
|
|
RefreshButton,
|
2026-05-14 11:14:20 +08:00
|
|
|
|
Checkbox,
|
|
|
|
|
|
Switch
|
2025-12-10 20:52:44 +08:00
|
|
|
|
} from '@/components/ui'
|
|
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
|
Plus,
|
|
|
|
|
|
SquarePen,
|
|
|
|
|
|
Key,
|
|
|
|
|
|
PauseCircle,
|
|
|
|
|
|
PlayCircle,
|
2026-03-08 00:05:48 +08:00
|
|
|
|
DollarSign,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
Trash2,
|
|
|
|
|
|
Copy,
|
|
|
|
|
|
Search,
|
2026-01-16 01:18:54 +08:00
|
|
|
|
CheckCircle,
|
|
|
|
|
|
Lock,
|
2026-03-17 16:34:09 +08:00
|
|
|
|
LockOpen,
|
2026-05-09 21:47:33 +08:00
|
|
|
|
MonitorSmartphone,
|
|
|
|
|
|
FolderKanban,
|
2026-05-13 01:18:38 +08:00
|
|
|
|
PackageCheck,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
} from 'lucide-vue-next'
|
|
|
|
|
|
|
|
|
|
|
|
// 功能组件
|
|
|
|
|
|
import UserFormDialog, { type UserFormData } from '@/features/users/components/UserFormDialog.vue'
|
2026-05-07 19:16:49 +08:00
|
|
|
|
import UserBatchActionDialog from '@/features/users/components/UserBatchActionDialog.vue'
|
2026-05-09 21:47:33 +08:00
|
|
|
|
import UserGroupsDialog from '@/features/users/components/UserGroupsDialog.vue'
|
2026-03-08 00:05:48 +08:00
|
|
|
|
import WalletOpsDrawer from '@/features/wallet/components/WalletOpsDrawer.vue'
|
2026-02-22 00:43:41 +08:00
|
|
|
|
import { parseApiError } from '@/utils/errorParser'
|
2026-03-15 14:22:59 +08:00
|
|
|
|
import { formatTokens, formatRateLimitInheritable, formatRateLimitSimple, isRateLimitInherited, isRateLimitUnlimited } from '@/utils/format'
|
|
|
|
|
|
import { parseNumberInput } from '@/utils/form'
|
2026-05-14 11:14:20 +08:00
|
|
|
|
import {
|
|
|
|
|
|
mergeChatPiiRedactionFeatureSettings,
|
|
|
|
|
|
readChatPiiRedactionFeatureSettings,
|
|
|
|
|
|
} from '@/utils/featureSettings'
|
2025-12-12 20:22:15 +08:00
|
|
|
|
import { log } from '@/utils/logger'
|
2026-05-07 19:16:49 +08:00
|
|
|
|
import { useBatchSelection } from '@/composables/useBatchSelection'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
const { success, error } = useToast()
|
2026-03-08 00:05:48 +08:00
|
|
|
|
const { confirmDanger } = useConfirm()
|
2025-12-28 20:41:52 +08:00
|
|
|
|
const { copyToClipboard } = useClipboard()
|
2025-12-10 20:52:44 +08:00
|
|
|
|
const usersStore = useUsersStore()
|
2026-05-13 18:12:46 +08:00
|
|
|
|
const authStore = useAuthStore()
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
// 用户表单对话框状态
|
|
|
|
|
|
const showUserFormDialog = ref(false)
|
|
|
|
|
|
const editingUser = ref<UserFormData | null>(null)
|
|
|
|
|
|
const userFormDialogRef = ref<InstanceType<typeof UserFormDialog>>()
|
|
|
|
|
|
|
|
|
|
|
|
// API Keys 对话框状态
|
|
|
|
|
|
const showApiKeysDialog = ref(false)
|
2026-03-17 16:34:09 +08:00
|
|
|
|
const showUserSessionsDialog = ref(false)
|
2026-05-13 01:18:38 +08:00
|
|
|
|
const showUserPlansDialog = ref(false)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
const showNewApiKeyDialog = ref(false)
|
2026-03-15 14:22:59 +08:00
|
|
|
|
const showUserApiKeyFormDialog = ref(false)
|
2026-02-22 00:43:41 +08:00
|
|
|
|
const selectedUser = ref<User | null>(null)
|
|
|
|
|
|
const userApiKeys = ref<ApiKey[]>([])
|
2026-03-17 16:34:09 +08:00
|
|
|
|
const userSessions = ref<UserSession[]>([])
|
2026-05-13 01:18:38 +08:00
|
|
|
|
const userPlanEntitlements = ref<AdminUserPlanEntitlement[]>([])
|
|
|
|
|
|
const availableBillingPlans = ref<BillingPlan[]>([])
|
|
|
|
|
|
const selectedGrantPlanId = ref('')
|
|
|
|
|
|
const grantReason = ref('')
|
2025-12-10 20:52:44 +08:00
|
|
|
|
const newApiKey = ref('')
|
|
|
|
|
|
const creatingApiKey = ref(false)
|
2026-03-17 16:34:09 +08:00
|
|
|
|
const loadingUserSessions = ref(false)
|
2026-05-13 01:18:38 +08:00
|
|
|
|
const loadingUserPlans = ref(false)
|
|
|
|
|
|
const loadingBillingPlans = ref(false)
|
|
|
|
|
|
const grantingUserPlan = ref(false)
|
2026-03-17 16:34:09 +08:00
|
|
|
|
const sessionDialogActionLoading = ref<string | null>(null)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
const apiKeyInput = ref<HTMLInputElement>()
|
2026-03-15 14:22:59 +08:00
|
|
|
|
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,
|
2026-05-14 11:14:20 +08:00
|
|
|
|
chat_pii_redaction_enabled: false,
|
|
|
|
|
|
chat_pii_redaction_placeholder_notice: true,
|
2026-03-15 14:22:59 +08:00
|
|
|
|
})
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
// 用户统计
|
2026-03-08 00:05:48 +08:00
|
|
|
|
const userWalletMap = ref<Record<string, AdminWallet>>({})
|
|
|
|
|
|
|
|
|
|
|
|
const showWalletActionDialogState = ref(false)
|
|
|
|
|
|
const walletActionTarget = ref<{ user: User; wallet: AdminWallet } | null>(null)
|
2026-05-07 19:16:49 +08:00
|
|
|
|
const showUserBatchDialog = ref(false)
|
2026-05-09 21:47:33 +08:00
|
|
|
|
const showUserGroupsDialog = ref(false)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
const searchQuery = ref('')
|
|
|
|
|
|
const filterRole = ref('all')
|
|
|
|
|
|
const filterStatus = ref('all')
|
2026-05-09 21:47:33 +08:00
|
|
|
|
const filterGroup = ref('all')
|
|
|
|
|
|
const userGroups = ref<UserGroup[]>([])
|
2026-05-01 02:14:49 +08:00
|
|
|
|
const userRoleFilterOptions = [
|
|
|
|
|
|
{ value: 'all', label: '全部角色' },
|
|
|
|
|
|
{ value: 'admin', label: '管理员' },
|
2026-05-13 18:12:46 +08:00
|
|
|
|
{ value: 'audit_admin', label: '审计管理员' },
|
2026-05-01 02:14:49 +08:00
|
|
|
|
{ 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)
|
2026-04-19 15:17:25 +08:00
|
|
|
|
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) => {
|
2026-05-13 18:12:46 +08:00
|
|
|
|
const roleRank = (role: string) => role === 'admin' ? 0 : role === 'audit_admin' ? 1 : 2
|
|
|
|
|
|
const roleDiff = roleRank(a.role) - roleRank(b.role)
|
|
|
|
|
|
if (roleDiff !== 0) return roleDiff
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 同角色按创建时间倒序(新用户在前)
|
|
|
|
|
|
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2025-12-17 16:41:10 +08:00
|
|
|
|
// 搜索(支持空格分隔的多关键词 AND 搜索)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
if (searchQuery.value) {
|
2025-12-17 16:41:10 +08:00
|
|
|
|
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
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 21:47:33 +08:00
|
|
|
|
if (filterGroup.value !== 'all') {
|
|
|
|
|
|
filtered = filtered.filter(u => (u.groups || []).some(group => group.id === filterGroup.value))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
return filtered
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const paginatedUsers = computed(() => {
|
|
|
|
|
|
const start = (currentPage.value - 1) * pageSize.value
|
|
|
|
|
|
return filteredUsers.value.slice(start, start + pageSize.value)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-07 19:16:49 +08:00
|
|
|
|
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
|
2026-05-13 18:12:46 +08:00
|
|
|
|
if (filterRole.value === 'admin' || filterRole.value === 'audit_admin' || filterRole.value === 'user') filters.role = filterRole.value
|
2026-05-07 19:16:49 +08:00
|
|
|
|
if (filterStatus.value === 'active') filters.is_active = true
|
|
|
|
|
|
if (filterStatus.value === 'inactive') filters.is_active = false
|
2026-05-09 21:47:33 +08:00
|
|
|
|
if (filterGroup.value !== 'all') filters.group_id = filterGroup.value
|
2026-05-07 19:16:49 +08:00
|
|
|
|
return filters
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-13 01:18:38 +08:00
|
|
|
|
const grantableBillingPlans = computed(() =>
|
|
|
|
|
|
availableBillingPlans.value.filter((plan) => hasPackageEntitlement(plan.entitlements))
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// Watch filter changes and reset to first page
|
2026-05-09 21:47:33 +08:00
|
|
|
|
watch([searchQuery, filterRole, filterStatus, filterGroup], () => {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
currentPage.value = 1
|
2026-05-07 19:16:49 +08:00
|
|
|
|
resetBatchSelection()
|
2025-12-10 20:52:44 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-07 19:16:49 +08:00
|
|
|
|
watch(paginatedUsers, (users) => rememberBatchPageUsers(users), { immediate: true })
|
|
|
|
|
|
|
2026-05-13 18:12:46 +08:00
|
|
|
|
function formatUserRole(role: string) {
|
|
|
|
|
|
if (role === 'admin') return '管理员'
|
|
|
|
|
|
if (role === 'audit_admin') return '审计管理员'
|
|
|
|
|
|
return '普通用户'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function userRoleBadgeVariant(role: string) {
|
|
|
|
|
|
return role === 'admin' ? 'default' : 'secondary'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-19 15:17:25 +08:00
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
void refreshUsers({ preferCache: true })
|
2025-12-10 20:52:44 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-04-19 15:17:25 +08:00
|
|
|
|
async function refreshUsers(options: { preferCache?: boolean } = {}) {
|
|
|
|
|
|
const cacheTtlMs = options.preferCache ? USERS_PAGE_CACHE_TTL_MS : 0
|
2026-05-09 21:47:33 +08:00
|
|
|
|
await Promise.all([
|
|
|
|
|
|
usersStore.fetchUsers({ cacheTtlMs }),
|
|
|
|
|
|
loadUserGroups(),
|
|
|
|
|
|
])
|
2026-04-19 15:17:25 +08:00
|
|
|
|
void loadUserWallets({
|
|
|
|
|
|
cacheTtlMs: options.preferCache ? USER_WALLETS_CACHE_TTL_MS : 0,
|
|
|
|
|
|
})
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 21:47:33 +08:00
|
|
|
|
async function loadUserGroups(): Promise<void> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await usersStore.listUserGroups()
|
|
|
|
|
|
userGroups.value = response.items
|
|
|
|
|
|
if (filterGroup.value !== 'all' && !userGroups.value.some((group) => group.id === filterGroup.value)) {
|
|
|
|
|
|
filterGroup.value = 'all'
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
log.error('加载用户分组失败:', err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleUserGroupsChanged(): Promise<void> {
|
|
|
|
|
|
await refreshUsers()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-07 19:16:49 +08:00
|
|
|
|
function openUserBatchDialog(): void {
|
2026-05-09 21:47:33 +08:00
|
|
|
|
if (selectedCount.value === 0 && userGroups.value.length === 0) return
|
2026-05-07 19:16:49 +08:00
|
|
|
|
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')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 01:18:38 +08:00
|
|
|
|
function formatDateTime(value?: string | null): string {
|
|
|
|
|
|
if (!value) return '-'
|
|
|
|
|
|
return new Date(value).toLocaleString('zh-CN', {
|
|
|
|
|
|
year: 'numeric',
|
|
|
|
|
|
month: '2-digit',
|
|
|
|
|
|
day: '2-digit',
|
|
|
|
|
|
hour: '2-digit',
|
|
|
|
|
|
minute: '2-digit',
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatPlanPrice(plan: BillingPlan): string {
|
|
|
|
|
|
return `${Number(plan.price_amount || 0).toFixed(2)} ${plan.price_currency || 'CNY'}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatPlanDuration(plan: BillingPlan): string {
|
|
|
|
|
|
const labels: Record<string, string> = {
|
|
|
|
|
|
day: '天',
|
|
|
|
|
|
month: '个月',
|
|
|
|
|
|
year: '年',
|
|
|
|
|
|
custom: '天',
|
|
|
|
|
|
}
|
|
|
|
|
|
const unit = labels[plan.duration_unit] || '天'
|
|
|
|
|
|
return `${Number(plan.duration_value || 1)}${unit}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function entitlementLabels(items: BillingEntitlement[] | undefined): string[] {
|
|
|
|
|
|
return (items || []).map((item) => {
|
|
|
|
|
|
if (item.type === 'wallet_credit') {
|
|
|
|
|
|
return `附赠余额 $${Number(item.amount_usd || 0).toFixed(2)}`
|
|
|
|
|
|
}
|
|
|
|
|
|
if (item.type === 'daily_quota') {
|
|
|
|
|
|
return `每日额度 $${Number(item.daily_quota_usd || 0).toFixed(2)}`
|
|
|
|
|
|
}
|
|
|
|
|
|
if (item.type === 'membership_group') {
|
|
|
|
|
|
return '会员权益'
|
|
|
|
|
|
}
|
|
|
|
|
|
return item.type
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function hasPackageEntitlement(items: BillingEntitlement[] | undefined): boolean {
|
|
|
|
|
|
return (items || []).some((item) => item.type === 'daily_quota' || item.type === 'membership_group')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-19 15:17:25 +08:00
|
|
|
|
async function loadUserWallets(options: { cacheTtlMs?: number } = {}) {
|
|
|
|
|
|
const requestId = ++userWalletsRequestId
|
2026-03-08 00:05:48 +08:00
|
|
|
|
try {
|
2026-04-19 15:17:25 +08:00
|
|
|
|
const wallets = await adminWalletApi.listAllWallets(
|
|
|
|
|
|
{ owner_type: 'user' },
|
|
|
|
|
|
{ cacheTtlMs: options.cacheTtlMs ?? 0 },
|
|
|
|
|
|
)
|
|
|
|
|
|
if (requestId !== userWalletsRequestId) return
|
2026-03-08 00:05:48 +08:00
|
|
|
|
userWalletMap.value = wallets
|
2026-04-19 15:17:25 +08:00
|
|
|
|
.filter((wallet) => !!wallet.user_id)
|
2026-03-08 00:05:48 +08:00
|
|
|
|
.reduce<Record<string, AdminWallet>>((acc, wallet) => {
|
|
|
|
|
|
acc[wallet.user_id as string] = wallet
|
|
|
|
|
|
return acc
|
|
|
|
|
|
}, {})
|
|
|
|
|
|
} catch (err) {
|
2026-04-19 15:17:25 +08:00
|
|
|
|
if (requestId !== userWalletsRequestId) return
|
2026-03-08 00:05:48 +08:00
|
|
|
|
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()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-08 00:05:48 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-05-13 01:18:38 +08:00
|
|
|
|
if (typeof wallet.total_available_balance === 'number' && Number.isFinite(wallet.total_available_balance)) {
|
|
|
|
|
|
return wallet.total_available_balance
|
|
|
|
|
|
}
|
|
|
|
|
|
return getUserWalletBalance(user) + getUserPackageBalance(user)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getUserWalletBalance(user: User): number {
|
|
|
|
|
|
const wallet = getUserWallet(user.id)
|
|
|
|
|
|
const value = wallet?.wallet_balance ?? wallet?.balance ?? 0
|
|
|
|
|
|
return Number.isFinite(value) ? value : 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getUserPackageBalance(user: User): number {
|
|
|
|
|
|
const value = getUserWallet(user.id)?.package_balance ?? 0
|
|
|
|
|
|
return Number.isFinite(value) ? value : 0
|
2026-03-08 00:05:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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} 并发`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 21:47:33 +08:00
|
|
|
|
function formatUserEffectiveRateLimitSource(user: User): string {
|
|
|
|
|
|
const source = user.effective_policy?.rate_limit
|
|
|
|
|
|
if (!source) return ''
|
|
|
|
|
|
if (source.source === 'group' && source.group_name) {
|
|
|
|
|
|
return `继承自分组:${source.group_name}`
|
|
|
|
|
|
}
|
2026-05-10 17:28:23 +08:00
|
|
|
|
if (source.source === 'combined') {
|
|
|
|
|
|
const groupNames = Array.isArray(source.group_names) ? source.group_names.join('、') : ''
|
|
|
|
|
|
return groupNames ? `用户额外限制与分组叠加:${groupNames}` : '用户额外限制与分组叠加'
|
|
|
|
|
|
}
|
2026-05-09 21:47:33 +08:00
|
|
|
|
if (source.source === 'user') {
|
|
|
|
|
|
return '用户单独配置'
|
|
|
|
|
|
}
|
|
|
|
|
|
return '系统默认'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-08 00:05:48 +08:00
|
|
|
|
function isNegativeWalletValue(value: number | null): boolean {
|
|
|
|
|
|
return typeof value === 'number' && value < 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-22 00:43:41 +08:00
|
|
|
|
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} 吗?`,
|
2025-12-25 00:02:56 +08:00
|
|
|
|
`${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}`)
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
error(parseApiError(err, '未知错误'), `${action}用户失败`)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ========== 用户表单对话框方法 ==========
|
|
|
|
|
|
|
|
|
|
|
|
function openCreateDialog() {
|
|
|
|
|
|
editingUser.value = null
|
|
|
|
|
|
showUserFormDialog.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-22 00:43:41 +08:00
|
|
|
|
function editUser(user: User) {
|
2026-01-28 01:01:05 +08:00
|
|
|
|
// 创建数组副本,避免与 store 数据共享引用
|
2025-12-10 20:52:44 +08:00
|
|
|
|
editingUser.value = {
|
|
|
|
|
|
id: user.id,
|
|
|
|
|
|
username: user.username,
|
|
|
|
|
|
email: user.email,
|
2026-03-08 00:05:48 +08:00
|
|
|
|
unlimited: user.unlimited,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
role: user.role,
|
|
|
|
|
|
is_active: user.is_active,
|
2026-05-09 21:47:33 +08:00
|
|
|
|
group_ids: (user.groups || []).map(group => group.id),
|
2026-05-14 11:14:20 +08:00
|
|
|
|
feature_settings: user.feature_settings ?? null,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
showUserFormDialog.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function closeUserFormDialog() {
|
|
|
|
|
|
showUserFormDialog.value = false
|
|
|
|
|
|
editingUser.value = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-08 00:05:48 +08:00
|
|
|
|
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) {
|
|
|
|
|
|
// 更新用户
|
2026-02-22 00:43:41 +08:00
|
|
|
|
const updateData: Record<string, unknown> = {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
username: data.username,
|
|
|
|
|
|
email: data.email || undefined,
|
2026-03-08 00:05:48 +08:00
|
|
|
|
unlimited: data.unlimited,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
role: data.role,
|
2026-05-09 21:47:33 +08:00
|
|
|
|
group_ids: data.group_ids ?? [],
|
2026-05-14 11:14:20 +08:00
|
|
|
|
feature_settings: data.feature_settings ?? null,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
if (data.password) {
|
|
|
|
|
|
updateData.password = data.password
|
|
|
|
|
|
}
|
|
|
|
|
|
await usersStore.updateUser(data.id, updateData)
|
2026-03-08 00:05:48 +08:00
|
|
|
|
await loadUserWallets()
|
2025-12-10 20:52:44 +08:00
|
|
|
|
success('用户信息已更新')
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 创建用户
|
|
|
|
|
|
const newUser = await usersStore.createUser({
|
|
|
|
|
|
username: data.username,
|
2026-02-22 00:43:41 +08:00
|
|
|
|
password: data.password ?? '',
|
2025-12-10 20:52:44 +08:00
|
|
|
|
email: data.email || undefined,
|
2026-03-08 00:05:48 +08:00
|
|
|
|
initial_gift_usd: data.initial_gift_usd,
|
|
|
|
|
|
unlimited: data.unlimited,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
role: data.role,
|
2026-05-09 21:47:33 +08:00
|
|
|
|
group_ids: data.group_ids ?? [],
|
2026-05-14 11:14:20 +08:00
|
|
|
|
feature_settings: data.feature_settings ?? null,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
})
|
|
|
|
|
|
// 如果创建时指定为禁用,则更新状态
|
|
|
|
|
|
if (data.is_active === false && newUser) {
|
|
|
|
|
|
await usersStore.updateUser(newUser.id, { is_active: false })
|
|
|
|
|
|
}
|
2026-03-08 00:05:48 +08:00
|
|
|
|
await loadUserWallets()
|
2025-12-10 20:52:44 +08:00
|
|
|
|
success('用户创建成功')
|
|
|
|
|
|
}
|
|
|
|
|
|
closeUserFormDialog()
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (err: unknown) {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
const title = data.id ? '更新用户失败' : '创建用户失败'
|
2026-02-22 00:43:41 +08:00
|
|
|
|
error(parseApiError(err, '未知错误'), title)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
userFormDialogRef.value?.setSaving(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-22 00:43:41 +08:00
|
|
|
|
async function manageApiKeys(user: User) {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
selectedUser.value = user
|
|
|
|
|
|
showApiKeysDialog.value = true
|
|
|
|
|
|
await loadUserApiKeys(user.id)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 16:34:09 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 01:18:38 +08:00
|
|
|
|
async function manageUserPlans(user: User) {
|
|
|
|
|
|
selectedUser.value = user
|
|
|
|
|
|
showUserPlansDialog.value = true
|
|
|
|
|
|
selectedGrantPlanId.value = ''
|
|
|
|
|
|
grantReason.value = ''
|
|
|
|
|
|
await Promise.all([
|
|
|
|
|
|
loadUserPlanEntitlements(user.id),
|
|
|
|
|
|
loadAvailableBillingPlans(),
|
|
|
|
|
|
])
|
|
|
|
|
|
if (!selectedGrantPlanId.value && grantableBillingPlans.value.length > 0) {
|
|
|
|
|
|
selectedGrantPlanId.value = grantableBillingPlans.value[0].id
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadUserPlanEntitlements(userId: string) {
|
|
|
|
|
|
loadingUserPlans.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await usersApi.listUserPlanEntitlements(userId)
|
|
|
|
|
|
userPlanEntitlements.value = response.items
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
error(parseApiError(err, '加载用户套餐失败'))
|
|
|
|
|
|
userPlanEntitlements.value = []
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loadingUserPlans.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadAvailableBillingPlans() {
|
|
|
|
|
|
loadingBillingPlans.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await adminBillingPlansApi.list()
|
|
|
|
|
|
availableBillingPlans.value = response.items
|
|
|
|
|
|
if (
|
|
|
|
|
|
selectedGrantPlanId.value
|
|
|
|
|
|
&& !response.items.some((plan) => plan.id === selectedGrantPlanId.value)
|
|
|
|
|
|
) {
|
|
|
|
|
|
selectedGrantPlanId.value = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
error(parseApiError(err, '加载套餐列表失败'))
|
|
|
|
|
|
availableBillingPlans.value = []
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loadingBillingPlans.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function grantPlanToSelectedUser() {
|
|
|
|
|
|
if (!selectedUser.value || !selectedGrantPlanId.value) return
|
|
|
|
|
|
grantingUserPlan.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await usersApi.grantUserPlan(selectedUser.value.id, {
|
|
|
|
|
|
plan_id: selectedGrantPlanId.value,
|
|
|
|
|
|
reason: grantReason.value.trim() || null,
|
|
|
|
|
|
})
|
|
|
|
|
|
userPlanEntitlements.value = response.items
|
|
|
|
|
|
grantReason.value = ''
|
|
|
|
|
|
success('套餐已发放')
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
error(parseApiError(err, '发放套餐失败'))
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
grantingUserPlan.value = false
|
2026-03-17 16:34:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
async function loadUserApiKeys(userId: string) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
userApiKeys.value = await usersStore.getUserApiKeys(userId)
|
|
|
|
|
|
} catch (err) {
|
2025-12-12 20:22:15 +08:00
|
|
|
|
log.error('加载API Keys失败:', err)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
userApiKeys.value = []
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-15 14:22:59 +08:00
|
|
|
|
function openCreateUserApiKeyDialog() {
|
2026-05-14 11:14:20 +08:00
|
|
|
|
const redactionFeature = readChatPiiRedactionFeatureSettings(null)
|
2026-03-15 14:22:59 +08:00
|
|
|
|
userApiKeyForm.value = {
|
|
|
|
|
|
name: `Key-${new Date().toISOString().split('T')[0]}`,
|
|
|
|
|
|
rate_limit: undefined,
|
2026-04-17 14:21:43 +08:00
|
|
|
|
concurrent_limit: undefined,
|
2026-05-14 11:14:20 +08:00
|
|
|
|
chat_pii_redaction_enabled: redactionFeature.enabled,
|
|
|
|
|
|
chat_pii_redaction_placeholder_notice: redactionFeature.inject_model_instruction,
|
2026-03-15 14:22:59 +08:00
|
|
|
|
}
|
|
|
|
|
|
editingUserApiKey.value = null
|
|
|
|
|
|
showUserApiKeyFormDialog.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function openEditUserApiKeyDialog(apiKey: ApiKey) {
|
2026-05-14 11:14:20 +08:00
|
|
|
|
const redactionFeature = readChatPiiRedactionFeatureSettings(apiKey.feature_settings)
|
2026-03-15 14:22:59 +08:00
|
|
|
|
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,
|
2026-05-14 11:14:20 +08:00
|
|
|
|
chat_pii_redaction_enabled: redactionFeature.enabled,
|
|
|
|
|
|
chat_pii_redaction_placeholder_notice: redactionFeature.inject_model_instruction,
|
2026-03-15 14:22:59 +08:00
|
|
|
|
}
|
|
|
|
|
|
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,
|
2026-05-14 11:14:20 +08:00
|
|
|
|
chat_pii_redaction_enabled: false,
|
|
|
|
|
|
chat_pii_redaction_placeholder_notice: true,
|
2026-03-15 14:22:59 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function submitUserApiKeyForm() {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
if (!selectedUser.value) return
|
2026-03-15 14:22:59 +08:00
|
|
|
|
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 {
|
2026-03-15 14:22:59 +08:00
|
|
|
|
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,
|
2026-05-14 11:14:20 +08:00
|
|
|
|
feature_settings: mergeChatPiiRedactionFeatureSettings(editingUserApiKey.value.feature_settings, {
|
|
|
|
|
|
enabled: userApiKeyForm.value.chat_pii_redaction_enabled,
|
|
|
|
|
|
inject_model_instruction: userApiKeyForm.value.chat_pii_redaction_placeholder_notice,
|
|
|
|
|
|
}),
|
2026-03-15 14:22:59 +08:00
|
|
|
|
})
|
|
|
|
|
|
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,
|
2026-05-14 11:14:20 +08:00
|
|
|
|
feature_settings: mergeChatPiiRedactionFeatureSettings(null, {
|
|
|
|
|
|
enabled: userApiKeyForm.value.chat_pii_redaction_enabled,
|
|
|
|
|
|
inject_model_instruction: userApiKeyForm.value.chat_pii_redaction_placeholder_notice,
|
|
|
|
|
|
}),
|
2026-03-15 14:22:59 +08:00
|
|
|
|
})
|
|
|
|
|
|
newApiKey.value = response.key || ''
|
|
|
|
|
|
showNewApiKeyDialog.value = true
|
|
|
|
|
|
success('API Key创建成功')
|
|
|
|
|
|
}
|
2025-12-10 20:52:44 +08:00
|
|
|
|
await loadUserApiKeys(selectedUser.value.id)
|
2026-03-15 14:22:59 +08:00
|
|
|
|
closeUserApiKeyFormDialog()
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (err: unknown) {
|
2026-03-15 14:22:59 +08:00
|
|
|
|
error(parseApiError(err, '未知错误'), editingUserApiKey.value ? '更新 API Key 失败' : '创建 API Key 失败')
|
2025-12-10 20:52:44 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
creatingApiKey.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 16:34:09 +08:00
|
|
|
|
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() {
|
2025-12-28 20:41:52 +08:00
|
|
|
|
await copyToClipboard(newApiKey.value)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function closeNewApiKeyDialog() {
|
|
|
|
|
|
showNewApiKeyDialog.value = false
|
|
|
|
|
|
newApiKey.value = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-22 00:43:41 +08:00
|
|
|
|
async function deleteApiKey(apiKey: ApiKey) {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
const confirmed = await confirmDanger(
|
2026-04-11 17:39:02 +08:00
|
|
|
|
`确定要删除这个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已删除')
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
error(parseApiError(err, '未知错误'), '删除 API Key 失败')
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-22 00:43:41 +08:00
|
|
|
|
async function toggleLockApiKey(apiKey: ApiKey) {
|
2026-03-08 00:05:48 +08:00
|
|
|
|
if (!selectedUser.value) return
|
2026-01-16 01:18:54 +08:00
|
|
|
|
try {
|
2026-03-08 00:05:48 +08:00
|
|
|
|
const response = await adminApi.toggleUserApiKeyLock(selectedUser.value.id, apiKey.id)
|
2026-01-16 01:18:54 +08:00
|
|
|
|
// 更新本地状态
|
|
|
|
|
|
const index = userApiKeys.value.findIndex(k => k.id === apiKey.id)
|
|
|
|
|
|
if (index !== -1) {
|
|
|
|
|
|
userApiKeys.value[index].is_locked = response.is_locked
|
|
|
|
|
|
}
|
|
|
|
|
|
success(response.message)
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (err: unknown) {
|
2026-01-16 01:18:54 +08:00
|
|
|
|
log.error('切换密钥锁定状态失败:', err)
|
2026-02-22 00:43:41 +08:00
|
|
|
|
error(parseApiError(err, '操作失败'), '锁定/解锁失败')
|
2026-01-16 01:18:54 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-22 00:43:41 +08:00
|
|
|
|
async function copyFullKey(apiKey: ApiKey) {
|
2026-03-08 00:05:48 +08:00
|
|
|
|
if (!selectedUser.value) return
|
2025-12-10 20:52:44 +08:00
|
|
|
|
try {
|
2026-03-08 00:05:48 +08:00
|
|
|
|
const response = await usersStore.getFullApiKey(selectedUser.value.id, apiKey.id)
|
2025-12-28 20:41:52 +08:00
|
|
|
|
await copyToClipboard(response.key)
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (err: unknown) {
|
2025-12-12 20:22:15 +08:00
|
|
|
|
log.error('复制密钥失败:', err)
|
2026-02-22 00:43:41 +08:00
|
|
|
|
error(parseApiError(err, '未知错误'), '复制密钥失败')
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-08 00:05:48 +08:00
|
|
|
|
function openWalletActionDialog(user: User) {
|
|
|
|
|
|
const wallet = getUserWallet(user.id)
|
|
|
|
|
|
if (!wallet) {
|
|
|
|
|
|
error('该用户的钱包尚未初始化,暂时无法进行资金操作')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-03-08 00:05:48 +08:00
|
|
|
|
walletActionTarget.value = {
|
|
|
|
|
|
user,
|
|
|
|
|
|
wallet,
|
|
|
|
|
|
}
|
|
|
|
|
|
showWalletActionDialogState.value = true
|
|
|
|
|
|
}
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-03-08 00:05:48 +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
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-22 00:43:41 +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('用户已删除')
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
error(parseApiError(err, '未知错误'), '删除用户失败')
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
</script>
|