fix: 修复用户列表使用时间排序冲突

This commit is contained in:
shaw
2026-06-30 13:36:36 +08:00
parent dcd8689d78
commit bf4f006736
4 changed files with 210 additions and 19 deletions
+11 -2
View File
@@ -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)"
>
<span
@@ -496,6 +497,7 @@
:class="isUsageSortActive(usageKey, metric)
? 'font-medium text-primary-600 dark:text-primary-400'
: 'text-gray-700 dark:text-gray-300'"
:data-test="`usage-sort-${usageKey}-${metric}`"
@click.stop="toggleUsageSort(usageKey, metric)"
>
<span>{{ metric === 'today' ? t('admin.users.today') : t('admin.users.total') }}</span>
@@ -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<string | null>(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<string | null>(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
@@ -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> = {}): 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: `
<div>
<div data-test="columns">{{ columns.map(col => col.key).join(',') }}</div>
<div data-test="row-order">{{ data.map(row => row.email).join(',') }}</div>
<button data-test="sort-last-used" @click="$emit('sort', 'last_used_at', 'desc')">sort</button>
<template v-for="col in columns" :key="col.key">
<slot :name="'header-' + col.key" :column="col" />
</template>
<div v-for="row in data" :key="row.id">
<slot name="cell-last_used_at" :value="row.last_used_at" :row="row" />
</div>
@@ -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: '<div><slot /></div>' },
TablePageLayout: {
template: '<div><slot name="filters" /><slot name="table" /><slot name="pagination" /></div>'
},
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)
)
})
})