fix: handle paginated API key search response (#5014)

* fix: handle paginated API key search response

* fix: add accessible label to API key filter
This commit is contained in:
yyhhyyyyyy
2026-05-25 23:15:59 +08:00
committed by GitHub
parent 465c5edab9
commit 349d5429ca
9 changed files with 76 additions and 41 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ export async function getApiKeys(
// Search API keys by keyword or token (with pagination) // Search API keys by keyword or token (with pagination)
export async function searchApiKeys( export async function searchApiKeys(
params: SearchApiKeysParams params: SearchApiKeysParams
): Promise<{ success: boolean; message?: string; data?: ApiKey[] }> { ): Promise<GetApiKeysResponse> {
const { keyword = '', token = '', p, size } = params const { keyword = '', token = '', p, size } = params
const queryParams = new URLSearchParams() const queryParams = new URLSearchParams()
if (keyword) queryParams.set('keyword', keyword) if (keyword) queryParams.set('keyword', keyword)
+62 -34
View File
@@ -30,6 +30,7 @@ import {
getSortedRowModel, getSortedRowModel,
useReactTable, useReactTable,
} from '@tanstack/react-table' } from '@tanstack/react-table'
import { useDebounce } from '@/hooks'
import { Database } from 'lucide-react' import { Database } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
@@ -43,6 +44,7 @@ import {
EmptyMedia, EmptyMedia,
EmptyTitle, EmptyTitle,
} from '@/components/ui/empty' } from '@/components/ui/empty'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { import {
DISABLED_ROW_DESKTOP, DISABLED_ROW_DESKTOP,
@@ -207,9 +209,35 @@ export function ApiKeysTable() {
navigate: route.useNavigate(), navigate: route.useNavigate(),
pagination: { defaultPage: 1, defaultPageSize: 20 }, pagination: { defaultPage: 1, defaultPageSize: 20 },
globalFilter: { enabled: true, key: 'filter' }, globalFilter: { enabled: true, key: 'filter' },
columnFilters: [{ columnId: 'status', searchKey: 'status', type: 'array' }], columnFilters: [
{ columnId: 'status', searchKey: 'status', type: 'array' },
{ columnId: '_tokenSearch', searchKey: 'token', type: 'string' },
],
}) })
const tokenFilterFromUrl =
(columnFilters.find((f) => f.id === '_tokenSearch')?.value as string) || ''
const [tokenFilterInput, setTokenFilterInput] = useState(tokenFilterFromUrl)
const debouncedTokenFilter = useDebounce(tokenFilterInput, 500)
useEffect(() => {
setTokenFilterInput(tokenFilterFromUrl)
}, [tokenFilterFromUrl])
useEffect(() => {
if (debouncedTokenFilter !== tokenFilterFromUrl) {
onColumnFiltersChange((prev) => {
const filtered = prev.filter((f) => f.id !== '_tokenSearch')
return debouncedTokenFilter
? [...filtered, { id: '_tokenSearch', value: debouncedTokenFilter }]
: filtered
})
}
}, [debouncedTokenFilter, tokenFilterFromUrl, onColumnFiltersChange])
const tokenFilter = tokenFilterFromUrl
const shouldSearch = Boolean(globalFilter?.trim() || tokenFilter.trim())
// Fetch data with React Query // Fetch data with React Query
// eslint-disable-next-line @tanstack/query/exhaustive-deps // eslint-disable-next-line @tanstack/query/exhaustive-deps
const { data, isLoading, isFetching } = useQuery({ const { data, isLoading, isFetching } = useQuery({
@@ -218,32 +246,31 @@ export function ApiKeysTable() {
pagination.pageIndex + 1, pagination.pageIndex + 1,
pagination.pageSize, pagination.pageSize,
globalFilter, globalFilter,
tokenFilter,
refreshTrigger, refreshTrigger,
], ],
queryFn: async () => { queryFn: async () => {
// If there's a global filter, use search const result = shouldSearch
const hasFilter = globalFilter?.trim() ? await searchApiKeys({
keyword: globalFilter,
if (hasFilter) { token: tokenFilter,
const result = await searchApiKeys({ keyword: globalFilter }) p: pagination.pageIndex + 1,
if (!result.success) { size: pagination.pageSize,
toast.error(result.message || t(ERROR_MESSAGES.SEARCH_FAILED)) })
return { items: [], total: 0 } : await getApiKeys({
} p: pagination.pageIndex + 1,
return { size: pagination.pageSize,
items: result.data || [], })
total: result.data?.length || 0,
}
}
// Otherwise use pagination
const result = await getApiKeys({
p: pagination.pageIndex + 1,
size: pagination.pageSize,
})
if (!result.success) { if (!result.success) {
toast.error(result.message || t(ERROR_MESSAGES.LOAD_FAILED)) toast.error(
result.message ||
t(
shouldSearch
? ERROR_MESSAGES.SEARCH_FAILED
: ERROR_MESSAGES.LOAD_FAILED
)
)
return { items: [], total: 0 } return { items: [], total: 0 }
} }
@@ -272,13 +299,7 @@ export function ApiKeysTable() {
onRowSelectionChange: setRowSelection, onRowSelectionChange: setRowSelection,
onSortingChange: setSorting, onSortingChange: setSorting,
onColumnVisibilityChange: setColumnVisibility, onColumnVisibilityChange: setColumnVisibility,
globalFilterFn: (row, _columnId, filterValue) => { globalFilterFn: () => true,
const name = String(row.getValue('name')).toLowerCase()
const key = String(row.original.key).toLowerCase()
const searchValue = String(filterValue).toLowerCase()
return name.includes(searchValue) || key.includes(searchValue)
},
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(), getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(), getPaginationRowModel: getPaginationRowModel(),
@@ -288,10 +309,8 @@ export function ApiKeysTable() {
onPaginationChange, onPaginationChange,
onGlobalFilterChange, onGlobalFilterChange,
onColumnFiltersChange, onColumnFiltersChange,
manualPagination: !globalFilter, manualPagination: true,
pageCount: globalFilter pageCount: Math.ceil((data?.total || 0) / pagination.pageSize),
? Math.ceil((data?.total || 0) / pagination.pageSize)
: Math.ceil((data?.total || 0) / pagination.pageSize),
}) })
const pageCount = table.getPageCount() const pageCount = table.getPageCount()
@@ -311,7 +330,16 @@ export function ApiKeysTable() {
)} )}
skeletonKeyPrefix='api-keys-skeleton' skeletonKeyPrefix='api-keys-skeleton'
toolbarProps={{ toolbarProps={{
searchPlaceholder: t('Filter by name or key...'), searchPlaceholder: t('Filter by name...'),
additionalSearch: (
<Input
placeholder={t('Filter by API key...')}
aria-label={t('Filter by API key...')}
value={tokenFilterInput}
onChange={(e) => setTokenFilterInput(e.target.value)}
className='w-full sm:w-50 lg:w-60'
/>
),
filters: [ filters: [
{ {
columnId: 'status', columnId: 'status',
+2 -1
View File
@@ -1715,8 +1715,9 @@
"Filter by Midjourney task ID": "Filter by Midjourney task ID", "Filter by Midjourney task ID": "Filter by Midjourney task ID",
"Filter by model name...": "Filter by model name...", "Filter by model name...": "Filter by model name...",
"Filter by model...": "Filter by model...", "Filter by model...": "Filter by model...",
"Filter by API key...": "Filter by API key...",
"Filter by name or ID...": "Filter by name or ID...", "Filter by name or ID...": "Filter by name or ID...",
"Filter by name or key...": "Filter by name or key...", "Filter by name...": "Filter by name...",
"Filter by name, ID, or key...": "Filter by name, ID, or key...", "Filter by name, ID, or key...": "Filter by name, ID, or key...",
"Filter by price field": "Filter by price field", "Filter by price field": "Filter by price field",
"Filter by ratio type": "Filter by ratio type", "Filter by ratio type": "Filter by ratio type",
+2 -1
View File
@@ -1715,8 +1715,9 @@
"Filter by Midjourney task ID": "Filtrer par ID de tâche Midjourney", "Filter by Midjourney task ID": "Filtrer par ID de tâche Midjourney",
"Filter by model name...": "Filtrer par nom du modèle...", "Filter by model name...": "Filtrer par nom du modèle...",
"Filter by model...": "Filtrer par modèle...", "Filter by model...": "Filtrer par modèle...",
"Filter by API key...": "Filtrer par clé API...",
"Filter by name or ID...": "Filtrer par nom ou ID...", "Filter by name or ID...": "Filtrer par nom ou ID...",
"Filter by name or key...": "Filtrer par nom ou clé...", "Filter by name...": "Filtrer par nom...",
"Filter by name, ID, or key...": "Filtrer par nom, ID ou clé...", "Filter by name, ID, or key...": "Filtrer par nom, ID ou clé...",
"Filter by price field": "Filtrer par champ de prix", "Filter by price field": "Filtrer par champ de prix",
"Filter by ratio type": "Filtrer par type de ratio", "Filter by ratio type": "Filtrer par type de ratio",
+2 -1
View File
@@ -1715,8 +1715,9 @@
"Filter by Midjourney task ID": "MidjourneyタスクIDでフィルター", "Filter by Midjourney task ID": "MidjourneyタスクIDでフィルター",
"Filter by model name...": "モデル名でフィルター...", "Filter by model name...": "モデル名でフィルター...",
"Filter by model...": "モデルでフィルタリング...", "Filter by model...": "モデルでフィルタリング...",
"Filter by API key...": "APIキーでフィルター...",
"Filter by name or ID...": "名前またはIDでフィルター...", "Filter by name or ID...": "名前またはIDでフィルター...",
"Filter by name or key...": "名前またはキーでフィルター...", "Filter by name...": "名前でフィルター...",
"Filter by name, ID, or key...": "名前、ID、またはキーでフィルター...", "Filter by name, ID, or key...": "名前、ID、またはキーでフィルター...",
"Filter by price field": "価格フィールドでフィルター", "Filter by price field": "価格フィールドでフィルター",
"Filter by ratio type": "倍率タイプで絞り込み", "Filter by ratio type": "倍率タイプで絞り込み",
+2 -1
View File
@@ -1715,8 +1715,9 @@
"Filter by Midjourney task ID": "Фильтр по ID задачи Midjourney", "Filter by Midjourney task ID": "Фильтр по ID задачи Midjourney",
"Filter by model name...": "Фильтр по имени модели...", "Filter by model name...": "Фильтр по имени модели...",
"Filter by model...": "Фильтровать по модели...", "Filter by model...": "Фильтровать по модели...",
"Filter by API key...": "Фильтр по API-ключу...",
"Filter by name or ID...": "Фильтр по имени или ID...", "Filter by name or ID...": "Фильтр по имени или ID...",
"Filter by name or key...": "Фильтровать по имени или ключу...", "Filter by name...": "Фильтр по имени...",
"Filter by name, ID, or key...": "Фильтровать по имени, ID или ключу...", "Filter by name, ID, or key...": "Фильтровать по имени, ID или ключу...",
"Filter by price field": "Фильтр по полю цены", "Filter by price field": "Фильтр по полю цены",
"Filter by ratio type": "Фильтровать по типу коэффициента", "Filter by ratio type": "Фильтровать по типу коэффициента",
+2 -1
View File
@@ -1715,8 +1715,9 @@
"Filter by Midjourney task ID": "Lọc theo ID nhiệm vụ Midjourney", "Filter by Midjourney task ID": "Lọc theo ID nhiệm vụ Midjourney",
"Filter by model name...": "Lọc theo tên mô hình...", "Filter by model name...": "Lọc theo tên mô hình...",
"Filter by model...": "Lọc theo mẫu...", "Filter by model...": "Lọc theo mẫu...",
"Filter by API key...": "Lọc theo khóa API...",
"Filter by name or ID...": "Lọc theo tên hoặc ID...", "Filter by name or ID...": "Lọc theo tên hoặc ID...",
"Filter by name or key...": "Lọc theo tên hoặc khóa...", "Filter by name...": "Lọc theo tên...",
"Filter by name, ID, or key...": "Lọc theo tên, ID hoặc khóa...", "Filter by name, ID, or key...": "Lọc theo tên, ID hoặc khóa...",
"Filter by price field": "Lọc theo trường giá", "Filter by price field": "Lọc theo trường giá",
"Filter by ratio type": "Lọc theo loại tỷ lệ", "Filter by ratio type": "Lọc theo loại tỷ lệ",
+2 -1
View File
@@ -1715,8 +1715,9 @@
"Filter by Midjourney task ID": "按 Midjourney 任务 ID 筛选", "Filter by Midjourney task ID": "按 Midjourney 任务 ID 筛选",
"Filter by model name...": "按模型名称筛选...", "Filter by model name...": "按模型名称筛选...",
"Filter by model...": "按模型筛选...", "Filter by model...": "按模型筛选...",
"Filter by API key...": "按 API 密钥筛选...",
"Filter by name or ID...": "按名称或 ID 筛选...", "Filter by name or ID...": "按名称或 ID 筛选...",
"Filter by name or key...": "按名称或密钥筛选...", "Filter by name...": "按名称筛选...",
"Filter by name, ID, or key...": "按名称、ID 或密钥筛选...", "Filter by name, ID, or key...": "按名称、ID 或密钥筛选...",
"Filter by price field": "按价格字段筛选", "Filter by price field": "按价格字段筛选",
"Filter by ratio type": "按倍率类型筛选", "Filter by ratio type": "按倍率类型筛选",
+1
View File
@@ -29,6 +29,7 @@ const apiKeySearchSchema = z.object({
.optional() .optional()
.catch([]), .catch([]),
filter: z.string().optional().catch(''), filter: z.string().optional().catch(''),
token: z.string().optional().catch(''),
}) })
export const Route = createFileRoute('/_authenticated/keys/')({ export const Route = createFileRoute('/_authenticated/keys/')({