feat: expose user activity timestamps in admin list
This commit is contained in:
@@ -21,6 +21,8 @@ func UserFromServiceShallow(u *service.User) *User {
|
|||||||
Concurrency: u.Concurrency,
|
Concurrency: u.Concurrency,
|
||||||
Status: u.Status,
|
Status: u.Status,
|
||||||
AllowedGroups: u.AllowedGroups,
|
AllowedGroups: u.AllowedGroups,
|
||||||
|
LastLoginAt: u.LastLoginAt,
|
||||||
|
LastActiveAt: u.LastActiveAt,
|
||||||
CreatedAt: u.CreatedAt,
|
CreatedAt: u.CreatedAt,
|
||||||
UpdatedAt: u.UpdatedAt,
|
UpdatedAt: u.UpdatedAt,
|
||||||
BalanceNotifyEnabled: u.BalanceNotifyEnabled,
|
BalanceNotifyEnabled: u.BalanceNotifyEnabled,
|
||||||
|
|||||||
@@ -7,16 +7,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Balance float64 `json:"balance"`
|
Balance float64 `json:"balance"`
|
||||||
Concurrency int `json:"concurrency"`
|
Concurrency int `json:"concurrency"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
AllowedGroups []int64 `json:"allowed_groups"`
|
AllowedGroups []int64 `json:"allowed_groups"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
LastActiveAt *time.Time `json:"last_active_at,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|
||||||
// 余额不足通知
|
// 余额不足通知
|
||||||
BalanceNotifyEnabled bool `json:"balance_notify_enabled"`
|
BalanceNotifyEnabled bool `json:"balance_notify_enabled"`
|
||||||
|
|||||||
32
backend/internal/handler/dto/user_mapper_activity_test.go
Normal file
32
backend/internal/handler/dto/user_mapper_activity_test.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUserFromServiceAdmin_MapsActivityTimestamps(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
lastLoginAt := time.Date(2026, time.April, 20, 10, 0, 0, 0, time.UTC)
|
||||||
|
lastActiveAt := lastLoginAt.Add(15 * time.Minute)
|
||||||
|
|
||||||
|
out := UserFromServiceAdmin(&service.User{
|
||||||
|
ID: 42,
|
||||||
|
Email: "admin@example.com",
|
||||||
|
Username: "admin",
|
||||||
|
Role: service.RoleAdmin,
|
||||||
|
Status: service.StatusActive,
|
||||||
|
LastLoginAt: &lastLoginAt,
|
||||||
|
LastActiveAt: &lastActiveAt,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NotNil(t, out)
|
||||||
|
require.NotNil(t, out.LastLoginAt)
|
||||||
|
require.NotNil(t, out.LastActiveAt)
|
||||||
|
require.WithinDuration(t, lastLoginAt, *out.LastLoginAt, time.Second)
|
||||||
|
require.WithinDuration(t, lastActiveAt, *out.LastActiveAt, time.Second)
|
||||||
|
}
|
||||||
@@ -1391,6 +1391,8 @@ export default {
|
|||||||
usage: 'Usage',
|
usage: 'Usage',
|
||||||
concurrency: 'Concurrency',
|
concurrency: 'Concurrency',
|
||||||
status: 'Status',
|
status: 'Status',
|
||||||
|
lastLogin: 'Last Login',
|
||||||
|
lastActive: 'Last Active',
|
||||||
created: 'Created',
|
created: 'Created',
|
||||||
actions: 'Actions'
|
actions: 'Actions'
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1417,6 +1417,8 @@ export default {
|
|||||||
usage: '用量',
|
usage: '用量',
|
||||||
concurrency: '并发数',
|
concurrency: '并发数',
|
||||||
status: '状态',
|
status: '状态',
|
||||||
|
lastLogin: '最后登录',
|
||||||
|
lastActive: '最后使用',
|
||||||
created: '创建时间',
|
created: '创建时间',
|
||||||
actions: '操作'
|
actions: '操作'
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ export interface User {
|
|||||||
balance_notify_threshold: number | null
|
balance_notify_threshold: number | null
|
||||||
balance_notify_extra_emails: NotifyEmailEntry[]
|
balance_notify_extra_emails: NotifyEmailEntry[]
|
||||||
subscriptions?: UserSubscription[] // User's active subscriptions
|
subscriptions?: UserSubscription[] // User's active subscriptions
|
||||||
|
last_login_at?: string | null
|
||||||
|
last_active_at?: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -455,6 +455,18 @@
|
|||||||
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDateTime(value) }}</span>
|
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDateTime(value) }}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template #cell-last_login_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) : '-' }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template #cell-actions="{ row }">
|
<template #cell-actions="{ row }">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<!-- Edit Button -->
|
<!-- Edit Button -->
|
||||||
@@ -700,6 +712,8 @@ const allColumns = computed<Column[]>(() => [
|
|||||||
{ key: 'usage', label: t('admin.users.columns.usage'), sortable: false },
|
{ key: 'usage', label: t('admin.users.columns.usage'), sortable: false },
|
||||||
{ key: 'concurrency', label: t('admin.users.columns.concurrency'), sortable: true },
|
{ key: 'concurrency', label: t('admin.users.columns.concurrency'), sortable: true },
|
||||||
{ key: 'status', label: t('admin.users.columns.status'), 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_active_at', label: t('admin.users.columns.lastActive'), sortable: true },
|
||||||
{ key: 'created_at', label: t('admin.users.columns.created'), sortable: true },
|
{ key: 'created_at', label: t('admin.users.columns.created'), sortable: true },
|
||||||
{ key: 'actions', label: t('admin.users.columns.actions'), sortable: false }
|
{ key: 'actions', label: t('admin.users.columns.actions'), sortable: false }
|
||||||
])
|
])
|
||||||
@@ -714,7 +728,7 @@ const toggleableColumns = computed(() =>
|
|||||||
const hiddenColumns = reactive<Set<string>>(new Set())
|
const hiddenColumns = reactive<Set<string>>(new Set())
|
||||||
|
|
||||||
// Default hidden columns (columns hidden by default on first load)
|
// Default hidden columns (columns hidden by default on first load)
|
||||||
const DEFAULT_HIDDEN_COLUMNS = ['notes', 'groups', 'subscriptions', 'usage', 'concurrency']
|
const DEFAULT_HIDDEN_COLUMNS = ['notes', 'groups', 'subscriptions', 'usage', 'concurrency', 'last_login_at', 'last_active_at']
|
||||||
|
|
||||||
// localStorage key for column settings
|
// localStorage key for column settings
|
||||||
const HIDDEN_COLUMNS_KEY = 'user-hidden-columns'
|
const HIDDEN_COLUMNS_KEY = 'user-hidden-columns'
|
||||||
@@ -787,7 +801,7 @@ const searchQuery = ref('')
|
|||||||
const USER_SORT_STORAGE_KEY = 'admin-users-table-sort'
|
const USER_SORT_STORAGE_KEY = 'admin-users-table-sort'
|
||||||
const loadInitialSortState = (): { sort_by: string; sort_order: 'asc' | 'desc' } => {
|
const loadInitialSortState = (): { sort_by: string; sort_order: 'asc' | 'desc' } => {
|
||||||
const fallback = { sort_by: 'created_at', sort_order: 'desc' as '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', 'created_at'])
|
const sortable = new Set(['email', 'id', 'username', 'role', 'balance', 'concurrency', 'status', 'last_login_at', 'last_active_at', 'created_at'])
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(USER_SORT_STORAGE_KEY)
|
const raw = localStorage.getItem(USER_SORT_STORAGE_KEY)
|
||||||
if (!raw) return fallback
|
if (!raw) return fallback
|
||||||
|
|||||||
Reference in New Issue
Block a user