feat: 代理管理添加批量操作功能,用户管理添加到期时间列
- 代理管理页面添加批量解封、批量封禁、批量删除功能 - 后端添加代理批量操作API - 用户管理添加独立的到期时间列 - 修复应用管理和日志记录页面顶部卡片图标颜色
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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": "操作"
|
||||
|
||||
Reference in New Issue
Block a user