add admin user last used support

This commit is contained in:
IanShaw027
2026-04-21 00:22:17 +08:00
parent beeab54ae3
commit bf3ef2d19a
11 changed files with 373 additions and 47 deletions

View File

@@ -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>
// 当前并发数(仅管理员列表接口返回)

View File

@@ -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

View 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)
)
})
})