mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 09:50:21 +08:00
Fix OAuth token import and table filters
This commit is contained in:
@@ -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',
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user