add admin user last used support
This commit is contained in:
@@ -93,6 +93,7 @@ export interface User {
|
||||
export interface AdminUser extends User {
|
||||
// 管理员备注(普通用户接口不返回)
|
||||
notes: string
|
||||
last_used_at?: string | null
|
||||
// 用户专属分组倍率配置 (group_id -> rate_multiplier)
|
||||
group_rates?: Record<number, number>
|
||||
// 当前并发数(仅管理员列表接口返回)
|
||||
|
||||
@@ -461,6 +461,12 @@
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-last_used_at="{ value }">
|
||||
<span class="text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ value ? formatDateTime(value) : '-' }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-last_active_at="{ value }">
|
||||
<span class="text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ value ? formatDateTime(value) : '-' }}
|
||||
@@ -713,6 +719,7 @@ const allColumns = computed<Column[]>(() => [
|
||||
{ key: 'concurrency', label: t('admin.users.columns.concurrency'), sortable: true },
|
||||
{ key: 'status', label: t('admin.users.columns.status'), sortable: true },
|
||||
{ key: 'last_login_at', label: t('admin.users.columns.lastLogin'), sortable: true },
|
||||
{ key: 'last_used_at', label: t('admin.users.columns.lastUsed'), sortable: true },
|
||||
{ key: 'last_active_at', label: t('admin.users.columns.lastActive'), sortable: true },
|
||||
{ key: 'created_at', label: t('admin.users.columns.created'), sortable: true },
|
||||
{ key: 'actions', label: t('admin.users.columns.actions'), sortable: false }
|
||||
@@ -801,7 +808,7 @@ 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', 'last_login_at', 'last_active_at', 'created_at'])
|
||||
const sortable = new Set(['email', 'id', 'username', 'role', 'balance', 'concurrency', 'status', 'last_login_at', 'last_used_at', 'last_active_at', 'created_at'])
|
||||
try {
|
||||
const raw = localStorage.getItem(USER_SORT_STORAGE_KEY)
|
||||
if (!raw) return fallback
|
||||
|
||||
162
frontend/src/views/admin/__tests__/UsersView.spec.ts
Normal file
162
frontend/src/views/admin/__tests__/UsersView.spec.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
|
||||
import type { AdminUser } from '@/types'
|
||||
import UsersView from '../UsersView.vue'
|
||||
|
||||
const {
|
||||
listUsers,
|
||||
getAllGroups,
|
||||
getBatchUsersUsage,
|
||||
listEnabledDefinitions,
|
||||
getBatchUserAttributes
|
||||
} = vi.hoisted(() => ({
|
||||
listUsers: vi.fn(),
|
||||
getAllGroups: vi.fn(),
|
||||
getBatchUsersUsage: vi.fn(),
|
||||
listEnabledDefinitions: vi.fn(),
|
||||
getBatchUserAttributes: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin', () => ({
|
||||
adminAPI: {
|
||||
users: {
|
||||
list: listUsers,
|
||||
toggleStatus: vi.fn(),
|
||||
delete: vi.fn()
|
||||
},
|
||||
groups: {
|
||||
getAll: getAllGroups
|
||||
},
|
||||
dashboard: {
|
||||
getBatchUsersUsage
|
||||
},
|
||||
userAttributes: {
|
||||
listEnabledDefinitions,
|
||||
getBatchUserAttributes
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showError: vi.fn(),
|
||||
showSuccess: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const createAdminUser = (): AdminUser => ({
|
||||
id: 42,
|
||||
username: 'scoped-user',
|
||||
email: 'scoped@example.com',
|
||||
role: 'user',
|
||||
balance: 0,
|
||||
concurrency: 1,
|
||||
status: 'active',
|
||||
allowed_groups: [],
|
||||
balance_notify_enabled: false,
|
||||
balance_notify_threshold: null,
|
||||
balance_notify_extra_emails: [],
|
||||
created_at: '2026-04-17T00:00:00Z',
|
||||
updated_at: '2026-04-17T00:00:00Z',
|
||||
notes: '',
|
||||
last_login_at: '2026-04-16T01:00:00Z',
|
||||
last_active_at: '2026-04-16T02:00:00Z',
|
||||
last_used_at: '2026-04-17T02:00:00Z',
|
||||
current_concurrency: 0
|
||||
})
|
||||
|
||||
const DataTableStub = {
|
||||
props: ['columns', 'data'],
|
||||
emits: ['sort'],
|
||||
template: `
|
||||
<div>
|
||||
<div data-test="columns">{{ columns.map(col => col.key).join(',') }}</div>
|
||||
<button data-test="sort-last-used" @click="$emit('sort', 'last_used_at', 'desc')">sort</button>
|
||||
<div v-for="row in data" :key="row.id">
|
||||
<slot name="cell-last_used_at" :value="row.last_used_at" :row="row" />
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
describe('admin UsersView', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
|
||||
listUsers.mockReset()
|
||||
getAllGroups.mockReset()
|
||||
getBatchUsersUsage.mockReset()
|
||||
listEnabledDefinitions.mockReset()
|
||||
getBatchUserAttributes.mockReset()
|
||||
|
||||
listUsers.mockResolvedValue({
|
||||
items: [createAdminUser()],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
pages: 1
|
||||
})
|
||||
getAllGroups.mockResolvedValue([])
|
||||
getBatchUsersUsage.mockResolvedValue({ stats: {} })
|
||||
listEnabledDefinitions.mockResolvedValue([])
|
||||
getBatchUserAttributes.mockResolvedValue({ values: {} })
|
||||
})
|
||||
|
||||
it('shows last_used_at column and requests last_used_at sort', async () => {
|
||||
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()
|
||||
|
||||
expect(wrapper.get('[data-test="columns"]').text()).toContain('last_used_at')
|
||||
|
||||
await wrapper.get('[data-test="sort-last-used"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
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