mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
fix: 修复用户列表使用时间排序冲突
This commit is contained in:
@@ -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 @@
|
||||
>
|
||||
<div class="flex items-center space-x-1">
|
||||
<span>{{ column.label }}</span>
|
||||
<span v-if="column.sortable" class="text-gray-400 dark:text-dark-500">
|
||||
<span
|
||||
v-if="column.sortable"
|
||||
class="inline-flex h-5 w-4 flex-col items-center justify-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
v-if="sortKey === column.key"
|
||||
class="h-4 w-4"
|
||||
:class="{ 'rotate-180 transform': sortOrder === 'desc' }"
|
||||
class="h-2.5 w-2.5"
|
||||
:class="getSortIndicatorClass(column.key, 'asc')"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
viewBox="0 0 10 10"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
<path d="M5 2L1.5 6.5h7L5 2z" />
|
||||
</svg>
|
||||
<svg v-else class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
||||
/>
|
||||
<svg
|
||||
class="-mt-0.5 h-2.5 w-2.5"
|
||||
:class="getSortIndicatorClass(column.key, 'desc')"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 10 10"
|
||||
>
|
||||
<path d="M5 8L1.5 3.5h7L5 8z" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user