feat: 代理管理添加批量操作功能,用户管理添加到期时间列

- 代理管理页面添加批量解封、批量封禁、批量删除功能
- 后端添加代理批量操作API
- 用户管理添加独立的到期时间列
- 修复应用管理和日志记录页面顶部卡片图标颜色
This commit is contained in:
2026-04-30 21:10:54 +08:00
parent 26349c15d3
commit 2da9447397
9 changed files with 187 additions and 27 deletions
@@ -23,6 +23,8 @@ func SetupAgentsRoutes(r *gin.RouterGroup) {
agents.DELETE("/:id", handleDeleteAgent)
agents.GET("/:id/cards", handleGetAgentCards)
agents.PUT("/:id/cards", handleUpdateAgentCards)
agents.POST("/batch/status", handleBatchUpdateAgentStatus)
agents.DELETE("/batch", handleBatchDeleteAgents)
}
}
@@ -583,3 +585,63 @@ func handleUpdateAgentCards(c *gin.Context) {
"message": "更新成功",
})
}
func handleBatchUpdateAgentStatus(c *gin.Context) {
var req struct {
AgentIDs []uint `json:"agent_ids" binding:"required"`
Status string `json:"status" binding:"required,oneof=active inactive banned"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, 400, "参数错误")
return
}
if len(req.AgentIDs) == 0 {
response.Error(c, 400, "请选择要操作的代理")
return
}
if err := database.DB.Model(&model.User{}).
Where("id IN ? AND role = ?", req.AgentIDs, "agent").
Update("status", req.Status).Error; err != nil {
response.Error(c, 500, "批量更新状态失败")
return
}
service.LogOperation(c, "batch_update_status", "agent", nil, fmt.Sprintf("批量更新代理状态: %d个代理 -> %s", len(req.AgentIDs), req.Status), nil)
response.Success(c, gin.H{
"updated": len(req.AgentIDs),
})
}
func handleBatchDeleteAgents(c *gin.Context) {
var req struct {
AgentIDs []uint `json:"agent_ids" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, 400, "参数错误")
return
}
if len(req.AgentIDs) == 0 {
response.Error(c, 400, "请选择要删除的代理")
return
}
for _, agentID := range req.AgentIDs {
var user model.User
if err := database.DB.Where("id = ? AND role = ?", agentID, "agent").First(&user).Error; err != nil {
continue
}
database.DB.Model(&model.User{}).Where("parent_agent_id = ?", user.ID).Update("parent_agent_id", nil)
database.DB.Delete(&user)
}
service.LogOperation(c, "batch_delete", "agent", nil, fmt.Sprintf("批量删除代理: %d个", len(req.AgentIDs)), nil)
response.Success(c, gin.H{
"deleted": len(req.AgentIDs),
})
}
@@ -7,6 +7,7 @@ import { useI18n } from 'vue-i18n'
import type { DataTableProps } from '@/components/data-table/types'
import type { Agent } from '../data/schema'
import BulkActions from '@/components/data-table/bulk-actions.vue'
import DataTable from '@/components/data-table/data-table.vue'
import { SelectColumn } from '@/components/data-table/table-columns'
import { generateVueTable } from '@/components/data-table/use-generate-vue-table'
@@ -26,6 +27,9 @@ const emit = defineEmits<{
'refresh': []
'toggleView': []
'update:searchFilter': [value: string]
'batchUnban': []
'batchBan': []
'batchDelete': []
}>()
const { t } = useI18n()
@@ -68,6 +72,20 @@ defineExpose({
<DataTable :columns="columns" :data :loading :table @refresh="emit('refresh')">
<template #toolbar>
<div class="space-y-3">
<div class="flex items-center justify-end">
<slot name="actions" />
</div>
<BulkActions :table="table" entity-name="agents">
<UiButton variant="outline" size="sm" @click="emit('batchUnban')">
{{ t('developer.agents.batchUnbanBtn') }}
</UiButton>
<UiButton variant="outline" size="sm" @click="emit('batchBan')">
{{ t('developer.agents.batchBanBtn') }}
</UiButton>
<UiButton variant="destructive" size="sm" @click="emit('batchDelete')">
{{ t('developer.agents.batchDeleteBtn') }}
</UiButton>
</BulkActions>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-2">
<div class="relative">
+49
View File
@@ -25,6 +25,8 @@ const viewMode = ref<'tree' | 'list'>('tree')
const deleteDialogOpen = ref(false)
const deleteTarget = ref<Agent | null>(null)
const batchDeleteDialogOpen = ref(false)
const batchDeleteIds = ref<number[]>([])
const filteredAgents = computed(() => {
let result = agents.value
@@ -185,6 +187,35 @@ async function handleDelete() {
}
}
function confirmBatchDelete(ids: number[]) {
batchDeleteIds.value = ids
batchDeleteDialogOpen.value = true
}
async function handleBatchDelete() {
try {
await api.delete('/dev/agents/batch', { agent_ids: batchDeleteIds.value } as any)
toast.success(t('developer.agents.batchDeleteSuccess'))
fetchAgents()
} catch (error: any) {
console.error('批量删除失败:', error)
toast.error(error.message || t('developer.agents.batchDeleteFailed'))
} finally {
batchDeleteIds.value = []
}
}
async function batchToggleStatus(ids: number[], status: string) {
try {
await api.post('/dev/agents/batch/status', { agent_ids: ids, status })
toast.success(t('developer.agents.batchUpdateSuccess'))
fetchAgents()
} catch (error: any) {
console.error('批量更新状态失败:', error)
toast.error(error.message || t('developer.agents.batchUpdateFailed'))
}
}
onMounted(() => {
fetchAgents()
})
@@ -276,6 +307,9 @@ onMounted(() => {
@refresh="fetchAgents"
@toggle-view="toggleViewMode"
@update:search-filter="searchFilter = $event"
@batch-unban="batchToggleStatus(tableRef?.table?.getSelectedRowModel().rows.filter((r: any) => r.original.status === 'banned').map((r: any) => r.original.id) || [], 'active')"
@batch-ban="batchToggleStatus(tableRef?.table?.getSelectedRowModel().rows.filter((r: any) => r.original.status !== 'banned').map((r: any) => r.original.id) || [], 'banned')"
@batch-delete="confirmBatchDelete(tableRef?.table?.getSelectedRowModel().rows.map((r: any) => r.original.id) || [])"
>
<template #filters>
<SingleFilter
@@ -303,5 +337,20 @@ onMounted(() => {
{{ t('developer.agents.deleteAgentConfirm', { username: deleteTarget?.username }) }}
</template>
</ConfirmDialog>
<ConfirmDialog
v-model:open="batchDeleteDialogOpen"
destructive
:confirm-button-text="t('common.delete')"
:cancel-button-text="t('common.cancel')"
@confirm="handleBatchDelete"
>
<template #title>
{{ t('developer.agents.batchDeleteAgents') }}
</template>
<template #description>
{{ t('developer.agents.batchDeleteConfirm', { count: batchDeleteIds.length }) }}
</template>
</ConfirmDialog>
</BasicPage>
</template>
+3 -3
View File
@@ -151,7 +151,7 @@ onMounted(() => {
<UiCardTitle class="text-sm font-medium">
{{ t('developer.applications.activeApps') }}
</UiCardTitle>
<Icon icon="lucide:check-circle" class="size-4 text-green-500" />
<Icon icon="lucide:check-circle" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
@@ -165,7 +165,7 @@ onMounted(() => {
<UiCardTitle class="text-sm font-medium">
{{ t('developer.applications.totalUsers') }}
</UiCardTitle>
<Icon icon="lucide:users" class="size-4 text-blue-500" />
<Icon icon="lucide:users" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
@@ -179,7 +179,7 @@ onMounted(() => {
<UiCardTitle class="text-sm font-medium">
{{ t('developer.applications.totalVerifyCount') }}
</UiCardTitle>
<Icon icon="lucide:activity" class="size-4 text-purple-500" />
<Icon icon="lucide:activity" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
+2 -2
View File
@@ -153,7 +153,7 @@ onMounted(() => {
<Icon icon="lucide:check-circle" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold text-green-600">
<div class="text-2xl font-bold">
{{ logs.filter(log => log.status === 'success').length }}
</div>
</UiCardContent>
@@ -167,7 +167,7 @@ onMounted(() => {
<Icon icon="lucide:x-circle" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold text-red-600">
<div class="text-2xl font-bold">
{{ logs.filter(log => log.status === 'failed').length }}
</div>
</UiCardContent>
@@ -76,7 +76,6 @@ export function getColumns(actions: {
header: () => t('developer.users.columns.balance'),
cell: ({ row }) => {
const balance = row.getValue('balance') as number
const expiryAt = row.original.expiry_at
const billingType = row.original.application?.billing_type
if (balance === -1) {
@@ -84,16 +83,42 @@ export function getColumns(actions: {
}
if (billingType === 'subscription') {
if (!expiryAt)
if (balance === null || balance === undefined)
return '-'
return h('span', { class: balance > 0 ? 'text-green-600 font-medium' : 'text-muted-foreground' }, balance.toFixed(2))
}
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('developer.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 as string)
const date = new Date(expiryAt)
if (Number.isNaN(date.getTime()))
return '-'
const now = new Date()
if (date < now) {
return h(Badge, { variant: 'destructive' }, () => t('developer.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('developer.users.expiry.daysLeft')}`),
@@ -103,12 +128,6 @@ export function getColumns(actions: {
catch {
return '-'
}
}
if (balance === null || balance === undefined)
return '-'
return h('span', { class: balance > 0 ? 'text-green-600 font-medium' : 'text-muted-foreground' }, balance.toFixed(2))
},
},
{
@@ -53,6 +53,8 @@ const columnLabels: Record<string, string> = {
'email': 'developer.users.columns.email',
'application.name': 'developer.users.columns.application',
'online_status': 'developer.users.columns.status',
'balance': 'developer.users.columns.balance',
'expiry_at': 'developer.users.columns.expiryAt',
'last_login_at': 'developer.users.columns.lastLoginAt',
'created_at': 'developer.users.columns.createdAt',
}
@@ -174,7 +174,7 @@ onMounted(() => {
<UiCardTitle class="text-sm font-medium">
{{ t('developer.applications.activeApps') }}
</UiCardTitle>
<Icon icon="lucide:check-circle" class="size-4 text-green-500" />
<Icon icon="lucide:check-circle" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
@@ -188,7 +188,7 @@ onMounted(() => {
<UiCardTitle class="text-sm font-medium">
{{ t('developer.applications.totalUsers') }}
</UiCardTitle>
<Icon icon="lucide:users" class="size-4 text-blue-500" />
<Icon icon="lucide:users" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
@@ -202,7 +202,7 @@ onMounted(() => {
<UiCardTitle class="text-sm font-medium">
{{ t('developer.applications.totalVerifyCount') }}
</UiCardTitle>
<Icon icon="lucide:activity" class="size-4 text-purple-500" />
<Icon icon="lucide:activity" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
+10
View File
@@ -468,6 +468,15 @@
"statusUpdateFailed": "状态更新失败",
"deleteSuccess": "删除成功",
"deleteFailed": "删除失败",
"batchUnbanBtn": "批量解封",
"batchBanBtn": "批量封禁",
"batchDeleteBtn": "批量删除",
"batchDeleteAgents": "批量删除代理",
"batchDeleteConfirm": "确定要删除选中的 {count} 个代理吗?此操作不可撤销。",
"batchDeleteSuccess": "批量删除成功",
"batchDeleteFailed": "批量删除失败",
"batchUpdateSuccess": "批量更新成功",
"batchUpdateFailed": "批量更新失败",
"edit": {
"title": "编辑代理",
"description": "修改代理信息",
@@ -793,6 +802,7 @@
"status": "状态",
"deviceCount": "绑定设备",
"balance": "余额",
"expiryAt": "到期时间",
"lastLoginAt": "最后登录",
"createdAt": "注册时间",
"actions": "操作"