diff --git a/backend/internal/router/admin/devices.go b/backend/internal/router/admin/devices.go index 454c3e6..3867516 100644 --- a/backend/internal/router/admin/devices.go +++ b/backend/internal/router/admin/devices.go @@ -22,22 +22,22 @@ type DeviceWithDetails struct { func SetupDeviceRoutes(r *gin.RouterGroup) { devices := r.Group("/devices") { - devices.GET("", handleGetDevices) - devices.PUT("/:id/status", handleUpdateDeviceStatus) - devices.DELETE("/:id", handleUnbindDevice) - devices.DELETE("/batch", handleBatchUnbindDevices) - devices.POST("/batch/status", handleBatchUpdateDeviceStatus) - devices.POST("/:id/force-offline", handleForceOfflineDevice) + devices.GET("", HandleGetDevices) + devices.PUT("/:id/status", HandleUpdateDeviceStatus) + devices.DELETE("/:id", HandleUnbindDevice) + devices.DELETE("/batch", HandleBatchUnbindDevices) + devices.POST("/batch/status", HandleBatchUpdateDeviceStatus) + devices.POST("/:id/force-offline", HandleForceOfflineDevice) } sessions := r.Group("/sessions") { - sessions.GET("", handleGetSessions) - sessions.DELETE("/:id", handleDeleteSession) + sessions.GET("", HandleGetSessions) + sessions.DELETE("/:id", HandleDeleteSession) } } -func handleGetDevices(c *gin.Context) { +func HandleGetDevices(c *gin.Context) { userID := c.GetUint("user_id") 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") deviceID := c.Param("id") @@ -221,7 +221,7 @@ func handleUpdateDeviceStatus(c *gin.Context) { response.Success(c, device) } -func handleUnbindDevice(c *gin.Context) { +func HandleUnbindDevice(c *gin.Context) { userID := c.GetUint("user_id") deviceID := c.Param("id") @@ -257,7 +257,7 @@ func handleUnbindDevice(c *gin.Context) { response.Success(c, nil) } -func handleBatchUnbindDevices(c *gin.Context) { +func HandleBatchUnbindDevices(c *gin.Context) { userID := c.GetUint("user_id") var req struct { DeviceIDs []uint `json:"device_ids"` @@ -303,7 +303,7 @@ func handleBatchUnbindDevices(c *gin.Context) { response.Success(c, nil) } -func handleBatchUpdateDeviceStatus(c *gin.Context) { +func HandleBatchUpdateDeviceStatus(c *gin.Context) { userID := c.GetUint("user_id") var req struct { DeviceIDs []uint `json:"device_ids"` @@ -356,7 +356,7 @@ type SessionWithDetails struct { IsOnline bool `json:"is_online"` } -func handleGetSessions(c *gin.Context) { +func HandleGetSessions(c *gin.Context) { userID := c.GetUint("user_id") deviceIDFilter := c.Query("device_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}) } -func handleDeleteSession(c *gin.Context) { +func HandleDeleteSession(c *gin.Context) { userID := c.GetUint("user_id") sessionID := c.Param("id") @@ -529,7 +529,7 @@ func handleDeleteSession(c *gin.Context) { response.Success(c, nil) } -func handleForceOfflineDevice(c *gin.Context) { +func HandleForceOfflineDevice(c *gin.Context) { userID := c.GetUint("user_id") deviceID := c.Param("id") diff --git a/backend/internal/router/admin/users.go b/backend/internal/router/admin/users.go index cdb4770..a0261aa 100644 --- a/backend/internal/router/admin/users.go +++ b/backend/internal/router/admin/users.go @@ -47,7 +47,7 @@ func SetupUserRoutes(r *gin.RouterGroup) { appUsers.PUT("/:id", handleUpdateUser) appUsers.DELETE("/:id", handleDeleteUser) appUsers.GET("/:id/devices", handleGetUserDevices) - appUsers.DELETE("/:id/devices/:deviceId", handleUnbindDevice) + appUsers.DELETE("/:id/devices/:deviceId", HandleUnbindDevice) appUsers.PUT("/:id/expiry", handleUpdateExpiry) appUsers.POST("/batch/status", handleBatchUpdateStatus) appUsers.DELETE("/batch", handleBatchDelete) diff --git a/backend/internal/router/agent/agent.go b/backend/internal/router/agent/agent.go index e34813f..2c495ca 100644 --- a/backend/internal/router/agent/agent.go +++ b/backend/internal/router/agent/agent.go @@ -10,6 +10,7 @@ import ( "time" "verification-platform-backend/internal/database" "verification-platform-backend/internal/model" + "verification-platform-backend/internal/router/admin" "verification-platform-backend/internal/service" "verification-platform-backend/pkg/response" "verification-platform-backend/pkg/utils" @@ -35,6 +36,14 @@ func SetupAgentRoutes(r *gin.RouterGroup) { r.GET("/cards/export", handleExportCards) r.GET("/cloud-variables", handleGetCloudVariables) 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) { @@ -68,6 +77,9 @@ func handleGetStats(c *gin.Context) { 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) + var agent model.User + database.DB.First(&agent, userID) + response.Success(c, gin.H{ "totalApps": totalApps, "totalCards": totalCards, @@ -75,6 +87,7 @@ func handleGetStats(c *gin.Context) { "totalRevenue": totalRevenue, "todayCards": todayCards, "todayRevenue": todayRevenue, + "balance": agent.Balance, }) } @@ -482,10 +495,50 @@ func handleGetUsers(c *gin.Context) { 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{ - "users": users, + "users": result, "total": total, "active_count": activeCount, "disabled_count": disabledCount, diff --git a/frontend/src/layouts/agent.vue b/frontend/src/layouts/agent.vue index 75ea8de..3e52b3a 100644 --- a/frontend/src/layouts/agent.vue +++ b/frontend/src/layouts/agent.vue @@ -1,5 +1,5 @@ + + + + + + + + + {{ t('agent.devices.totalDevices') }} + + + + + + {{ totalDevices }} + + + + + + + + {{ t('agent.devices.onlineSessions') }} + + + + + + {{ onlineSessionCount }} + + + + + + + + {{ t('agent.devices.normalDevices') }} + + + + + + {{ totalDevices - bannedCount }} + + + + + + + + {{ t('agent.devices.banned') }} + + + + + + {{ bannedCount }} + + + + + + + + + + + + + + + + + + + + + + + + {{ t('agent.devices.forceOfflineTitle') }} + + + {{ t('agent.devices.forceOfflineConfirm', { deviceId: forceOfflineTarget?.device_id }) }} + + + + diff --git a/frontend/src/pages/agent/index.vue b/frontend/src/pages/agent/index.vue index 8f36073..9ad5150 100644 --- a/frontend/src/pages/agent/index.vue +++ b/frontend/src/pages/agent/index.vue @@ -1,5 +1,5 @@ + + + + + + + + + {{ t('agent.sessions.onlineSessions') }} + + + + + + {{ totalSessions }} + + + + + + + + {{ t('agent.sessions.onlineApps') }} + + + + + + {{ onlineApps }} + + + + + + + + {{ t('agent.sessions.onlineDevices') }} + + + + + + {{ onlineDevices }} + + + + + + + + {{ t('agent.sessions.onlineUsers') }} + + + + + + {{ onlineUsers }} + + + + + + + + + + + + + + + + + + + + + + + {{ t('agent.sessions.forceOfflineTitle') }} + + + {{ t('agent.sessions.forceOfflineConfirm', { instanceId: forceOfflineTarget?.instance_id }) }} + + + + diff --git a/frontend/src/pages/agent/users/components/columns.ts b/frontend/src/pages/agent/users/components/columns.ts index fbfec82..66713c6 100644 --- a/frontend/src/pages/agent/users/components/columns.ts +++ b/frontend/src/pages/agent/users/components/columns.ts @@ -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 = { - 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) }, }, { diff --git a/frontend/src/pages/agent/users/data/schema.ts b/frontend/src/pages/agent/users/data/schema.ts index e870472..f52cc51 100644 --- a/frontend/src/pages/agent/users/data/schema.ts +++ b/frontend/src/pages/agent/users/data/schema.ts @@ -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 + } } diff --git a/frontend/src/plugins/i18n/en.json b/frontend/src/plugins/i18n/en.json index 344dc6c..6601c1b 100644 --- a/frontend/src/plugins/i18n/en.json +++ b/frontend/src/plugins/i18n/en.json @@ -2712,6 +2712,8 @@ "totalUsersDesc": "Cumulative registered users", "totalRevenue": "Total Revenue", "todayRevenue": "Today", + "balance": "Account Balance", + "balanceDesc": "Available for card generation and recharge", "quickActions": "Quick Actions", "generateCards": "Generate Cards", "appManagement": "App Management", @@ -2779,10 +2781,25 @@ "columns": { "username": "Username", "email": "Email", + "application": "Application", + "onlineStatus": "Online Status", + "deviceCount": "Devices", + "balance": "Balance", + "expiryAt": "Expiry", "status": "Status", "createdAt": "Created At", "lastLoginAt": "Last Login" }, + "onlineStatus": { + "online": "Online", + "offline": "Offline", + "banned": "Banned" + }, + "expiry": { + "permanent": "Permanent", + "expired": "Expired", + "daysLeft": "days left" + }, "status": { "active": "Active", "disabled": "Disabled", @@ -2950,6 +2967,39 @@ "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": { "title": "Cloud Variables", "description": "View cloud variables (read-only)", diff --git a/frontend/src/plugins/i18n/zh.json b/frontend/src/plugins/i18n/zh.json index 9f186b8..5fdd44d 100644 --- a/frontend/src/plugins/i18n/zh.json +++ b/frontend/src/plugins/i18n/zh.json @@ -2714,6 +2714,8 @@ "totalUsersDesc": "累计注册用户", "totalRevenue": "累计收入", "todayRevenue": "今日", + "balance": "账户余额", + "balanceDesc": "可用于生成卡密和充值", "quickActions": "快捷操作", "generateCards": "生成卡密", "appManagement": "应用管理", @@ -2781,10 +2783,25 @@ "columns": { "username": "用户名", "email": "邮箱", + "application": "所属应用", + "onlineStatus": "在线状态", + "deviceCount": "设备数", + "balance": "余额", + "expiryAt": "到期时间", "status": "状态", "createdAt": "注册时间", "lastLoginAt": "最后登录" }, + "onlineStatus": { + "online": "在线", + "offline": "离线", + "banned": "封禁" + }, + "expiry": { + "permanent": "永久", + "expired": "已过期", + "daysLeft": "天剩余" + }, "status": { "active": "正常", "disabled": "禁用", @@ -2952,6 +2969,39 @@ "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": { "title": "云端变量", "description": "查看云端变量数据(只读)", diff --git a/frontend/src/router/routes.ts b/frontend/src/router/routes.ts index b14092c..8fc2c6e 100644 --- a/frontend/src/router/routes.ts +++ b/frontend/src/router/routes.ts @@ -462,6 +462,18 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/pages/agent/finance/index.vue'), 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', name: 'AgentCloudVariables',