feat: add balance display, user columns, device mgmt and sessions for agent
- Backend: add balance to agent stats API - Backend: return full user details (balance, expiry, online_status, device_count, application) in agent users API - Backend: export admin device/session handlers, add agent routes for devices and sessions - Backend: add card type permission check and balance deduction for agent user creation - Backend: extract checkAgentUserPermission helper, add user edit API - Frontend: add balance card to agent dashboard - Frontend: add application, online_status, device_count, balance, expiry_at columns to agent user table - Frontend: add agent devices page (reuse admin data-table component) - Frontend: add agent sessions page (reuse admin data-table component) - Frontend: add devices and sessions to agent navigation and routes - Add i18n keys for agent devices, sessions, user columns, dashboard balance
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
<script setup lang="ts">
|
||||
import { Ban, CheckCircle, Layers, Loader2, Monitor } from 'lucide-vue-next'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import type { Device } from '@/pages/admin/devices/data/schema'
|
||||
|
||||
import ConfirmDialog from '@/components/confirm-dialog.vue'
|
||||
import SingleFilter from '@/components/data-table/single-filter.vue'
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import DataTable from '@/pages/admin/devices/components/data-table.vue'
|
||||
import api from '@/services/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
|
||||
interface Application {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
const loading = ref(true)
|
||||
const devices = ref<Device[]>([])
|
||||
const tableRef = ref()
|
||||
const applications = ref<Application[]>([])
|
||||
|
||||
const appFilter = ref<string>('')
|
||||
const statusFilter = ref<string>('')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const bannedCount = ref(0)
|
||||
|
||||
const forceOfflineDialogOpen = ref(false)
|
||||
const forceOfflineTarget = ref<Device | null>(null)
|
||||
|
||||
const totalDevices = computed(() => total.value)
|
||||
const onlineSessionCount = computed(() => devices.value.reduce((sum, d) => sum + (d.online_sessions || 0), 0))
|
||||
|
||||
const applicationOptions = computed(() => {
|
||||
return applications.value.map(app => ({
|
||||
label: app.name,
|
||||
value: String(app.id),
|
||||
}))
|
||||
})
|
||||
|
||||
const statusOptions = computed(() => [
|
||||
{ label: t('agent.devices.normal'), value: 'active' },
|
||||
{ label: t('agent.devices.banned'), value: 'banned' },
|
||||
])
|
||||
|
||||
async function fetchApplications() {
|
||||
try {
|
||||
const data = await api.get<{ apps: Application[] }>('/agent/apps')
|
||||
applications.value = Array.isArray(data?.apps) ? data.apps : []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取应用列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDevices() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.append('page', String(currentPage.value))
|
||||
params.append('page_size', String(pageSize.value))
|
||||
|
||||
if (appFilter.value) {
|
||||
params.append('application_id', appFilter.value)
|
||||
}
|
||||
if (statusFilter.value) {
|
||||
params.append('status', statusFilter.value)
|
||||
}
|
||||
|
||||
const data = await api.get<any>(`/agent/devices?${params.toString()}`)
|
||||
devices.value = Array.isArray(data?.devices) ? data.devices : []
|
||||
total.value = data?.total || 0
|
||||
bannedCount.value = data?.banned_count || 0
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取设备列表失败:', error)
|
||||
devices.value = []
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleDeviceStatus(device: Device) {
|
||||
const newStatus = device.status === 'banned' ? 'active' : 'banned'
|
||||
try {
|
||||
await api.put(`/agent/devices/${device.id}/status`, { status: newStatus })
|
||||
toast.success(t('agent.devices.statusUpdateSuccess'))
|
||||
fetchDevices()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('切换设备状态失败:', error)
|
||||
toast.error(error.message || t('agent.devices.statusUpdateFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
function confirmForceOffline(device: Device) {
|
||||
forceOfflineTarget.value = device
|
||||
forceOfflineDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleForceOffline() {
|
||||
if (!forceOfflineTarget.value) return
|
||||
|
||||
try {
|
||||
const data = await api.post<{ count: number }>(`/agent/devices/${forceOfflineTarget.value.id}/force-offline`)
|
||||
toast.success(t('agent.devices.forceOfflineSuccess', { count: data?.count || 0 }))
|
||||
fetchDevices()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('强制离线失败:', error)
|
||||
toast.error(error.message || t('agent.devices.forceOfflineFailed'))
|
||||
}
|
||||
finally {
|
||||
forceOfflineTarget.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchApplications()
|
||||
fetchDevices()
|
||||
})
|
||||
|
||||
watch([currentPage, pageSize], () => {
|
||||
fetchDevices()
|
||||
})
|
||||
|
||||
watch([appFilter, statusFilter], () => {
|
||||
currentPage.value = 1
|
||||
fetchDevices()
|
||||
})
|
||||
|
||||
const serverPagination = computed(() => ({
|
||||
page: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
total: total.value,
|
||||
onPageChange: (page: number) => {
|
||||
currentPage.value = page
|
||||
},
|
||||
onPageSizeChange: (size: number) => {
|
||||
pageSize.value = size
|
||||
currentPage.value = 1
|
||||
},
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('agent.devices.title')"
|
||||
:description="t('agent.devices.description')"
|
||||
:breadcrumbs="[
|
||||
{ title: t('nav.dashboard'), href: '/agent' },
|
||||
{ title: t('nav.devices') },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('agent.devices.totalDevices') }}
|
||||
</UiCardTitle>
|
||||
<Monitor class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ totalDevices }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('agent.devices.onlineSessions') }}
|
||||
</UiCardTitle>
|
||||
<Layers class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ onlineSessionCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('agent.devices.normalDevices') }}
|
||||
</UiCardTitle>
|
||||
<CheckCircle class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ totalDevices - bannedCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('agent.devices.banned') }}
|
||||
</UiCardTitle>
|
||||
<Ban class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ bannedCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="p-6">
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<Loader2 class="size-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
v-else
|
||||
ref="tableRef"
|
||||
:loading
|
||||
:data="devices"
|
||||
:server-pagination="serverPagination"
|
||||
:on-toggle-status="toggleDeviceStatus"
|
||||
:on-delete="() => {}"
|
||||
:on-force-offline="confirmForceOffline"
|
||||
@refresh="fetchDevices"
|
||||
>
|
||||
<template #filters>
|
||||
<SingleFilter
|
||||
v-model="appFilter"
|
||||
:title="t('agent.devices.application')"
|
||||
:options="applicationOptions"
|
||||
/>
|
||||
<SingleFilter
|
||||
v-model="statusFilter"
|
||||
:title="t('agent.devices.status')"
|
||||
:options="statusOptions"
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="forceOfflineDialogOpen"
|
||||
destructive
|
||||
:confirm-button-text="t('agent.devices.forceOffline')"
|
||||
:cancel-button-text="t('common.cancel')"
|
||||
@confirm="handleForceOffline"
|
||||
>
|
||||
<template #title>
|
||||
{{ t('agent.devices.forceOfflineTitle') }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{ t('agent.devices.forceOfflineConfirm', { deviceId: forceOfflineTarget?.device_id }) }}
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { DollarSign, Key, Plus, Users } from 'lucide-vue-next'
|
||||
import { DollarSign, Key, Plus, Users, Wallet } from 'lucide-vue-next'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -15,6 +15,7 @@ const stats = ref({
|
||||
totalRevenue: 0,
|
||||
todayCards: 0,
|
||||
todayRevenue: 0,
|
||||
balance: 0,
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -25,6 +26,7 @@ onMounted(async () => {
|
||||
totalRevenue?: number
|
||||
todayCards?: number
|
||||
todayRevenue?: number
|
||||
balance?: number
|
||||
}>('/agent/stats')
|
||||
if (data) {
|
||||
stats.value = {
|
||||
@@ -33,6 +35,7 @@ onMounted(async () => {
|
||||
totalRevenue: data.totalRevenue || 0,
|
||||
todayCards: data.todayCards || 0,
|
||||
todayRevenue: data.todayRevenue || 0,
|
||||
balance: data.balance || 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,7 +54,24 @@ onMounted(async () => {
|
||||
description="代理商后台控制台"
|
||||
sticky
|
||||
>
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('agent.dashboard.balance') }}
|
||||
</UiCardTitle>
|
||||
<Wallet class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
¥{{ stats.balance.toFixed(2) }}
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('agent.dashboard.balanceDesc') }}
|
||||
</p>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
<script setup lang="ts">
|
||||
import { Boxes, Loader2, Monitor, Users, Wifi } from 'lucide-vue-next'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import type { Session } from '@/pages/admin/sessions/data/schema'
|
||||
|
||||
import ConfirmDialog from '@/components/confirm-dialog.vue'
|
||||
import SingleFilter from '@/components/data-table/single-filter.vue'
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import DataTable from '@/pages/admin/sessions/components/data-table.vue'
|
||||
import api from '@/services/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
|
||||
interface Application {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
const loading = ref(true)
|
||||
const sessions = ref<Session[]>([])
|
||||
const applications = ref<Application[]>([])
|
||||
|
||||
const appFilter = ref<string>('')
|
||||
const searchFilter = ref<string>('')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
|
||||
const forceOfflineDialogOpen = ref(false)
|
||||
const forceOfflineTarget = ref<Session | null>(null)
|
||||
|
||||
const applicationOptions = computed(() => {
|
||||
return applications.value.map(app => ({
|
||||
label: app.name,
|
||||
value: String(app.id),
|
||||
}))
|
||||
})
|
||||
|
||||
const filteredSessions = computed(() => {
|
||||
return sessions.value.filter(s => s.is_online)
|
||||
})
|
||||
|
||||
const totalSessions = computed(() => total.value)
|
||||
const onlineApps = computed(() => new Set(filteredSessions.value.map(s => s.application_id)).size)
|
||||
const onlineDevices = computed(() => new Set(filteredSessions.value.filter(s => s.device_identifier).map(s => s.device_identifier)).size)
|
||||
const onlineUsers = computed(() => new Set(filteredSessions.value.filter(s => s.username).map(s => s.username)).size)
|
||||
|
||||
const serverPagination = computed(() => ({
|
||||
page: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
total: total.value,
|
||||
onPageChange: (newPage: number) => {
|
||||
currentPage.value = newPage
|
||||
},
|
||||
onPageSizeChange: (newPageSize: number) => {
|
||||
pageSize.value = newPageSize
|
||||
currentPage.value = 1
|
||||
},
|
||||
}))
|
||||
|
||||
async function fetchApplications() {
|
||||
try {
|
||||
const data = await api.get<{ apps: Application[] }>('/agent/apps')
|
||||
applications.value = Array.isArray(data?.apps) ? data.apps : []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取应用列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSessions() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.append('page', String(currentPage.value))
|
||||
params.append('page_size', String(pageSize.value))
|
||||
|
||||
if (appFilter.value) {
|
||||
params.append('app_id', appFilter.value)
|
||||
}
|
||||
if (searchFilter.value) {
|
||||
params.append('search', searchFilter.value)
|
||||
}
|
||||
|
||||
const data = await api.get<{ sessions: Session[], total: number }>(`/agent/sessions?${params.toString()}`)
|
||||
sessions.value = data?.sessions || []
|
||||
total.value = data?.total || 0
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('获取会话列表失败:', error)
|
||||
toast.error(error.message || t('agent.sessions.loadFailed'))
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmForceOffline(session: Session) {
|
||||
forceOfflineTarget.value = session
|
||||
forceOfflineDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleForceOffline() {
|
||||
if (!forceOfflineTarget.value) return
|
||||
|
||||
try {
|
||||
await api.delete(`/agent/sessions/${forceOfflineTarget.value.id}`)
|
||||
toast.success(t('agent.sessions.forceOfflineSuccess'))
|
||||
fetchSessions()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('强制下线失败:', error)
|
||||
toast.error(error.message || t('agent.sessions.forceOfflineFailed'))
|
||||
}
|
||||
finally {
|
||||
forceOfflineTarget.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchApplications()
|
||||
fetchSessions()
|
||||
})
|
||||
|
||||
watch([currentPage, pageSize], () => {
|
||||
fetchSessions()
|
||||
})
|
||||
|
||||
watch([appFilter, searchFilter], () => {
|
||||
currentPage.value = 1
|
||||
fetchSessions()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('agent.sessions.title')"
|
||||
:description="t('agent.sessions.description')"
|
||||
:breadcrumbs="[
|
||||
{ title: t('nav.dashboard'), href: '/agent' },
|
||||
{ title: t('nav.sessions') },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('agent.sessions.onlineSessions') }}
|
||||
</UiCardTitle>
|
||||
<Wifi class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ totalSessions }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('agent.sessions.onlineApps') }}
|
||||
</UiCardTitle>
|
||||
<Boxes class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ onlineApps }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('agent.sessions.onlineDevices') }}
|
||||
</UiCardTitle>
|
||||
<Monitor class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ onlineDevices }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('agent.sessions.onlineUsers') }}
|
||||
</UiCardTitle>
|
||||
<Users class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ onlineUsers }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="p-6">
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<Loader2 class="size-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
v-else
|
||||
:loading
|
||||
:data="filteredSessions"
|
||||
:server-pagination="serverPagination"
|
||||
:applications="applications"
|
||||
:app-filter="appFilter"
|
||||
:search-filter="searchFilter"
|
||||
:on-force-offline="confirmForceOffline"
|
||||
@update:app-filter="appFilter = $event"
|
||||
@update:search-filter="searchFilter = $event"
|
||||
@refresh="fetchSessions"
|
||||
>
|
||||
<template #filters>
|
||||
<SingleFilter
|
||||
v-model="appFilter"
|
||||
:title="t('agent.sessions.application')"
|
||||
:options="applicationOptions"
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="forceOfflineDialogOpen"
|
||||
destructive
|
||||
:confirm-button-text="t('agent.sessions.forceOffline')"
|
||||
:cancel-button-text="t('common.cancel')"
|
||||
@confirm="handleForceOffline"
|
||||
>
|
||||
<template #title>
|
||||
{{ t('agent.sessions.forceOfflineTitle') }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{ t('agent.sessions.forceOfflineConfirm', { instanceId: forceOfflineTarget?.instance_id }) }}
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -15,6 +15,22 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
|
||||
function getOnlineStatusVariant(status: string): 'default' | 'secondary' | 'destructive' {
|
||||
if (status === 'online')
|
||||
return 'default'
|
||||
if (status === 'banned')
|
||||
return 'destructive'
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
function getOnlineStatusLabel(t: Composer['t'], status: string): string {
|
||||
if (status === 'online')
|
||||
return t('agent.users.onlineStatus.online')
|
||||
if (status === 'banned')
|
||||
return t('agent.users.onlineStatus.banned')
|
||||
return t('agent.users.onlineStatus.offline')
|
||||
}
|
||||
|
||||
export function getColumns(actions: {
|
||||
onEdit: (row: User) => void
|
||||
onToggleStatus: (row: User) => void
|
||||
@@ -44,17 +60,80 @@ export function getColumns(actions: {
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: () => t('agent.users.columns.status'),
|
||||
accessorKey: 'application.name',
|
||||
header: () => t('agent.users.columns.application'),
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue('status') as string
|
||||
const statusMap: Record<string, { label: string, variant: 'default' | 'secondary' | 'destructive' }> = {
|
||||
active: { label: t('agent.users.status.active'), variant: 'default' },
|
||||
disabled: { label: t('agent.users.status.disabled'), variant: 'secondary' },
|
||||
banned: { label: t('agent.users.status.banned'), variant: 'destructive' },
|
||||
const appName = row.original.application?.name
|
||||
return appName ? h(Badge, { variant: 'secondary' }, () => appName) : '-'
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'online_status',
|
||||
header: () => t('agent.users.columns.onlineStatus'),
|
||||
cell: ({ row }) => {
|
||||
const status = (row.getValue('online_status') as string) || 'offline'
|
||||
return h(Badge, { variant: getOnlineStatusVariant(status) }, () => getOnlineStatusLabel(t, status))
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'device_count',
|
||||
header: () => t('agent.users.columns.deviceCount'),
|
||||
cell: ({ row }) => {
|
||||
const count = row.getValue('device_count') as number
|
||||
return h('span', { class: 'font-medium' }, count ?? 0)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'balance',
|
||||
header: () => t('agent.users.columns.balance'),
|
||||
cell: ({ row }) => {
|
||||
const balance = row.getValue('balance') as number
|
||||
const billingType = row.original.application?.billing_type
|
||||
|
||||
if (balance === -1) {
|
||||
return h(Badge, { variant: 'default', class: 'bg-green-500 hover:bg-green-600' }, () => t('agent.users.expiry.permanent'))
|
||||
}
|
||||
|
||||
if (balance === null || balance === undefined)
|
||||
return '-'
|
||||
|
||||
return h('span', { class: balance > 0 ? 'text-green-600 font-medium' : 'text-muted-foreground' }, balance.toFixed(2))
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'expiry_at',
|
||||
header: () => t('agent.users.columns.expiryAt'),
|
||||
cell: ({ row }) => {
|
||||
const expiryAt = row.getValue('expiry_at') as string
|
||||
const billingType = row.original.application?.billing_type
|
||||
|
||||
if (billingType !== 'subscription') {
|
||||
return h('span', { class: 'text-muted-foreground' }, '-')
|
||||
}
|
||||
|
||||
if (!expiryAt) {
|
||||
return h('span', { class: 'text-muted-foreground' }, '-')
|
||||
}
|
||||
|
||||
try {
|
||||
const date = new Date(expiryAt)
|
||||
if (Number.isNaN(date.getTime()))
|
||||
return '-'
|
||||
|
||||
const now = new Date()
|
||||
if (date < now) {
|
||||
return h(Badge, { variant: 'destructive' }, () => t('agent.users.expiry.expired'))
|
||||
}
|
||||
|
||||
const daysLeft = Math.ceil((date.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
return h('div', { class: 'flex flex-col' }, [
|
||||
h('span', { class: 'text-green-600 font-medium' }, `${daysLeft} ${t('agent.users.expiry.daysLeft')}`),
|
||||
h('span', { class: 'text-xs text-muted-foreground' }, date.toLocaleDateString('zh-CN')),
|
||||
])
|
||||
}
|
||||
catch {
|
||||
return '-'
|
||||
}
|
||||
const { label, variant } = statusMap[status] || { label: status || '-', variant: 'secondary' }
|
||||
return h(Badge, { variant }, () => label)
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -3,6 +3,18 @@ export interface User {
|
||||
username: string
|
||||
email: string
|
||||
status: string
|
||||
created_at: string
|
||||
balance: number | null
|
||||
expiry_at: string | null
|
||||
last_login_at: string | null
|
||||
last_heartbeat_at: string | null
|
||||
device_count: number
|
||||
online_status: string
|
||||
application_id: number
|
||||
is_trial_user: boolean
|
||||
created_at: string
|
||||
application?: {
|
||||
id: number
|
||||
name: string
|
||||
billing_type: string | null
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user