mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
Fix OAuth token import and table filters
This commit is contained in:
@@ -119,6 +119,7 @@ export interface PoolKeyDetail {
|
||||
oauth_account_user_id?: string | null
|
||||
oauth_account_name?: string | null
|
||||
oauth_organizations?: OAuthOrganizationInfo[] | null
|
||||
oauth_temporary?: boolean | null
|
||||
account_status_code?: string | null // 兼容字段;优先使用 status_snapshot.account
|
||||
account_status_label?: string | null // 兼容字段;优先使用 status_snapshot.account
|
||||
account_status_reason?: string | null // 兼容字段;优先使用 status_snapshot.account
|
||||
@@ -155,6 +156,7 @@ export interface PoolKeyDetail {
|
||||
sticky_sessions: number
|
||||
lru_score: number | null
|
||||
created_at: string | null
|
||||
imported_at?: string | null
|
||||
last_used_at: string | null
|
||||
scheduling_status?: 'available' | 'degraded' | 'blocked'
|
||||
scheduling_reason?:
|
||||
@@ -196,6 +198,8 @@ export interface PoolKeysQuery {
|
||||
status?: 'all' | 'active' | 'cooldown' | 'inactive'
|
||||
quick_selectors?: string[]
|
||||
search_scope?: 'name' | 'full'
|
||||
sort_by?: 'imported_at' | 'last_used_at'
|
||||
sort_order?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
export interface PoolKeySelectionRequest {
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface ProviderOAuthCompleteResponse {
|
||||
provider_type: string
|
||||
expires_at?: number | null
|
||||
has_refresh_token: boolean
|
||||
temporary?: boolean
|
||||
email?: string | null
|
||||
account_state_recheck_attempted?: boolean
|
||||
account_state_recheck_error?: string | null
|
||||
@@ -27,6 +28,7 @@ export interface ProviderOAuthCompleteResponseWithKey {
|
||||
provider_type: string
|
||||
expires_at?: number | null
|
||||
has_refresh_token: boolean
|
||||
temporary?: boolean
|
||||
email?: string | null
|
||||
replaced?: boolean
|
||||
}
|
||||
@@ -99,7 +101,7 @@ export async function completeProviderLevelOAuth(
|
||||
|
||||
export async function importProviderRefreshToken(
|
||||
providerId: string,
|
||||
data: { refresh_token: string; name?: string; proxy_node_id?: string }
|
||||
data: { refresh_token?: string; access_token?: string; name?: string; proxy_node_id?: string }
|
||||
): Promise<ProviderOAuthCompleteResponseWithKey> {
|
||||
const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/import-refresh-token`, data)
|
||||
return resp.data
|
||||
|
||||
@@ -299,6 +299,7 @@ export interface EndpointAPIKey {
|
||||
oauth_account_user_id?: string | null // Codex ChatGPT account-user 联合 ID
|
||||
oauth_account_name?: string | null
|
||||
oauth_organizations?: OAuthOrganizationInfo[] | null // OAuth 关联组织/工作区摘要
|
||||
oauth_temporary?: boolean | null // 是否为仅 Access Token 导入的临时 OAuth 账号
|
||||
oauth_invalid_at?: number | null // 兼容字段;优先使用 status_snapshot.oauth
|
||||
oauth_invalid_reason?: string | null // 兼容字段;优先使用 status_snapshot.oauth
|
||||
status_snapshot?: ProviderKeyStatusSnapshot | null
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface QuotaStatusSnapshot {
|
||||
exhausted: boolean
|
||||
usage_ratio?: number | null
|
||||
updated_at?: number | null
|
||||
reset_at?: number | null
|
||||
reset_seconds?: number | null
|
||||
plan_type?: string | null
|
||||
credits?: QuotaCreditsSnapshot | null
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<SelectTrigger class="h-8 w-32 text-xs border-border/60">
|
||||
<SelectValue placeholder="选择时间段" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent :searchable="false">
|
||||
<SelectItem value="today">
|
||||
今天
|
||||
</SelectItem>
|
||||
|
||||
@@ -51,6 +51,8 @@ export { default as TableHead } from './table-head.vue'
|
||||
export { default as TableHeader } from './table-header.vue'
|
||||
export { default as TableRow } from './table-row.vue'
|
||||
export { default as TableCard } from './table-card.vue'
|
||||
export { default as SortableTableHead } from './sortable-table-head.vue'
|
||||
export { default as TableFilterMenu } from './table-filter-menu.vue'
|
||||
|
||||
// Avatar 头像系列
|
||||
export { default as Avatar } from './avatar.vue'
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { PopoverTrigger } from 'radix-vue'
|
||||
import { useAttrs } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
withDefaults(defineProps<{
|
||||
asChild?: boolean
|
||||
}>()
|
||||
as?: string
|
||||
}>(), {
|
||||
asChild: false,
|
||||
as: 'button',
|
||||
})
|
||||
|
||||
const attrs = useAttrs()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverTrigger :as-child="asChild">
|
||||
<PopoverTrigger
|
||||
v-bind="attrs"
|
||||
:as-child="asChild"
|
||||
:as="as"
|
||||
>
|
||||
<slot />
|
||||
</PopoverTrigger>
|
||||
</template>
|
||||
|
||||
229
frontend/src/components/ui/sortable-table-head.vue
Normal file
229
frontend/src/components/ui/sortable-table-head.vue
Normal file
@@ -0,0 +1,229 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useAttrs, useSlots } from 'vue'
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, ListFilter } from 'lucide-vue-next'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import TableHead from './table-head.vue'
|
||||
|
||||
type SortDirection = 'asc' | 'desc'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
class?: string
|
||||
columnKey?: string
|
||||
sortable?: boolean
|
||||
activeKey?: string | null
|
||||
direction?: SortDirection
|
||||
defaultDirection?: SortDirection
|
||||
align?: 'left' | 'center' | 'right'
|
||||
title?: string
|
||||
filterActive?: boolean
|
||||
filterTitle?: string
|
||||
filterContentClass?: string
|
||||
}>(), {
|
||||
columnKey: undefined,
|
||||
sortable: true,
|
||||
activeKey: null,
|
||||
direction: 'asc',
|
||||
defaultDirection: 'asc',
|
||||
align: 'left',
|
||||
title: undefined,
|
||||
filterActive: false,
|
||||
filterTitle: '筛选',
|
||||
filterContentClass: undefined,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
sort: [payload: { key: string, direction: SortDirection }]
|
||||
}>()
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const attrs = useAttrs()
|
||||
const slots = useSlots()
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
const filterTriggerRef = ref<HTMLButtonElement | null>(null)
|
||||
const filterPanelRef = ref<HTMLElement | null>(null)
|
||||
const filterOpen = ref(false)
|
||||
const filterPanelStyle = ref<Record<string, string>>({})
|
||||
const canSort = computed(() => props.sortable && Boolean(props.columnKey))
|
||||
const hasFilter = computed(() => Boolean(slots.filter))
|
||||
const isActive = computed(() => props.activeKey === props.columnKey)
|
||||
const nextDirection = computed<SortDirection>(() => {
|
||||
if (!isActive.value) return props.defaultDirection
|
||||
return props.direction === 'asc' ? 'desc' : 'asc'
|
||||
})
|
||||
const icon = computed(() => {
|
||||
if (!isActive.value) return ArrowUpDown
|
||||
return props.direction === 'asc' ? ArrowUp : ArrowDown
|
||||
})
|
||||
const ariaSort = computed(() => {
|
||||
if (!canSort.value) return undefined
|
||||
if (!isActive.value) return 'none'
|
||||
return props.direction === 'asc' ? 'ascending' : 'descending'
|
||||
})
|
||||
const wrapperClass = computed(() => cn(
|
||||
'relative flex w-full items-center gap-1.5',
|
||||
props.align === 'center' && 'justify-center',
|
||||
props.align === 'right' && 'justify-end',
|
||||
))
|
||||
const labelClass = computed(() => cn(
|
||||
'inline-flex min-w-0 items-center gap-1.5 text-xs font-semibold text-muted-foreground',
|
||||
props.align === 'center' && 'justify-center',
|
||||
props.align === 'right' && 'justify-end',
|
||||
))
|
||||
const buttonClass = computed(() => cn(
|
||||
labelClass.value,
|
||||
'rounded-sm transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
))
|
||||
const iconClass = computed(() => cn(
|
||||
'h-3.5 w-3.5 shrink-0 transition-colors',
|
||||
isActive.value ? 'text-foreground' : 'text-muted-foreground/60',
|
||||
))
|
||||
const filterButtonClass = computed(() => cn(
|
||||
'inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
props.filterActive
|
||||
? 'bg-primary/10 text-primary hover:bg-primary/15'
|
||||
: 'text-muted-foreground/60 hover:bg-muted/50 hover:text-foreground',
|
||||
))
|
||||
const filterPanelClass = computed(() => cn(
|
||||
'fixed z-[1000] w-64 rounded-md border bg-popover p-3 text-popover-foreground shadow-md outline-none',
|
||||
props.filterContentClass,
|
||||
))
|
||||
|
||||
function handleSort() {
|
||||
if (!canSort.value || !props.columnKey) return
|
||||
emit('sort', {
|
||||
key: props.columnKey,
|
||||
direction: nextDirection.value,
|
||||
})
|
||||
}
|
||||
|
||||
function updateFilterPosition() {
|
||||
if (!filterOpen.value || !filterTriggerRef.value) return
|
||||
|
||||
const rect = filterTriggerRef.value.getBoundingClientRect()
|
||||
const panelWidth = filterPanelRef.value?.offsetWidth ?? 256
|
||||
const viewportPadding = 8
|
||||
let left = rect.left
|
||||
|
||||
if (props.align === 'center') {
|
||||
left = rect.left + rect.width / 2 - panelWidth / 2
|
||||
} else if (props.align === 'right') {
|
||||
left = rect.right - panelWidth
|
||||
}
|
||||
|
||||
left = Math.max(viewportPadding, Math.min(left, window.innerWidth - panelWidth - viewportPadding))
|
||||
|
||||
filterPanelStyle.value = {
|
||||
left: `${Math.round(left)}px`,
|
||||
top: `${Math.round(rect.bottom + 8)}px`,
|
||||
}
|
||||
}
|
||||
|
||||
async function openFilter() {
|
||||
filterOpen.value = true
|
||||
await nextTick()
|
||||
updateFilterPosition()
|
||||
}
|
||||
|
||||
function toggleFilter() {
|
||||
if (filterOpen.value) {
|
||||
closeFilter()
|
||||
} else {
|
||||
void openFilter()
|
||||
}
|
||||
}
|
||||
|
||||
function closeFilter() {
|
||||
filterOpen.value = false
|
||||
}
|
||||
|
||||
function handleDocumentPointerDown(event: PointerEvent) {
|
||||
if (!filterOpen.value) return
|
||||
const target = event.target
|
||||
if (target instanceof Node && rootRef.value?.contains(target)) return
|
||||
if (target instanceof Node && filterPanelRef.value?.contains(target)) return
|
||||
closeFilter()
|
||||
}
|
||||
|
||||
function handleDocumentKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
closeFilter()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('pointerdown', handleDocumentPointerDown)
|
||||
document.addEventListener('keydown', handleDocumentKeydown)
|
||||
document.addEventListener('scroll', updateFilterPosition, true)
|
||||
window.addEventListener('resize', updateFilterPosition)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointerdown', handleDocumentPointerDown)
|
||||
document.removeEventListener('keydown', handleDocumentKeydown)
|
||||
document.removeEventListener('scroll', updateFilterPosition, true)
|
||||
window.removeEventListener('resize', updateFilterPosition)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TableHead
|
||||
v-bind="attrs"
|
||||
:class="cn(props.class, (canSort || hasFilter) && 'select-none')"
|
||||
:aria-sort="ariaSort"
|
||||
>
|
||||
<div
|
||||
ref="rootRef"
|
||||
:class="wrapperClass"
|
||||
>
|
||||
<template v-if="hasFilter">
|
||||
<button
|
||||
ref="filterTriggerRef"
|
||||
type="button"
|
||||
:class="filterButtonClass"
|
||||
:title="filterTitle"
|
||||
:aria-pressed="filterActive"
|
||||
@click.stop="toggleFilter"
|
||||
>
|
||||
<ListFilter class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="filterOpen"
|
||||
ref="filterPanelRef"
|
||||
:class="filterPanelClass"
|
||||
:style="filterPanelStyle"
|
||||
@click.stop
|
||||
>
|
||||
<slot
|
||||
name="filter"
|
||||
:close="closeFilter"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
<button
|
||||
v-if="canSort"
|
||||
type="button"
|
||||
:class="buttonClass"
|
||||
:title="title || '排序'"
|
||||
@click="handleSort"
|
||||
>
|
||||
<slot />
|
||||
<component
|
||||
:is="icon"
|
||||
:class="iconClass"
|
||||
/>
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
:class="labelClass"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</div>
|
||||
</TableHead>
|
||||
</template>
|
||||
43
frontend/src/components/ui/table-filter-menu.vue
Normal file
43
frontend/src/components/ui/table-filter-menu.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { Check } from 'lucide-vue-next'
|
||||
|
||||
export interface TableFilterMenuOption {
|
||||
value: string
|
||||
label: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
options: TableFilterMenuOption[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
select: [value: string]
|
||||
}>()
|
||||
|
||||
function selectOption(value: string) {
|
||||
emit('update:modelValue', value)
|
||||
emit('select', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="relative flex w-full cursor-pointer select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-left text-sm text-foreground outline-none transition-colors hover:bg-accent focus:bg-accent disabled:pointer-events-none disabled:opacity-50"
|
||||
:disabled="option.disabled"
|
||||
@click="selectOption(option.value)"
|
||||
>
|
||||
<Check
|
||||
class="absolute left-2 h-4 w-4"
|
||||
:class="modelValue === option.value ? 'opacity-100' : 'opacity-0'"
|
||||
/>
|
||||
<span>{{ option.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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)
|
||||
|
||||
@@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
getAccountStatusDisplay,
|
||||
getOAuthRefreshButtonTitle,
|
||||
getOAuthStatusDisplay,
|
||||
getOAuthStatusDisplayWithFallback,
|
||||
getOAuthStatusTitle,
|
||||
} from '@/utils/providerKeyStatus'
|
||||
|
||||
@@ -155,4 +157,50 @@ describe('providerKeyStatus', () => {
|
||||
oauth_expires_at: future,
|
||||
}, 0)).toContain('Token 剩余有效期:')
|
||||
})
|
||||
|
||||
it('shows missing refresh token state for access-token-only oauth credentials', () => {
|
||||
const input = {
|
||||
auth_type: 'oauth',
|
||||
oauth_managed: true,
|
||||
oauth_temporary: true,
|
||||
}
|
||||
|
||||
expect(getOAuthStatusDisplay(input, 0)).toBeNull()
|
||||
expect(getOAuthStatusDisplayWithFallback(input, 0)).toEqual({
|
||||
text: '未添加',
|
||||
isExpired: false,
|
||||
isExpiringSoon: false,
|
||||
isInvalid: false,
|
||||
})
|
||||
expect(getOAuthStatusTitle(input, 0)).toBe('Refresh Token 未添加,无法自动刷新')
|
||||
expect(getOAuthRefreshButtonTitle(input, 0)).toBe('仅 Access Token 导入,无法自动刷新,到期后需要重新导入')
|
||||
})
|
||||
|
||||
it('does not show invalid oauth state when refresh token is missing', () => {
|
||||
const input = {
|
||||
auth_type: 'oauth',
|
||||
oauth_managed: true,
|
||||
oauth_temporary: true,
|
||||
status_snapshot: {
|
||||
oauth: {
|
||||
code: 'invalid',
|
||||
reason: 'missing_refresh_token',
|
||||
requires_reauth: true,
|
||||
},
|
||||
account: {
|
||||
code: 'ok',
|
||||
blocked: false,
|
||||
},
|
||||
quota: { code: 'ok', exhausted: false },
|
||||
},
|
||||
}
|
||||
|
||||
expect(getOAuthStatusDisplayWithFallback(input, 0)).toEqual({
|
||||
text: '未添加',
|
||||
isExpired: false,
|
||||
isExpiringSoon: false,
|
||||
isInvalid: false,
|
||||
})
|
||||
expect(getOAuthStatusTitle(input, 0)).toBe('Refresh Token 未添加,无法自动刷新')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ export interface ProviderKeyAuthCarrier {
|
||||
credential_kind?: string | null
|
||||
runtime_auth_kind?: string | null
|
||||
oauth_managed?: boolean | null
|
||||
oauth_temporary?: boolean | null
|
||||
can_refresh_oauth?: boolean | null
|
||||
can_export_oauth?: boolean | null
|
||||
can_edit_oauth?: boolean | null
|
||||
@@ -68,12 +69,19 @@ export function isServiceAccountCredential(input: ProviderKeyAuthCarrier): boole
|
||||
}
|
||||
|
||||
export function canRefreshOAuthCredential(input: ProviderKeyAuthCarrier): boolean {
|
||||
if (input.oauth_temporary === true) {
|
||||
return false
|
||||
}
|
||||
if (typeof input.can_refresh_oauth === 'boolean') {
|
||||
return input.can_refresh_oauth
|
||||
}
|
||||
return isOAuthManagedCredential(input)
|
||||
}
|
||||
|
||||
export function shouldShowOAuthRefreshControl(input: ProviderKeyAuthCarrier): boolean {
|
||||
return isOAuthManagedCredential(input)
|
||||
}
|
||||
|
||||
export function canExportOAuthCredential(input: ProviderKeyAuthCarrier): boolean {
|
||||
if (typeof input.can_export_oauth === 'boolean') {
|
||||
return input.can_export_oauth
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
isRefreshFailedReason,
|
||||
} from './accountBlock'
|
||||
import {
|
||||
canRefreshOAuthCredential,
|
||||
isOAuthManagedCredential,
|
||||
type ProviderKeyAuthCarrier,
|
||||
} from './providerKeyAuth'
|
||||
@@ -157,6 +158,19 @@ function mergeOAuthStatusDisplay(
|
||||
return snapshotStatus ?? legacyStatus
|
||||
}
|
||||
|
||||
function isOAuthCredentialWithoutRefreshToken(input: ProviderKeyStatusCarrier): boolean {
|
||||
return isOAuthManagedCredential(input) && !canRefreshOAuthCredential(input)
|
||||
}
|
||||
|
||||
function getMissingRefreshTokenStatus(): OAuthStatusInfo {
|
||||
return {
|
||||
text: '未添加',
|
||||
isExpired: false,
|
||||
isExpiringSoon: false,
|
||||
isInvalid: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function getOAuthStatusDisplay(
|
||||
input: ProviderKeyStatusCarrier,
|
||||
tick: number,
|
||||
@@ -167,12 +181,38 @@ export function getOAuthStatusDisplay(
|
||||
)
|
||||
}
|
||||
|
||||
export function getOAuthStatusDisplayWithFallback(
|
||||
input: ProviderKeyStatusCarrier,
|
||||
tick: number,
|
||||
): OAuthStatusInfo | null {
|
||||
if (isOAuthCredentialWithoutRefreshToken(input)) {
|
||||
return getMissingRefreshTokenStatus()
|
||||
}
|
||||
|
||||
const status = getOAuthStatusDisplay(input, tick)
|
||||
if (status) return status
|
||||
if (!isOAuthManagedCredential(input)) return null
|
||||
|
||||
return {
|
||||
text: '有效期未知',
|
||||
isExpired: false,
|
||||
isExpiringSoon: false,
|
||||
isInvalid: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function getOAuthStatusTitle(
|
||||
input: ProviderKeyStatusCarrier,
|
||||
tick: number,
|
||||
): string {
|
||||
if (isOAuthCredentialWithoutRefreshToken(input)) {
|
||||
return 'Refresh Token 未添加,无法自动刷新'
|
||||
}
|
||||
|
||||
const status = getOAuthStatusDisplay(input, tick)
|
||||
if (!status) return ''
|
||||
if (!status) {
|
||||
return isOAuthManagedCredential(input) ? 'Token 有效期未知' : ''
|
||||
}
|
||||
if (status.isInvalid) {
|
||||
const reason = normalizeText(status.invalidReason)
|
||||
return reason ? `Token 已失效: ${reason}` : 'Token 已失效'
|
||||
@@ -187,6 +227,13 @@ export function getOAuthRefreshButtonTitle(
|
||||
input: ProviderKeyStatusCarrier,
|
||||
tick: number,
|
||||
): string {
|
||||
if (isOAuthManagedCredential(input) && !canRefreshOAuthCredential(input)) {
|
||||
if (input.oauth_temporary === true) {
|
||||
return '仅 Access Token 导入,无法自动刷新,到期后需要重新导入'
|
||||
}
|
||||
return '当前 OAuth 凭据无法自动刷新,到期后需要重新导入'
|
||||
}
|
||||
|
||||
const status = getOAuthStatusDisplay(input, tick)
|
||||
if (status?.isInvalid || status?.isExpired) {
|
||||
return '重新授权'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user