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:
2026-05-11 10:46:29 +08:00
parent a76a30ee78
commit 853c22fdbe
12 changed files with 848 additions and 32 deletions
+13 -1
View File
@@ -1,5 +1,5 @@
<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 { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
@@ -87,6 +87,16 @@ const navMain = computed(() => {
url: '/agent/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> = {
cards: t('nav.cards'),
users: t('nav.users'),
devices: t('nav.devices'),
sessions: t('nav.sessions'),
finance: t('nav.finance'),
'cloud-variables': t('nav.cloudVariables'),
profile: t('nav.profile'),
+273
View File
@@ -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>
+22 -2
View File
@@ -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">
+255
View File
@@ -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)
},
},
{
+13 -1
View File
@@ -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
}
}
+50
View File
@@ -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)",
+50
View File
@@ -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": "查看云端变量数据(只读)",
+12
View File
@@ -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',