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:
@@ -22,22 +22,22 @@ type DeviceWithDetails struct {
|
|||||||
func SetupDeviceRoutes(r *gin.RouterGroup) {
|
func SetupDeviceRoutes(r *gin.RouterGroup) {
|
||||||
devices := r.Group("/devices")
|
devices := r.Group("/devices")
|
||||||
{
|
{
|
||||||
devices.GET("", handleGetDevices)
|
devices.GET("", HandleGetDevices)
|
||||||
devices.PUT("/:id/status", handleUpdateDeviceStatus)
|
devices.PUT("/:id/status", HandleUpdateDeviceStatus)
|
||||||
devices.DELETE("/:id", handleUnbindDevice)
|
devices.DELETE("/:id", HandleUnbindDevice)
|
||||||
devices.DELETE("/batch", handleBatchUnbindDevices)
|
devices.DELETE("/batch", HandleBatchUnbindDevices)
|
||||||
devices.POST("/batch/status", handleBatchUpdateDeviceStatus)
|
devices.POST("/batch/status", HandleBatchUpdateDeviceStatus)
|
||||||
devices.POST("/:id/force-offline", handleForceOfflineDevice)
|
devices.POST("/:id/force-offline", HandleForceOfflineDevice)
|
||||||
}
|
}
|
||||||
|
|
||||||
sessions := r.Group("/sessions")
|
sessions := r.Group("/sessions")
|
||||||
{
|
{
|
||||||
sessions.GET("", handleGetSessions)
|
sessions.GET("", HandleGetSessions)
|
||||||
sessions.DELETE("/:id", handleDeleteSession)
|
sessions.DELETE("/:id", HandleDeleteSession)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleGetDevices(c *gin.Context) {
|
func HandleGetDevices(c *gin.Context) {
|
||||||
userID := c.GetUint("user_id")
|
userID := c.GetUint("user_id")
|
||||||
log.Printf("[DEBUG] handleGetDevices called, userID: %d", userID)
|
log.Printf("[DEBUG] handleGetDevices called, userID: %d", userID)
|
||||||
|
|
||||||
@@ -180,7 +180,7 @@ func handleGetDevices(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleUpdateDeviceStatus(c *gin.Context) {
|
func HandleUpdateDeviceStatus(c *gin.Context) {
|
||||||
userID := c.GetUint("user_id")
|
userID := c.GetUint("user_id")
|
||||||
deviceID := c.Param("id")
|
deviceID := c.Param("id")
|
||||||
|
|
||||||
@@ -221,7 +221,7 @@ func handleUpdateDeviceStatus(c *gin.Context) {
|
|||||||
response.Success(c, device)
|
response.Success(c, device)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleUnbindDevice(c *gin.Context) {
|
func HandleUnbindDevice(c *gin.Context) {
|
||||||
userID := c.GetUint("user_id")
|
userID := c.GetUint("user_id")
|
||||||
deviceID := c.Param("id")
|
deviceID := c.Param("id")
|
||||||
|
|
||||||
@@ -257,7 +257,7 @@ func handleUnbindDevice(c *gin.Context) {
|
|||||||
response.Success(c, nil)
|
response.Success(c, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleBatchUnbindDevices(c *gin.Context) {
|
func HandleBatchUnbindDevices(c *gin.Context) {
|
||||||
userID := c.GetUint("user_id")
|
userID := c.GetUint("user_id")
|
||||||
var req struct {
|
var req struct {
|
||||||
DeviceIDs []uint `json:"device_ids"`
|
DeviceIDs []uint `json:"device_ids"`
|
||||||
@@ -303,7 +303,7 @@ func handleBatchUnbindDevices(c *gin.Context) {
|
|||||||
response.Success(c, nil)
|
response.Success(c, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleBatchUpdateDeviceStatus(c *gin.Context) {
|
func HandleBatchUpdateDeviceStatus(c *gin.Context) {
|
||||||
userID := c.GetUint("user_id")
|
userID := c.GetUint("user_id")
|
||||||
var req struct {
|
var req struct {
|
||||||
DeviceIDs []uint `json:"device_ids"`
|
DeviceIDs []uint `json:"device_ids"`
|
||||||
@@ -356,7 +356,7 @@ type SessionWithDetails struct {
|
|||||||
IsOnline bool `json:"is_online"`
|
IsOnline bool `json:"is_online"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleGetSessions(c *gin.Context) {
|
func HandleGetSessions(c *gin.Context) {
|
||||||
userID := c.GetUint("user_id")
|
userID := c.GetUint("user_id")
|
||||||
deviceIDFilter := c.Query("device_id")
|
deviceIDFilter := c.Query("device_id")
|
||||||
appIDFilter := c.Query("app_id")
|
appIDFilter := c.Query("app_id")
|
||||||
@@ -478,7 +478,7 @@ func handleGetSessions(c *gin.Context) {
|
|||||||
response.Success(c, gin.H{"sessions": sessionsWithDetails, "total": total, "page": page, "page_size": pageSize})
|
response.Success(c, gin.H{"sessions": sessionsWithDetails, "total": total, "page": page, "page_size": pageSize})
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleDeleteSession(c *gin.Context) {
|
func HandleDeleteSession(c *gin.Context) {
|
||||||
userID := c.GetUint("user_id")
|
userID := c.GetUint("user_id")
|
||||||
sessionID := c.Param("id")
|
sessionID := c.Param("id")
|
||||||
|
|
||||||
@@ -529,7 +529,7 @@ func handleDeleteSession(c *gin.Context) {
|
|||||||
response.Success(c, nil)
|
response.Success(c, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleForceOfflineDevice(c *gin.Context) {
|
func HandleForceOfflineDevice(c *gin.Context) {
|
||||||
userID := c.GetUint("user_id")
|
userID := c.GetUint("user_id")
|
||||||
deviceID := c.Param("id")
|
deviceID := c.Param("id")
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ func SetupUserRoutes(r *gin.RouterGroup) {
|
|||||||
appUsers.PUT("/:id", handleUpdateUser)
|
appUsers.PUT("/:id", handleUpdateUser)
|
||||||
appUsers.DELETE("/:id", handleDeleteUser)
|
appUsers.DELETE("/:id", handleDeleteUser)
|
||||||
appUsers.GET("/:id/devices", handleGetUserDevices)
|
appUsers.GET("/:id/devices", handleGetUserDevices)
|
||||||
appUsers.DELETE("/:id/devices/:deviceId", handleUnbindDevice)
|
appUsers.DELETE("/:id/devices/:deviceId", HandleUnbindDevice)
|
||||||
appUsers.PUT("/:id/expiry", handleUpdateExpiry)
|
appUsers.PUT("/:id/expiry", handleUpdateExpiry)
|
||||||
appUsers.POST("/batch/status", handleBatchUpdateStatus)
|
appUsers.POST("/batch/status", handleBatchUpdateStatus)
|
||||||
appUsers.DELETE("/batch", handleBatchDelete)
|
appUsers.DELETE("/batch", handleBatchDelete)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
"verification-platform-backend/internal/database"
|
"verification-platform-backend/internal/database"
|
||||||
"verification-platform-backend/internal/model"
|
"verification-platform-backend/internal/model"
|
||||||
|
"verification-platform-backend/internal/router/admin"
|
||||||
"verification-platform-backend/internal/service"
|
"verification-platform-backend/internal/service"
|
||||||
"verification-platform-backend/pkg/response"
|
"verification-platform-backend/pkg/response"
|
||||||
"verification-platform-backend/pkg/utils"
|
"verification-platform-backend/pkg/utils"
|
||||||
@@ -35,6 +36,14 @@ func SetupAgentRoutes(r *gin.RouterGroup) {
|
|||||||
r.GET("/cards/export", handleExportCards)
|
r.GET("/cards/export", handleExportCards)
|
||||||
r.GET("/cloud-variables", handleGetCloudVariables)
|
r.GET("/cloud-variables", handleGetCloudVariables)
|
||||||
r.GET("/cloud-variables/:id/records", handleGetCloudVariableRecords)
|
r.GET("/cloud-variables/:id/records", handleGetCloudVariableRecords)
|
||||||
|
|
||||||
|
r.GET("/devices", admin.HandleGetDevices)
|
||||||
|
r.PUT("/devices/:id/status", admin.HandleUpdateDeviceStatus)
|
||||||
|
r.DELETE("/devices/:id", admin.HandleUnbindDevice)
|
||||||
|
r.POST("/devices/:id/force-offline", admin.HandleForceOfflineDevice)
|
||||||
|
|
||||||
|
r.GET("/sessions", admin.HandleGetSessions)
|
||||||
|
r.DELETE("/sessions/:id", admin.HandleDeleteSession)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleGetStats(c *gin.Context) {
|
func handleGetStats(c *gin.Context) {
|
||||||
@@ -68,6 +77,9 @@ func handleGetStats(c *gin.Context) {
|
|||||||
var todayRevenue float64
|
var todayRevenue float64
|
||||||
database.DB.Model(&model.Card{}).Where("(agent_id = ? OR (agent_id IS NULL AND creator_id = ?)) AND DATE(created_at) = ?", userID, userID, today).Select("COALESCE(SUM(price), 0)").Scan(&todayRevenue)
|
database.DB.Model(&model.Card{}).Where("(agent_id = ? OR (agent_id IS NULL AND creator_id = ?)) AND DATE(created_at) = ?", userID, userID, today).Select("COALESCE(SUM(price), 0)").Scan(&todayRevenue)
|
||||||
|
|
||||||
|
var agent model.User
|
||||||
|
database.DB.First(&agent, userID)
|
||||||
|
|
||||||
response.Success(c, gin.H{
|
response.Success(c, gin.H{
|
||||||
"totalApps": totalApps,
|
"totalApps": totalApps,
|
||||||
"totalCards": totalCards,
|
"totalCards": totalCards,
|
||||||
@@ -75,6 +87,7 @@ func handleGetStats(c *gin.Context) {
|
|||||||
"totalRevenue": totalRevenue,
|
"totalRevenue": totalRevenue,
|
||||||
"todayCards": todayCards,
|
"todayCards": todayCards,
|
||||||
"todayRevenue": todayRevenue,
|
"todayRevenue": todayRevenue,
|
||||||
|
"balance": agent.Balance,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,10 +495,50 @@ func handleGetUsers(c *gin.Context) {
|
|||||||
limit = pageSizeInt
|
limit = pageSizeInt
|
||||||
}
|
}
|
||||||
|
|
||||||
database.DB.Where("id IN ?", appUserIDs).Order("created_at DESC").Limit(limit).Offset(offset).Find(&users)
|
database.DB.Preload("Application").Where("id IN ?", appUserIDs).Order("created_at DESC").Limit(limit).Offset(offset).Find(&users)
|
||||||
|
|
||||||
|
result := make([]gin.H, 0, len(users))
|
||||||
|
for _, u := range users {
|
||||||
|
var deviceCount int64
|
||||||
|
database.DB.Model(&model.UserDevice{}).Where("user_id = ?", u.ID).Count(&deviceCount)
|
||||||
|
|
||||||
|
onlineStatus := "offline"
|
||||||
|
if u.Status == "banned" {
|
||||||
|
onlineStatus = "banned"
|
||||||
|
} else if u.LastHeartbeatAt != nil {
|
||||||
|
lastHeartbeat := *u.LastHeartbeatAt
|
||||||
|
if time.Since(lastHeartbeat) < 5*time.Minute {
|
||||||
|
onlineStatus = "online"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item := gin.H{
|
||||||
|
"id": u.ID,
|
||||||
|
"username": u.Username,
|
||||||
|
"email": u.Email,
|
||||||
|
"status": u.Status,
|
||||||
|
"balance": u.Balance,
|
||||||
|
"expiry_at": u.ExpiryAt,
|
||||||
|
"last_login_at": u.LastLoginAt,
|
||||||
|
"last_heartbeat_at": u.LastHeartbeatAt,
|
||||||
|
"device_count": deviceCount,
|
||||||
|
"online_status": onlineStatus,
|
||||||
|
"application_id": u.ApplicationID,
|
||||||
|
"is_trial_user": u.IsTrialUser,
|
||||||
|
"created_at": u.CreatedAt,
|
||||||
|
}
|
||||||
|
if u.Application.ID > 0 {
|
||||||
|
item["application"] = gin.H{
|
||||||
|
"id": u.Application.ID,
|
||||||
|
"name": u.Application.Name,
|
||||||
|
"billing_type": u.Application.BillingType,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = append(result, item)
|
||||||
|
}
|
||||||
|
|
||||||
response.Success(c, gin.H{
|
response.Success(c, gin.H{
|
||||||
"users": users,
|
"users": result,
|
||||||
"total": total,
|
"total": total,
|
||||||
"active_count": activeCount,
|
"active_count": activeCount,
|
||||||
"disabled_count": disabledCount,
|
"disabled_count": disabledCount,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Boxes, Cloud, CreditCard, Gauge, Key, LogOut, User, Users } from 'lucide-vue-next'
|
import { Boxes, Cloud, CreditCard, Gauge, Key, LogOut, Monitor, User, Users, Wifi } from 'lucide-vue-next'
|
||||||
import { computed, onMounted, reactive } from 'vue'
|
import { computed, onMounted, reactive } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
@@ -87,6 +87,16 @@ const navMain = computed(() => {
|
|||||||
url: '/agent/users',
|
url: '/agent/users',
|
||||||
icon: Users,
|
icon: Users,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: t('nav.devices'),
|
||||||
|
url: '/agent/devices',
|
||||||
|
icon: Monitor,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('nav.sessions'),
|
||||||
|
url: '/agent/sessions',
|
||||||
|
icon: Wifi,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -121,6 +131,8 @@ const breadcrumbs = computed(() => {
|
|||||||
const titleMap: Record<string, string> = {
|
const titleMap: Record<string, string> = {
|
||||||
cards: t('nav.cards'),
|
cards: t('nav.cards'),
|
||||||
users: t('nav.users'),
|
users: t('nav.users'),
|
||||||
|
devices: t('nav.devices'),
|
||||||
|
sessions: t('nav.sessions'),
|
||||||
finance: t('nav.finance'),
|
finance: t('nav.finance'),
|
||||||
'cloud-variables': t('nav.cloudVariables'),
|
'cloud-variables': t('nav.cloudVariables'),
|
||||||
profile: t('nav.profile'),
|
profile: t('nav.profile'),
|
||||||
|
|||||||
@@ -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">
|
<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 { onMounted, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
@@ -15,6 +15,7 @@ const stats = ref({
|
|||||||
totalRevenue: 0,
|
totalRevenue: 0,
|
||||||
todayCards: 0,
|
todayCards: 0,
|
||||||
todayRevenue: 0,
|
todayRevenue: 0,
|
||||||
|
balance: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -25,6 +26,7 @@ onMounted(async () => {
|
|||||||
totalRevenue?: number
|
totalRevenue?: number
|
||||||
todayCards?: number
|
todayCards?: number
|
||||||
todayRevenue?: number
|
todayRevenue?: number
|
||||||
|
balance?: number
|
||||||
}>('/agent/stats')
|
}>('/agent/stats')
|
||||||
if (data) {
|
if (data) {
|
||||||
stats.value = {
|
stats.value = {
|
||||||
@@ -33,6 +35,7 @@ onMounted(async () => {
|
|||||||
totalRevenue: data.totalRevenue || 0,
|
totalRevenue: data.totalRevenue || 0,
|
||||||
todayCards: data.todayCards || 0,
|
todayCards: data.todayCards || 0,
|
||||||
todayRevenue: data.todayRevenue || 0,
|
todayRevenue: data.todayRevenue || 0,
|
||||||
|
balance: data.balance || 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,7 +54,24 @@ onMounted(async () => {
|
|||||||
description="代理商后台控制台"
|
description="代理商后台控制台"
|
||||||
sticky
|
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>
|
<UiCard>
|
||||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2">
|
<UiCardHeader class="flex flex-row items-center justify-between pb-2">
|
||||||
<UiCardTitle class="text-sm font-medium">
|
<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,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} 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: {
|
export function getColumns(actions: {
|
||||||
onEdit: (row: User) => void
|
onEdit: (row: User) => void
|
||||||
onToggleStatus: (row: User) => void
|
onToggleStatus: (row: User) => void
|
||||||
@@ -44,17 +60,80 @@ export function getColumns(actions: {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'status',
|
accessorKey: 'application.name',
|
||||||
header: () => t('agent.users.columns.status'),
|
header: () => t('agent.users.columns.application'),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const status = row.getValue('status') as string
|
const appName = row.original.application?.name
|
||||||
const statusMap: Record<string, { label: string, variant: 'default' | 'secondary' | 'destructive' }> = {
|
return appName ? h(Badge, { variant: 'secondary' }, () => appName) : '-'
|
||||||
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' },
|
{
|
||||||
|
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
|
username: string
|
||||||
email: string
|
email: string
|
||||||
status: string
|
status: string
|
||||||
created_at: string
|
balance: number | null
|
||||||
|
expiry_at: string | null
|
||||||
last_login_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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2712,6 +2712,8 @@
|
|||||||
"totalUsersDesc": "Cumulative registered users",
|
"totalUsersDesc": "Cumulative registered users",
|
||||||
"totalRevenue": "Total Revenue",
|
"totalRevenue": "Total Revenue",
|
||||||
"todayRevenue": "Today",
|
"todayRevenue": "Today",
|
||||||
|
"balance": "Account Balance",
|
||||||
|
"balanceDesc": "Available for card generation and recharge",
|
||||||
"quickActions": "Quick Actions",
|
"quickActions": "Quick Actions",
|
||||||
"generateCards": "Generate Cards",
|
"generateCards": "Generate Cards",
|
||||||
"appManagement": "App Management",
|
"appManagement": "App Management",
|
||||||
@@ -2779,10 +2781,25 @@
|
|||||||
"columns": {
|
"columns": {
|
||||||
"username": "Username",
|
"username": "Username",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
|
"application": "Application",
|
||||||
|
"onlineStatus": "Online Status",
|
||||||
|
"deviceCount": "Devices",
|
||||||
|
"balance": "Balance",
|
||||||
|
"expiryAt": "Expiry",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"createdAt": "Created At",
|
"createdAt": "Created At",
|
||||||
"lastLoginAt": "Last Login"
|
"lastLoginAt": "Last Login"
|
||||||
},
|
},
|
||||||
|
"onlineStatus": {
|
||||||
|
"online": "Online",
|
||||||
|
"offline": "Offline",
|
||||||
|
"banned": "Banned"
|
||||||
|
},
|
||||||
|
"expiry": {
|
||||||
|
"permanent": "Permanent",
|
||||||
|
"expired": "Expired",
|
||||||
|
"daysLeft": "days left"
|
||||||
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"active": "Active",
|
"active": "Active",
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
@@ -2950,6 +2967,39 @@
|
|||||||
"generateNow": "Generate Now"
|
"generateNow": "Generate Now"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"devices": {
|
||||||
|
"title": "Device Management",
|
||||||
|
"description": "Manage devices under your applications",
|
||||||
|
"totalDevices": "Total Devices",
|
||||||
|
"onlineSessions": "Online Sessions",
|
||||||
|
"normalDevices": "Normal Devices",
|
||||||
|
"banned": "Banned",
|
||||||
|
"normal": "Normal",
|
||||||
|
"application": "Application",
|
||||||
|
"status": "Status",
|
||||||
|
"statusUpdateSuccess": "Device status updated successfully",
|
||||||
|
"statusUpdateFailed": "Failed to update device status",
|
||||||
|
"forceOffline": "Force Offline",
|
||||||
|
"forceOfflineTitle": "Force Offline",
|
||||||
|
"forceOfflineConfirm": "Are you sure you want to force offline all sessions for device \"{deviceId}\"?",
|
||||||
|
"forceOfflineSuccess": "Forced {count} sessions offline",
|
||||||
|
"forceOfflineFailed": "Failed to force offline"
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"title": "Online Sessions",
|
||||||
|
"description": "View all online session details",
|
||||||
|
"onlineSessions": "Online Sessions",
|
||||||
|
"onlineApps": "Online Apps",
|
||||||
|
"onlineDevices": "Online Devices",
|
||||||
|
"onlineUsers": "Online Users",
|
||||||
|
"application": "Application",
|
||||||
|
"loadFailed": "Failed to load sessions",
|
||||||
|
"forceOffline": "Force Offline",
|
||||||
|
"forceOfflineTitle": "Force Offline",
|
||||||
|
"forceOfflineConfirm": "Are you sure you want to force offline session \"{instanceId}\"?",
|
||||||
|
"forceOfflineSuccess": "Session forced offline successfully",
|
||||||
|
"forceOfflineFailed": "Failed to force offline session"
|
||||||
|
},
|
||||||
"cloudVariables": {
|
"cloudVariables": {
|
||||||
"title": "Cloud Variables",
|
"title": "Cloud Variables",
|
||||||
"description": "View cloud variables (read-only)",
|
"description": "View cloud variables (read-only)",
|
||||||
|
|||||||
@@ -2714,6 +2714,8 @@
|
|||||||
"totalUsersDesc": "累计注册用户",
|
"totalUsersDesc": "累计注册用户",
|
||||||
"totalRevenue": "累计收入",
|
"totalRevenue": "累计收入",
|
||||||
"todayRevenue": "今日",
|
"todayRevenue": "今日",
|
||||||
|
"balance": "账户余额",
|
||||||
|
"balanceDesc": "可用于生成卡密和充值",
|
||||||
"quickActions": "快捷操作",
|
"quickActions": "快捷操作",
|
||||||
"generateCards": "生成卡密",
|
"generateCards": "生成卡密",
|
||||||
"appManagement": "应用管理",
|
"appManagement": "应用管理",
|
||||||
@@ -2781,10 +2783,25 @@
|
|||||||
"columns": {
|
"columns": {
|
||||||
"username": "用户名",
|
"username": "用户名",
|
||||||
"email": "邮箱",
|
"email": "邮箱",
|
||||||
|
"application": "所属应用",
|
||||||
|
"onlineStatus": "在线状态",
|
||||||
|
"deviceCount": "设备数",
|
||||||
|
"balance": "余额",
|
||||||
|
"expiryAt": "到期时间",
|
||||||
"status": "状态",
|
"status": "状态",
|
||||||
"createdAt": "注册时间",
|
"createdAt": "注册时间",
|
||||||
"lastLoginAt": "最后登录"
|
"lastLoginAt": "最后登录"
|
||||||
},
|
},
|
||||||
|
"onlineStatus": {
|
||||||
|
"online": "在线",
|
||||||
|
"offline": "离线",
|
||||||
|
"banned": "封禁"
|
||||||
|
},
|
||||||
|
"expiry": {
|
||||||
|
"permanent": "永久",
|
||||||
|
"expired": "已过期",
|
||||||
|
"daysLeft": "天剩余"
|
||||||
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"active": "正常",
|
"active": "正常",
|
||||||
"disabled": "禁用",
|
"disabled": "禁用",
|
||||||
@@ -2952,6 +2969,39 @@
|
|||||||
"generateNow": "立即生成"
|
"generateNow": "立即生成"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"devices": {
|
||||||
|
"title": "设备管理",
|
||||||
|
"description": "管理您应用下的设备",
|
||||||
|
"totalDevices": "设备总数",
|
||||||
|
"onlineSessions": "在线实例",
|
||||||
|
"normalDevices": "正常设备",
|
||||||
|
"banned": "已禁用",
|
||||||
|
"normal": "正常",
|
||||||
|
"application": "应用",
|
||||||
|
"status": "状态",
|
||||||
|
"statusUpdateSuccess": "设备状态更新成功",
|
||||||
|
"statusUpdateFailed": "设备状态更新失败",
|
||||||
|
"forceOffline": "强制离线",
|
||||||
|
"forceOfflineTitle": "强制离线",
|
||||||
|
"forceOfflineConfirm": "确定要将设备 \"{deviceId}\" 的所有在线实例强制离线吗?",
|
||||||
|
"forceOfflineSuccess": "已强制离线 {count} 个实例",
|
||||||
|
"forceOfflineFailed": "强制离线失败"
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"title": "在线实例",
|
||||||
|
"description": "查看所有在线实例详情",
|
||||||
|
"onlineSessions": "在线实例数",
|
||||||
|
"onlineApps": "在线应用数",
|
||||||
|
"onlineDevices": "在线设备数",
|
||||||
|
"onlineUsers": "在线用户数",
|
||||||
|
"application": "应用",
|
||||||
|
"loadFailed": "获取会话列表失败",
|
||||||
|
"forceOffline": "强制下线",
|
||||||
|
"forceOfflineTitle": "强制下线",
|
||||||
|
"forceOfflineConfirm": "确定要将实例 \"{instanceId}\" 强制下线吗?",
|
||||||
|
"forceOfflineSuccess": "强制下线成功",
|
||||||
|
"forceOfflineFailed": "强制下线失败"
|
||||||
|
},
|
||||||
"cloudVariables": {
|
"cloudVariables": {
|
||||||
"title": "云端变量",
|
"title": "云端变量",
|
||||||
"description": "查看云端变量数据(只读)",
|
"description": "查看云端变量数据(只读)",
|
||||||
|
|||||||
@@ -462,6 +462,18 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/pages/agent/finance/index.vue'),
|
component: () => import('@/pages/agent/finance/index.vue'),
|
||||||
meta: { title: '财务管理 - 代理商后台' },
|
meta: { title: '财务管理 - 代理商后台' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'devices',
|
||||||
|
name: 'AgentDevices',
|
||||||
|
component: () => import('@/pages/agent/devices/index.vue'),
|
||||||
|
meta: { title: '设备管理 - 代理商后台' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'sessions',
|
||||||
|
name: 'AgentSessions',
|
||||||
|
component: () => import('@/pages/agent/sessions/index.vue'),
|
||||||
|
meta: { title: '在线实例 - 代理商后台' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'cloud-variables',
|
path: 'cloud-variables',
|
||||||
name: 'AgentCloudVariables',
|
name: 'AgentCloudVariables',
|
||||||
|
|||||||
Reference in New Issue
Block a user