Unverified Commit 1ef3782d authored by Wesley Liddick's avatar Wesley Liddick Committed by GitHub
Browse files

Merge pull request #1538 from IanShaw027/fix/bug-cleanup-main

 fix: 修复多个 UI 和功能问题 - 表格排序搜索、导出逻辑、分页配置和状态筛选
parents 00c08c57 f480e573
const MIN_TABLE_PAGE_SIZE = 5
const MAX_TABLE_PAGE_SIZE = 1000
export const DEFAULT_TABLE_PAGE_SIZE = 20
export const DEFAULT_TABLE_PAGE_SIZE_OPTIONS = [10, 20, 50, 100]
const sanitizePageSize = (value: unknown): number | null => {
const size = Number(value)
if (!Number.isInteger(size)) return null
if (size < MIN_TABLE_PAGE_SIZE || size > MAX_TABLE_PAGE_SIZE) return null
return size
}
const parsePageSizeForSelection = (value: unknown): number | null => {
const size = Number(value)
if (!Number.isInteger(size)) return null
if (size < MIN_TABLE_PAGE_SIZE) return null
return size
}
const getInjectedAppConfig = () => {
if (typeof window === 'undefined') return null
return window.__APP_CONFIG__ ?? null
}
const getSanitizedConfiguredOptions = (): number[] => {
const configured = getInjectedAppConfig()?.table_page_size_options
if (!Array.isArray(configured)) return []
return Array.from(
new Set(
configured
.map((value) => sanitizePageSize(value))
.filter((value): value is number => value !== null)
)
).sort((a, b) => a - b)
}
const normalizePageSizeToOptions = (value: number, options: number[]): number => {
for (const option of options) {
if (option >= value) {
return option
}
}
return options[options.length - 1]
}
export const getConfiguredTableDefaultPageSize = (): number => {
const configured = sanitizePageSize(getInjectedAppConfig()?.table_default_page_size)
if (configured === null) {
return DEFAULT_TABLE_PAGE_SIZE
}
return configured
}
export const getConfiguredTablePageSizeOptions = (): number[] => {
const unique = getSanitizedConfiguredOptions()
if (unique.length === 0) {
return [...DEFAULT_TABLE_PAGE_SIZE_OPTIONS]
}
return unique.length > 0 ? unique : [...DEFAULT_TABLE_PAGE_SIZE_OPTIONS]
}
export const normalizeTablePageSize = (value: unknown): number => {
const normalized = parsePageSizeForSelection(value)
const defaultSize = getConfiguredTableDefaultPageSize()
const options = getConfiguredTablePageSizeOptions()
if (normalized !== null) {
return normalizePageSizeToOptions(normalized, options)
}
return normalizePageSizeToOptions(defaultSize, options)
}
...@@ -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 = ''
......
...@@ -81,7 +81,15 @@ ...@@ -81,7 +81,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">{{ <span class="font-medium text-gray-900 dark:text-white">{{
value value
...@@ -2924,6 +2932,10 @@ const pagination = reactive({ ...@@ -2924,6 +2932,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;
...@@ -3290,6 +3302,8 @@ const loadGroups = async () => { ...@@ -3290,6 +3302,8 @@ const loadGroups = async () => {
? filters.is_exclusive === "true" ? filters.is_exclusive === "true"
: undefined, : undefined,
search: searchQuery.value.trim() || undefined, search: searchQuery.value.trim() || undefined,
sort_by: sortState.sort_by,
sort_order: sortState.sort_order,
}, },
{ signal }, { signal },
); );
...@@ -3392,6 +3406,13 @@ const handlePageSizeChange = (pageSize: number) => { ...@@ -3392,6 +3406,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)
......
...@@ -1788,6 +1788,48 @@ ...@@ -1788,6 +1788,48 @@
</p> </p>
</div> </div>
<!-- Global Table Preferences -->
<div class="border-t border-gray-100 pt-4 dark:border-dark-700">
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.settings.site.tablePreferencesTitle') }}
</h3>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.site.tablePreferencesDescription') }}
</p>
<div class="mt-4 grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.site.tableDefaultPageSize') }}
</label>
<input
v-model.number="form.table_default_page_size"
type="number"
min="5"
max="1000"
step="1"
class="input w-40"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.site.tableDefaultPageSizeHint') }}
</p>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.site.tablePageSizeOptions') }}
</label>
<input
v-model="tablePageSizeOptionsInput"
type="text"
class="input font-mono text-sm"
:placeholder="t('admin.settings.site.tablePageSizeOptionsPlaceholder')"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.site.tablePageSizeOptionsHint') }}
</p>
</div>
</div>
</div>
<!-- Custom Endpoints --> <!-- Custom Endpoints -->
<div> <div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
...@@ -2445,6 +2487,7 @@ const smtpPasswordManuallyEdited = ref(false) ...@@ -2445,6 +2487,7 @@ const smtpPasswordManuallyEdited = ref(false)
const testEmailAddress = ref('') const testEmailAddress = ref('')
const registrationEmailSuffixWhitelistTags = ref<string[]>([]) const registrationEmailSuffixWhitelistTags = ref<string[]>([])
const registrationEmailSuffixWhitelistDraft = ref('') const registrationEmailSuffixWhitelistDraft = ref('')
const tablePageSizeOptionsInput = ref('10, 20, 50, 100')
// Admin API Key 状态 // Admin API Key 状态
const adminApiKeyLoading = ref(true) const adminApiKeyLoading = ref(true)
...@@ -2499,6 +2542,10 @@ const betaPolicyForm = reactive({ ...@@ -2499,6 +2542,10 @@ const betaPolicyForm = reactive({
}> }>
}) })
const tablePageSizeMin = 5
const tablePageSizeMax = 1000
const tablePageSizeDefault = 20
interface DefaultSubscriptionGroupOption { interface DefaultSubscriptionGroupOption {
value: number value: number
label: string label: string
...@@ -2539,6 +2586,8 @@ const form = reactive<SettingsForm>({ ...@@ -2539,6 +2586,8 @@ const form = reactive<SettingsForm>({
hide_ccs_import_button: false, hide_ccs_import_button: false,
purchase_subscription_enabled: false, purchase_subscription_enabled: false,
purchase_subscription_url: '', purchase_subscription_url: '',
table_default_page_size: tablePageSizeDefault,
table_page_size_options: [10, 20, 50, 100],
custom_menu_items: [] as Array<{id: string; label: string; icon_svg: string; url: string; visibility: 'user' | 'admin'; sort_order: number}>, custom_menu_items: [] as Array<{id: string; label: string; icon_svg: string; url: string; visibility: 'user' | 'admin'; sort_order: number}>,
custom_endpoints: [] as Array<{name: string; endpoint: string; description: string}>, custom_endpoints: [] as Array<{name: string; endpoint: string; description: string}>,
frontend_url: '', frontend_url: '',
...@@ -2762,6 +2811,35 @@ function removeEndpoint(index: number) { ...@@ -2762,6 +2811,35 @@ function removeEndpoint(index: number) {
form.custom_endpoints.splice(index, 1) form.custom_endpoints.splice(index, 1)
} }
function formatTablePageSizeOptions(options: number[]): string {
return options.join(', ')
}
function parseTablePageSizeOptionsInput(raw: string): number[] | null {
const tokens = raw
.split(',')
.map((token) => token.trim())
.filter((token) => token.length > 0)
if (tokens.length === 0) {
return null
}
const parsed = tokens.map((token) => Number(token))
if (parsed.some((value) => !Number.isInteger(value))) {
return null
}
const deduped = Array.from(new Set(parsed)).sort((a, b) => a - b)
if (
deduped.some((value) => value < tablePageSizeMin || value > tablePageSizeMax)
) {
return null
}
return deduped
}
async function loadSettings() { async function loadSettings() {
loading.value = true loading.value = true
loadFailed.value = false loadFailed.value = false
...@@ -2780,6 +2858,9 @@ async function loadSettings() { ...@@ -2780,6 +2858,9 @@ async function loadSettings() {
registrationEmailSuffixWhitelistTags.value = normalizeRegistrationEmailSuffixDomains( registrationEmailSuffixWhitelistTags.value = normalizeRegistrationEmailSuffixDomains(
settings.registration_email_suffix_whitelist settings.registration_email_suffix_whitelist
) )
tablePageSizeOptionsInput.value = formatTablePageSizeOptions(
Array.isArray(settings.table_page_size_options) ? settings.table_page_size_options : [10, 20, 50, 100]
)
registrationEmailSuffixWhitelistDraft.value = '' registrationEmailSuffixWhitelistDraft.value = ''
form.smtp_password = '' form.smtp_password = ''
smtpPasswordManuallyEdited.value = false smtpPasswordManuallyEdited.value = false
...@@ -2826,6 +2907,37 @@ function removeDefaultSubscription(index: number) { ...@@ -2826,6 +2907,37 @@ function removeDefaultSubscription(index: number) {
async function saveSettings() { async function saveSettings() {
saving.value = true saving.value = true
try { try {
const normalizedTableDefaultPageSize = Math.floor(Number(form.table_default_page_size))
if (
!Number.isInteger(normalizedTableDefaultPageSize) ||
normalizedTableDefaultPageSize < tablePageSizeMin ||
normalizedTableDefaultPageSize > tablePageSizeMax
) {
appStore.showError(
t('admin.settings.site.tableDefaultPageSizeRangeError', {
min: tablePageSizeMin,
max: tablePageSizeMax
})
)
return
}
const normalizedTablePageSizeOptions = parseTablePageSizeOptionsInput(
tablePageSizeOptionsInput.value
)
if (!normalizedTablePageSizeOptions) {
appStore.showError(
t('admin.settings.site.tablePageSizeOptionsFormatError', {
min: tablePageSizeMin,
max: tablePageSizeMax
})
)
return
}
form.table_default_page_size = normalizedTableDefaultPageSize
form.table_page_size_options = normalizedTablePageSizeOptions
const normalizedDefaultSubscriptions = form.default_subscriptions const normalizedDefaultSubscriptions = form.default_subscriptions
.filter((item) => item.group_id > 0 && item.validity_days > 0) .filter((item) => item.group_id > 0 && item.validity_days > 0)
.map((item: DefaultSubscriptionSetting) => ({ .map((item: DefaultSubscriptionSetting) => ({
...@@ -2903,6 +3015,8 @@ async function saveSettings() { ...@@ -2903,6 +3015,8 @@ async function saveSettings() {
hide_ccs_import_button: form.hide_ccs_import_button, hide_ccs_import_button: form.hide_ccs_import_button,
purchase_subscription_enabled: form.purchase_subscription_enabled, purchase_subscription_enabled: form.purchase_subscription_enabled,
purchase_subscription_url: form.purchase_subscription_url, purchase_subscription_url: form.purchase_subscription_url,
table_default_page_size: form.table_default_page_size,
table_page_size_options: form.table_page_size_options,
custom_menu_items: form.custom_menu_items, custom_menu_items: form.custom_menu_items,
custom_endpoints: form.custom_endpoints, custom_endpoints: form.custom_endpoints,
frontend_url: form.frontend_url, frontend_url: form.frontend_url,
...@@ -2961,6 +3075,9 @@ async function saveSettings() { ...@@ -2961,6 +3075,9 @@ async function saveSettings() {
registrationEmailSuffixWhitelistTags.value = normalizeRegistrationEmailSuffixDomains( registrationEmailSuffixWhitelistTags.value = normalizeRegistrationEmailSuffixDomains(
updated.registration_email_suffix_whitelist updated.registration_email_suffix_whitelist
) )
tablePageSizeOptionsInput.value = formatTablePageSizeOptions(
Array.isArray(updated.table_page_size_options) ? updated.table_page_size_options : [10, 20, 50, 100]
)
registrationEmailSuffixWhitelistDraft.value = '' registrationEmailSuffixWhitelistDraft.value = ''
form.smtp_password = '' form.smtp_password = ''
smtpPasswordManuallyEdited.value = false smtpPasswordManuallyEdited.value = false
......
...@@ -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