fix: 修复授权详情API返回格式,重构授权管理页面表格和批量操作

This commit is contained in:
2026-05-07 02:31:15 +08:00
parent 9e8f5bc826
commit 64569e305e
4 changed files with 338 additions and 152 deletions
+12 -4
View File
@@ -568,9 +568,11 @@ func handleGetAgentAppDetail(c *gin.Context) {
}
type CardTypeResponse struct {
ID uint `json:"id"`
CardTypeID uint `json:"card_type_id"`
Name string `json:"name"`
BillingType string `json:"billing_type"`
Price float64 `json:"price"`
OriginPrice float64 `json:"origin_price"`
CanGenerate bool `json:"can_generate"`
}
@@ -583,6 +585,7 @@ func handleGetAgentAppDetail(c *gin.Context) {
AppName string `json:"app_name"`
Discount float64 `json:"discount"`
Status string `json:"status"`
Balance float64 `json:"balance"`
CardTypes []CardTypeResponse `json:"card_types"`
CreatedAt string `json:"created_at"`
}
@@ -590,9 +593,11 @@ func handleGetAgentAppDetail(c *gin.Context) {
var cardTypes []CardTypeResponse
for _, ct := range agentApp.CardTypes {
cardTypes = append(cardTypes, CardTypeResponse{
ID: ct.CardTypeID,
CardTypeID: ct.CardTypeID,
Name: ct.CardType.Name,
Price: ct.CardType.Price,
BillingType: ct.CardType.RechargeType,
Price: ct.Price,
OriginPrice: ct.CardType.Price,
CanGenerate: ct.CanGenerate,
})
}
@@ -605,6 +610,7 @@ func handleGetAgentAppDetail(c *gin.Context) {
AppName: application.Name,
Discount: agentApp.Discount,
Status: agentApp.Status,
Balance: agent.Balance,
CardTypes: cardTypes,
CreatedAt: agentApp.CreatedAt.Format("2006-01-02 15:04:05"),
}
@@ -613,7 +619,9 @@ func handleGetAgentAppDetail(c *gin.Context) {
result.AgentEmail = *agent.Email
}
response.Success(c, result)
response.Success(c, gin.H{
"agent_app": result,
})
}
func handleUpdateAgentApp(c *gin.Context) {
@@ -0,0 +1,144 @@
import type { ColumnDef } from '@tanstack/vue-table'
import { Eye, MoreHorizontal, Pencil, Trash2 } from 'lucide-vue-next'
import { h } from 'vue'
import type { AgentApp } from '@/pages/admin/agent-apps/data/schema'
import Badge from '@/components/ui/badge/Badge.vue'
import Button from '@/components/ui/button/Button.vue'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
export function getColumns(actions: {
onView: (row: AgentApp) => void
onEdit: (row: AgentApp) => void
onDelete: (row: AgentApp) => void
}): ColumnDef<AgentApp>[] {
return [
{
accessorKey: 'agent_name',
header: '代理',
cell: ({ row }) => {
const name = row.getValue('agent_name') as string
const email = row.original.agent_email
return h('div', { class: 'flex items-center space-x-3' }, [
h('div', { class: 'h-8 w-8 rounded bg-primary/10 flex items-center justify-center flex-shrink-0' }, [
h('span', { class: 'text-primary font-bold text-sm' }, name ? name.charAt(0).toUpperCase() : '?'),
]),
h('div', {}, [
h('p', { class: 'font-medium' }, name || '-'),
email ? h('p', { class: 'text-xs text-muted-foreground' }, email) : null,
]),
])
},
},
{
accessorKey: 'app_name',
header: '应用',
cell: ({ row }) => {
const appName = row.getValue('app_name') as string
return appName ? h(Badge, { variant: 'secondary' }, () => appName) : '-'
},
},
{
accessorKey: 'card_types',
header: '卡密权限',
cell: ({ row }) => {
const cardTypes = row.original.card_types || []
if (cardTypes.length === 0)
return h('span', { class: 'text-muted-foreground text-sm' }, '-')
const authorized = cardTypes.filter(ct => ct.can_generate).length
return h('span', { class: 'text-sm' }, `${authorized}/${cardTypes.length}`)
},
},
{
accessorKey: 'balance',
header: '余额',
cell: ({ row }) => {
const balance = row.getValue('balance') as number
return h('span', { class: 'font-medium' }, `¥${(balance || 0).toFixed(2)}`)
},
},
{
accessorKey: 'status',
header: '状态',
cell: ({ row }) => {
const status = row.getValue('status') as string
const isActive = status === 'active'
return h(Badge, { variant: isActive ? 'default' : 'secondary' }, () => isActive ? '已启用' : '已禁用')
},
},
{
accessorKey: 'created_at',
header: '授权时间',
cell: ({ row }) => {
const createdAt = row.getValue('created_at')
if (!createdAt)
return '-'
try {
const date = new Date(createdAt as string)
if (Number.isNaN(date.getTime()))
return '-'
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
catch {
return '-'
}
},
},
{
id: 'actions',
header: () => h('span', { class: 'sr-only' }, '操作'),
cell: ({ row }) => {
const item = row.original
return h(
DropdownMenu,
{},
{
default: () => [
h(DropdownMenuTrigger, { asChild: true }, () =>
h(Button, { variant: 'ghost', class: 'h-8 w-8 p-0' }, () => [
h(MoreHorizontal, { class: 'h-4 w-4' }),
h('span', { class: 'sr-only' }, '打开菜单'),
]),
),
h(
DropdownMenuContent,
{ align: 'end' },
() => [
h(DropdownMenuItem, { onClick: () => actions.onView(item) }, () => [
h(Eye, { class: 'mr-2 h-4 w-4' }),
'查看授权',
]),
h(DropdownMenuItem, { onClick: () => actions.onEdit(item) }, () => [
h(Pencil, { class: 'mr-2 h-4 w-4' }),
'编辑授权',
]),
h(DropdownMenuSeparator),
h(DropdownMenuItem, { class: 'text-destructive', onClick: () => actions.onDelete(item) }, () => [
h(Trash2, { class: 'mr-2 h-4 w-4' }),
'移除授权',
]),
],
),
],
},
)
},
enableSorting: false,
enableHiding: false,
},
]
}
@@ -0,0 +1,78 @@
<script setup lang="ts">
import type { ColumnDef } from '@tanstack/vue-table'
import { computed } from 'vue'
import type { DataTableProps } from '@/components/data-table/types'
import type { AgentApp } from '@/pages/admin/agent-apps/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'
import DataTableViewOptions from '@/components/data-table/view-options.vue'
import { getColumns } from '@/pages/admin/agent-apps/components/columns'
const props = defineProps<Omit<DataTableProps<AgentApp>, 'columns'> & {
onView: (row: AgentApp) => void
onEdit: (row: AgentApp) => void
onDelete: (row: AgentApp) => void
}>()
const emit = defineEmits<{
refresh: []
batchDelete: [ids: (string | number)[]]
}>()
const columns = computed(() => [
SelectColumn as ColumnDef<AgentApp>,
...getColumns({
onView: props.onView,
onEdit: props.onEdit,
onDelete: props.onDelete,
}),
])
const table = generateVueTable<AgentApp>({
get data() { return props.data },
get loading() { return props.loading },
columns: columns.value,
}, columns.value)
const columnLabels: Record<string, string> = {
'select': '选择',
'agent_name': '代理',
'app_name': '应用',
'card_types': '卡密权限',
'balance': '余额',
'status': '状态',
'created_at': '授权时间',
}
defineExpose({
table,
})
</script>
<template>
<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="agent-apps">
<UiButton variant="destructive" size="sm" @click="emit('batchDelete', table?.getSelectedRowModel().rows.map((r: any) => r.original.id) || [])">
批量移除
</UiButton>
</BulkActions>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-2">
<slot name="filters" />
</div>
<DataTableViewOptions :table="table" :column-labels="columnLabels" />
</div>
</div>
</template>
</DataTable>
</template>
+104 -148
View File
@@ -1,45 +1,49 @@
<script setup lang="ts">
import { CheckCircle, Eye, Key, MoreHorizontal, Pencil, Plus, Share2, Trash2, XCircle } from 'lucide-vue-next'
import { computed, onMounted, ref } from 'vue'
import { CheckCircle, Key, Plus, Share2, XCircle } from 'lucide-vue-next'
import { computed, onActivated, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import type { AgentApp } from '@/pages/admin/agent-apps/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/agent-apps/components/data-table.vue'
import api from '@/services/api'
const router = useRouter()
const loading = ref(true)
const agentApps = ref<AgentApp[]>([])
const showRemoveDialog = ref(false)
const removingItem = ref<AgentApp | null>(null)
const tableRef = ref()
const activeCount = computed(() => agentApps.value.filter(app => app.status === 'active').length)
const inactiveCount = computed(() => agentApps.value.filter(app => app.status === 'inactive').length)
const totalBalance = computed(() => agentApps.value.reduce((sum, app) => sum + (app.balance || 0), 0))
const statusFilter = ref<string>('')
const searchFilter = ref('')
const statusFilter = ref('all')
const deleteDialogOpen = ref(false)
const deleteTarget = ref<AgentApp | null>(null)
const batchDeleteDialogOpen = ref(false)
const batchDeleteIds = ref<(string | number)[]>([])
const filteredApps = computed(() => {
let result = agentApps.value
if (statusFilter.value && statusFilter.value !== 'all') {
if (statusFilter.value) {
result = result.filter(app => app.status === statusFilter.value)
}
if (searchFilter.value) {
const search = searchFilter.value.toLowerCase()
result = result.filter(app =>
app.agent_name?.toLowerCase().includes(search)
|| app.app_name?.toLowerCase().includes(search)
|| app.agent_email?.toLowerCase().includes(search),
)
}
return result
})
const activeCount = computed(() => filteredApps.value.filter(app => app.status === 'active').length)
const inactiveCount = computed(() => filteredApps.value.filter(app => app.status === 'inactive').length)
const totalBalance = computed(() => filteredApps.value.reduce((sum, app) => sum + (app.balance || 0), 0))
const statusOptions = computed(() => [
{ label: '已启用', value: 'active' },
{ label: '已禁用', value: 'inactive' },
])
async function fetchData() {
loading.value = true
try {
@@ -67,30 +71,58 @@ function handleEdit(item: AgentApp) {
router.push(`/admin/agent-apps/${item.id}/edit`)
}
function confirmRemove(item: AgentApp) {
removingItem.value = item
showRemoveDialog.value = true
function confirmDelete(item: AgentApp) {
deleteTarget.value = item
deleteDialogOpen.value = true
}
async function handleRemove() {
if (!removingItem.value)
async function handleDelete() {
if (!deleteTarget.value)
return
try {
await api.delete(`/dev/agent-apps/${removingItem.value.id}`)
await api.delete(`/dev/agent-apps/${deleteTarget.value.id}`)
toast.success('移除授权成功')
showRemoveDialog.value = false
fetchData()
}
catch (error: any) {
console.error('移除失败:', error)
toast.error(error.message || '移除失败')
}
finally {
deleteTarget.value = null
}
}
function confirmBatchDelete(ids: (string | number)[]) {
batchDeleteIds.value = ids
batchDeleteDialogOpen.value = true
}
async function handleBatchDelete() {
try {
for (const id of batchDeleteIds.value) {
await api.delete(`/dev/agent-apps/${id}`)
}
toast.success('批量移除成功')
fetchData()
}
catch (error: any) {
console.error('批量移除失败:', error)
toast.error(error.message || '批量移除失败')
}
finally {
batchDeleteIds.value = []
}
}
onMounted(() => {
fetchData()
})
onActivated(() => {
fetchData()
})
</script>
<template>
@@ -117,7 +149,7 @@ onMounted(() => {
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ agentApps.length }}
{{ filteredApps.length }}
</div>
</UiCardContent>
</UiCard>
@@ -167,132 +199,56 @@ onMounted(() => {
<UiCard>
<UiCardContent class="p-6">
<div class="flex flex-wrap items-center gap-2 mb-4">
<UiInput
v-model="searchFilter"
placeholder="搜索代理名称、应用名称..."
class="h-9 w-[200px]"
/>
<UiSelect v-model="statusFilter">
<UiSelectTrigger class="h-9 w-[120px]">
<UiSelectValue placeholder="全部状态" />
</UiSelectTrigger>
<UiSelectContent>
<UiSelectItem value="all">
全部状态
</UiSelectItem>
<UiSelectItem value="active">
已启用
</UiSelectItem>
<UiSelectItem value="inactive">
已禁用
</UiSelectItem>
</UiSelectContent>
</UiSelect>
</div>
<div v-if="loading" class="flex items-center justify-center py-12">
<div class="i-lucide-loader-2 h-8 w-8 animate-spin text-muted-foreground" />
</div>
<div v-else-if="filteredApps.length === 0" class="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Share2 class="h-12 w-12 mb-4" />
<p>暂无授权记录</p>
<p class="text-sm mt-1">
点击"新建授权"为代理分配应用权限
</p>
</div>
<UiTable v-else>
<UiTableHeader>
<UiTableRow>
<UiTableHead>代理</UiTableHead>
<UiTableHead>应用</UiTableHead>
<UiTableHead>卡密权限</UiTableHead>
<UiTableHead>余额</UiTableHead>
<UiTableHead>状态</UiTableHead>
<UiTableHead>授权时间</UiTableHead>
<UiTableHead class="text-right">
操作
</UiTableHead>
</UiTableRow>
</UiTableHeader>
<UiTableBody>
<UiTableRow v-for="item in filteredApps" :key="item.id">
<UiTableCell>
<div>
<div class="font-medium">
{{ item.agent_name }}
</div>
<div v-if="item.agent_email" class="text-xs text-muted-foreground">
{{ item.agent_email }}
</div>
</div>
</UiTableCell>
<UiTableCell>
<UiBadge variant="secondary">
{{ item.app_name }}
</UiBadge>
</UiTableCell>
<UiTableCell>
<div v-if="item.card_types && item.card_types.length > 0" class="flex items-center gap-1">
<Key class="h-3 w-3 text-muted-foreground" />
<span class="text-sm">
{{ item.card_types.filter(ct => ct.can_generate).length }}/{{ item.card_types.length }}
</span>
</div>
<span v-else class="text-muted-foreground text-sm">-</span>
</UiTableCell>
<UiTableCell>
<span class="font-medium">¥{{ (item.balance || 0).toFixed(2) }}</span>
</UiTableCell>
<UiTableCell>
<UiBadge
:variant="item.status === 'active' ? 'default' : 'secondary'"
>
{{ item.status === 'active' ? '已启用' : '已禁用' }}
</UiBadge>
</UiTableCell>
<UiTableCell class="text-muted-foreground text-sm">
{{ item.created_at }}
</UiTableCell>
<UiTableCell class="text-right">
<UiDropdownMenu>
<UiDropdownMenuTrigger as-child>
<UiButton variant="ghost" size="sm">
<MoreHorizontal class="h-4 w-4" />
</UiButton>
</UiDropdownMenuTrigger>
<UiDropdownMenuContent align="end">
<UiDropdownMenuItem @click="handleView(item)">
<Eye class="mr-2 h-4 w-4" />
查看授权
</UiDropdownMenuItem>
<UiDropdownMenuItem @click="handleEdit(item)">
<Pencil class="mr-2 h-4 w-4" />
编辑授权
</UiDropdownMenuItem>
<UiDropdownMenuSeparator />
<UiDropdownMenuItem class="text-destructive" @click="confirmRemove(item)">
<Trash2 class="mr-2 h-4 w-4" />
移除授权
</UiDropdownMenuItem>
</UiDropdownMenuContent>
</UiDropdownMenu>
</UiTableCell>
</UiTableRow>
</UiTableBody>
</UiTable>
<DataTable
ref="tableRef"
:loading
:data="filteredApps"
:on-view="handleView"
:on-edit="handleEdit"
:on-delete="confirmDelete"
@refresh="fetchData"
@batch-delete="confirmBatchDelete"
>
<template #filters>
<SingleFilter
v-model="statusFilter"
title="状态"
:options="statusOptions"
/>
</template>
</DataTable>
</UiCardContent>
</UiCard>
</div>
<ConfirmDialog
v-model:open="showRemoveDialog"
title="移除授权"
:description="`确定移除 ${removingItem?.agent_name} 对 ${removingItem?.app_name} 的授权吗?`"
confirm-text="确定移除"
@confirm="handleRemove"
/>
v-model:open="deleteDialogOpen"
destructive
confirm-button-text="确定移除"
cancel-button-text="取消"
@confirm="handleDelete"
>
<template #title>
移除授权
</template>
<template #description>
确定要移除 {{ deleteTarget?.agent_name }} {{ deleteTarget?.app_name }} 的授权吗
</template>
</ConfirmDialog>
<ConfirmDialog
v-model:open="batchDeleteDialogOpen"
destructive
confirm-button-text="确定移除"
cancel-button-text="取消"
@confirm="handleBatchDelete"
>
<template #title>
批量移除授权
</template>
<template #description>
确定要移除选中的 {{ batchDeleteIds.length }} 项授权吗
</template>
</ConfirmDialog>
</BasicPage>
</template>