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

@@ -37,6 +37,8 @@ describe('poolManagementState', () => {
status: 'cooldown',
page: 5,
pageSize: 20,
sortBy: 'last_used_at',
sortOrder: 'asc',
},
storage,
)
@@ -48,6 +50,8 @@ describe('poolManagementState', () => {
status: 'inactive',
page: '3',
pageSize: '100',
sortBy: 'imported_at',
sortOrder: 'desc',
},
storage,
)
@@ -58,6 +62,8 @@ describe('poolManagementState', () => {
status: 'inactive',
page: 3,
pageSize: 100,
sortBy: 'imported_at',
sortOrder: 'desc',
})
})
@@ -69,6 +75,8 @@ describe('poolManagementState', () => {
status: 'active',
page: 2,
pageSize: 50,
sortBy: 'last_used_at',
sortOrder: 'asc',
},
storage,
)
@@ -81,6 +89,8 @@ describe('poolManagementState', () => {
status: 'active',
page: 2,
pageSize: 50,
sortBy: 'last_used_at',
sortOrder: 'asc',
})
})
@@ -92,6 +102,8 @@ describe('poolManagementState', () => {
status: 'all',
page: 1,
pageSize: 50,
sortBy: null,
sortOrder: 'desc',
}),
).toEqual({
providerId: 'provider-d',
@@ -99,6 +111,25 @@ describe('poolManagementState', () => {
status: undefined,
page: undefined,
pageSize: undefined,
sortBy: undefined,
sortOrder: undefined,
})
})
it('keeps sortable column state in query patch', () => {
expect(
buildPoolManagementQueryPatch({
providerId: 'provider-e',
search: '',
status: 'all',
page: 1,
pageSize: 50,
sortBy: 'last_used_at',
sortOrder: 'asc',
}),
).toMatchObject({
sortBy: 'last_used_at',
sortOrder: 'asc',
})
})

View File

@@ -54,4 +54,14 @@ describe('poolMobilePresentation', () => {
overflow: [],
})
})
it('can show a disabled refresh action even when refresh is not available', () => {
expect(
splitPoolMobileActions({
canDownloadOrCopy: true,
canRefreshToken: false,
showRefreshToken: true,
}).primary,
).toContain('refresh_token')
})
})

View File

@@ -1,4 +1,6 @@
export type PoolManagementStatus = 'all' | 'active' | 'cooldown' | 'inactive'
export type PoolManagementSortBy = 'imported_at' | 'last_used_at'
export type PoolManagementSortOrder = 'asc' | 'desc'
export interface PoolManagementViewState {
providerId: string | null
@@ -6,6 +8,8 @@ export interface PoolManagementViewState {
status: PoolManagementStatus
page: number
pageSize: number
sortBy: PoolManagementSortBy | null
sortOrder: PoolManagementSortOrder
}
export interface PoolManagementStateSource {
@@ -14,6 +18,8 @@ export interface PoolManagementStateSource {
status?: string
page?: string
pageSize?: string
sortBy?: string
sortOrder?: string
}
export interface StorageLike {
@@ -30,6 +36,8 @@ export const DEFAULT_POOL_MANAGEMENT_VIEW_STATE: PoolManagementViewState = {
status: 'all',
page: 1,
pageSize: 50,
sortBy: null,
sortOrder: 'desc',
}
function normalizeProviderId(value: unknown): string | null {
@@ -56,6 +64,17 @@ function normalizePositiveInteger(value: unknown, fallback: number): number {
return normalized
}
function normalizeSortBy(value: unknown): PoolManagementSortBy | null {
if (value === 'imported_at' || value === 'last_used_at') {
return value
}
return null
}
function normalizeSortOrder(value: unknown): PoolManagementSortOrder {
return value === 'asc' ? 'asc' : 'desc'
}
function normalizeViewState(input: Partial<PoolManagementViewState>): PoolManagementViewState {
return {
providerId: normalizeProviderId(input.providerId),
@@ -63,6 +82,8 @@ function normalizeViewState(input: Partial<PoolManagementViewState>): PoolManage
status: normalizeStatus(input.status),
page: normalizePositiveInteger(input.page, DEFAULT_POOL_MANAGEMENT_VIEW_STATE.page),
pageSize: normalizePositiveInteger(input.pageSize, DEFAULT_POOL_MANAGEMENT_VIEW_STATE.pageSize),
sortBy: normalizeSortBy(input.sortBy),
sortOrder: normalizeSortOrder(input.sortOrder),
}
}
@@ -91,6 +112,8 @@ export function readPoolManagementViewState(
status: source.status ?? stored.status,
page: source.page ?? stored.page,
pageSize: source.pageSize ?? stored.pageSize,
sortBy: source.sortBy ?? stored.sortBy,
sortOrder: source.sortOrder ?? stored.sortOrder,
})
}
@@ -125,6 +148,8 @@ export function buildPoolManagementQueryPatch(
normalized.pageSize === DEFAULT_POOL_MANAGEMENT_VIEW_STATE.pageSize
? undefined
: String(normalized.pageSize),
sortBy: normalized.sortBy || undefined,
sortOrder: normalized.sortBy ? normalized.sortOrder : undefined,
}
}

View File

@@ -32,6 +32,7 @@ export type PoolMobileActionId =
export interface PoolMobileActionInput {
canDownloadOrCopy?: boolean
canRefreshToken?: boolean
showRefreshToken?: boolean
canClearCooldown?: boolean
canRecoverHealth?: boolean
hasProxy?: boolean
@@ -62,9 +63,11 @@ export function splitPoolMobileActions(input: PoolMobileActionInput): {
primary: PoolMobileActionId[]
overflow: PoolMobileActionId[]
} {
const showRefreshToken = input.showRefreshToken ?? input.canRefreshToken
if (input.canDownloadOrCopy) {
const primary: PoolMobileActionId[] = ['copy_or_download']
if (input.canRefreshToken) {
if (showRefreshToken) {
primary.push('refresh_token')
}
if (input.canClearCooldown) {
@@ -86,7 +89,7 @@ export function splitPoolMobileActions(input: PoolMobileActionInput): {
}
const primary: PoolMobileActionId[] = []
if (input.canRefreshToken) {
if (showRefreshToken) {
primary.push('refresh_token')
}
if (input.canClearCooldown) {

View File

@@ -410,8 +410,8 @@
:reset-key="importInputResetKey"
drop-title="拖入授权文件或点击选择"
drop-hint="支持 .json / .txt可多选"
manual-placeholder="粘贴 Refresh Token JSON 内容"
paste-toggle-text="或手动粘贴 Refresh Token"
manual-placeholder="粘贴 Refresh Token / Access Token JSON 内容"
paste-toggle-text="或手动粘贴 Token"
file-toggle-text="或选择 JSON 文件导入"
textarea-class="min-h-[200px] text-xs font-mono break-all !rounded-xl"
@error="handleImportInputError"
@@ -949,7 +949,7 @@ function isBatchImport(text: string): boolean {
return lines.length > 1
}
function parseImportText(text: string): { refresh_token: string; name?: string } | null {
function parseImportText(text: string): { refresh_token?: string; access_token?: string; name?: string } | null {
const trimmed = text.trim()
if (!trimmed) return null
@@ -963,9 +963,19 @@ function parseImportText(text: string): { refresh_token: string; name?: string }
if (typeof parsed === 'object' && parsed !== null) {
const obj = parsed as Record<string, unknown>
const refreshToken = obj.refresh_token
if (typeof refreshToken === 'string' && refreshToken.trim()) {
const refreshTokenCamel = obj.refreshToken
const accessToken = obj.access_token
const accessTokenCamel = obj.accessToken
const normalizedRefreshToken = typeof refreshToken === 'string' && refreshToken.trim()
? refreshToken.trim()
: (typeof refreshTokenCamel === 'string' && refreshTokenCamel.trim() ? refreshTokenCamel.trim() : undefined)
const normalizedAccessToken = typeof accessToken === 'string' && accessToken.trim()
? accessToken.trim()
: (typeof accessTokenCamel === 'string' && accessTokenCamel.trim() ? accessTokenCamel.trim() : undefined)
if (normalizedRefreshToken || normalizedAccessToken) {
return {
refresh_token: refreshToken.trim(),
refresh_token: normalizedRefreshToken,
access_token: normalizedAccessToken,
name: (typeof obj.name === 'string' ? obj.name : undefined) || (typeof obj.oauth_email === 'string' ? obj.oauth_email : undefined),
}
}
@@ -975,9 +985,34 @@ function parseImportText(text: string): { refresh_token: string; name?: string }
// Not JSON: treat as raw token.
}
if (isLikelyJwtToken(trimmed)) {
return { access_token: trimmed }
}
return { refresh_token: trimmed }
}
function isLikelyJwtToken(token: string): boolean {
const parts = token.trim().split('.')
if (parts.length !== 3 || parts.some(part => !part)) return false
try {
const header = JSON.parse(decodeBase64Url(parts[0])) as Record<string, unknown>
const payload = JSON.parse(decodeBase64Url(parts[1])) as Record<string, unknown>
const tokenType = typeof header.typ === 'string' ? header.typ.toLowerCase() : ''
if (tokenType && tokenType !== 'jwt' && tokenType !== 'at+jwt') return false
return ['exp', 'aud', 'iss', 'scope', 'scp'].some(key => key in payload)
} catch {
return false
}
}
function decodeBase64Url(value: string): string {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '=')
return atob(padded)
}
function handleImportInputError(payload: { message: string; title?: string }) {
showError(payload.message, payload.title)
}

View File

@@ -348,7 +348,7 @@
<Copy class="w-2.5 h-2.5" />
</Button>
<!-- OAuth 状态失效/过期/倒计时和刷新按钮 -->
<template v-if="getKeyOAuthExpires(key)">
<template v-if="shouldShowOAuthRefreshControl(key)">
<!-- 账号级别异常醒目提示 + 清除按钮 -->
<template v-if="isAccountLevelBlock(key)">
<Badge
@@ -386,11 +386,19 @@
>
{{ getKeyOAuthExpires(key)?.text }}
</span>
<Badge
v-if="key.oauth_temporary"
variant="outline"
class="text-[10px] px-1.5 py-0 shrink-0"
title="仅通过 Access Token 导入,无法自动刷新,到期后需要重新导入"
>
临时
</Badge>
<Button
variant="ghost"
size="icon"
class="h-4 w-4 shrink-0"
:disabled="refreshingOAuthKeyId === key.id"
:disabled="refreshingOAuthKeyId === key.id || !canRefreshOAuthCredential(key)"
:title="getOAuthRefreshButtonTitle(key)"
@click.stop="handleRefreshOAuth(key)"
>
@@ -1172,14 +1180,17 @@ import { getOAuthRefreshFeedback } from '@/utils/oauthRefreshFeedback'
import {
canEditOAuthCredential,
canExportOAuthCredential,
canRefreshOAuthCredential,
isOAuthManagedCredential,
isServiceAccountCredential,
shouldShowOAuthRefreshControl,
} from '@/utils/providerKeyAuth'
import {
getAccountStatusDisplay,
getAccountStatusTitle,
getOAuthRefreshButtonTitle as resolveOAuthRefreshButtonTitle,
getOAuthStatusDisplay,
getOAuthStatusDisplayWithFallback,
getOAuthStatusTitle as resolveOAuthStatusTitle,
} from '@/utils/providerKeyStatus'
@@ -3104,7 +3115,7 @@ function getOAuthPlanTypeClass(planType: string): string {
// OAuth 状态信息(包括失效和过期)
function getKeyOAuthExpires(key: EndpointAPIKey) {
return getOAuthStatusDisplay(key, countdownTick.value)
return getOAuthStatusDisplayWithFallback(key, countdownTick.value)
}
function getOAuthRefreshButtonTitle(key: EndpointAPIKey): string {

View File

@@ -22,61 +22,67 @@
</div>
<!-- 状态筛选 -->
<Select
:model-value="filterStatus"
@update:model-value="$emit('update:filterStatus', $event)"
>
<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 class="xl:hidden">
<Select
:model-value="filterStatus"
@update:model-value="$emit('update:filterStatus', $event)"
>
<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>
<!-- API 格式筛选 -->
<Select
:model-value="filterApiFormat"
@update:model-value="$emit('update:filterApiFormat', $event)"
>
<SelectTrigger class="w-20 sm:w-28 h-8 text-xs border-border/60">
<SelectValue placeholder="全部格式" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="fmt in apiFormatFilters"
:key="fmt.value"
:value="fmt.value"
>
{{ fmt.label }}
</SelectItem>
</SelectContent>
</Select>
<div class="xl:hidden">
<Select
:model-value="filterApiFormat"
@update:model-value="$emit('update:filterApiFormat', $event)"
>
<SelectTrigger class="w-20 sm:w-28 h-8 text-xs border-border/60">
<SelectValue placeholder="全部格式" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="fmt in apiFormatFilters"
:key="fmt.value"
:value="fmt.value"
>
{{ fmt.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- 模型筛选 -->
<Select
:model-value="filterModel"
@update:model-value="$emit('update:filterModel', $event)"
>
<SelectTrigger class="w-20 sm:w-36 h-8 text-xs border-border/60">
<SelectValue placeholder="全部模型" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="model in modelFilters"
:key="model.value"
:value="model.value"
>
{{ model.label }}
</SelectItem>
</SelectContent>
</Select>
<div class="xl:hidden">
<Select
:model-value="filterModel"
@update:model-value="$emit('update:filterModel', $event)"
>
<SelectTrigger class="w-20 sm:w-36 h-8 text-xs border-border/60">
<SelectValue placeholder="全部模型" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="model in modelFilters"
:key="model.value"
:value="model.value"
>
{{ model.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- 重置筛选 -->
<Button

View File

@@ -21,131 +21,133 @@
/>
</div>
<!-- 用户筛选仅管理员可见 -->
<Select
v-if="isAdmin && availableUsers.length > 0"
:model-value="filterUser"
@update:model-value="$emit('update:filterUser', $event)"
>
<SelectTrigger class="flex-1 min-w-0 sm:flex-none sm:w-36 h-8 text-xs border-border/60">
<SelectValue placeholder="用户" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">
全部用户
</SelectItem>
<SelectItem
v-for="user in availableUsers"
:key="user.id"
:value="user.id"
>
{{ user.username || user.email }}
</SelectItem>
</SelectContent>
</Select>
<div class="contents md:hidden">
<!-- 用户筛选仅管理员可见 -->
<Select
v-if="isAdmin && availableUsers.length > 0"
:model-value="filterUser"
@update:model-value="$emit('update:filterUser', $event)"
>
<SelectTrigger class="flex-1 min-w-0 sm:flex-none sm:w-36 h-8 text-xs border-border/60">
<SelectValue placeholder="用户" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">
全部用户
</SelectItem>
<SelectItem
v-for="user in availableUsers"
:key="user.id"
:value="user.id"
>
{{ user.username || user.email }}
</SelectItem>
</SelectContent>
</Select>
<!-- 模型筛选 -->
<Select
:model-value="filterModel"
@update:model-value="$emit('update:filterModel', $event)"
>
<SelectTrigger class="flex-1 min-w-0 sm:flex-none sm:w-40 h-8 text-xs border-border/60">
<SelectValue placeholder="模型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">
全部模型
</SelectItem>
<SelectItem
v-for="model in availableModels"
:key="model"
:value="model"
>
{{ model.replace('claude-', '') }}
</SelectItem>
</SelectContent>
</Select>
<!-- 模型筛选 -->
<Select
:model-value="filterModel"
@update:model-value="$emit('update:filterModel', $event)"
>
<SelectTrigger class="flex-1 min-w-0 sm:flex-none sm:w-40 h-8 text-xs border-border/60">
<SelectValue placeholder="模型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">
全部模型
</SelectItem>
<SelectItem
v-for="model in availableModels"
:key="model"
:value="model"
>
{{ model.replace('claude-', '') }}
</SelectItem>
</SelectContent>
</Select>
<!-- 提供商筛选仅管理员可见 -->
<Select
v-if="isAdmin"
:model-value="filterProvider"
@update:model-value="$emit('update:filterProvider', $event)"
>
<SelectTrigger class="flex-1 min-w-0 sm:flex-none sm:w-32 h-8 text-xs border-border/60">
<SelectValue placeholder="提供商" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">
全部提供商
</SelectItem>
<SelectItem
v-for="provider in availableProviders"
:key="provider"
:value="provider"
>
{{ provider }}
</SelectItem>
</SelectContent>
</Select>
<!-- 提供商筛选仅管理员可见 -->
<Select
v-if="isAdmin"
:model-value="filterProvider"
@update:model-value="$emit('update:filterProvider', $event)"
>
<SelectTrigger class="flex-1 min-w-0 sm:flex-none sm:w-32 h-8 text-xs border-border/60">
<SelectValue placeholder="提供商" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">
全部提供商
</SelectItem>
<SelectItem
v-for="provider in availableProviders"
:key="provider"
:value="provider"
>
{{ provider }}
</SelectItem>
</SelectContent>
</Select>
<!-- API格式筛选 -->
<Select
:model-value="filterApiFormat"
@update:model-value="$emit('update:filterApiFormat', $event)"
>
<SelectTrigger class="flex-1 min-w-0 sm:flex-none sm:w-32 h-8 text-xs border-border/60">
<SelectValue placeholder="格式" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">
全部格式
</SelectItem>
<SelectItem
v-for="format in availableApiFormats"
:key="format.value"
:value="format.value"
>
{{ format.label }}
</SelectItem>
</SelectContent>
</Select>
<!-- API格式筛选 -->
<Select
:model-value="filterApiFormat"
@update:model-value="$emit('update:filterApiFormat', $event)"
>
<SelectTrigger class="flex-1 min-w-0 sm:flex-none sm:w-32 h-8 text-xs border-border/60">
<SelectValue placeholder="格式" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">
全部格式
</SelectItem>
<SelectItem
v-for="format in availableApiFormats"
:key="format.value"
:value="format.value"
>
{{ format.label }}
</SelectItem>
</SelectContent>
</Select>
<!-- 状态筛选 -->
<Select
:model-value="filterStatus"
@update:model-value="$emit('update:filterStatus', $event)"
>
<SelectTrigger class="flex-1 min-w-0 sm:flex-none sm:w-28 h-8 text-xs border-border/60">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">
全部状态
</SelectItem>
<SelectItem value="stream">
流式
</SelectItem>
<SelectItem value="standard">
标准
</SelectItem>
<SelectItem value="active">
活跃
</SelectItem>
<SelectItem value="failed">
失败
</SelectItem>
<SelectItem value="cancelled">
已取消
</SelectItem>
<SelectItem value="has_retry">
发生重试
</SelectItem>
<SelectItem value="has_fallback">
发生转移
</SelectItem>
</SelectContent>
</Select>
<!-- 状态筛选 -->
<Select
:model-value="filterStatus"
@update:model-value="$emit('update:filterStatus', $event)"
>
<SelectTrigger class="flex-1 min-w-0 sm:flex-none sm:w-28 h-8 text-xs border-border/60">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">
全部状态
</SelectItem>
<SelectItem value="stream">
流式
</SelectItem>
<SelectItem value="standard">
标准
</SelectItem>
<SelectItem value="active">
活跃
</SelectItem>
<SelectItem value="failed">
失败
</SelectItem>
<SelectItem value="cancelled">
已取消
</SelectItem>
<SelectItem value="has_retry">
发生重试
</SelectItem>
<SelectItem value="has_fallback">
发生转移
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- 分隔线 -->
<div class="hidden sm:block h-4 w-px bg-border" />
@@ -286,39 +288,132 @@
</div>
<!-- 桌面端表格视图 -->
<Table class="hidden md:table">
<Table :class="isAdmin ? 'hidden md:table table-fixed min-w-[1288px]' : 'hidden md:table table-fixed min-w-[1178px]'">
<colgroup v-if="isAdmin">
<col class="w-[88px]">
<col class="w-[180px]">
<col class="w-[190px]">
<col class="w-[160px]">
<col class="w-[190px]">
<col class="w-[120px]">
<col class="w-[150px]">
<col class="w-[120px]">
<col class="w-[90px]">
</colgroup>
<colgroup v-else>
<col class="w-[88px]">
<col class="w-[200px]">
<col class="w-[220px]">
<col class="w-[190px]">
<col class="w-[120px]">
<col class="w-[150px]">
<col class="w-[120px]">
<col class="w-[90px]">
</colgroup>
<TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="h-12 font-semibold w-[70px]">
时间
</TableHead>
<TableHead
<SortableTableHead
v-if="isAdmin"
class="h-12 font-semibold w-[100px]"
column-key="user"
:sortable="false"
:filter-active="filterUser !== '__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="filterUser"
:options="userFilterOptions"
@update:model-value="$emit('update:filterUser', $event)"
@select="close"
/>
</template>
</SortableTableHead>
<TableHead
v-if="!isAdmin"
class="h-12 font-semibold w-[100px]"
>
密钥
</TableHead>
<TableHead class="h-12 font-semibold w-[140px]">
<SortableTableHead
class="h-12 font-semibold w-[140px]"
column-key="model"
:sortable="false"
: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
<template #filter="{ close }">
<TableFilterMenu
:model-value="filterModel"
:options="modelFilterOptions"
@update:model-value="$emit('update:filterModel', $event)"
@select="close"
/>
</template>
</SortableTableHead>
<SortableTableHead
v-if="isAdmin"
class="h-12 font-semibold w-[100px]"
column-key="provider"
:sortable="false"
:filter-active="filterProvider !== '__all__'"
filter-title="筛选提供商"
filter-content-class="w-48 p-1 rounded-2xl border-border bg-card text-foreground shadow-2xl backdrop-blur-xl"
>
提供商
</TableHead>
<TableHead class="h-12 font-semibold w-[120px]">
<template #filter="{ close }">
<TableFilterMenu
:model-value="filterProvider"
:options="providerFilterOptions"
@update:model-value="$emit('update:filterProvider', $event)"
@select="close"
/>
</template>
</SortableTableHead>
<SortableTableHead
class="h-12 font-semibold w-[120px]"
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"
>
API格式
</TableHead>
<TableHead class="h-12 font-semibold w-[110px] text-center">
<template #filter="{ close }">
<TableFilterMenu
:model-value="filterApiFormat"
:options="apiFormatFilterOptions"
@update:model-value="$emit('update:filterApiFormat', $event)"
@select="close"
/>
</template>
</SortableTableHead>
<SortableTableHead
class="h-12 font-semibold w-[110px] text-center"
column-key="status"
:sortable="false"
align="center"
:filter-active="filterStatus !== '__all__'"
filter-title="筛选类型"
filter-content-class="w-44 p-1 rounded-2xl border-border bg-card text-foreground shadow-2xl backdrop-blur-xl"
>
类型
</TableHead>
<template #filter="{ close }">
<TableFilterMenu
:model-value="filterStatus"
:options="statusFilterOptions"
@update:model-value="$emit('update:filterStatus', $event)"
@select="close"
/>
</template>
</SortableTableHead>
<TableHead class="h-12 font-semibold w-[140px] text-right">
Tokens
</TableHead>
@@ -685,6 +780,8 @@ import {
TableHead,
TableCell,
Pagination,
SortableTableHead,
TableFilterMenu,
} from '@/components/ui'
import { RefreshCcw, Search } from 'lucide-vue-next'
import { formatTokens, formatCurrency } from '@/utils/format'
@@ -709,6 +806,12 @@ export interface UserOption {
email: string
}
interface FilterOption {
value: string
label: string
disabled?: boolean
}
const props = defineProps<{
records: UsageRecord[]
isAdmin: boolean
@@ -766,6 +869,49 @@ const AVAILABLE_API_FORMATS = [
// 使用模块级常量
const availableApiFormats = AVAILABLE_API_FORMATS
const userFilterOptions = computed<FilterOption[]>(() => [
{ value: '__all__', label: '全部用户' },
...props.availableUsers.map((user) => ({
value: user.id,
label: user.username || user.email,
})),
])
const modelFilterOptions = computed<FilterOption[]>(() => [
{ value: '__all__', label: '全部模型' },
...props.availableModels.map((model) => ({
value: model,
label: model.replace('claude-', ''),
})),
])
const providerFilterOptions = computed<FilterOption[]>(() => [
{ value: '__all__', label: '全部提供商' },
...props.availableProviders.map((provider) => ({
value: provider,
label: provider,
})),
])
const apiFormatFilterOptions = computed<FilterOption[]>(() => [
{ value: '__all__', label: '全部格式' },
...availableApiFormats.map((format) => ({
value: format.value,
label: format.label,
})),
])
const statusFilterOptions: FilterOption[] = [
{ value: '__all__', label: '全部状态' },
{ value: 'stream', label: '流式' },
{ value: 'standard', label: '标准' },
{ value: 'active', label: '活跃' },
{ value: 'failed', label: '失败' },
{ value: 'cancelled', label: '已取消' },
{ value: 'has_retry', label: '发生重试' },
{ value: 'has_fallback', label: '发生转移' },
]
const timeRangeModel = computed({
get: () => props.timeRange,
set: (value: DateRangeParams) => emit('update:timeRange', value)