"backend/internal/handler/vscode:/vscode.git/clone" did not exist on "ad80606a4483ac9fb11f9ccc76ff09b3590fc627"
Commit 5f8e60a1 authored by IanShaw027's avatar IanShaw027
Browse files

feat(table): 表格排序与搜索改为后端处理

parent 66e15a54
...@@ -52,7 +52,7 @@ Pagination component with page numbers, navigation, and page size selector. ...@@ -52,7 +52,7 @@ Pagination component with page numbers, navigation, and page size selector.
- `total: number` - Total number of items - `total: number` - Total number of items
- `page: number` - Current page (1-indexed) - `page: number` - Current page (1-indexed)
- `pageSize: number` - Items per page - `pageSize: number` - Items per page
- `pageSizeOptions?: number[]` - Available page size options (default: [10, 20, 50, 100]) - `pageSizeOptions?: number[]` - Available page size options (default: [10, 20, 50])
**Events:** **Events:**
......
...@@ -2036,6 +2036,7 @@ export default { ...@@ -2036,6 +2036,7 @@ export default {
rateLimited: 'Rate Limited', rateLimited: 'Rate Limited',
overloaded: 'Overloaded', overloaded: 'Overloaded',
tempUnschedulable: 'Temp Unschedulable', tempUnschedulable: 'Temp Unschedulable',
unschedulable: 'Unschedulable',
rateLimitedUntil: 'Rate limited and removed from scheduling. Auto resumes at {time}', rateLimitedUntil: 'Rate limited and removed from scheduling. Auto resumes at {time}',
rateLimitedAutoResume: 'Auto resumes in {time}', rateLimitedAutoResume: 'Auto resumes in {time}',
modelRateLimitedUntil: '{model} rate limited until {time}', modelRateLimitedUntil: '{model} rate limited until {time}',
...@@ -4287,6 +4288,15 @@ export default { ...@@ -4287,6 +4288,15 @@ export default {
apiBaseUrlPlaceholder: 'https://api.example.com', apiBaseUrlPlaceholder: 'https://api.example.com',
apiBaseUrlHint: apiBaseUrlHint:
'Used for "Use Key" and "Import to CC Switch" features. Leave empty to use current site URL.', 'Used for "Use Key" and "Import to CC Switch" features. Leave empty to use current site URL.',
tablePreferencesTitle: 'Global Table Preferences',
tablePreferencesDescription: 'Configure default pagination behavior for shared table components',
tableDefaultPageSize: 'Default Rows Per Page',
tableDefaultPageSizeHint: 'Must be an integer between 5 and 1000',
tablePageSizeOptions: 'Rows Per Page Options',
tablePageSizeOptionsPlaceholder: '10, 20, 50',
tablePageSizeOptionsHint: 'Use commas to separate integers between 5 and 1000; values are deduplicated and sorted on save',
tableDefaultPageSizeRangeError: 'Default rows per page must be between {min} and {max}',
tablePageSizeOptionsFormatError: 'Invalid options format. Enter comma-separated integers between {min} and {max}',
customEndpoints: { customEndpoints: {
title: 'Custom Endpoints', title: 'Custom Endpoints',
description: 'Add additional API endpoint URLs for users to quickly copy on the API Keys page', description: 'Add additional API endpoint URLs for users to quickly copy on the API Keys page',
......
...@@ -2220,6 +2220,7 @@ export default { ...@@ -2220,6 +2220,7 @@ export default {
rateLimited: '限流中', rateLimited: '限流中',
overloaded: '过载中', overloaded: '过载中',
tempUnschedulable: '临时不可调度', tempUnschedulable: '临时不可调度',
unschedulable: '不可调度',
rateLimitedUntil: '限流中,当前不参与调度,预计 {time} 自动恢复', rateLimitedUntil: '限流中,当前不参与调度,预计 {time} 自动恢复',
rateLimitedAutoResume: '{time} 自动恢复', rateLimitedAutoResume: '{time} 自动恢复',
modelRateLimitedUntil: '{model} 限流至 {time}', modelRateLimitedUntil: '{model} 限流至 {time}',
...@@ -4449,6 +4450,15 @@ export default { ...@@ -4449,6 +4450,15 @@ export default {
apiBaseUrl: 'API 端点地址', apiBaseUrl: 'API 端点地址',
apiBaseUrlHint: '用于"使用密钥"和"导入到 CC Switch"功能,留空则使用当前站点地址', apiBaseUrlHint: '用于"使用密钥"和"导入到 CC Switch"功能,留空则使用当前站点地址',
apiBaseUrlPlaceholder: 'https://api.example.com', apiBaseUrlPlaceholder: 'https://api.example.com',
tablePreferencesTitle: '通用表格设置',
tablePreferencesDescription: '设置后台与用户侧表格组件的默认分页行为',
tableDefaultPageSize: '默认每页条数',
tableDefaultPageSizeHint: '必须为 5-1000 之间的整数',
tablePageSizeOptions: '可选每页条数列表',
tablePageSizeOptionsPlaceholder: '10, 20, 50',
tablePageSizeOptionsHint: '使用英文逗号分隔,取值范围 5-1000,保存时会自动去重并排序',
tableDefaultPageSizeRangeError: '默认每页条数必须在 {min}-{max} 之间',
tablePageSizeOptionsFormatError: '可选每页条数格式无效,请输入 {min}-{max} 之间的整数并用英文逗号分隔',
customEndpoints: { customEndpoints: {
title: '自定义端点', title: '自定义端点',
description: '添加额外的 API 端点地址,用户可在「API Keys」页面快速复制', description: '添加额外的 API 端点地址,用户可在「API Keys」页面快速复制',
......
...@@ -106,6 +106,8 @@ export interface PublicSettings { ...@@ -106,6 +106,8 @@ export interface PublicSettings {
hide_ccs_import_button: boolean hide_ccs_import_button: boolean
purchase_subscription_enabled: boolean purchase_subscription_enabled: boolean
purchase_subscription_url: string purchase_subscription_url: string
table_default_page_size: number
table_page_size_options: number[]
custom_menu_items: CustomMenuItem[] custom_menu_items: CustomMenuItem[]
custom_endpoints: CustomEndpoint[] custom_endpoints: CustomEndpoint[]
linuxdo_oauth_enabled: boolean linuxdo_oauth_enabled: boolean
...@@ -1350,6 +1352,8 @@ export interface UsageQueryParams { ...@@ -1350,6 +1352,8 @@ export interface UsageQueryParams {
billing_type?: number | null billing_type?: number | null
start_date?: string start_date?: string
end_date?: string end_date?: string
sort_by?: string
sort_order?: 'asc' | 'desc'
} }
// ==================== Account Usage Statistics ==================== // ==================== Account Usage Statistics ====================
......
...@@ -148,6 +148,8 @@ ...@@ -148,6 +148,8 @@
:data="accounts" :data="accounts"
:loading="loading" :loading="loading"
row-key="id" row-key="id"
:server-side-sort="true"
@sort="handleSort"
default-sort-key="name" default-sort-key="name"
default-sort-order="asc" default-sort-order="asc"
:sort-storage-key="ACCOUNT_SORT_STORAGE_KEY" :sort-storage-key="ACCOUNT_SORT_STORAGE_KEY"
...@@ -401,6 +403,37 @@ const HIDDEN_COLUMNS_KEY = 'account-hidden-columns' ...@@ -401,6 +403,37 @@ const HIDDEN_COLUMNS_KEY = 'account-hidden-columns'
// Sorting settings // Sorting settings
const ACCOUNT_SORT_STORAGE_KEY = 'account-table-sort' const ACCOUNT_SORT_STORAGE_KEY = 'account-table-sort'
type AccountSortOrder = 'asc' | 'desc'
type AccountSortState = {
sort_by: string
sort_order: AccountSortOrder
}
const ACCOUNT_SORTABLE_KEYS = new Set([
'name',
'status',
'schedulable',
'priority',
'rate_multiplier',
'last_used_at',
'expires_at'
])
const loadInitialAccountSortState = (): AccountSortState => {
const fallback: AccountSortState = { sort_by: 'name', sort_order: 'asc' }
try {
const raw = localStorage.getItem(ACCOUNT_SORT_STORAGE_KEY)
if (!raw) return fallback
const parsed = JSON.parse(raw) as { key?: string; order?: string }
const key = typeof parsed.key === 'string' ? parsed.key : ''
if (!ACCOUNT_SORTABLE_KEYS.has(key)) return fallback
return {
sort_by: key,
sort_order: parsed.order === 'desc' ? 'desc' : 'asc'
}
} catch {
return fallback
}
}
const sortState = reactive<AccountSortState>(loadInitialAccountSortState())
// Auto refresh settings // Auto refresh settings
const showAutoRefreshDropdown = ref(false) const showAutoRefreshDropdown = ref(false)
...@@ -594,7 +627,16 @@ const { ...@@ -594,7 +627,16 @@ const {
handlePageSizeChange: baseHandlePageSizeChange handlePageSizeChange: baseHandlePageSizeChange
} = useTableLoader<Account, any>({ } = useTableLoader<Account, any>({
fetchFn: adminAPI.accounts.list, fetchFn: adminAPI.accounts.list,
initialParams: { platform: '', type: '', status: '', privacy_mode: '', group: '', search: '' } initialParams: {
platform: '',
type: '',
status: '',
privacy_mode: '',
group: '',
search: '',
sort_by: sortState.sort_by,
sort_order: sortState.sort_order
}
}) })
const { const {
...@@ -671,6 +713,19 @@ const handlePageSizeChange = (size: number) => { ...@@ -671,6 +713,19 @@ const handlePageSizeChange = (size: number) => {
baseHandlePageSizeChange(size) baseHandlePageSizeChange(size)
} }
const handleSort = (key: string, order: AccountSortOrder) => {
sortState.sort_by = key
sortState.sort_order = order
const requestParams = params as any
requestParams.sort_by = key
requestParams.sort_order = order
pagination.page = 1
hasPendingListSync.value = false
resetAutoRefreshCache()
pendingTodayStatsRefresh.value = true
load()
}
watch(loading, (isLoading, wasLoading) => { watch(loading, (isLoading, wasLoading) => {
if (wasLoading && !isLoading && pendingTodayStatsRefresh.value) { if (wasLoading && !isLoading && pendingTodayStatsRefresh.value) {
pendingTodayStatsRefresh.value = false pendingTodayStatsRefresh.value = false
...@@ -774,6 +829,8 @@ const refreshAccountsIncrementally = async () => { ...@@ -774,6 +829,8 @@ const refreshAccountsIncrementally = async () => {
privacy_mode?: string privacy_mode?: string
group?: string group?: string
search?: string search?: string
sort_by?: string
sort_order?: AccountSortOrder
}, },
{ etag: autoRefreshETag.value } { etag: autoRefreshETag.value }
...@@ -1103,19 +1160,58 @@ const handleBulkToggleSchedulable = async (schedulable: boolean) => { ...@@ -1103,19 +1160,58 @@ const handleBulkToggleSchedulable = async (schedulable: boolean) => {
} }
const handleBulkUpdated = () => { showBulkEdit.value = false; clearSelection(); reload() } const handleBulkUpdated = () => { showBulkEdit.value = false; clearSelection(); reload() }
const handleDataImported = () => { showImportData.value = false; reload() } const handleDataImported = () => { showImportData.value = false; reload() }
const ACCOUNT_UNGROUPED_GROUP_QUERY_VALUE = 'ungrouped'
const ACCOUNT_PRIVACY_MODE_UNSET_QUERY_VALUE = '__unset__'
const buildAccountQueryFilters = () => ({
platform: params.platform || '',
type: params.type || '',
status: params.status || '',
group: params.group || '',
privacy_mode: params.privacy_mode || '',
search: params.search || '',
sort_by: sortState.sort_by,
sort_order: sortState.sort_order
})
const accountMatchesCurrentFilters = (account: Account) => { const accountMatchesCurrentFilters = (account: Account) => {
if (params.platform && account.platform !== params.platform) return false const filters = buildAccountQueryFilters()
if (params.type && account.type !== params.type) return false if (filters.platform && account.platform !== filters.platform) return false
if (params.status) { if (filters.type && account.type !== filters.type) return false
if (params.status === 'rate_limited') { if (filters.status) {
if (!account.rate_limit_reset_at) return false const now = Date.now()
const resetAt = new Date(account.rate_limit_reset_at).getTime() const rateLimitResetAt = account.rate_limit_reset_at ? new Date(account.rate_limit_reset_at).getTime() : Number.NaN
if (!Number.isFinite(resetAt) || resetAt <= Date.now()) return false const isRateLimited = Number.isFinite(rateLimitResetAt) && rateLimitResetAt > now
} else if (account.status !== params.status) { const tempUnschedUntil = account.temp_unschedulable_until ? new Date(account.temp_unschedulable_until).getTime() : Number.NaN
const isTempUnschedulable = Number.isFinite(tempUnschedUntil) && tempUnschedUntil > now
if (filters.status === 'active') {
if (account.status !== 'active' || isRateLimited || isTempUnschedulable || !account.schedulable) return false
} else if (filters.status === 'rate_limited') {
if (account.status !== 'active' || !isRateLimited || isTempUnschedulable) return false
} else if (filters.status === 'temp_unschedulable') {
if (account.status !== 'active' || !isTempUnschedulable) return false
} else if (filters.status === 'unschedulable') {
if (account.status !== 'active' || account.schedulable || isRateLimited || isTempUnschedulable) return false
} else if (account.status !== filters.status) {
return false
}
}
if (filters.group) {
const groupIds = account.group_ids ?? account.groups?.map((group) => group.id) ?? []
if (filters.group === ACCOUNT_UNGROUPED_GROUP_QUERY_VALUE) {
if (groupIds.length > 0) return false
} else if (!groupIds.includes(Number(filters.group))) {
return false
}
}
const privacyMode = typeof account.extra?.privacy_mode === 'string' ? account.extra.privacy_mode : ''
if (filters.privacy_mode) {
if (filters.privacy_mode === ACCOUNT_PRIVACY_MODE_UNSET_QUERY_VALUE) {
if (privacyMode.trim() !== '') return false
} else if (privacyMode !== filters.privacy_mode) {
return false return false
} }
} }
const search = String(params.search || '').trim().toLowerCase() const search = String(filters.search || '').trim().toLowerCase()
if (search && !account.name.toLowerCase().includes(search)) return false if (search && !account.name.toLowerCase().includes(search)) return false
return true return true
} }
...@@ -1181,12 +1277,7 @@ const handleExportData = async () => { ...@@ -1181,12 +1277,7 @@ const handleExportData = async () => {
? { ids: selIds.value, includeProxies: includeProxyOnExport.value } ? { ids: selIds.value, includeProxies: includeProxyOnExport.value }
: { : {
includeProxies: includeProxyOnExport.value, includeProxies: includeProxyOnExport.value,
filters: { filters: buildAccountQueryFilters()
platform: params.platform,
type: params.type,
status: params.status,
search: params.search
}
} }
) )
const timestamp = formatExportTimestamp() const timestamp = formatExportTimestamp()
......
...@@ -39,7 +39,15 @@ ...@@ -39,7 +39,15 @@
</template> </template>
<template #table> <template #table>
<DataTable :columns="columns" :data="announcements" :loading="loading"> <DataTable
:columns="columns"
:data="announcements"
:loading="loading"
:server-side-sort="true"
default-sort-key="created_at"
default-sort-order="desc"
@sort="handleSort"
>
<template #cell-title="{ value, row }"> <template #cell-title="{ value, row }">
<div class="min-w-0"> <div class="min-w-0">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
...@@ -68,7 +76,7 @@ ...@@ -68,7 +76,7 @@
</span> </span>
</template> </template>
<template #cell-notifyMode="{ row }"> <template #cell-notify_mode="{ row }">
<span <span
:class="[ :class="[
'badge', 'badge',
...@@ -100,7 +108,7 @@ ...@@ -100,7 +108,7 @@
</div> </div>
</template> </template>
<template #cell-createdAt="{ value }"> <template #cell-created_at="{ value }">
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDateTime(value) }}</span> <span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDateTime(value) }}</span>
</template> </template>
...@@ -236,7 +244,7 @@ ...@@ -236,7 +244,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { getPersistedPageSize } from '@/composables/usePersistedPageSize' import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
...@@ -276,6 +284,11 @@ const pagination = reactive({ ...@@ -276,6 +284,11 @@ const pagination = reactive({
pages: 0 pages: 0
}) })
const sortState = reactive({
sort_by: 'created_at',
sort_order: 'desc' as 'asc' | 'desc'
})
const statusFilterOptions = computed(() => [ const statusFilterOptions = computed(() => [
{ value: '', label: t('admin.announcements.allStatus') }, { value: '', label: t('admin.announcements.allStatus') },
{ value: 'draft', label: t('admin.announcements.statusLabels.draft') }, { value: 'draft', label: t('admin.announcements.statusLabels.draft') },
...@@ -295,12 +308,12 @@ const notifyModeOptions = computed(() => [ ...@@ -295,12 +308,12 @@ const notifyModeOptions = computed(() => [
]) ])
const columns = computed<Column[]>(() => [ const columns = computed<Column[]>(() => [
{ key: 'title', label: t('admin.announcements.columns.title') }, { key: 'title', label: t('admin.announcements.columns.title'), sortable: true },
{ key: 'status', label: t('admin.announcements.columns.status') }, { key: 'status', label: t('admin.announcements.columns.status'), sortable: true },
{ key: 'notifyMode', label: t('admin.announcements.columns.notifyMode') }, { key: 'notify_mode', label: t('admin.announcements.columns.notifyMode'), sortable: true },
{ key: 'targeting', label: t('admin.announcements.columns.targeting') }, { key: 'targeting', label: t('admin.announcements.columns.targeting') },
{ key: 'timeRange', label: t('admin.announcements.columns.timeRange') }, { key: 'timeRange', label: t('admin.announcements.columns.timeRange') },
{ key: 'createdAt', label: t('admin.announcements.columns.createdAt') }, { key: 'created_at', label: t('admin.announcements.columns.createdAt'), sortable: true },
{ key: 'actions', label: t('admin.announcements.columns.actions') } { key: 'actions', label: t('admin.announcements.columns.actions') }
]) ])
...@@ -321,15 +334,21 @@ const targetingSummary = (targeting: AnnouncementTargeting) => { ...@@ -321,15 +334,21 @@ const targetingSummary = (targeting: AnnouncementTargeting) => {
let currentController: AbortController | null = null let currentController: AbortController | null = null
async function loadAnnouncements() { async function loadAnnouncements() {
if (currentController) currentController.abort() currentController?.abort()
currentController = new AbortController() const requestController = new AbortController()
currentController = requestController
const { signal } = requestController
try { try {
loading.value = true loading.value = true
const res = await adminAPI.announcements.list(pagination.page, pagination.page_size, { const res = await adminAPI.announcements.list(pagination.page, pagination.page_size, {
status: filters.status || undefined, status: filters.status || undefined,
search: searchQuery.value || undefined search: searchQuery.value || undefined,
}) sort_by: sortState.sort_by,
sort_order: sortState.sort_order
}, { signal })
if (signal.aborted || currentController !== requestController) return
announcements.value = res.items announcements.value = res.items
pagination.total = res.total pagination.total = res.total
...@@ -337,11 +356,21 @@ async function loadAnnouncements() { ...@@ -337,11 +356,21 @@ async function loadAnnouncements() {
pagination.page = res.page pagination.page = res.page
pagination.page_size = res.page_size pagination.page_size = res.page_size
} catch (error: any) { } catch (error: any) {
if (currentController.signal.aborted || error?.name === 'AbortError') return if (
signal.aborted ||
currentController !== requestController ||
error?.name === 'AbortError' ||
error?.code === 'ERR_CANCELED'
) {
return
}
console.error('Error loading announcements:', error) console.error('Error loading announcements:', error)
appStore.showError(error.response?.data?.detail || t('admin.announcements.failedToLoad')) appStore.showError(error.response?.data?.detail || t('admin.announcements.failedToLoad'))
} finally { } finally {
loading.value = false if (currentController === requestController) {
loading.value = false
currentController = null
}
} }
} }
...@@ -361,6 +390,13 @@ function handleStatusChange() { ...@@ -361,6 +390,13 @@ function handleStatusChange() {
loadAnnouncements() loadAnnouncements()
} }
function handleSort(key: string, order: 'asc' | 'desc') {
sortState.sort_by = key
sortState.sort_order = order
pagination.page = 1
loadAnnouncements()
}
let searchDebounceTimer: number | null = null let searchDebounceTimer: number | null = null
function handleSearch() { function handleSearch() {
if (searchDebounceTimer) window.clearTimeout(searchDebounceTimer) if (searchDebounceTimer) window.clearTimeout(searchDebounceTimer)
...@@ -562,4 +598,9 @@ onMounted(async () => { ...@@ -562,4 +598,9 @@ onMounted(async () => {
await loadSubscriptionGroups() await loadSubscriptionGroups()
await loadAnnouncements() await loadAnnouncements()
}) })
onUnmounted(() => {
if (searchDebounceTimer) window.clearTimeout(searchDebounceTimer)
currentController?.abort()
})
</script> </script>
...@@ -48,7 +48,15 @@ ...@@ -48,7 +48,15 @@
</template> </template>
<template #table> <template #table>
<DataTable :columns="columns" :data="channels" :loading="loading"> <DataTable
:columns="columns"
:data="channels"
:loading="loading"
:server-side-sort="true"
default-sort-key="created_at"
default-sort-order="desc"
@sort="handleSort"
>
<template #cell-name="{ value }"> <template #cell-name="{ value }">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span> <span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
</template> </template>
...@@ -486,6 +494,10 @@ const pagination = reactive({ ...@@ -486,6 +494,10 @@ const pagination = reactive({
page_size: getPersistedPageSize(), page_size: getPersistedPageSize(),
total: 0 total: 0
}) })
const sortState = reactive({
sort_by: 'created_at',
sort_order: 'desc' as 'asc' | 'desc'
})
// Dialog state // Dialog state
const showDialog = ref(false) const showDialog = ref(false)
...@@ -766,7 +778,9 @@ async function loadChannels() { ...@@ -766,7 +778,9 @@ async function loadChannels() {
try { try {
const response = await adminAPI.channels.list(pagination.page, pagination.page_size, { const response = await adminAPI.channels.list(pagination.page, pagination.page_size, {
status: filters.status || undefined, status: filters.status || undefined,
search: searchQuery.value || undefined search: searchQuery.value || undefined,
sort_by: sortState.sort_by,
sort_order: sortState.sort_order
}, { signal: ctrl.signal }) }, { signal: ctrl.signal })
if (ctrl.signal.aborted || abortController !== ctrl) return if (ctrl.signal.aborted || abortController !== ctrl) return
...@@ -825,6 +839,13 @@ function handlePageSizeChange(pageSize: number) { ...@@ -825,6 +839,13 @@ function handlePageSizeChange(pageSize: number) {
loadChannels() loadChannels()
} }
function handleSort(key: string, order: 'asc' | 'desc') {
sortState.sort_by = key
sortState.sort_order = order
pagination.page = 1
loadChannels()
}
// ── Dialog ── // ── Dialog ──
function resetForm() { function resetForm() {
form.name = '' form.name = ''
......
...@@ -73,7 +73,15 @@ ...@@ -73,7 +73,15 @@
</template> </template>
<template #table> <template #table>
<DataTable :columns="columns" :data="groups" :loading="loading"> <DataTable
:columns="columns"
:data="groups"
:loading="loading"
:server-side-sort="true"
default-sort-key="sort_order"
default-sort-order="asc"
@sort="handleSort"
>
<template #cell-name="{ value }"> <template #cell-name="{ value }">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span> <span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
</template> </template>
...@@ -1983,6 +1991,10 @@ const pagination = reactive({ ...@@ -1983,6 +1991,10 @@ const pagination = reactive({
total: 0, total: 0,
pages: 0 pages: 0
}) })
const sortState = reactive({
sort_by: 'sort_order',
sort_order: 'asc' as 'asc' | 'desc'
})
let abortController: AbortController | null = null let abortController: AbortController | null = null
...@@ -2297,7 +2309,9 @@ const loadGroups = async () => { ...@@ -2297,7 +2309,9 @@ const loadGroups = async () => {
platform: (filters.platform as GroupPlatform) || undefined, platform: (filters.platform as GroupPlatform) || undefined,
status: filters.status as any, status: filters.status as any,
is_exclusive: filters.is_exclusive ? filters.is_exclusive === 'true' : undefined, is_exclusive: filters.is_exclusive ? filters.is_exclusive === 'true' : undefined,
search: searchQuery.value.trim() || undefined search: searchQuery.value.trim() || undefined,
sort_by: sortState.sort_by,
sort_order: sortState.sort_order
}, { signal }) }, { signal })
if (signal.aborted) return if (signal.aborted) return
groups.value = response.items groups.value = response.items
...@@ -2381,6 +2395,13 @@ const handlePageSizeChange = (pageSize: number) => { ...@@ -2381,6 +2395,13 @@ const handlePageSizeChange = (pageSize: number) => {
loadGroups() loadGroups()
} }
const handleSort = (key: string, order: 'asc' | 'desc') => {
sortState.sort_by = key
sortState.sort_order = order
pagination.page = 1
loadGroups()
}
const closeCreateModal = () => { const closeCreateModal = () => {
showCreateModal.value = false showCreateModal.value = false
createModelRoutingRules.value.forEach((rule) => { createModelRoutingRules.value.forEach((rule) => {
......
...@@ -39,7 +39,15 @@ ...@@ -39,7 +39,15 @@
</template> </template>
<template #table> <template #table>
<DataTable :columns="columns" :data="codes" :loading="loading"> <DataTable
:columns="columns"
:data="codes"
:loading="loading"
:server-side-sort="true"
default-sort-key="created_at"
default-sort-order="desc"
@sort="handleSort"
>
<template #cell-code="{ value }"> <template #cell-code="{ value }">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<code class="font-mono text-sm text-gray-900 dark:text-gray-100">{{ value }}</code> <code class="font-mono text-sm text-gray-900 dark:text-gray-100">{{ value }}</code>
...@@ -349,7 +357,6 @@ ...@@ -349,7 +357,6 @@
:page="usagesPage" :page="usagesPage"
:total="usagesTotal" :total="usagesTotal"
:page-size="usagesPageSize" :page-size="usagesPageSize"
:page-size-options="[10, 20, 50]"
@update:page="handleUsagesPageChange" @update:page="handleUsagesPageChange"
@update:page-size="(size: number) => { usagesPageSize = size; usagesPage = 1; loadUsages() }" @update:page-size="(size: number) => { usagesPageSize = size; usagesPage = 1; loadUsages() }"
/> />
...@@ -418,6 +425,10 @@ const pagination = reactive({ ...@@ -418,6 +425,10 @@ const pagination = reactive({
page_size: getPersistedPageSize(), page_size: getPersistedPageSize(),
total: 0 total: 0
}) })
const sortState = reactive({
sort_by: 'created_at',
sort_order: 'desc' as 'asc' | 'desc'
})
// Dialogs // Dialogs
const showCreateDialog = ref(false) const showCreateDialog = ref(false)
...@@ -514,19 +525,29 @@ const loadCodes = async () => { ...@@ -514,19 +525,29 @@ const loadCodes = async () => {
pagination.page_size, pagination.page_size,
{ {
status: filters.status || undefined, status: filters.status || undefined,
search: searchQuery.value || undefined search: searchQuery.value || undefined,
} sort_by: sortState.sort_by,
sort_order: sortState.sort_order
},
{ signal: currentController.signal }
) )
if (currentController.signal.aborted) return if (currentController.signal.aborted || abortController !== currentController) return
codes.value = response.items codes.value = response.items
pagination.total = response.total pagination.total = response.total
} catch (error: any) { } catch (error: any) {
if (currentController.signal.aborted || error?.name === 'AbortError') return if (
currentController.signal.aborted ||
abortController !== currentController ||
error?.name === 'AbortError' ||
error?.code === 'ERR_CANCELED'
) {
return
}
appStore.showError(t('admin.promo.failedToLoad')) appStore.showError(t('admin.promo.failedToLoad'))
console.error('Error loading promo codes:', error) console.error('Error loading promo codes:', error)
} finally { } finally {
if (abortController === currentController && !currentController.signal.aborted) { if (abortController === currentController) {
loading.value = false loading.value = false
abortController = null abortController = null
} }
...@@ -553,6 +574,13 @@ const handlePageSizeChange = (pageSize: number) => { ...@@ -553,6 +574,13 @@ const handlePageSizeChange = (pageSize: number) => {
loadCodes() loadCodes()
} }
const handleSort = (key: string, order: 'asc' | 'desc') => {
sortState.sort_by = key
sortState.sort_order = order
pagination.page = 1
loadCodes()
}
const copyToClipboard = async (text: string) => { const copyToClipboard = async (text: string) => {
const success = await clipboardCopy(text, t('admin.promo.copied')) const success = await clipboardCopy(text, t('admin.promo.copied'))
if (success) { if (success) {
......
...@@ -89,7 +89,15 @@ ...@@ -89,7 +89,15 @@
<template #table> <template #table>
<div ref="proxyTableRef" class="flex min-h-0 flex-1 flex-col overflow-hidden"> <div ref="proxyTableRef" class="flex min-h-0 flex-1 flex-col overflow-hidden">
<DataTable :columns="columns" :data="proxies" :loading="loading"> <DataTable
:columns="columns"
:data="proxies"
:loading="loading"
:server-side-sort="true"
default-sort-key="id"
default-sort-order="desc"
@sort="handleSort"
>
<template #header-select> <template #header-select>
<input <input
type="checkbox" type="checkbox"
...@@ -946,6 +954,10 @@ const pagination = reactive({ ...@@ -946,6 +954,10 @@ const pagination = reactive({
total: 0, total: 0,
pages: 0 pages: 0
}) })
const sortState = reactive({
sort_by: 'id',
sort_order: 'desc' as 'asc' | 'desc'
})
const showCreateModal = ref(false) const showCreateModal = ref(false)
const createPasswordVisible = ref(false) const createPasswordVisible = ref(false)
...@@ -1050,6 +1062,14 @@ const toggleSelectAllVisible = (event: Event) => { ...@@ -1050,6 +1062,14 @@ const toggleSelectAllVisible = (event: Event) => {
toggleVisible(target.checked) toggleVisible(target.checked)
} }
const buildProxyQueryFilters = () => ({
protocol: filters.protocol || undefined,
status: (filters.status || undefined) as 'active' | 'inactive' | undefined,
search: searchQuery.value || undefined,
sort_by: sortState.sort_by,
sort_order: sortState.sort_order
})
const loadProxies = async () => { const loadProxies = async () => {
if (abortController) { if (abortController) {
abortController.abort() abortController.abort()
...@@ -1058,11 +1078,12 @@ const loadProxies = async () => { ...@@ -1058,11 +1078,12 @@ const loadProxies = async () => {
abortController = currentAbortController abortController = currentAbortController
loading.value = true loading.value = true
try { try {
const response = await adminAPI.proxies.list(pagination.page, pagination.page_size, { const response = await adminAPI.proxies.list(
protocol: filters.protocol || undefined, pagination.page,
status: filters.status as any, pagination.page_size,
search: searchQuery.value || undefined buildProxyQueryFilters(),
}, { signal: currentAbortController.signal }) { signal: currentAbortController.signal }
)
if (currentAbortController.signal.aborted || abortController !== currentAbortController) { if (currentAbortController.signal.aborted || abortController !== currentAbortController) {
return return
} }
...@@ -1103,6 +1124,13 @@ const handlePageSizeChange = (pageSize: number) => { ...@@ -1103,6 +1124,13 @@ const handlePageSizeChange = (pageSize: number) => {
loadProxies() loadProxies()
} }
const handleSort = (key: string, order: 'asc' | 'desc') => {
sortState.sort_by = key
sortState.sort_order = order
pagination.page = 1
loadProxies()
}
const closeCreateModal = () => { const closeCreateModal = () => {
showCreateModal.value = false showCreateModal.value = false
createMode.value = 'standard' createMode.value = 'standard'
...@@ -1581,7 +1609,9 @@ const fetchAllProxiesForBatch = async (): Promise<Proxy[]> => { ...@@ -1581,7 +1609,9 @@ const fetchAllProxiesForBatch = async (): Promise<Proxy[]> => {
{ {
protocol: filters.protocol || undefined, protocol: filters.protocol || undefined,
status: filters.status as any, status: filters.status as any,
search: searchQuery.value || undefined search: searchQuery.value || undefined,
sort_by: sortState.sort_by,
sort_order: sortState.sort_order
} }
) )
result.push(...response.items) result.push(...response.items)
...@@ -1689,11 +1719,7 @@ const handleExportData = async () => { ...@@ -1689,11 +1719,7 @@ const handleExportData = async () => {
selectedCount.value > 0 selectedCount.value > 0
? { ids: Array.from(selectedProxyIds.value) } ? { ids: Array.from(selectedProxyIds.value) }
: { : {
filters: { filters: buildProxyQueryFilters()
protocol: filters.protocol || undefined,
status: (filters.status || undefined) as 'active' | 'inactive' | undefined,
search: searchQuery.value || undefined
}
} }
) )
const timestamp = formatExportTimestamp() const timestamp = formatExportTimestamp()
......
...@@ -47,7 +47,15 @@ ...@@ -47,7 +47,15 @@
</template> </template>
<template #table> <template #table>
<DataTable :columns="columns" :data="codes" :loading="loading"> <DataTable
:columns="columns"
:data="codes"
:loading="loading"
:server-side-sort="true"
default-sort-key="id"
default-sort-order="desc"
@sort="handleSort"
>
<template #cell-code="{ value }"> <template #cell-code="{ value }">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<code class="font-mono text-sm text-gray-900 dark:text-gray-100">{{ value }}</code> <code class="font-mono text-sm text-gray-900 dark:text-gray-100">{{ value }}</code>
...@@ -537,6 +545,10 @@ const pagination = reactive({ ...@@ -537,6 +545,10 @@ const pagination = reactive({
total: 0, total: 0,
pages: 0 pages: 0
}) })
const sortState = reactive({
sort_by: 'id',
sort_order: 'desc' as 'asc' | 'desc'
})
let abortController: AbortController | null = null let abortController: AbortController | null = null
...@@ -565,6 +577,14 @@ watch( ...@@ -565,6 +577,14 @@ watch(
} }
) )
const buildRedeemQueryFilters = () => ({
type: (filters.type || undefined) as RedeemCodeType | undefined,
status: (filters.status || undefined) as 'used' | 'expired' | 'unused' | undefined,
search: searchQuery.value || undefined,
sort_by: sortState.sort_by,
sort_order: sortState.sort_order
})
const loadCodes = async () => { const loadCodes = async () => {
if (abortController) { if (abortController) {
abortController.abort() abortController.abort()
...@@ -576,11 +596,7 @@ const loadCodes = async () => { ...@@ -576,11 +596,7 @@ const loadCodes = async () => {
const response = await adminAPI.redeem.list( const response = await adminAPI.redeem.list(
pagination.page, pagination.page,
pagination.page_size, pagination.page_size,
{ buildRedeemQueryFilters(),
type: filters.type as RedeemCodeType,
status: filters.status as any,
search: searchQuery.value || undefined
},
{ {
signal: currentController.signal signal: currentController.signal
} }
...@@ -629,6 +645,13 @@ const handlePageSizeChange = (pageSize: number) => { ...@@ -629,6 +645,13 @@ const handlePageSizeChange = (pageSize: number) => {
loadCodes() loadCodes()
} }
const handleSort = (key: string, order: 'asc' | 'desc') => {
sortState.sort_by = key
sortState.sort_order = order
pagination.page = 1
loadCodes()
}
const handleGenerateCodes = async () => { const handleGenerateCodes = async () => {
// 订阅类型必须选择分组 // 订阅类型必须选择分组
if (generateForm.type === 'subscription' && !generateForm.group_id) { if (generateForm.type === 'subscription' && !generateForm.group_id) {
...@@ -672,10 +695,7 @@ const copyToClipboard = async (text: string) => { ...@@ -672,10 +695,7 @@ const copyToClipboard = async (text: string) => {
const handleExportCodes = async () => { const handleExportCodes = async () => {
try { try {
const blob = await adminAPI.redeem.exportCodes({ const blob = await adminAPI.redeem.exportCodes(buildRedeemQueryFilters())
type: filters.type as RedeemCodeType,
status: filters.status as any
})
// Create download link // Create download link
const url = window.URL.createObjectURL(blob) const url = window.URL.createObjectURL(blob)
......
...@@ -174,6 +174,8 @@ ...@@ -174,6 +174,8 @@
:data="subscriptions" :data="subscriptions"
:loading="loading" :loading="loading"
:server-side-sort="true" :server-side-sort="true"
default-sort-key="created_at"
default-sort-order="desc"
@sort="handleSort" @sort="handleSort"
> >
<template #cell-user="{ row }"> <template #cell-user="{ row }">
......
...@@ -100,7 +100,16 @@ ...@@ -100,7 +100,16 @@
</div> </div>
</template> </template>
</UsageFilters> </UsageFilters>
<UsageTable :data="usageLogs" :loading="loading" :columns="visibleColumns" @userClick="handleUserClick" /> <UsageTable
:data="usageLogs"
:loading="loading"
:columns="visibleColumns"
:server-side-sort="true"
:default-sort-key="'created_at'"
:default-sort-order="'desc'"
@sort="handleSort"
@userClick="handleUserClick"
/>
<Pagination v-if="pagination.total > 0" :page="pagination.page" :total="pagination.total" :page-size="pagination.page_size" @update:page="handlePageChange" @update:pageSize="handlePageSizeChange" /> <Pagination v-if="pagination.total > 0" :page="pagination.page" :total="pagination.total" :page-size="pagination.page_size" @update:page="handlePageChange" @update:pageSize="handlePageSizeChange" />
</div> </div>
</AppLayout> </AppLayout>
...@@ -219,6 +228,10 @@ const defaultRange = getLast24HoursRangeDates() ...@@ -219,6 +228,10 @@ const defaultRange = getLast24HoursRangeDates()
const startDate = ref(defaultRange.start); const endDate = ref(defaultRange.end) const startDate = ref(defaultRange.start); const endDate = ref(defaultRange.end)
const filters = ref<AdminUsageQueryParams>({ user_id: undefined, model: undefined, group_id: undefined, request_type: undefined, billing_type: null, start_date: startDate.value, end_date: endDate.value }) const filters = ref<AdminUsageQueryParams>({ user_id: undefined, model: undefined, group_id: undefined, request_type: undefined, billing_type: null, start_date: startDate.value, end_date: endDate.value })
const pagination = reactive({ page: 1, page_size: getPersistedPageSize(), total: 0 }) const pagination = reactive({ page: 1, page_size: getPersistedPageSize(), total: 0 })
const sortState = reactive({
sort_by: 'created_at',
sort_order: 'desc' as 'asc' | 'desc'
})
const getSingleQueryValue = (value: string | null | Array<string | null> | undefined): string | undefined => { const getSingleQueryValue = (value: string | null | Array<string | null> | undefined): string | undefined => {
if (Array.isArray(value)) return value.find((item): item is string => typeof item === 'string' && item.length > 0) if (Array.isArray(value)) return value.find((item): item is string => typeof item === 'string' && item.length > 0)
...@@ -265,12 +278,31 @@ const onDateRangeChange = (range: { startDate: string; endDate: string; preset: ...@@ -265,12 +278,31 @@ const onDateRangeChange = (range: { startDate: string; endDate: string; preset:
applyFilters() applyFilters()
} }
const buildUsageListParams = (
page: number,
pageSize: number,
exactTotal: boolean
): AdminUsageQueryParams => {
const requestType = filters.value.request_type
const legacyStream = requestType ? requestTypeToLegacyStream(requestType) : filters.value.stream
return {
page,
page_size: pageSize,
exact_total: exactTotal,
...filters.value,
stream: legacyStream === null ? undefined : legacyStream,
sort_by: sortState.sort_by,
sort_order: sortState.sort_order
}
}
const loadLogs = async () => { const loadLogs = async () => {
abortController?.abort(); const c = new AbortController(); abortController = c; loading.value = true abortController?.abort(); const c = new AbortController(); abortController = c; loading.value = true
try { try {
const requestType = filters.value.request_type const res = await adminAPI.usage.list(
const legacyStream = requestType ? requestTypeToLegacyStream(requestType) : filters.value.stream buildUsageListParams(pagination.page, pagination.page_size, false),
const res = await adminAPI.usage.list({ page: pagination.page, page_size: pagination.page_size, exact_total: false, ...filters.value, stream: legacyStream === null ? undefined : legacyStream }, { signal: c.signal }) { signal: c.signal }
)
if(!c.signal.aborted) { usageLogs.value = res.items; pagination.total = res.total } if(!c.signal.aborted) { usageLogs.value = res.items; pagination.total = res.total }
} catch (error: any) { if(error?.name !== 'AbortError') console.error('Failed to load usage logs:', error) } finally { if(abortController === c) loading.value = false } } catch (error: any) { if(error?.name !== 'AbortError') console.error('Failed to load usage logs:', error) } finally { if(abortController === c) loading.value = false }
} }
...@@ -412,6 +444,12 @@ const resetFilters = () => { ...@@ -412,6 +444,12 @@ const resetFilters = () => {
} }
const handlePageChange = (p: number) => { pagination.page = p; loadLogs() } const handlePageChange = (p: number) => { pagination.page = p; loadLogs() }
const handlePageSizeChange = (s: number) => { pagination.page_size = s; pagination.page = 1; loadLogs() } const handlePageSizeChange = (s: number) => { pagination.page_size = s; pagination.page = 1; loadLogs() }
const handleSort = (key: string, order: 'asc' | 'desc') => {
sortState.sort_by = key
sortState.sort_order = order
pagination.page = 1
loadLogs()
}
const cancelExport = () => exportAbortController?.abort() const cancelExport = () => exportAbortController?.abort()
const openCleanupDialog = () => { cleanupDialogVisible.value = true } const openCleanupDialog = () => { cleanupDialogVisible.value = true }
const getRequestTypeLabel = (log: AdminUsageLog): string => { const getRequestTypeLabel = (log: AdminUsageLog): string => {
...@@ -443,9 +481,10 @@ const exportToExcel = async () => { ...@@ -443,9 +481,10 @@ const exportToExcel = async () => {
] ]
const ws = XLSX.utils.aoa_to_sheet([headers]) const ws = XLSX.utils.aoa_to_sheet([headers])
while (true) { while (true) {
const requestType = filters.value.request_type const res = await adminUsageAPI.list(
const legacyStream = requestType ? requestTypeToLegacyStream(requestType) : filters.value.stream buildUsageListParams(p, 100, true),
const res = await adminUsageAPI.list({ page: p, page_size: 100, exact_total: true, ...filters.value, stream: legacyStream === null ? undefined : legacyStream }, { signal: c.signal }) { signal: c.signal }
)
if (c.signal.aborted) break; if (p === 1) { total = res.total; exportProgress.total = total } if (c.signal.aborted) break; if (p === 1) { total = res.total; exportProgress.total = total }
const rows = (res.items || []).map((log: AdminUsageLog) => [ const rows = (res.items || []).map((log: AdminUsageLog) => [
log.created_at, log.user?.email || '', log.api_key?.name || '', log.account?.name || '', log.model, log.created_at, log.user?.email || '', log.api_key?.name || '', log.account?.name || '', log.model,
......
...@@ -235,7 +235,17 @@ ...@@ -235,7 +235,17 @@
<!-- Users Table --> <!-- Users Table -->
<template #table> <template #table>
<DataTable :columns="columns" :data="users" :loading="loading" :actions-count="7"> <DataTable
:columns="columns"
:data="users"
:loading="loading"
:actions-count="7"
:server-side-sort="true"
default-sort-key="created_at"
default-sort-order="desc"
:sort-storage-key="USER_SORT_STORAGE_KEY"
@sort="handleSort"
>
<template #cell-email="{ value }"> <template #cell-email="{ value }">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div <div
...@@ -774,6 +784,25 @@ const columns = computed<Column[]>(() => ...@@ -774,6 +784,25 @@ const columns = computed<Column[]>(() =>
const users = ref<AdminUser[]>([]) const users = ref<AdminUser[]>([])
const loading = ref(false) const loading = ref(false)
const searchQuery = ref('') const searchQuery = ref('')
const USER_SORT_STORAGE_KEY = 'admin-users-table-sort'
const loadInitialSortState = (): { sort_by: string; sort_order: 'asc' | 'desc' } => {
const fallback = { sort_by: 'created_at', sort_order: 'desc' as 'asc' | 'desc' }
const sortable = new Set(['email', 'id', 'username', 'role', 'balance', 'concurrency', 'status', 'created_at'])
try {
const raw = localStorage.getItem(USER_SORT_STORAGE_KEY)
if (!raw) return fallback
const parsed = JSON.parse(raw) as { key?: string; order?: string }
const key = typeof parsed.key === 'string' ? parsed.key : ''
if (!sortable.has(key)) return fallback
return {
sort_by: key,
sort_order: parsed.order === 'asc' ? 'asc' : 'desc'
}
} catch {
return fallback
}
}
const sortState = reactive(loadInitialSortState())
// Groups data for the groups column // Groups data for the groups column
const allGroups = ref<AdminGroup[]>([]) const allGroups = ref<AdminGroup[]>([])
...@@ -1125,7 +1154,9 @@ const loadUsers = async () => { ...@@ -1125,7 +1154,9 @@ const loadUsers = async () => {
search: searchQuery.value || undefined, search: searchQuery.value || undefined,
group_name: filters.group || undefined, group_name: filters.group || undefined,
attributes: Object.keys(attrFilters).length > 0 ? attrFilters : undefined, attributes: Object.keys(attrFilters).length > 0 ? attrFilters : undefined,
include_subscriptions: hasVisibleSubscriptionsColumn.value include_subscriptions: hasVisibleSubscriptionsColumn.value,
sort_by: sortState.sort_by,
sort_order: sortState.sort_order
}, },
{ signal } { signal }
) )
...@@ -1184,6 +1215,13 @@ const handlePageSizeChange = (pageSize: number) => { ...@@ -1184,6 +1215,13 @@ const handlePageSizeChange = (pageSize: number) => {
loadUsers() loadUsers()
} }
const handleSort = (key: string, order: 'asc' | 'desc') => {
sortState.sort_by = key
sortState.sort_order = order
pagination.page = 1
loadUsers()
}
// Filter helpers // Filter helpers
const getAttributeDefinitionName = (attrId: number): string => { const getAttributeDefinitionName = (attrId: number): string => {
const def = attributeDefinitions.value.find(d => d.id === attrId) const def = attributeDefinitions.value.find(d => d.id === attrId)
......
...@@ -202,7 +202,6 @@ ...@@ -202,7 +202,6 @@
:total="total" :total="total"
:page="page" :page="page"
:page-size="pageSize" :page-size="pageSize"
:page-size-options="[10]"
@update:page="emit('update:page', $event)" @update:page="emit('update:page', $event)"
@update:pageSize="emit('update:pageSize', $event)" @update:pageSize="emit('update:pageSize', $event)"
/> />
......
...@@ -512,7 +512,6 @@ onMounted(async () => { ...@@ -512,7 +512,6 @@ onMounted(async () => {
:total="total" :total="total"
:page="page" :page="page"
:page-size="pageSize" :page-size="pageSize"
:page-size-options="[10, 20, 50, 100, 200]"
@update:page="onPageChange" @update:page="onPageChange"
@update:page-size="onPageSizeChange" @update:page-size="onPageSizeChange"
/> />
......
...@@ -49,7 +49,15 @@ ...@@ -49,7 +49,15 @@
</template> </template>
<template #table> <template #table>
<DataTable :columns="columns" :data="apiKeys" :loading="loading"> <DataTable
:columns="columns"
:data="apiKeys"
:loading="loading"
:server-side-sort="true"
default-sort-key="created_at"
default-sort-order="desc"
@sort="handleSort"
>
<template #cell-key="{ value, row }"> <template #cell-key="{ value, row }">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<code class="code text-xs"> <code class="code text-xs">
...@@ -1114,6 +1122,10 @@ const pagination = ref({ ...@@ -1114,6 +1122,10 @@ const pagination = ref({
total: 0, total: 0,
pages: 0 pages: 0
}) })
const sortState = ref({
sort_by: 'created_at',
sort_order: 'desc' as 'asc' | 'desc'
})
// Filter state // Filter state
const filterSearch = ref('') const filterSearch = ref('')
...@@ -1277,10 +1289,18 @@ const loadApiKeys = async () => { ...@@ -1277,10 +1289,18 @@ const loadApiKeys = async () => {
loading.value = true loading.value = true
try { try {
// Build filters // Build filters
const filters: { search?: string; status?: string; group_id?: number | string } = {} const filters: {
search?: string
status?: string
group_id?: number | string
sort_by?: string
sort_order?: 'asc' | 'desc'
} = {}
if (filterSearch.value) filters.search = filterSearch.value if (filterSearch.value) filters.search = filterSearch.value
if (filterStatus.value) filters.status = filterStatus.value if (filterStatus.value) filters.status = filterStatus.value
if (filterGroupId.value !== '') filters.group_id = filterGroupId.value if (filterGroupId.value !== '') filters.group_id = filterGroupId.value
filters.sort_by = sortState.value.sort_by
filters.sort_order = sortState.value.sort_order
const response = await keysAPI.list(pagination.value.page, pagination.value.page_size, filters, { const response = await keysAPI.list(pagination.value.page, pagination.value.page_size, filters, {
signal signal
...@@ -1360,6 +1380,13 @@ const handlePageSizeChange = (pageSize: number) => { ...@@ -1360,6 +1380,13 @@ const handlePageSizeChange = (pageSize: number) => {
loadApiKeys() loadApiKeys()
} }
const handleSort = (key: string, order: 'asc' | 'desc') => {
sortState.value.sort_by = key
sortState.value.sort_order = order
pagination.value.page = 1
loadApiKeys()
}
const editKey = (key: ApiKey) => { const editKey = (key: ApiKey) => {
selectedKey.value = key selectedKey.value = key
const hasIPRestriction = (key.ip_whitelist?.length > 0) || (key.ip_blacklist?.length > 0) const hasIPRestriction = (key.ip_whitelist?.length > 0) || (key.ip_blacklist?.length > 0)
......
...@@ -149,7 +149,15 @@ ...@@ -149,7 +149,15 @@
</template> </template>
<template #table> <template #table>
<DataTable :columns="columns" :data="usageLogs" :loading="loading"> <DataTable
:columns="columns"
:data="usageLogs"
:loading="loading"
:server-side-sort="true"
default-sort-key="created_at"
default-sort-order="desc"
@sort="handleSort"
>
<template #cell-api_key="{ row }"> <template #cell-api_key="{ row }">
<span class="text-sm text-gray-900 dark:text-white">{{ <span class="text-sm text-gray-900 dark:text-white">{{
row.api_key?.name || '-' row.api_key?.name || '-'
...@@ -598,6 +606,10 @@ const pagination = reactive({ ...@@ -598,6 +606,10 @@ const pagination = reactive({
total: 0, total: 0,
pages: 0 pages: 0
}) })
const sortState = reactive({
sort_by: 'created_at',
sort_order: 'desc' as 'asc' | 'desc'
})
const formatDuration = (ms: number): string => { const formatDuration = (ms: number): string => {
if (ms < 1000) return `${ms.toFixed(0)}ms` if (ms < 1000) return `${ms.toFixed(0)}ms`
...@@ -660,6 +672,18 @@ const formatTokens = (value: number): string => { ...@@ -660,6 +672,18 @@ const formatTokens = (value: number): string => {
return value.toLocaleString() return value.toLocaleString()
} }
type UsageTableQueryParams = UsageQueryParams & {
sort_by?: string
sort_order?: 'asc' | 'desc'
}
const buildUsageQueryParams = (page: number, pageSize: number): UsageTableQueryParams => ({
page,
page_size: pageSize,
...filters.value,
sort_by: sortState.sort_by,
sort_order: sortState.sort_order
})
const loadUsageLogs = async () => { const loadUsageLogs = async () => {
if (abortController) { if (abortController) {
...@@ -670,13 +694,10 @@ const loadUsageLogs = async () => { ...@@ -670,13 +694,10 @@ const loadUsageLogs = async () => {
const { signal } = currentAbortController const { signal } = currentAbortController
loading.value = true loading.value = true
try { try {
const params: UsageQueryParams = { const response = await usageAPI.query(
page: pagination.page, buildUsageQueryParams(pagination.page, pagination.page_size),
page_size: pagination.page_size, { signal }
...filters.value )
}
const response = await usageAPI.query(params, { signal })
if (signal.aborted) { if (signal.aborted) {
return return
} }
...@@ -758,6 +779,13 @@ const handlePageSizeChange = (pageSize: number) => { ...@@ -758,6 +779,13 @@ const handlePageSizeChange = (pageSize: number) => {
loadUsageLogs() loadUsageLogs()
} }
const handleSort = (key: string, order: 'asc' | 'desc') => {
sortState.sort_by = key
sortState.sort_order = order
pagination.page = 1
loadUsageLogs()
}
/** /**
* Escape CSV value to prevent injection and handle special characters * Escape CSV value to prevent injection and handle special characters
*/ */
...@@ -795,12 +823,7 @@ const exportToCSV = async () => { ...@@ -795,12 +823,7 @@ const exportToCSV = async () => {
const totalRequests = Math.ceil(pagination.total / pageSize) const totalRequests = Math.ceil(pagination.total / pageSize)
for (let page = 1; page <= totalRequests; page++) { for (let page = 1; page <= totalRequests; page++) {
const params: UsageQueryParams = { const response = await usageAPI.query(buildUsageQueryParams(page, pageSize))
page: page,
page_size: pageSize,
...filters.value
}
const response = await usageAPI.query(params)
allLogs.push(...response.items) allLogs.push(...response.items)
} }
......
...@@ -256,6 +256,17 @@ describe('user UsageView tooltip', () => { ...@@ -256,6 +256,17 @@ describe('user UsageView tooltip', () => {
await setupState.exportToCSV() await setupState.exportToCSV()
expect(exportedBlob).not.toBeNull() expect(exportedBlob).not.toBeNull()
const hasSortedExportQuery = query.mock.calls.some((call) => {
const params = call[0] as Record<string, unknown> | undefined
const config = call[1]
return (
params?.page_size === 100 &&
params?.sort_by === 'created_at' &&
params?.sort_order === 'desc' &&
config === undefined
)
})
expect(hasSortedExportQuery).toBe(true)
expect(clickSpy).toHaveBeenCalled() expect(clickSpy).toHaveBeenCalled()
expect(showSuccess).toHaveBeenCalled() expect(showSuccess).toHaveBeenCalled()
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment