Fix OAuth token import and table filters

This commit is contained in:
fawney19
2026-05-01 02:14:49 +08:00
parent 9570e5c2c1
commit 4fc7cecf30
54 changed files with 3160 additions and 727 deletions

View File

@@ -1,100 +1,90 @@
<template>
<div class="space-y-6 pb-8">
<Card
variant="default"
class="overflow-hidden"
>
<!-- 加载状态 -->
<div
v-if="loading"
class="py-16 text-center space-y-4"
>
<Skeleton class="mx-auto h-10 w-10 rounded-full" />
<Skeleton class="mx-auto h-4 w-32" />
</div>
<div v-else>
<div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div class="shrink-0">
<h3 class="text-sm sm:text-base font-semibold">
独立余额 API Keys
</h3>
</div>
<div class="flex flex-wrap 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
v-model="searchQuery"
type="text"
placeholder="搜索..."
class="h-8 w-28 sm:w-40 pl-8 pr-2 text-xs"
/>
</div>
<!-- 分隔线 -->
<div class="hidden sm:block h-4 w-px bg-border" />
<!-- 状态筛选 -->
<Select
v-model="filterStatus"
>
<SelectTrigger class="w-20 sm:w-28 h-8 text-xs border-border/60">
<SelectValue placeholder="全部状态" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="status in statusFilters"
:key="status.value"
:value="status.value"
>
{{ status.label }}
</SelectItem>
</SelectContent>
</Select>
<!-- 余额类型筛选 -->
<Select
v-model="filterBalance"
>
<SelectTrigger class="w-20 sm:w-28 h-8 text-xs border-border/60">
<SelectValue placeholder="全部类型" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="balance in balanceFilters"
:key="balance.value"
:value="balance.value"
>
{{ balance.label }}
</SelectItem>
</SelectContent>
</Select>
<!-- 分隔线 -->
<div class="hidden sm:block h-4 w-px bg-border" />
<!-- 创建独立 Key 按钮 -->
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="创建独立 Key"
@click="openCreateDialog"
>
<Plus class="w-3.5 h-3.5" />
</Button>
<!-- 刷新按钮 -->
<RefreshButton
:loading="loading"
@click="refreshApiKeys"
/>
</div>
</div>
<TableCard title="独立余额 API Keys">
<template #actions>
<!-- 搜索框 -->
<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
v-model="searchQuery"
type="text"
placeholder="搜索..."
class="h-8 w-28 sm:w-40 pl-8 pr-2 text-xs"
/>
</div>
<!-- 分隔线 -->
<div class="hidden sm:block h-4 w-px bg-border" />
<!-- 状态筛选 -->
<div class="xl:hidden">
<Select
v-model="filterStatus"
>
<SelectTrigger class="w-20 sm:w-28 h-8 text-xs border-border/60">
<SelectValue placeholder="全部状态" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="status in statusFilters"
:key="status.value"
:value="status.value"
>
{{ status.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- 余额类型筛选 -->
<div class="xl:hidden">
<Select
v-model="filterBalance"
>
<SelectTrigger class="w-20 sm:w-28 h-8 text-xs border-border/60">
<SelectValue placeholder="全部类型" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="balance in balanceFilters"
:key="balance.value"
:value="balance.value"
>
{{ balance.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- 分隔线 -->
<div class="hidden sm:block h-4 w-px bg-border" />
<!-- 创建独立 Key 按钮 -->
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="创建独立 Key"
@click="openCreateDialog"
>
<Plus class="w-3.5 h-3.5" />
</Button>
<!-- 刷新按钮 -->
<RefreshButton
:loading="loading"
@click="refreshApiKeys"
/>
</template>
<!-- 加载状态 -->
<LoadingState
v-if="loading"
message="加载中..."
size="lg"
/>
<div v-else>
<div class="hidden xl:block overflow-x-auto">
<Table>
<TableHeader>
@@ -102,21 +92,52 @@
<TableHead class="w-[200px] h-12 font-semibold">
密钥信息
</TableHead>
<TableHead class="w-[240px] h-12 font-semibold">
<SortableTableHead
class="w-[240px] h-12 font-semibold"
column-key="balance"
:sortable="false"
:filter-active="filterBalance !== 'all'"
filter-title="筛选余额类型"
filter-content-class="w-40 p-1 rounded-2xl border-border bg-card text-foreground shadow-2xl backdrop-blur-xl"
>
钱包
</TableHead>
<template #filter="{ close }">
<TableFilterMenu
v-model="filterBalance"
:options="balanceFilters"
@select="close"
/>
</template>
</SortableTableHead>
<TableHead class="w-[190px] h-12 font-semibold">
统计/限制
</TableHead>
<TableHead class="w-[140px] h-12 font-semibold">
创建时间
</TableHead>
<TableHead class="w-[110px] h-12 font-semibold">
有效期
</TableHead>
<TableHead class="w-[140px] h-12 font-semibold">
最近使用
</TableHead>
<TableHead class="w-[180px] h-12 font-semibold">
<SortableTableHead
class="w-[100px] 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"
>
状态
</TableHead>
<template #filter="{ close }">
<TableFilterMenu
v-model="filterStatus"
:options="statusFilters"
@select="close"
/>
</template>
</SortableTableHead>
<TableHead class="w-[130px] h-12 font-semibold text-center">
操作
</TableHead>
@@ -125,38 +146,20 @@
<TableBody>
<TableRow v-if="filteredApiKeys.length === 0">
<TableCell
colspan="7"
colspan="8"
class="h-64 text-center"
>
<div class="flex flex-col items-center justify-center space-y-4">
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Key class="h-8 w-8 text-muted-foreground" />
</div>
<div v-if="hasActiveFilters">
<h3 class="text-lg font-semibold">
未找到匹配的 Key
</h3>
<p class="mt-2 text-sm text-muted-foreground">
尝试调整筛选条件
</p>
<Button
variant="outline"
size="sm"
class="mt-3"
@click="clearFilters"
>
清除筛选
</Button>
</div>
<div v-else>
<h3 class="text-lg font-semibold">
暂无独立余额 Key
</h3>
<p class="mt-2 text-sm text-muted-foreground">
点击右上角按钮创建独立余额 Key
</p>
</div>
</div>
<EmptyState
:type="hasActiveFilters ? 'filter' : 'empty'"
:icon="hasActiveFilters ? undefined : Key"
:title="hasActiveFilters ? '未找到匹配的 Key' : '暂无独立余额 Key'"
:description="hasActiveFilters ? '尝试调整筛选条件' : '点击右上角按钮创建独立余额 Key'"
:action-text="hasActiveFilters ? '清除筛选' : undefined"
action-variant="outline"
action-size="sm"
size="sm"
@action="clearFilters"
/>
</TableCell>
</TableRow>
<TableRow
@@ -167,7 +170,7 @@
<TableCell class="py-4">
<div class="space-y-1">
<div
class="text-sm font-semibold text-foreground truncate"
class="text-sm font-medium text-foreground truncate"
:title="apiKey.name || '未命名 Key'"
>
{{ apiKey.name || '未命名 Key' }}
@@ -201,7 +204,7 @@
</Badge>
<span
v-else
class="text-sm font-semibold tabular-nums"
class="text-sm font-medium tabular-nums"
:class="isNegativeWalletAmount(getApiKeyWalletTotalBalance(apiKey)) ? 'text-rose-600' : 'text-foreground'"
>
{{ formatWalletAmount(getApiKeyWalletTotalBalance(apiKey), '-') }}
@@ -257,6 +260,11 @@
</div>
</div>
</TableCell>
<TableCell class="py-4">
<div class="text-xs">
<span class="text-foreground">{{ formatDate(apiKey.created_at) }}</span>
</div>
</TableCell>
<TableCell class="py-4">
<div class="text-xs">
<div
@@ -290,7 +298,7 @@
>暂无记录</span>
</div>
</TableCell>
<TableCell class="py-4">
<TableCell class="w-[100px] py-4">
<div class="flex flex-col items-start gap-1.5">
<Badge
:variant="apiKey.is_active ? 'success' : 'destructive'"
@@ -312,38 +320,38 @@
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
class="h-7 w-7"
title="编辑"
@click="editApiKey(apiKey)"
>
<SquarePen class="h-4 w-4" />
<SquarePen class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
class="h-7 w-7"
title="资金操作"
@click="openAddBalanceDialog(apiKey)"
>
<DollarSign class="h-4 w-4" />
<DollarSign class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
class="h-7 w-7"
:title="apiKey.is_active ? '禁用' : '启用'"
@click="toggleApiKey(apiKey)"
>
<Power class="h-4 w-4" />
<Power class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
class="h-7 w-7"
title="删除"
@click="deleteApiKey(apiKey)"
>
<Trash2 class="h-4 w-4" />
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
@@ -353,21 +361,18 @@
</div>
<div class="xl:hidden bg-muted/[0.14] p-3 sm:p-4">
<div
<EmptyState
v-if="filteredApiKeys.length === 0"
class="rounded-2xl border border-dashed border-border/60 bg-card/70 px-6 py-10 text-center"
>
<Key class="mx-auto mb-3 h-12 w-12 text-muted-foreground/50" />
<p class="text-sm font-medium text-foreground">
{{ hasActiveFilters ? '未找到匹配的 Key' : '暂无独立余额 Key' }}
</p>
<p
v-if="hasActiveFilters"
class="mt-1 text-xs text-muted-foreground"
>
尝试调整筛选条件
</p>
</div>
:type="hasActiveFilters ? 'filter' : 'empty'"
:icon="hasActiveFilters ? undefined : Key"
:title="hasActiveFilters ? '未找到匹配的 Key' : '暂无独立余额 Key'"
:description="hasActiveFilters ? '尝试调整筛选条件' : '点击右上角按钮创建独立余额 Key'"
:action-text="hasActiveFilters ? '清除筛选' : undefined"
action-variant="outline"
action-size="sm"
size="sm"
@action="clearFilters"
/>
<div
v-else
@@ -376,9 +381,9 @@
<div
v-for="apiKey in filteredApiKeys"
:key="apiKey.id"
class="rounded-2xl border border-border/60 bg-card/95 p-4 shadow-[0_10px_26px_-22px_hsl(var(--foreground))]"
class="rounded-lg border border-border/60 bg-card p-3"
>
<div class="space-y-4">
<div class="space-y-3">
<div class="flex items-start gap-3">
<div class="min-w-0 flex-1 space-y-2">
<div class="flex items-center gap-2">
@@ -396,7 +401,7 @@
</Button>
</div>
<div
class="truncate text-sm font-semibold text-foreground"
class="truncate text-sm font-medium text-foreground"
:class="{ 'text-muted-foreground': !apiKey.name }"
:title="apiKey.name || '未命名 Key'"
>
@@ -440,7 +445,7 @@
</Badge>
</div>
<div class="rounded-xl border border-border/60 bg-muted/40 p-3.5">
<div class="rounded-lg border border-border/60 bg-muted/30 p-3">
<div class="flex items-start justify-between gap-3">
<div class="space-y-1">
<p class="text-[11px] text-muted-foreground">
@@ -455,7 +460,7 @@
</Badge>
<p
v-else
class="text-base font-semibold tabular-nums leading-none"
class="text-sm font-medium tabular-nums leading-none"
:class="isNegativeWalletAmount(getApiKeyWalletTotalBalance(apiKey)) ? 'text-rose-600' : 'text-foreground'"
>
{{ formatWalletAmount(getApiKeyWalletTotalBalance(apiKey), '-') }}
@@ -477,7 +482,7 @@
<div class="mb-1 text-muted-foreground">
请求次数
</div>
<div class="font-semibold text-foreground">
<div class="font-medium text-foreground">
{{ (apiKey.total_requests || 0).toLocaleString() }}
</div>
</div>
@@ -485,7 +490,7 @@
<div class="mb-1 text-muted-foreground">
Tokens
</div>
<div class="font-semibold text-foreground">
<div class="font-medium text-foreground">
{{ formatApiKeyTotalTokens(apiKey) }}
</div>
</div>
@@ -493,7 +498,7 @@
<div class="mb-1 text-muted-foreground">
有效期
</div>
<div class="font-semibold text-foreground">
<div class="font-medium text-foreground">
{{ apiKey.expires_at ? formatDate(apiKey.expires_at) : '永不过期' }}
</div>
<div
@@ -571,16 +576,17 @@
</div>
</div>
<!-- 分页 -->
<Pagination
v-if="!loading && apiKeys.length > 0"
:current="currentPage"
:total="total"
:page-size="limit"
:show-page-size-selector="false"
@update:current="handlePageChange"
/>
</Card>
<template #pagination>
<Pagination
v-if="!loading && apiKeys.length > 0"
:current="currentPage"
:total="total"
:page-size="limit"
:show-page-size-selector="false"
@update:current="handlePageChange"
/>
</template>
</TableCard>
<!-- 创建/编辑独立Key对话框 -->
<StandaloneKeyFormDialog
@@ -669,19 +675,21 @@ import { adminApi, type AdminApiKey, type CreateStandaloneApiKeyRequest } from '
import type { AdminWallet } from '@/api/admin-wallets'
import { walletStatusBadge, walletStatusLabel } from '@/utils/walletDisplay'
import WalletOpsDrawer from '@/features/wallet/components/WalletOpsDrawer.vue'
import { EmptyState, LoadingState } from '@/components/common'
import {
Dialog,
Card,
TableCard,
Button,
Badge,
Input,
Skeleton,
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
SortableTableHead,
TableFilterMenu,
TableCell,
Pagination,
RefreshButton,

View File

@@ -31,72 +31,45 @@
<!-- 分隔线 -->
<div class="hidden sm:block h-4 w-px bg-border" />
<!-- 事件类型筛选 -->
<Select
v-model="filters.eventType"
@update:model-value="handleEventTypeChange"
>
<SelectTrigger class="w-24 sm:w-40 h-8 border-border/60">
<SelectValue placeholder="全部类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">
全部类型
</SelectItem>
<SelectItem value="login_success">
登录成功
</SelectItem>
<SelectItem value="login_failed">
登录失败
</SelectItem>
<SelectItem value="logout">
退出登录
</SelectItem>
<SelectItem value="api_key_created">
API密钥创建
</SelectItem>
<SelectItem value="api_key_deleted">
API密钥删除
</SelectItem>
<SelectItem value="request_success">
请求成功
</SelectItem>
<SelectItem value="request_failed">
请求失败
</SelectItem>
<SelectItem value="user_created">
用户创建
</SelectItem>
<SelectItem value="user_updated">
用户更新
</SelectItem>
<SelectItem value="user_deleted">
用户删除
</SelectItem>
</SelectContent>
</Select>
<div class="xl:hidden">
<Select
v-model="filters.eventType"
@update:model-value="handleEventTypeChange"
>
<SelectTrigger class="w-24 sm:w-40 h-8 border-border/60">
<SelectValue placeholder="全部类型" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="option in auditEventTypeFilterOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- 时间范围筛选 -->
<Select
v-model="filtersDaysString"
@update:model-value="handleDaysChange"
>
<SelectTrigger class="w-20 sm:w-28 h-8 border-border/60">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">
1
</SelectItem>
<SelectItem value="7">
7
</SelectItem>
<SelectItem value="30">
30
</SelectItem>
<SelectItem value="90">
90
</SelectItem>
</SelectContent>
</Select>
<div class="xl:hidden">
<Select
v-model="filtersDaysString"
@update:model-value="handleDaysChange"
>
<SelectTrigger class="w-20 sm:w-28 h-8 border-border/60">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="option in auditDaysFilterOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- 重置筛选 -->
<Button
v-if="hasActiveFilters"
@@ -146,15 +119,45 @@
<Table class="hidden xl:table">
<TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="h-12 font-semibold">
<SortableTableHead
class="h-12 font-semibold"
column-key="created_at"
:sortable="false"
:filter-active="filters.days !== 7"
filter-title="筛选时间范围"
filter-content-class="w-32 p-1 rounded-2xl border-border bg-card text-foreground shadow-2xl backdrop-blur-xl"
>
时间
</TableHead>
<template #filter="{ close }">
<TableFilterMenu
:model-value="filtersDaysString"
:options="auditDaysFilterOptions"
@update:model-value="handleDaysChange"
@select="close"
/>
</template>
</SortableTableHead>
<TableHead class="h-12 font-semibold">
用户
</TableHead>
<TableHead class="h-12 font-semibold">
<SortableTableHead
class="h-12 font-semibold"
column-key="event_type"
:sortable="false"
:filter-active="filters.eventType !== '__all__'"
filter-title="筛选事件类型"
filter-content-class="w-48 p-1 rounded-2xl border-border bg-card text-foreground shadow-2xl backdrop-blur-xl"
>
事件类型
</TableHead>
<template #filter="{ close }">
<TableFilterMenu
:model-value="filters.eventType"
:options="auditEventTypeFilterOptions"
@update:model-value="handleEventTypeChange"
@select="close"
/>
</template>
</SortableTableHead>
<TableHead class="h-12 font-semibold">
描述
</TableHead>
@@ -423,6 +426,8 @@ import {
TableBody,
TableRow,
TableHead,
SortableTableHead,
TableFilterMenu,
TableCell,
Input,
Pagination,
@@ -476,6 +481,25 @@ const filters = ref({
})
const filtersDaysString = ref('7')
const auditEventTypeFilterOptions = [
{ value: '__all__', label: '全部类型' },
{ value: 'login_success', label: '登录成功' },
{ value: 'login_failed', label: '登录失败' },
{ value: 'logout', label: '退出登录' },
{ value: 'api_key_created', label: 'API密钥创建' },
{ value: 'api_key_deleted', label: 'API密钥删除' },
{ value: 'request_success', label: '请求成功' },
{ value: 'request_failed', label: '请求失败' },
{ value: 'user_created', label: '用户创建' },
{ value: 'user_updated', label: '用户更新' },
{ value: 'user_deleted', label: '用户删除' },
]
const auditDaysFilterOptions = [
{ value: '1', label: '1天' },
{ value: '7', label: '7天' },
{ value: '30', label: '30天' },
{ value: '90', label: '90天' },
]
const currentPage = ref(1)
const pageSize = ref(20)

View File

@@ -210,25 +210,6 @@
class="w-40 pl-8 pr-2 h-8 text-xs bg-background/50 border-border/60"
/>
</div>
<Select v-model="statusFilter">
<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="cooldown">
冷却中
</SelectItem>
<SelectItem value="inactive">
禁用
</SelectItem>
</SelectContent>
</Select>
<div
v-if="selectedProviderId"
class="h-4 w-px bg-border"
@@ -356,7 +337,7 @@
<template v-else>
<!-- Desktop table -->
<div
v-if="keyPage.keys.length > 0"
v-if="keyPage.keys.length > 0 || hasPoolKeyFilters"
class="hidden xl:block overflow-x-auto"
>
<Table class="w-full table-fixed">
@@ -381,18 +362,51 @@
>
统计
</TableHead>
<TableHead
<SortableTableHead
class="font-semibold text-center whitespace-nowrap"
column-key="imported_at"
:active-key="sortBy"
:direction="sortOrder"
default-direction="desc"
align="center"
:style="{ width: desktopColumnWidths.imported }"
title="按导入时间排序"
@sort="handleTableSort"
>
导入时间
</SortableTableHead>
<SortableTableHead
class="font-semibold text-center whitespace-nowrap"
column-key="last_used_at"
:active-key="sortBy"
:direction="sortOrder"
default-direction="desc"
align="center"
:style="{ width: desktopColumnWidths.lastUsed }"
title="按最后使用时间排序"
@sort="handleTableSort"
>
最后使用
</TableHead>
<TableHead
</SortableTableHead>
<SortableTableHead
class="font-semibold text-center whitespace-nowrap"
column-key="status"
:sortable="false"
align="center"
:filter-active="statusFilter !== 'all'"
filter-title="筛选状态"
filter-content-class="w-44 p-1 rounded-2xl border-border bg-card text-foreground shadow-2xl backdrop-blur-xl"
:style="{ width: desktopColumnWidths.status }"
>
状态
</TableHead>
<template #filter="{ close }">
<TableFilterMenu
v-model="statusFilter"
:options="poolKeyStatusFilterOptions"
@select="close"
/>
</template>
</SortableTableHead>
<TableHead
class="px-2 font-semibold text-center whitespace-nowrap"
:style="{ width: desktopColumnWidths.actions }"
@@ -463,12 +477,12 @@
<span class="font-mono">
{{ getProviderMaskedSecretLabel(key) }}
</span>
<template v-if="canRefreshOAuthCredential(key)">
<template v-if="keyUiStateMap[key.key_id]?.showOAuthRefreshControl">
<Button
variant="ghost"
size="icon"
class="h-4 w-4 shrink-0"
:disabled="refreshingOAuthKeyId === key.key_id"
:disabled="refreshingOAuthKeyId === key.key_id || !keyUiStateMap[key.key_id]?.canRefreshToken"
:title="keyUiStateMap[key.key_id]?.oauthRefreshButtonTitle || ''"
@click.stop="handleRefreshOAuth(key)"
>
@@ -578,6 +592,11 @@
</div>
</div>
</TableCell>
<TableCell class="py-3 text-center">
<span class="text-[10px] text-muted-foreground whitespace-nowrap">
{{ keyUiStateMap[key.key_id]?.importedAtRelative || '-' }}
</span>
</TableCell>
<TableCell class="py-3 text-center">
<span class="text-[10px] text-muted-foreground whitespace-nowrap">
{{ keyUiStateMap[key.key_id]?.lastUsedRelative || '-' }}
@@ -775,6 +794,8 @@
<span class="mx-1.5 text-muted-foreground/40">|</span>
<span class="font-medium text-foreground/90">费用:{{ formatStatUsd(key.total_cost_usd) }}</span>
<span class="mx-1.5 text-muted-foreground/40">|</span>
<span class="font-medium text-foreground/90">导入:{{ keyUiStateMap[key.key_id]?.importedAtRelative || '-' }}</span>
<span class="mx-1.5 text-muted-foreground/40">|</span>
<span class="font-medium text-foreground/90">最后使用:{{ keyUiStateMap[key.key_id]?.lastUsedRelative || '-' }}</span>
</div>
</div>
@@ -863,7 +884,7 @@
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0"
:disabled="refreshingOAuthKeyId === key.key_id"
:disabled="refreshingOAuthKeyId === key.key_id || !keyUiStateMap[key.key_id]?.canRefreshToken"
:title="keyUiStateMap[key.key_id]?.oauthRefreshButtonTitle || ''"
@click.stop="handleRefreshOAuth(key)"
>
@@ -980,16 +1001,26 @@
<!-- Empty keys -->
<div
v-if="keyPage.keys.length === 0 && !keysLoading"
v-if="keyPage.keys.length === 0 && !keysLoading && keysLoadedOnce"
class="flex flex-col items-center justify-center py-16 text-center"
>
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<KeyRound class="h-8 w-8 text-muted-foreground" />
</div>
<p class="text-sm text-muted-foreground mt-4">
暂无账号
{{ hasPoolKeyFilters ? '未找到匹配账号' : '暂无账号' }}
</p>
<Button
v-if="hasPoolKeyFilters"
variant="outline"
size="sm"
class="mt-3"
@click="clearPoolKeyFilters"
>
清除筛选
</Button>
<Button
v-else
variant="outline"
size="sm"
class="mt-3"
@@ -1112,6 +1143,8 @@ import {
TableBody,
TableRow,
TableHead,
SortableTableHead,
TableFilterMenu,
TableCell,
Pagination,
Popover,
@@ -1174,6 +1207,8 @@ import {
buildPoolManagementQueryPatch,
readPoolManagementViewState,
resolvePoolManagementPageAfterLoad,
type PoolManagementSortBy,
type PoolManagementSortOrder,
type PoolManagementViewState,
writePoolManagementViewState,
} from '@/features/pool/utils/poolManagementState'
@@ -1187,12 +1222,13 @@ import {
getProviderMaskedSecretLabel,
isOAuthManagedCredential,
isServiceAccountCredential,
shouldShowOAuthRefreshControl,
} from '@/utils/providerKeyAuth'
import {
getAccountStatusDisplay,
getAccountStatusTitle,
getOAuthRefreshButtonTitle as resolveOAuthRefreshButtonTitle,
getOAuthStatusDisplay,
getOAuthStatusDisplayWithFallback,
getOAuthStatusTitle as resolveOAuthStatusTitle,
} from '@/utils/providerKeyStatus'
import {
@@ -1215,6 +1251,8 @@ const restoredViewState = readPoolManagementViewState(
status: getQueryValue('status'),
page: getQueryValue('page'),
pageSize: getQueryValue('pageSize'),
sortBy: getQueryValue('sortBy'),
sortOrder: getQueryValue('sortOrder'),
},
poolManagementViewStorage,
)
@@ -1232,6 +1270,12 @@ let hasHydratedInitialProviderSelection = false
const POOL_OVERVIEW_CACHE_TTL_MS = 10 * 1000
const POOL_KEYS_CACHE_TTL_MS = 10 * 1000
const POOL_SCHEDULING_PRESETS_CACHE_TTL_MS = 5 * 60 * 1000
const poolKeyStatusFilterOptions: Array<{ value: PoolManagementViewState['status'], label: string }> = [
{ value: 'all', label: '全部状态' },
{ value: 'active', label: '可调度' },
{ value: 'cooldown', label: '冷却中' },
{ value: 'inactive', label: '禁用' },
]
async function loadOverview(options: { cacheTtlMs?: number } = {}) {
const requestId = ++overviewRequestId
@@ -1243,8 +1287,17 @@ async function loadOverview(options: { cacheTtlMs?: number } = {}) {
const enabledProviders = allProviders.filter(item => item.pool_enabled)
poolProviders.value = enabledProviders
// Keep selected provider aligned with dropdown options.
const selectedId = selectedProviderId.value
const queryProviderId = getQueryValue('providerId')
const queryProviderExists = Boolean(
queryProviderId && enabledProviders.some(item => item.provider_id === queryProviderId),
)
const currentSelectedId = selectedProviderId.value
const currentSelectedExists = Boolean(
currentSelectedId && enabledProviders.some(item => item.provider_id === currentSelectedId),
)
const selectedId = currentSelectedExists
? currentSelectedId
: (queryProviderExists ? queryProviderId : currentSelectedId)
const selectedStillExists = Boolean(
selectedId && enabledProviders.some(item => item.provider_id === selectedId),
)
@@ -1252,8 +1305,8 @@ async function loadOverview(options: { cacheTtlMs?: number } = {}) {
if (selectedStillExists && selectedId) {
// 页面刷新时可能先恢复了选中的 Provider但列表请求尚未触发
// overview 回来后补一次初始化拉取,确保空态不会卡住。
if (!hasHydratedInitialProviderSelection) {
void selectProvider(selectedId, {
if (!hasHydratedInitialProviderSelection || selectedId !== selectedProviderId.value) {
await selectProvider(selectedId, {
preserveSearch: true,
preserveStatus: true,
preservePagination: true,
@@ -1264,10 +1317,9 @@ async function loadOverview(options: { cacheTtlMs?: number } = {}) {
}
if (enabledProviders.length > 0) {
// Do not block overview loading on key list fetch; keys area has its own loader.
const fallbackProviderId = enabledProviders[0].provider_id
const shouldPreserveViewState = Boolean(selectedId)
void selectProvider(fallbackProviderId, {
await selectProvider(fallbackProviderId, {
preserveSearch: shouldPreserveViewState,
preserveStatus: shouldPreserveViewState,
preservePagination: shouldPreserveViewState,
@@ -1276,6 +1328,7 @@ async function loadOverview(options: { cacheTtlMs?: number } = {}) {
} else {
selectedProviderId.value = null
selectedProviderData.value = null
keysLoadedOnce.value = false
showAccountBatchDialog.value = false
closeProviderProxyPopovers()
resetKeyPage()
@@ -1450,21 +1503,23 @@ const showAccountQuotaColumn = computed(() => {
const desktopColumnWidths = computed(() => {
if (showAccountQuotaColumn.value) {
return {
name: '28%',
quota: '23%',
stats: '15%',
lastUsed: '10%',
status: '8%',
name: '24%',
quota: '21%',
stats: '13%',
imported: '10%',
lastUsed: '9%',
status: '7%',
actions: '16%',
}
}
return {
name: '40%',
name: '34%',
quota: '0%',
stats: '18%',
stats: '16%',
imported: '12%',
lastUsed: '12%',
status: '10%',
actions: '20%',
status: '9%',
actions: '17%',
}
})
@@ -1504,6 +1559,7 @@ async function selectProvider(
clearTimeout(keysSearchDebounceTimer)
keysSearchDebounceTimer = null
}
keysLoadedOnce.value = false
resetKeyPage(currentPage.value, pageSize.value)
const keysTask = loadKeys({ cacheTtlMs: options.cacheTtlMs ?? 0 })
// Provider summary is non-blocking for key list rendering.
@@ -1535,11 +1591,15 @@ function createEmptyKeyPage(page = 1, pageSizeValue = 50): PoolKeysPageResponse
const keyPage = ref<PoolKeysPageResponse>(createEmptyKeyPage())
const keysLoading = ref(false)
const keysLoadedOnce = ref(false)
const refreshingCurrentPageQuota = ref(false)
const searchQuery = ref(restoredViewState.search)
const statusFilter = ref(restoredViewState.status)
const currentPage = ref(restoredViewState.page)
const pageSize = ref(restoredViewState.pageSize)
const sortBy = ref<PoolManagementSortBy | null>(restoredViewState.sortBy)
const sortOrder = ref<PoolManagementSortOrder>(restoredViewState.sortOrder)
const hasPoolKeyFilters = computed(() => searchQuery.value.trim().length > 0 || statusFilter.value !== 'all')
const MANUAL_QUOTA_REFRESH_COOLDOWN_SECONDS = 5 * 60
const refreshingOAuthKeyId = ref<string | null>(null)
const savingProxyKeyId = ref<string | null>(null)
@@ -1556,6 +1616,19 @@ const keyFormDialogOpen = ref(false)
const oauthKeyEditDialogOpen = ref(false)
const editingKeyDetail = ref<PoolKeyDetail | null>(null)
function clearPoolKeyFilters() {
if (!hasPoolKeyFilters.value) return
suppressFiltersWatch = true
searchQuery.value = ''
statusFilter.value = 'all'
suppressFiltersWatch = false
if (currentPage.value !== 1) {
currentPage.value = 1
return
}
void loadKeys({ cacheTtlMs: POOL_KEYS_CACHE_TTL_MS })
}
watch(
() => getQueryValue('search') ?? '',
(value) => {
@@ -1594,10 +1667,25 @@ watch(
{ immediate: true },
)
watch(
() => readPoolManagementViewState({
sortBy: getQueryValue('sortBy'),
sortOrder: getQueryValue('sortOrder'),
}),
(value) => {
if (sortBy.value === value.sortBy && sortOrder.value === value.sortOrder) return
sortBy.value = value.sortBy
sortOrder.value = value.sortOrder
},
{ immediate: true },
)
watch(
() => getQueryValue('providerId'),
(value) => {
if (overviewLoading.value) return
if (!value || value === selectedProviderId.value) return
if (!poolProviders.value.some(item => item.provider_id === value)) return
void selectProvider(value, {
preserveSearch: true,
preserveStatus: true,
@@ -1605,18 +1693,19 @@ watch(
cacheTtlMs: POOL_KEYS_CACHE_TTL_MS,
})
},
{ immediate: true },
)
watch(
[selectedProviderId, searchQuery, statusFilter, currentPage, pageSize],
([providerId, search, status, page, pageSizeValue]) => {
[selectedProviderId, searchQuery, statusFilter, currentPage, pageSize, sortBy, sortOrder],
([providerId, search, status, page, pageSizeValue, sortByValue, sortOrderValue]) => {
const nextState: PoolManagementViewState = {
providerId,
search,
status: status as PoolManagementViewState['status'],
page,
pageSize: pageSizeValue,
sortBy: sortByValue,
sortOrder: sortOrderValue,
}
patchQuery(buildPoolManagementQueryPatch(nextState))
writePoolManagementViewState(nextState, poolManagementViewStorage)
@@ -1638,13 +1727,16 @@ type PoolKeyUiState = {
schedulingBadgeVariant: PoolStatusVariant
schedulingTitle: string
oauthOrgBadge: ReturnType<typeof getOAuthOrgBadge>
visibleOAuthState: ReturnType<typeof getOAuthStatusDisplay>
visibleOAuthState: ReturnType<typeof getOAuthStatusDisplayWithFallback>
oauthStatusTitle: string
oauthRefreshButtonTitle: string
showOAuthRefreshControl: boolean
canRefreshToken: boolean
planLabel: string
planClass: string
quotaFallbackText: string | null
quotaTextClass: string
importedAtRelative: string
lastUsedRelative: string
mobileTagItems: PoolMobileTagItem[]
mobileActionIds: PoolMobileActionId[]
@@ -1666,6 +1758,7 @@ const keyUiStateMap = computed<Record<string, PoolKeyUiState>>(() => {
const oauthOrgBadge = getOAuthOrgBadge(key)
const quotaFallbackText = getQuotaFallbackText(key)
const canRefreshToken = canRefreshOAuthCredential(key)
const showOAuthRefreshControl = shouldShowOAuthRefreshControl(key)
map[key.key_id] = {
rowClass: getRowClass(key),
@@ -1675,16 +1768,19 @@ const keyUiStateMap = computed<Record<string, PoolKeyUiState>>(() => {
oauthOrgBadge,
visibleOAuthState,
oauthStatusTitle: visibleOAuthState ? getOAuthStatusTitle(key) : '',
oauthRefreshButtonTitle: canRefreshToken ? getOAuthRefreshButtonTitle(key) : '',
oauthRefreshButtonTitle: showOAuthRefreshControl ? getOAuthRefreshButtonTitle(key) : '',
showOAuthRefreshControl,
canRefreshToken,
planLabel: key.oauth_plan_type ? formatOAuthPlanType(key.oauth_plan_type) : '',
planClass: key.oauth_plan_type ? getOAuthPlanTypeClass(key.oauth_plan_type) : '',
quotaFallbackText,
quotaTextClass: quotaFallbackText ? getQuotaTextClass(quotaFallbackText) : '',
importedAtRelative: formatPoolKeyImportedAt(key),
lastUsedRelative: key.last_used_at ? formatRelativeTime(key.last_used_at) : '-',
mobileTagItems: getMobileTagItems(key),
mobileActionIds: splitPoolMobileActions({
canDownloadOrCopy: true,
canRefreshToken,
showRefreshToken: showOAuthRefreshControl,
canClearCooldown: Boolean(key.cooldown_reason),
hasProxy: true,
}).primary,
@@ -1835,6 +1931,7 @@ async function loadKeys(options: { cacheTtlMs?: number } = {}) {
const pageSizeValue = pageSize.value
const search = searchQuery.value || undefined
const status = statusFilter.value as 'all' | 'active' | 'cooldown' | 'inactive'
const sortByValue = sortBy.value || undefined
keysLoading.value = true
try {
const nextPage = await listPoolKeys(providerId, {
@@ -1842,6 +1939,8 @@ async function loadKeys(options: { cacheTtlMs?: number } = {}) {
page_size: pageSizeValue,
search,
status,
sort_by: sortByValue || undefined,
sort_order: sortByValue ? sortOrder.value : undefined,
}, {
cacheTtlMs: options.cacheTtlMs ?? 0,
})
@@ -1856,9 +1955,11 @@ async function loadKeys(options: { cacheTtlMs?: number } = {}) {
return
}
keyPage.value = nextPage
keysLoadedOnce.value = true
} catch (err) {
if (requestId !== keysRequestId || selectedProviderId.value !== providerId) return
resetKeyPage(page, pageSizeValue)
keysLoadedOnce.value = true
showError(parseApiError(err))
} finally {
if (requestId === keysRequestId) {
@@ -1877,6 +1978,14 @@ watch(statusFilter, () => {
void loadKeys({ cacheTtlMs: POOL_KEYS_CACHE_TTL_MS })
})
watch([sortBy, sortOrder], () => {
if (currentPage.value !== 1) {
currentPage.value = 1
return
}
void loadKeys({ cacheTtlMs: POOL_KEYS_CACHE_TTL_MS })
})
watch(searchQuery, () => {
if (suppressFiltersWatch) return
currentPage.value = 1
@@ -1943,6 +2052,7 @@ function toEndpointApiKey(key: PoolKeyDetail): EndpointAPIKey {
oauth_account_user_id: key.oauth_account_user_id ?? null,
oauth_account_name: key.oauth_account_name ?? null,
oauth_organizations: key.oauth_organizations ?? [],
oauth_temporary: key.oauth_temporary ?? false,
oauth_invalid_at: key.oauth_invalid_at ?? null,
oauth_invalid_reason: key.oauth_invalid_reason ?? null,
status_snapshot: key.status_snapshot ?? null,
@@ -1964,6 +2074,12 @@ function sortCurrentPageKeysByPriority() {
})
}
function handleTableSort(payload: { key: string, direction: PoolManagementSortOrder }) {
if (payload.key !== 'imported_at' && payload.key !== 'last_used_at') return
sortBy.value = payload.key
sortOrder.value = payload.direction
}
function startEditInternalPriority(key: PoolKeyDetail) {
editingPriorityKeyId.value = key.key_id
editingPriorityValue.value = Number(key.internal_priority ?? 50)
@@ -2626,7 +2742,7 @@ function getOAuthPlanTypeClass(planType: string): string {
}
function getVisibleOAuthState(key: PoolKeyDetail) {
return getOAuthStatusDisplay(key, countdownTick.value)
return getOAuthStatusDisplayWithFallback(key, countdownTick.value)
}
function getOAuthRefreshButtonTitle(key: PoolKeyDetail): string {
@@ -3102,6 +3218,11 @@ function formatRelativeTime(isoStr: string): string {
return `${M}-${D} ${h}:${m}`
}
function formatPoolKeyImportedAt(key: PoolKeyDetail): string {
const value = key.imported_at || key.created_at
return value ? formatRelativeTime(value) : '-'
}
// --- Init ---
onMounted(() => {
startCountdownTimer()

View File

@@ -138,15 +138,59 @@
<TableHead class="w-[20%] min-w-[180px]">
余额监控
</TableHead>
<TableHead class="w-[12%] min-w-[100px] text-center">
<SortableTableHead
class="w-[12%] min-w-[100px] text-center"
column-key="model"
:sortable="false"
align="center"
:filter-active="filterModel !== 'all'"
filter-title="筛选模型"
filter-content-class="w-64 p-1 rounded-2xl border-border bg-card text-foreground shadow-2xl backdrop-blur-xl"
>
资源统计
</TableHead>
<TableHead class="w-[24%] min-w-[260px]">
<template #filter="{ close }">
<TableFilterMenu
v-model="filterModel"
:options="modelFilters"
@select="close"
/>
</template>
</SortableTableHead>
<SortableTableHead
class="w-[24%] min-w-[260px]"
column-key="api_format"
:sortable="false"
:filter-active="filterApiFormat !== 'all'"
filter-title="筛选 API 格式"
filter-content-class="w-72 p-1 rounded-2xl border-border bg-card text-foreground shadow-2xl backdrop-blur-xl"
>
端点健康
</TableHead>
<TableHead class="w-[8%] min-w-[60px] text-center">
<template #filter="{ close }">
<TableFilterMenu
v-model="filterApiFormat"
:options="apiFormatFilters"
@select="close"
/>
</template>
</SortableTableHead>
<SortableTableHead
class="w-[8%] min-w-[60px] text-center"
column-key="status"
:sortable="false"
align="center"
: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"
>
状态
</TableHead>
<template #filter="{ close }">
<TableFilterMenu
v-model="filterStatus"
:options="statusFilters"
@select="close"
/>
</template>
</SortableTableHead>
<TableHead class="w-[18%] min-w-[120px] text-center">
操作
</TableHead>
@@ -265,6 +309,8 @@ import TableHeader from '@/components/ui/table-header.vue'
import TableBody from '@/components/ui/table-body.vue'
import TableRow from '@/components/ui/table-row.vue'
import TableHead from '@/components/ui/table-head.vue'
import SortableTableHead from '@/components/ui/sortable-table-head.vue'
import TableFilterMenu from '@/components/ui/table-filter-menu.vue'
import Pagination from '@/components/ui/pagination.vue'
import { ProviderFormDialog, PriorityManagementDialog, ProviderAuthDialog } from '@/features/providers/components'
import ProviderDetailDrawer from '@/features/providers/components/ProviderDetailDrawer.vue'

View File

@@ -82,22 +82,24 @@
/>
</div>
<div class="h-4 w-px bg-border" />
<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="online">
在线
</SelectItem>
<SelectItem value="offline">
离线
</SelectItem>
</SelectContent>
</Select>
<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="online">
在线
</SelectItem>
<SelectItem value="offline">
离线
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="h-4 w-px bg-border" />
<Button
variant="outline"
@@ -138,9 +140,24 @@
<TableHead class="w-[100px] h-12 font-semibold">
区域
</TableHead>
<TableHead class="w-[90px] h-12 font-semibold text-center">
<SortableTableHead
class="w-[90px] h-12 font-semibold text-center"
column-key="status"
:sortable="false"
align="center"
:filter-active="filterStatus !== 'all'"
filter-title="筛选状态"
filter-content-class="w-36 p-1 rounded-2xl border-border bg-card text-foreground shadow-2xl backdrop-blur-xl"
>
状态
</TableHead>
<template #filter="{ close }">
<TableFilterMenu
v-model="filterStatus"
:options="proxyNodeStatusFilterOptions"
@select="close"
/>
</template>
</SortableTableHead>
<TableHead class="w-[100px] h-12 font-semibold text-center">
总请求
</TableHead>
@@ -783,6 +800,8 @@ import {
TableBody,
TableRow,
TableHead,
SortableTableHead,
TableFilterMenu,
TableCell,
Pagination,
RefreshButton,
@@ -800,6 +819,11 @@ const store = useProxyNodesStore()
const searchQuery = ref('')
const filterStatus = ref('all')
const proxyNodeStatusFilterOptions = [
{ value: 'all', label: '全部状态' },
{ value: 'online', label: '在线' },
{ value: 'offline', label: '离线' },
]
const currentPage = ref(1)
const pageSize = ref(20)

View File

@@ -106,44 +106,48 @@
<div class="h-4 w-px bg-border" />
<!-- 角色筛选 -->
<Select
v-model="filterRole"
>
<SelectTrigger class="w-32 h-8 text-xs border-border/60">
<SelectValue placeholder="全部角色" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
全部角色
</SelectItem>
<SelectItem value="admin">
管理员
</SelectItem>
<SelectItem value="user">
普通用户
</SelectItem>
</SelectContent>
</Select>
<div class="xl:hidden">
<Select
v-model="filterRole"
>
<SelectTrigger class="w-32 h-8 text-xs border-border/60">
<SelectValue placeholder="全部角色" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
全部角色
</SelectItem>
<SelectItem value="admin">
管理员
</SelectItem>
<SelectItem value="user">
普通用户
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- 状态筛选 -->
<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 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>
<!-- 分隔线 -->
<div class="h-4 w-px bg-border" />
@@ -173,9 +177,23 @@
<Table>
<TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="w-[260px] h-12 font-semibold">
<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"
>
用户信息
</TableHead>
<template #filter="{ close }">
<TableFilterMenu
v-model="filterRole"
:options="userRoleFilterOptions"
@select="close"
/>
</template>
</SortableTableHead>
<TableHead class="w-[240px] h-12 font-semibold">
钱包
</TableHead>
@@ -185,9 +203,23 @@
<TableHead class="w-[110px] h-12 font-semibold">
创建时间
</TableHead>
<TableHead class="w-[180px] h-12 font-semibold">
<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"
>
状态
</TableHead>
<template #filter="{ close }">
<TableFilterMenu
v-model="filterStatus"
:options="userStatusFilterOptions"
@select="close"
/>
</template>
</SortableTableHead>
<TableHead class="w-[220px] h-12 font-semibold text-center">
操作
</TableHead>
@@ -1055,6 +1087,8 @@ import {
TableBody,
TableRow,
TableHead,
SortableTableHead,
TableFilterMenu,
TableCell,
Avatar,
AvatarFallback,
@@ -1125,6 +1159,16 @@ const walletActionTarget = ref<{ user: User; wallet: AdminWallet } | null>(null)
const searchQuery = ref('')
const filterRole = ref('all')
const filterStatus = ref('all')
const userRoleFilterOptions = [
{ value: 'all', label: '全部角色' },
{ value: 'admin', label: '管理员' },
{ value: 'user', label: '普通用户' },
]
const userStatusFilterOptions = [
{ value: 'all', label: '全部状态' },
{ value: 'active', label: '活跃' },
{ value: 'inactive', label: '禁用' },
]
const currentPage = ref(1)
const pageSize = ref(20)