diff --git a/frontend/src/components/common/DataTable.vue b/frontend/src/components/common/DataTable.vue index 9e3a1b5f16..bb7315dc21 100644 --- a/frontend/src/components/common/DataTable.vue +++ b/frontend/src/components/common/DataTable.vue @@ -76,6 +76,7 @@ v-for="(column, index) in columns" :key="column.key" scope="col" + :aria-sort="column.sortable ? getColumnAriaSort(column.key) : undefined" :class="[ 'sticky-header-cell py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400', getAdaptivePaddingClass(), @@ -93,24 +94,26 @@ >
{{ column.label }} - +
@@ -464,6 +467,17 @@ const applySortState = (state: PersistedSortState | null) => { sortOrder.value = state.order } +const getSortIndicatorClass = (key: string, order: 'asc' | 'desc') => { + return sortKey.value === key && sortOrder.value === order + ? 'text-primary-600 dark:text-primary-400' + : 'text-gray-300 transition-colors dark:text-dark-500' +} + +const getColumnAriaSort = (key: string) => { + if (sortKey.value !== key) return 'none' + return sortOrder.value === 'asc' ? 'ascending' : 'descending' +} + const isNullishOrEmpty = (value: any) => value === null || value === undefined || value === '' const toFiniteNumberOrNull = (value: any): number | null => { diff --git a/frontend/src/components/common/__tests__/DataTable.spec.ts b/frontend/src/components/common/__tests__/DataTable.spec.ts new file mode 100644 index 0000000000..98f1711a29 --- /dev/null +++ b/frontend/src/components/common/__tests__/DataTable.spec.ts @@ -0,0 +1,65 @@ +import { mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import DataTable from '../DataTable.vue' + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string) => key + }) +})) + +const stubDesktopMatchMedia = () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: true, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn() + })) + }) +} + +describe('DataTable', () => { + beforeEach(() => { + stubDesktopMatchMedia() + localStorage.clear() + }) + + it('renders paired sort arrows and highlights the active direction', async () => { + const wrapper = mount(DataTable, { + props: { + columns: [ + { key: 'name', label: 'Name', sortable: true }, + { key: 'created_at', label: 'Created', sortable: true } + ], + data: [ + { id: 1, name: 'Beta', created_at: '2026-01-02T00:00:00Z' }, + { id: 2, name: 'Alpha', created_at: '2026-01-01T00:00:00Z' } + ], + defaultSortKey: 'name', + defaultSortOrder: 'asc' + } + }) + + await wrapper.vm.$nextTick() + + const nameHeader = wrapper.findAll('th')[0] + expect(nameHeader.attributes('aria-sort')).toBe('ascending') + expect(nameHeader.findAll('svg')).toHaveLength(2) + expect(nameHeader.findAll('svg')[0].classes()).toContain('text-primary-600') + expect(nameHeader.findAll('svg')[1].classes()).toContain('text-gray-300') + + await nameHeader.trigger('click') + await wrapper.vm.$nextTick() + + expect(nameHeader.attributes('aria-sort')).toBe('descending') + expect(nameHeader.findAll('svg')[0].classes()).toContain('text-gray-300') + expect(nameHeader.findAll('svg')[1].classes()).toContain('text-primary-600') + }) +}) diff --git a/frontend/src/views/admin/UsersView.vue b/frontend/src/views/admin/UsersView.vue index 6076188205..0bda5c8953 100644 --- a/frontend/src/views/admin/UsersView.vue +++ b/frontend/src/views/admin/UsersView.vue @@ -460,6 +460,7 @@ ? 'text-primary-600 dark:text-primary-400' : 'text-gray-400 dark:text-dark-500'" :title="t('admin.users.sortBy')" + :data-test="`usage-sort-trigger-${usageKey}`" @click.stop="toggleUsageSortMenu(usageKey)" > {{ metric === 'today' ? t('admin.users.today') : t('admin.users.total') }} @@ -1180,6 +1182,8 @@ const getPlatformUsage = (userId: number, platform: string) => type UsageMetric = 'today' | 'total' type UsageSortState = { key: string; metric: UsageMetric; order: 'asc' | 'desc' } | null const USAGE_SORT_STORAGE_KEY = 'admin-users-usage-sort' +// 列头排序按钮点击后弹出的"今日/近30天"选择菜单,同时只允许一个列展开。 +const openUsageSortMenu = ref(null) const loadInitialUsageSort = (): UsageSortState => { try { @@ -1206,6 +1210,12 @@ const persistUsageSort = () => { console.error('Failed to persist usage sort:', e) } } +const clearUsageSort = () => { + if (!usageSort.value) return + usageSort.value = null + openUsageSortMenu.value = null + persistUsageSort() +} const isUsageSortActive = (key: string, metric: UsageMetric) => !!usageSort.value && usageSort.value.key === key && usageSort.value.metric === metric @@ -1225,9 +1235,7 @@ const toggleUsageSort = (key: string, metric: UsageMetric) => { openUsageSortMenu.value = null } -// 列头排序按钮点击后弹出的"今日/近30天"选择菜单,同时只允许一个列展开。 // 点击图标本身不触发排序,仅开关菜单;首次排序由用户在菜单内选择 metric 触发(默认 desc,详见 toggleUsageSort)。 -const openUsageSortMenu = ref(null) const toggleUsageSortMenu = (key: string) => { openUsageSortMenu.value = openUsageSortMenu.value === key ? null : key } @@ -1599,6 +1607,7 @@ const handlePageSizeChange = (pageSize: number) => { } const handleSort = (key: string, order: 'asc' | 'desc') => { + clearUsageSort() sortState.sort_by = key sortState.sort_order = order pagination.page = 1 diff --git a/frontend/src/views/admin/__tests__/UsersView.spec.ts b/frontend/src/views/admin/__tests__/UsersView.spec.ts index 532d89f173..8233ac5f34 100644 --- a/frontend/src/views/admin/__tests__/UsersView.spec.ts +++ b/frontend/src/views/admin/__tests__/UsersView.spec.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' import type { AdminUser } from '@/types' @@ -55,7 +55,7 @@ vi.mock('vue-i18n', async () => { } }) -const createAdminUser = (): AdminUser => ({ +const createAdminUser = (overrides: Partial = {}): AdminUser => ({ id: 42, username: 'scoped-user', email: 'scoped@example.com', @@ -72,7 +72,8 @@ const createAdminUser = (): AdminUser => ({ notes: '', last_active_at: '2026-04-16T02:00:00Z', last_used_at: '2026-04-17T02:00:00Z', - current_concurrency: 0 + current_concurrency: 0, + ...overrides }) const DataTableStub = { @@ -81,7 +82,11 @@ const DataTableStub = { template: `
{{ columns.map(col => col.key).join(',') }}
+
{{ data.map(row => row.email).join(',') }}
+
@@ -91,6 +96,7 @@ const DataTableStub = { describe('admin UsersView', () => { beforeEach(() => { + vi.useRealTimers() localStorage.clear() listUsers.mockReset() @@ -112,6 +118,10 @@ describe('admin UsersView', () => { getBatchUserAttributes.mockResolvedValue({ values: {} }) }) + afterEach(() => { + vi.useRealTimers() + }) + it('shows active, used, and created activity columns in order and requests last_used_at sort', async () => { const wrapper = mount(UsersView, { global: { @@ -161,4 +171,97 @@ describe('admin UsersView', () => { expect.any(Object) ) }) + + it('clears usage current-page sort when switching to last_used_at server sort', async () => { + vi.useFakeTimers() + localStorage.setItem('user-column-settings-version', '3') + localStorage.setItem( + 'user-hidden-columns', + JSON.stringify([ + 'notes', + 'groups', + 'subscriptions', + 'concurrency', + 'usage_anthropic', + 'usage_openai', + 'usage_gemini', + 'usage_antigravity', + 'balance_platform_quota' + ]) + ) + + listUsers.mockResolvedValue({ + items: [ + createAdminUser({ id: 1, email: 'last-used-first@example.com' }), + createAdminUser({ id: 2, email: 'usage-first@example.com' }) + ], + total: 2, + page: 1, + page_size: 20, + pages: 1 + }) + getBatchUsersUsage.mockResolvedValue({ + stats: { + 1: { user_id: 1, today_actual_cost: 1, total_actual_cost: 1, by_platform: [] }, + 2: { user_id: 2, today_actual_cost: 9, total_actual_cost: 9, by_platform: [] } + } + }) + + const wrapper = mount(UsersView, { + global: { + stubs: { + AppLayout: { template: '
' }, + TablePageLayout: { + template: '
' + }, + DataTable: DataTableStub, + Pagination: true, + ConfirmDialog: true, + EmptyState: true, + GroupBadge: true, + Select: true, + UserAttributesConfigModal: true, + UserConcurrencyCell: true, + UserCreateModal: true, + UserEditModal: true, + UserApiKeysModal: true, + UserAllowedGroupsModal: true, + UserBalanceModal: true, + UserBalanceHistoryModal: true, + GroupReplaceModal: true, + Icon: true, + Teleport: true + } + } + }) + + await flushPromises() + await vi.advanceTimersByTimeAsync(50) + await flushPromises() + + expect(wrapper.get('[data-test="row-order"]').text()).toBe('last-used-first@example.com,usage-first@example.com') + + await wrapper.get('[data-test="usage-sort-trigger-usage"]').trigger('click') + await flushPromises() + await wrapper.get('[data-test="usage-sort-usage-today"]').trigger('click') + await flushPromises() + + expect(wrapper.get('[data-test="row-order"]').text()).toBe('usage-first@example.com,last-used-first@example.com') + expect(localStorage.getItem('admin-users-usage-sort')).toContain('"key":"usage"') + + await wrapper.get('[data-test="sort-last-used"]').trigger('click') + await flushPromises() + + expect(localStorage.getItem('admin-users-usage-sort')).toBeNull() + expect(wrapper.get('[data-test="row-order"]').text()).toBe('last-used-first@example.com,usage-first@example.com') + expect(listUsers).toHaveBeenLastCalledWith( + 1, + 20, + expect.objectContaining({ + sort_by: 'last_used_at', + sort_order: 'desc' + }), + expect.any(Object) + ) + }) })