feat: 重构授权管理为层级模型 - 管理员直接对代理授权应用卡密权限

This commit is contained in:
2026-05-06 22:16:31 +08:00
parent 53e60c7c07
commit 3cd22fdce1
12 changed files with 549 additions and 1039 deletions
@@ -135,7 +135,7 @@ const navMain = [
icon: Network,
},
{
title: '代理授权',
title: '授权管理',
url: '/admin/agent-apps',
icon: Share2,
},
+1 -1
View File
@@ -25,7 +25,7 @@ const breadcrumbs = computed(() => {
announcements: '公告管理',
versions: '版本管理',
'card-types': '卡类管理',
'agent-apps': '代理授权',
'agent-apps': '授权管理',
agents: '代理管理',
finance: '财务管理',
logs: '日志记录',
@@ -1,228 +0,0 @@
import type { ColumnDef } from '@tanstack/vue-table'
import { h } from 'vue'
import { useRouter } from 'vue-router'
import type { CombinedItem } 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 getStatusVariant(status: string, hasAuthorization: boolean) {
if (hasAuthorization) {
return status === 'active' ? 'default' : 'secondary'
}
if (status === 'pending')
return 'outline'
if (status === 'approved')
return 'default'
return 'destructive'
}
export function getStatusText(status: string, hasAuthorization: boolean) {
if (hasAuthorization) {
return status === 'active' ? '已授权' : '已禁用'
}
const map: Record<string, string> = {
pending: '待处理',
approved: '已通过',
rejected: '已拒绝',
}
return map[status] || status
}
export function getItemTypeText(itemType: string) {
const map: Record<string, string> = {
authorization: '授权记录',
request: '收到的申请',
my_request: '发出的申请',
}
return map[itemType] || itemType
}
export function getColumns(actions: {
onApprove: (row: CombinedItem) => void
onReject: (row: CombinedItem) => void
onRemove: (row: CombinedItem) => void
}): ColumnDef<CombinedItem>[] {
return [
{
accessorKey: 'itemType',
header: '类型',
cell: ({ row }) => {
const itemType = row.getValue('itemType') as string
const variants: Record<string, 'default' | 'secondary' | 'outline' | 'destructive'> = {
authorization: 'default',
request: 'outline',
my_request: 'secondary',
}
return h(Badge, { variant: variants[itemType] || 'default' }, () => getItemTypeText(itemType))
},
},
{
accessorKey: 'agent_name',
header: '代理商/管理员',
cell: ({ row }) => {
const agentName = row.getValue('agent_name') as string
const itemType = row.original.itemType
const label = itemType === 'my_request' ? '管理员' : '代理商'
return h('div', {}, [
h('div', { class: 'font-medium' }, agentName || '-'),
h('div', { class: 'text-xs text-muted-foreground' }, label),
])
},
},
{
accessorKey: 'app_name',
header: '应用',
cell: ({ row }) => {
const appName = row.getValue('app_name') as string
return appName ? h(Badge, { variant: 'secondary' }, () => appName) : '-'
},
},
{
accessorKey: 'status',
header: '状态',
cell: ({ row }) => {
const status = row.getValue('status') as string
const hasAuthorization = row.original.hasAuthorization
const variant = getStatusVariant(status, hasAuthorization)
const text = getStatusText(status, hasAuthorization)
return h(Badge, { variant }, () => text)
},
},
{
accessorKey: 'discount',
header: '折扣',
cell: ({ row }) => {
const hasAuthorization = row.original.hasAuthorization
if (!hasAuthorization)
return '-'
const discount = row.getValue('discount') as number
return `${(discount * 10).toFixed(1)}`
},
},
{
accessorKey: 'balance',
header: '余额',
cell: ({ row }) => {
const hasAuthorization = row.original.hasAuthorization
if (!hasAuthorization)
return '-'
const balance = row.getValue('balance') as number
return h('span', { class: 'font-medium' }, `¥${balance.toFixed(2)}`)
},
},
{
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
const router = useRouter()
return h(
DropdownMenu,
{},
{
default: () => [
h(DropdownMenuTrigger, { asChild: true }, () =>
h(Button, { variant: 'ghost', class: 'h-8 w-8 p-0' }, () =>
h('span', { class: 'sr-only' }, '打开菜单'))),
h(
DropdownMenuContent,
{ align: 'end' },
() => {
const items = []
if (item.itemType === 'request' && item.status === 'pending') {
items.push(
h(DropdownMenuItem, { onClick: () => actions.onApprove(item) }, () => [
h('span', { class: 'i-lucide-check mr-2 h-4 w-4' }),
'批准',
]),
h(DropdownMenuItem, { onClick: () => actions.onReject(item) }, () => [
h('span', { class: 'i-lucide-x mr-2 h-4 w-4' }),
'拒绝',
]),
)
}
if (item.hasAuthorization) {
items.push(
h(DropdownMenuItem, { onClick: () => router.push(`/admin/agent-apps/${item.authorizationId}/edit`) }, () => [
h('span', { class: 'i-lucide-edit mr-2 h-4 w-4' }),
'编辑',
]),
h(DropdownMenuItem, { onClick: () => router.push(`/admin/agent-apps/${item.authorizationId}/card-types`) }, () => [
h('span', { class: 'i-lucide-key mr-2 h-4 w-4' }),
'卡密权限',
]),
h(DropdownMenuItem, { onClick: () => router.push(`/admin/agent-apps/${item.authorizationId}/recharge`) }, () => [
h('span', { class: 'i-lucide-wallet mr-2 h-4 w-4' }),
'充值余额',
]),
)
}
if (item.itemType === 'my_request' && item.status === 'pending') {
items.push(
h(DropdownMenuSeparator),
h(DropdownMenuItem, { class: 'text-destructive', onClick: () => actions.onRemove(item) }, () => [
h('span', { class: 'i-lucide-trash-2 mr-2 h-4 w-4' }),
'撤回申请',
]),
)
}
else if (item.hasAuthorization) {
items.push(
h(DropdownMenuSeparator),
h(DropdownMenuItem, { class: 'text-destructive', onClick: () => actions.onRemove(item) }, () => [
h('span', { class: 'i-lucide-trash-2 mr-2 h-4 w-4' }),
'移除授权',
]),
)
}
return items
},
),
],
},
)
},
enableSorting: false,
enableHiding: false,
},
]
}
@@ -1,40 +0,0 @@
<script setup lang="ts">
import type { Table } from '@tanstack/vue-table'
import { X } from 'lucide-vue-next'
import { computed } from 'vue'
import type { CombinedItem } from '@/pages/admin/agent-apps/data/schema'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
interface DataTableToolbarProps {
table: Table<CombinedItem>
}
const props = defineProps<DataTableToolbarProps>()
const isFiltered = computed(() => props.table.getState().columnFilters.length > 0)
</script>
<template>
<div class="flex items-center flex-1 space-x-2">
<Input
placeholder="搜索代理商/管理员..."
:model-value="(table.getColumn('agent_name')?.getFilterValue() as string) ?? ''"
class="h-8 w-[150px] lg:w-[200px]"
@input="table.getColumn('agent_name')?.setFilterValue($event.target.value)"
/>
<Button
v-if="isFiltered"
variant="ghost"
class="h-8 px-2 lg:px-3"
@click="table.resetColumnFilters()"
>
重置
<X class="size-4 ml-2" />
</Button>
</div>
</template>
@@ -1,69 +0,0 @@
<script setup lang="ts">
import type { ColumnDef } from '@tanstack/vue-table'
import type { DataTableProps } from '@/components/data-table/types'
import type { CombinedItem } from '@/pages/admin/agent-apps/data/schema'
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'
import DataTableToolbar from '@/pages/admin/agent-apps/components/data-table-toolbar.vue'
const props = defineProps<Omit<DataTableProps<CombinedItem>, 'columns'> & {
onApprove: (row: CombinedItem) => void
onReject: (row: CombinedItem) => void
onRemove: (row: CombinedItem) => void
}>()
const emit = defineEmits<{
refresh: []
}>()
const columns = computed(() => [
SelectColumn as ColumnDef<CombinedItem>,
...getColumns({
onApprove: props.onApprove,
onReject: props.onReject,
onRemove: props.onRemove,
}),
])
const table = generateVueTable<CombinedItem>({
get data() { return props.data },
get loading() { return props.loading },
columns: columns.value,
}, columns.value)
const columnLabels: Record<string, string> = {
select: 'admin.agentApps.select',
itemType: 'admin.agentApps.columns.type',
agent_name: 'admin.agentApps.columns.agentName',
app_name: 'admin.agentApps.columns.appName',
status: 'admin.agentApps.columns.status',
created_at: 'admin.agentApps.columns.createdAt',
}
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>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-2">
<DataTableToolbar :table />
<slot name="filters" />
</div>
<DataTableViewOptions :table="table" :column-labels="columnLabels" />
</div>
</div>
</template>
</DataTable>
</template>
@@ -1,12 +0,0 @@
export const agentAppStatuses = [
{ label: '已授权', value: 'active' },
{ label: '已禁用', value: 'inactive' },
{ label: '待处理', value: 'pending' },
{ label: '已拒绝', value: 'rejected' },
]
export const itemTypes = [
{ label: '授权记录', value: 'authorization' },
{ label: '收到的申请', value: 'request' },
{ label: '发出的申请', value: 'my_request' },
]
@@ -1,54 +1,24 @@
import { z } from 'zod'
export const agentAppStatusSchema = z.enum(['active', 'inactive', 'pending', 'approved', 'rejected'])
export const agentAppStatusSchema = z.enum(['active', 'inactive'])
export const agentAppSchema = z.object({
id: z.number(),
agent_id: z.number(),
agent_name: z.string(),
agent_email: z.string().optional(),
application_id: z.number(),
app_name: z.string(),
status: agentAppStatusSchema,
discount: z.number(),
balance: z.number(),
card_types: z.array(z.object({
id: z.number().optional(),
card_type_id: z.number(),
name: z.string().optional(),
can_generate: z.boolean(),
})).optional(),
created_at: z.string(),
updated_at: z.string().optional(),
is_received: z.boolean().optional(),
})
export const agentRequestSchema = z.object({
id: z.number(),
admin_id: z.number(),
admin_name: z.string(),
agent_id: z.number(),
agent_name: z.string(),
application_id: z.number(),
app_name: z.string(),
status: z.enum(['pending', 'approved', 'rejected']),
message: z.string().optional(),
created_at: z.string(),
})
export type AgentApp = z.infer<typeof agentAppSchema>
export type AgentRequest = z.infer<typeof agentRequestSchema>
export interface CombinedItem {
id: number
agent_id: number
agent_name: string
application_id: number
app_name: string
status: string
discount: number
balance: number
card_types?: Array<{ card_type_id: number, can_generate: boolean }>
created_at: string
itemType: 'authorization' | 'request' | 'my_request'
hasAuthorization: boolean
authorizationId: number | null
is_received?: boolean
}
+384 -237
View File
@@ -1,16 +1,14 @@
<script setup lang="ts">
import { Check, CheckCircle, Clock, Send, Share2, Trash2, UserPlus, Wallet, X } from 'lucide-vue-next'
import { CheckCircle, Key, Plus, Share2, Wallet, XCircle } from 'lucide-vue-next'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import type { AgentApp, AgentRequest, CombinedItem } from '@/pages/admin/agent-apps/data/schema'
import type { AgentApp } from '@/pages/admin/agent-apps/data/schema'
import ConfirmDialog from '@/components/confirm-dialog.vue'
import BulkActions from '@/components/data-table/bulk-actions.vue'
import { BasicPage } from '@/components/global-layout'
import DataTable from '@/pages/admin/agent-apps/components/data-table.vue'
import api from '@/services/api'
const { t } = useI18n()
@@ -18,213 +16,174 @@ const router = useRouter()
const loading = ref(true)
const agentApps = ref<AgentApp[]>([])
const requests = ref<AgentRequest[]>([])
const myRequests = ref<AgentRequest[]>([])
const tableRef = ref()
const showRejectDialog = ref(false)
const showRemoveDialog = ref(false)
const showBatchRejectDialog = ref(false)
const showBatchRemoveDialog = ref(false)
const rejectingItem = ref<CombinedItem | null>(null)
const removingItem = ref<CombinedItem | null>(null)
const batchRejectIds = ref<(string | number)[]>([])
const batchRemoveIds = ref<(string | number)[]>([])
const removingItem = ref<AgentApp | null>(null)
const showAuthorizeDialog = ref(false)
const authorizing = ref(false)
const agents = ref<Array<{ id: number, username: string, email?: string }>>([])
const applications = ref<Array<{ id: number, name: string }>>([])
const cardTypes = ref<Array<{ id: number, name: string, billing_type: string, price: number }>>([])
const authorizeForm = ref({
agent_id: '' as string | number,
application_id: '' as string | number,
discount: 1.0,
card_types: [] as Array<{ card_type_id: number, can_generate: boolean }>,
})
const activeCount = computed(() => agentApps.value.filter(app => app.status === 'active').length)
const pendingCount = computed(() => requests.value.filter(r => r.status === 'pending').length + myRequests.value.filter(r => r.status === 'pending').length)
const totalBalance = computed(() => agentApps.value.reduce((sum, app) => sum + app.balance, 0))
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 allItems = computed<CombinedItem[]>(() => {
const items = agentApps.value.map(item => ({
...item,
itemType: 'authorization' as const,
status: item.status,
created_at: item.created_at,
hasAuthorization: item.status === 'active' || item.status === 'inactive',
authorizationId: item.id,
}))
const searchFilter = ref('')
const statusFilter = ref('')
const requestItems = requests.value
.filter(item => item.status === 'pending')
.map(item => ({
id: item.id,
agent_id: item.agent_id,
agent_name: item.agent_name,
application_id: item.application_id,
app_name: item.app_name,
status: item.status,
discount: 1,
balance: 0,
card_types: [],
created_at: item.created_at,
itemType: 'request' as const,
hasAuthorization: false,
authorizationId: null,
}))
const filteredApps = computed(() => {
let result = agentApps.value
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 myRequestItems = myRequests.value
.filter(item => item.status === 'pending')
.map(item => ({
id: item.id,
agent_id: item.admin_id,
agent_name: item.admin_name,
application_id: item.application_id,
app_name: item.app_name,
status: item.status,
discount: 1,
balance: 0,
card_types: [],
created_at: item.created_at,
itemType: 'my_request' as const,
hasAuthorization: false,
authorizationId: null,
}))
return [...items, ...requestItems, ...myRequestItems].sort((a, b) => {
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
})
const selectedAppCardTypes = computed(() => {
if (!authorizeForm.value.application_id)
return []
const appId = Number(authorizeForm.value.application_id)
return cardTypes.value.filter(ct => ct.id && true)
})
async function fetchData() {
loading.value = true
try {
const [appsData, requestsData, myRequestsData] = await Promise.all([
api.get<{ agent_apps: AgentApp[] }>('/dev/agent-apps'),
api.get<{ requests: AgentRequest[] }>('/dev/agent-apps/requests'),
api.get<{ requests: AgentRequest[] }>('/dev/agent-apps/my-requests'),
])
agentApps.value = appsData?.agent_apps || []
requests.value = requestsData?.requests || []
myRequests.value = myRequestsData?.requests || []
const data = await api.get<{ agent_apps: AgentApp[] }>('/dev/agent-apps')
agentApps.value = data?.agent_apps || []
}
catch (error) {
console.error('获取数据失败:', error)
toast.error(t('common.fetchFailed'))
toast.error('获取授权列表失败')
}
finally {
loading.value = false
}
}
function goToRequest() {
router.push('/admin/agent-apps/request')
}
function goToInvite() {
router.push('/admin/agent-apps/invite')
}
async function approveRequest(item: CombinedItem) {
async function fetchDialogData() {
try {
await api.post(`/dev/agent-apps/requests/${item.id}/approve`)
toast.success(t('admin.agentApps.approveSuccess'))
fetchData()
const [agentsData, appsData] = await Promise.all([
api.get<{ agents: Array<{ id: number, username: string, email?: string }> }>('/dev/agents'),
api.get<{ applications: Array<{ id: number, name: string }> }>('/dev/applications'),
])
agents.value = agentsData?.agents || []
applications.value = appsData?.applications || []
}
catch (error: any) {
console.error('批准申请失败:', error)
toast.error(error.message || t('admin.agentApps.approveFailed'))
catch (error) {
console.error('获取数据失败:', error)
}
}
async function rejectRequest() {
if (!rejectingItem.value)
async function onApplicationChange() {
const appId = Number(authorizeForm.value.application_id)
if (!appId) {
cardTypes.value = []
authorizeForm.value.card_types = []
return
}
try {
await api.post(`/dev/agent-apps/requests/${rejectingItem.value.id}/reject`, { reason: '' })
toast.success(t('admin.agentApps.rejectSuccess'))
showRejectDialog.value = false
fetchData()
const data = await api.get<{ card_types: Array<{ id: number, name: string, billing_type: string, price: number }> }>('/dev/card-types')
const allTypes = data?.card_types || []
cardTypes.value = allTypes
authorizeForm.value.card_types = allTypes.map(ct => ({
card_type_id: ct.id,
can_generate: false,
}))
}
catch (error: any) {
console.error('拒绝申请失败:', error)
toast.error(error.message || t('admin.agentApps.rejectFailed'))
catch (error) {
console.error('获取卡类失败:', error)
}
}
function confirmReject(item: CombinedItem) {
rejectingItem.value = item
showRejectDialog.value = true
function openAuthorizeDialog() {
authorizeForm.value = {
agent_id: '',
application_id: '',
discount: 1.0,
card_types: [],
}
cardTypes.value = []
showAuthorizeDialog.value = true
fetchDialogData()
}
async function removeItem() {
if (!removingItem.value)
async function handleAuthorize() {
if (!authorizeForm.value.agent_id) {
toast.error('请选择代理')
return
}
if (!authorizeForm.value.application_id) {
toast.error('请选择应用')
return
}
authorizing.value = true
try {
const endpoint = removingItem.value.itemType === 'my_request'
? `/dev/agent-apps/my-requests/${removingItem.value.id}`
: `/dev/agent-apps/${removingItem.value.authorizationId}`
await api.delete(endpoint)
toast.success(t('common.success'))
showRemoveDialog.value = false
await api.post('/dev/agent-apps/authorize', {
agent_id: Number(authorizeForm.value.agent_id),
application_id: Number(authorizeForm.value.application_id),
discount: authorizeForm.value.discount,
card_types: authorizeForm.value.card_types,
})
toast.success('授权成功')
showAuthorizeDialog.value = false
fetchData()
}
catch (error: any) {
console.error('操作失败:', error)
toast.error(error.message || t('common.failed'))
console.error('授权失败:', error)
toast.error(error.message || '授权失败')
}
finally {
authorizing.value = false
}
}
function confirmRemove(item: CombinedItem) {
function confirmRemove(item: AgentApp) {
removingItem.value = item
showRemoveDialog.value = true
}
async function batchApprove(ids: (string | number)[]) {
try {
await api.post('/dev/agent-apps/requests/batch-approve', { ids })
toast.success(t('admin.agentApps.batchApproveSuccess'))
fetchData()
}
catch (error: any) {
console.error('批量批准失败:', error)
toast.error(error.message || t('admin.agentApps.batchApproveFailed'))
}
}
async function batchReject() {
if (batchRejectIds.value.length === 0)
async function handleRemove() {
if (!removingItem.value)
return
try {
await api.post('/dev/agent-apps/requests/batch-reject', { ids: batchRejectIds.value, reason: '' })
toast.success(t('admin.agentApps.batchRejectSuccess'))
showBatchRejectDialog.value = false
await api.delete(`/dev/agent-apps/${removingItem.value.id}`)
toast.success('移除授权成功')
showRemoveDialog.value = false
fetchData()
}
catch (error: any) {
console.error('批量拒绝失败:', error)
toast.error(error.message || t('admin.agentApps.batchRejectFailed'))
console.error('移除失败:', error)
toast.error(error.message || '移除失败')
}
}
function confirmBatchReject(ids: (string | number)[]) {
batchRejectIds.value = ids
showBatchRejectDialog.value = true
}
async function batchRemove() {
if (batchRemoveIds.value.length === 0)
return
try {
await api.delete('/dev/agent-apps/batch', { ids: batchRemoveIds.value })
toast.success(t('admin.agentApps.batchRemoveSuccess'))
showBatchRemoveDialog.value = false
fetchData()
function getBillingTypeText(type: string) {
const map: Record<string, string> = {
subscription: '订阅',
time: '计时',
point: '点卡',
}
catch (error: any) {
console.error('批量移除失败:', error)
toast.error(error.message || t('admin.agentApps.batchRemoveFailed'))
}
}
function confirmBatchRemove(ids: (string | number)[]) {
batchRemoveIds.value = ids
showBatchRemoveDialog.value = true
return map[type] || type
}
onMounted(() => {
@@ -234,18 +193,14 @@ onMounted(() => {
<template>
<BasicPage
:title="t('admin.agentApps.title')"
:description="t('admin.agentApps.description')"
title="授权管理"
description="管理代理的应用卡密权限授权"
sticky
>
<template #actions>
<UiButton variant="outline" @click="goToRequest">
<Send class="mr-2 h-4 w-4" />
{{ t('admin.agentApps.requestAuth') }}
</UiButton>
<UiButton @click="goToInvite">
<UserPlus class="mr-2 h-4 w-4" />
{{ t('admin.agentApps.inviteAuth') }}
<UiButton @click="openAuthorizeDialog">
<Plus class="mr-2 h-4 w-4" />
新建授权
</UiButton>
</template>
@@ -254,7 +209,7 @@ onMounted(() => {
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.agentApps.totalAuth') }}
授权总数
</UiCardTitle>
<Share2 class="size-4 text-muted-foreground" />
</UiCardHeader>
@@ -268,12 +223,12 @@ onMounted(() => {
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.agentApps.activeAuth') }}
已启用
</UiCardTitle>
<CheckCircle class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
<div class="text-2xl font-bold text-green-600">
{{ activeCount }}
</div>
</UiCardContent>
@@ -282,13 +237,13 @@ onMounted(() => {
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.agentApps.pendingRequests') }}
已禁用
</UiCardTitle>
<Clock class="size-4 text-muted-foreground" />
<XCircle class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ pendingCount }}
<div class="text-2xl font-bold text-muted-foreground">
{{ inactiveCount }}
</div>
</UiCardContent>
</UiCard>
@@ -296,7 +251,7 @@ onMounted(() => {
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.agentApps.agentBalance') }}
代理余额总计
</UiCardTitle>
<Wallet class="size-4 text-muted-foreground" />
</UiCardHeader>
@@ -310,80 +265,272 @@ onMounted(() => {
<UiCard>
<UiCardContent class="p-6">
<DataTable
ref="tableRef"
:loading
:data="allItems"
:on-approve="approveRequest"
:on-reject="confirmReject"
:on-remove="confirmRemove"
@refresh="fetchData"
/>
<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="">
全部状态
</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>授权时间</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>
{{ (item.discount * 10).toFixed(1) }}
</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">
<span class="i-lucide-more-horizontal h-4 w-4" />
</UiButton>
</UiDropdownMenuTrigger>
<UiDropdownMenuContent align="end">
<UiDropdownMenuItem @click="router.push(`/admin/agent-apps/${item.id}/edit`)">
<span class="i-lucide-edit mr-2 h-4 w-4" />
编辑授权
</UiDropdownMenuItem>
<UiDropdownMenuItem @click="router.push(`/admin/agent-apps/${item.id}/card-types`)">
<span class="i-lucide-key mr-2 h-4 w-4" />
卡密权限
</UiDropdownMenuItem>
<UiDropdownMenuItem @click="router.push(`/admin/agent-apps/${item.id}/recharge`)">
<span class="i-lucide-wallet mr-2 h-4 w-4" />
充值余额
</UiDropdownMenuItem>
<UiDropdownMenuSeparator />
<UiDropdownMenuItem class="text-destructive" @click="confirmRemove(item)">
<span class="i-lucide-trash-2 mr-2 h-4 w-4" />
移除授权
</UiDropdownMenuItem>
</UiDropdownMenuContent>
</UiDropdownMenu>
</UiTableCell>
</UiTableRow>
</UiTableBody>
</UiTable>
</UiCardContent>
</UiCard>
</div>
<BulkActions
v-if="tableRef?.table"
:table="tableRef.table"
:entity-name="t('admin.agentApps.auth')"
>
<UiButton
variant="outline"
size="sm"
@click="batchApprove(tableRef.table.getSelectedRowModel().rows.map((r: any) => r.original.id))"
>
<Check class="mr-2 h-4 w-4" />
{{ t('admin.agentApps.batchApprove') }}
</UiButton>
<UiButton
variant="outline"
size="sm"
@click="confirmBatchReject(tableRef.table.getSelectedRowModel().rows.map((r: any) => r.original.id))"
>
<X class="mr-2 h-4 w-4" />
{{ t('admin.agentApps.batchReject') }}
</UiButton>
<UiButton
variant="destructive"
size="sm"
@click="confirmBatchRemove(tableRef.table.getSelectedRowModel().rows.map((r: any) => r.original.id))"
>
<Trash2 class="mr-2 h-4 w-4" />
{{ t('admin.agentApps.batchRemove') }}
</UiButton>
</BulkActions>
<UiDialog v-model:open="showAuthorizeDialog">
<UiDialogContent class="max-w-2xl max-h-[85vh] overflow-y-auto">
<UiDialogHeader>
<UiDialogTitle>新建授权</UiDialogTitle>
<UiDialogDescription>
为代理分配应用卡密权限
</UiDialogDescription>
</UiDialogHeader>
<ConfirmDialog
v-model:open="showRejectDialog"
:title="t('admin.agentApps.reject')"
:description="t('admin.agentApps.rejectConfirm')"
confirm-text="确定"
@confirm="rejectRequest"
/>
<div class="space-y-6 py-4">
<div class="space-y-2">
<UiLabel>选择代理</UiLabel>
<UiSelect v-model="authorizeForm.agent_id">
<UiSelectTrigger>
<UiSelectValue placeholder="请选择代理" />
</UiSelectTrigger>
<UiSelectContent>
<UiSelectItem
v-for="agent in agents"
:key="agent.id"
:value="String(agent.id)"
>
{{ agent.username }}
<span v-if="agent.email" class="text-muted-foreground ml-1">({{ agent.email }})</span>
</UiSelectItem>
</UiSelectContent>
</UiSelect>
</div>
<div class="space-y-2">
<UiLabel>选择应用</UiLabel>
<UiSelect v-model="authorizeForm.application_id" @update:model-value="onApplicationChange">
<UiSelectTrigger>
<UiSelectValue placeholder="请选择应用" />
</UiSelectTrigger>
<UiSelectContent>
<UiSelectItem
v-for="app in applications"
:key="app.id"
:value="String(app.id)"
>
{{ app.name }}
</UiSelectItem>
</UiSelectContent>
</UiSelect>
</div>
<div class="space-y-2">
<UiLabel>折扣</UiLabel>
<div class="flex items-center gap-4">
<UiInput
v-model.number="authorizeForm.discount"
type="number"
step="0.01"
min="0.01"
max="1"
class="flex-1"
/>
<span class="text-sm text-muted-foreground w-20">
{{ (authorizeForm.discount * 10).toFixed(1) }}
</span>
</div>
<p class="text-xs text-muted-foreground">
代理购买卡密的折扣范围0.01-1
</p>
</div>
<div v-if="authorizeForm.card_types.length > 0" class="space-y-2">
<div class="flex items-center justify-between">
<UiLabel>卡密权限</UiLabel>
<div class="flex gap-2">
<UiButton variant="outline" size="sm" @click="authorizeForm.card_types.forEach(ct => ct.can_generate = true)">
全选
</UiButton>
<UiButton variant="outline" size="sm" @click="authorizeForm.card_types.forEach(ct => ct.can_generate = false)">
清空
</UiButton>
</div>
</div>
<div class="border rounded-lg">
<UiTable>
<UiTableHeader>
<UiTableRow>
<UiTableHead class="w-12" />
<UiTableHead>卡类名称</UiTableHead>
<UiTableHead>计费类型</UiTableHead>
<UiTableHead>价格</UiTableHead>
</UiTableRow>
</UiTableHeader>
<UiTableBody>
<UiTableRow
v-for="(ct, index) in authorizeForm.card_types"
:key="ct.card_type_id"
:class="ct.can_generate ? 'bg-accent/50' : ''"
class="cursor-pointer"
@click="ct.can_generate = !ct.can_generate"
>
<UiTableCell>
<UiCheckbox
:checked="ct.can_generate"
@update:checked="ct.can_generate = $event"
@click.stop
/>
</UiTableCell>
<UiTableCell class="font-medium">
{{ cardTypes[index]?.name || `卡类 #${ct.card_type_id}` }}
</UiTableCell>
<UiTableCell>
<UiBadge variant="outline">
{{ getBillingTypeText(cardTypes[index]?.billing_type || '') }}
</UiBadge>
</UiTableCell>
<UiTableCell>
¥{{ cardTypes[index]?.price || 0 }}
</UiTableCell>
</UiTableRow>
</UiTableBody>
</UiTable>
</div>
</div>
</div>
<UiDialogFooter>
<UiButton variant="outline" @click="showAuthorizeDialog = false">
取消
</UiButton>
<UiButton :disabled="authorizing" @click="handleAuthorize">
<div v-if="authorizing" class="i-lucide-loader-2 mr-2 h-4 w-4 animate-spin" />
确认授权
</UiButton>
</UiDialogFooter>
</UiDialogContent>
</UiDialog>
<ConfirmDialog
v-model:open="showRemoveDialog"
:title="t('admin.agentApps.remove')"
:description="removingItem?.itemType === 'my_request' ? t('admin.agentApps.withdrawConfirm') : t('admin.agentApps.removeConfirm')"
confirm-text="确定"
@confirm="removeItem"
/>
<ConfirmDialog
v-model:open="showBatchRejectDialog"
:title="t('admin.agentApps.batchReject')"
:description="t('admin.agentApps.batchRejectConfirm', { count: batchRejectIds.length })"
confirm-text="确定"
@confirm="batchReject"
/>
<ConfirmDialog
v-model:open="showBatchRemoveDialog"
:title="t('admin.agentApps.batchRemove')"
:description="t('admin.agentApps.batchRemoveConfirm', { count: batchRemoveIds.length })"
confirm-text="确定"
@confirm="batchRemove"
title="移除授权"
:description="`确定要移除 ${removingItem?.agent_name} 对 ${removingItem?.app_name} 的授权吗?`"
confirm-text="确定移除"
@confirm="handleRemove"
/>
</BasicPage>
</template>
@@ -1,170 +0,0 @@
<script setup lang="ts">
import { ArrowLeft, Loader2 } from 'lucide-vue-next'
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import { BasicPage } from '@/components/global-layout'
import { API_BASE } from '@/utils/config'
const router = useRouter()
const saving = ref(false)
const applications = ref<Array<{ id: number, name: string }>>([])
const form = ref({
agent_id: '',
application_id: '',
message: '',
})
async function fetchApplications() {
try {
const token = localStorage.getItem('token')
const response = await fetch(`${API_BASE}/dev/applications`, {
headers: { Authorization: `Bearer ${token}` },
})
const data = await response.json()
if (data.code === 200) {
applications.value = data.applications || []
}
}
catch (error) {
console.error('获取应用列表失败:', error)
}
}
async function handleSubmit() {
if (!form.value.agent_id) {
toast.error('请输入代理商ID')
return
}
if (!form.value.application_id) {
toast.error('请选择应用')
return
}
saving.value = true
try {
const token = localStorage.getItem('token')
const response = await fetch(`${API_BASE}/dev/agent-apps/invite`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
agent_id: Number(form.value.agent_id),
application_id: Number(form.value.application_id),
message: form.value.message,
}),
})
const data = await response.json()
if (data.code === 200) {
toast.success('邀请已发送')
router.push('/admin/agent-apps')
}
else {
toast.error(data.message || '发送邀请失败')
}
}
catch (error) {
console.error('发送邀请失败:', error)
toast.error('发送邀请失败')
}
finally {
saving.value = false
}
}
function goBack() {
router.push('/admin/agent-apps')
}
onMounted(() => {
fetchApplications()
})
</script>
<template>
<BasicPage
title="邀请授权"
description="邀请代理商成为授权方"
>
<template #actions>
<UiButton variant="outline" @click="goBack">
<ArrowLeft class="mr-2 h-4 w-4" />
返回
</UiButton>
</template>
<div class="max-w-2xl">
<UiCard>
<UiCardHeader>
<UiCardTitle>邀请信息</UiCardTitle>
<UiCardDescription>
填写以下信息邀请代理商成为授权方
</UiCardDescription>
</UiCardHeader>
<UiCardContent class="space-y-6">
<div class="space-y-2">
<UiLabel for="agent_id">
代理商ID
</UiLabel>
<UiInput
id="agent_id"
v-model="form.agent_id"
type="number"
placeholder="请输入代理商ID"
/>
<p class="text-xs text-muted-foreground">
请向代理商索取其ID
</p>
</div>
<div class="space-y-2">
<UiLabel for="application_id">
选择应用
</UiLabel>
<UiSelect v-model="form.application_id">
<UiSelectTrigger>
<UiSelectValue placeholder="请选择应用" />
</UiSelectTrigger>
<UiSelectContent>
<UiSelectItem
v-for="app in applications"
:key="app.id"
:value="String(app.id)"
>
{{ app.name }}
</UiSelectItem>
</UiSelectContent>
</UiSelect>
</div>
<div class="space-y-2">
<UiLabel for="message">
邀请说明
</UiLabel>
<UiTextarea
id="message"
v-model="form.message"
placeholder="请输入邀请说明(可选)"
:rows="4"
/>
</div>
</UiCardContent>
<UiCardFooter class="flex justify-end gap-2">
<UiButton variant="outline" @click="goBack">
取消
</UiButton>
<UiButton :disabled="saving" @click="handleSubmit">
<Loader2 v-if="saving" class="mr-2 h-4 w-4 animate-spin" />
发送邀请
</UiButton>
</UiCardFooter>
</UiCard>
</div>
</BasicPage>
</template>
@@ -1,170 +0,0 @@
<script setup lang="ts">
import { ArrowLeft, Loader2 } from 'lucide-vue-next'
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import { BasicPage } from '@/components/global-layout'
import { API_BASE } from '@/utils/config'
const router = useRouter()
const saving = ref(false)
const applications = ref<Array<{ id: number, name: string }>>([])
const form = ref({
admin_id: '',
application_id: '',
message: '',
})
async function fetchApplications() {
try {
const token = localStorage.getItem('token')
const response = await fetch(`${API_BASE}/dev/applications`, {
headers: { Authorization: `Bearer ${token}` },
})
const data = await response.json()
if (data.code === 200) {
applications.value = data.applications || []
}
}
catch (error) {
console.error('获取应用列表失败:', error)
}
}
async function handleSubmit() {
if (!form.value.admin_id) {
toast.error('请输入管理员ID')
return
}
if (!form.value.application_id) {
toast.error('请选择应用')
return
}
saving.value = true
try {
const token = localStorage.getItem('token')
const response = await fetch(`${API_BASE}/dev/agent-apps/request`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
admin_id: Number(form.value.admin_id),
application_id: Number(form.value.application_id),
message: form.value.message,
}),
})
const data = await response.json()
if (data.code === 200) {
toast.success('申请已提交')
router.push('/admin/agent-apps')
}
else {
toast.error(data.message || '提交申请失败')
}
}
catch (error) {
console.error('提交申请失败:', error)
toast.error('提交申请失败')
}
finally {
saving.value = false
}
}
function goBack() {
router.push('/admin/agent-apps')
}
onMounted(() => {
fetchApplications()
})
</script>
<template>
<BasicPage
title="申请授权"
description="向管理员申请应用授权"
>
<template #actions>
<UiButton variant="outline" @click="goBack">
<ArrowLeft class="mr-2 h-4 w-4" />
返回
</UiButton>
</template>
<div class="max-w-2xl">
<UiCard>
<UiCardHeader>
<UiCardTitle>申请信息</UiCardTitle>
<UiCardDescription>
填写以下信息向管理员申请应用授权
</UiCardDescription>
</UiCardHeader>
<UiCardContent class="space-y-6">
<div class="space-y-2">
<UiLabel for="admin_id">
管理员ID
</UiLabel>
<UiInput
id="admin_id"
v-model="form.admin_id"
type="number"
placeholder="请输入管理员ID"
/>
<p class="text-xs text-muted-foreground">
请向管理员索取其ID
</p>
</div>
<div class="space-y-2">
<UiLabel for="application_id">
选择应用
</UiLabel>
<UiSelect v-model="form.application_id">
<UiSelectTrigger>
<UiSelectValue placeholder="请选择应用" />
</UiSelectTrigger>
<UiSelectContent>
<UiSelectItem
v-for="app in applications"
:key="app.id"
:value="String(app.id)"
>
{{ app.name }}
</UiSelectItem>
</UiSelectContent>
</UiSelect>
</div>
<div class="space-y-2">
<UiLabel for="message">
申请说明
</UiLabel>
<UiTextarea
id="message"
v-model="form.message"
placeholder="请输入申请说明(可选)"
:rows="4"
/>
</div>
</UiCardContent>
<UiCardFooter class="flex justify-end gap-2">
<UiButton variant="outline" @click="goBack">
取消
</UiButton>
<UiButton :disabled="saving" @click="handleSubmit">
<Loader2 v-if="saving" class="mr-2 h-4 w-4 animate-spin" />
提交申请
</UiButton>
</UiCardFooter>
</UiCard>
</div>
</BasicPage>
</template>
+1 -13
View File
@@ -171,7 +171,7 @@ const routes: RouteRecordRaw[] = [
path: 'agent-apps',
name: 'AdminAgentApps',
component: () => import('@/pages/admin/agent-apps/index.vue'),
meta: { title: '代理授权 - 管理后台' },
meta: { title: '授权管理 - 管理后台' },
},
{
path: 'agents',
@@ -191,18 +191,6 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/pages/admin/agents/[id].vue'),
meta: { title: '编辑代理 - 管理后台' },
},
{
path: 'agent-apps/request',
name: 'AdminAgentAppRequest',
component: () => import('@/pages/admin/agent-apps/request.vue'),
meta: { title: '申请授权 - 管理后台' },
},
{
path: 'agent-apps/invite',
name: 'AdminAgentAppInvite',
component: () => import('@/pages/admin/agent-apps/invite.vue'),
meta: { title: '邀请授权 - 管理后台' },
},
{
path: 'agent-apps/:id/edit',
name: 'AdminAgentAppEdit',