完成授权页面样式重设计、云端数据权限功能、修复代理后台页面问题

This commit is contained in:
2026-05-09 09:21:48 +08:00
parent 0bd52e6bf1
commit 17d7d4c68d
13 changed files with 975 additions and 417 deletions
-19
View File
@@ -44,7 +44,6 @@
"vue-input-otp": "^0.3.2", "vue-input-otp": "^0.3.2",
"vue-router": "^5.0.3", "vue-router": "^5.0.3",
"vue-sonner": "^2.0.9", "vue-sonner": "^2.0.9",
"vue3-flag-icons": "^0.1.4",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
@@ -6198,12 +6197,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/flag-icons": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz",
"integrity": "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==",
"license": "MIT"
},
"node_modules/flat-cache": { "node_modules/flat-cache": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
@@ -10799,18 +10792,6 @@
"typescript": ">=5.0.0" "typescript": ">=5.0.0"
} }
}, },
"node_modules/vue3-flag-icons": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/vue3-flag-icons/-/vue3-flag-icons-0.1.4.tgz",
"integrity": "sha512-bJrrZuVHcs5XCW1CiXG3gpZnkzczk3gXlLgVmKt4XTeKRb3U/qt57hNtTP2ybXEXaigE26WxhO9rVXF9gsL7Dg==",
"license": "MIT",
"dependencies": {
"flag-icons": "^7.2.3"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/webpack-virtual-modules": { "node_modules/webpack-virtual-modules": {
"version": "0.6.2", "version": "0.6.2",
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
+41 -25
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { Boxes, CreditCard, Gauge, Key, LogOut, User, Users } from 'lucide-vue-next' import { Boxes, Cloud, CreditCard, Gauge, Key, LogOut, User, Users } from 'lucide-vue-next'
import { computed, onMounted, reactive } from 'vue' import { computed, onMounted, reactive } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -18,6 +18,7 @@ const user = reactive({
email: 'agent@example.com', email: 'agent@example.com',
avatar: '/avatars/agent.jpg', avatar: '/avatars/agent.jpg',
role: 'agent', role: 'agent',
can_view_cloud_data: false,
}) })
onMounted(() => { onMounted(() => {
@@ -29,6 +30,7 @@ onMounted(() => {
user.email = parsedUser.email || user.email user.email = parsedUser.email || user.email
user.avatar = parsedUser.avatar || user.avatar user.avatar = parsedUser.avatar || user.avatar
user.role = parsedUser.role || user.role user.role = parsedUser.role || user.role
user.can_view_cloud_data = parsedUser.can_view_cloud_data || false
} }
catch (e) { catch (e) {
console.error('Failed to parse user from localStorage', e) console.error('Failed to parse user from localStorage', e)
@@ -44,28 +46,39 @@ const teams = [
}, },
] ]
const navMain = computed(() => [ const navMain = computed(() => {
{ const groups: any[] = [
title: t('nav.mainFeatures'), {
items: [ title: t('nav.mainFeatures'),
{ items: [
title: t('nav.console'), {
url: '/agent', title: t('nav.console'),
icon: Gauge, url: '/agent',
}, icon: Gauge,
{ },
title: t('nav.cards'), {
url: '/agent/cards', title: t('nav.cards'),
icon: Key, url: '/agent/cards',
}, icon: Key,
{ },
title: t('nav.users'), {
url: '/agent/users', title: t('nav.users'),
icon: Users, url: '/agent/users',
}, icon: Users,
], },
}, ],
{ },
]
if (user.can_view_cloud_data) {
groups[0].items.push({
title: '云端数据',
url: '/agent/cloud-data',
icon: Cloud,
})
}
groups.push({
title: t('nav.financeGroup'), title: t('nav.financeGroup'),
items: [ items: [
{ {
@@ -74,8 +87,10 @@ const navMain = computed(() => [
icon: CreditCard, icon: CreditCard,
}, },
], ],
}, })
])
return groups
})
const breadcrumbs = computed(() => { const breadcrumbs = computed(() => {
const path = router.currentRoute.value.path const path = router.currentRoute.value.path
@@ -86,6 +101,7 @@ const breadcrumbs = computed(() => {
cards: t('nav.cards'), cards: t('nav.cards'),
users: t('nav.users'), users: t('nav.users'),
finance: t('nav.finance'), finance: t('nav.finance'),
'cloud-data': '云端数据',
profile: t('nav.profile'), profile: t('nav.profile'),
create: t('nav.create'), create: t('nav.create'),
} }
+180 -110
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { CheckCircle, Loader2 } from 'lucide-vue-next' import { Check, Eye, Info, Loader2, Shield } from 'lucide-vue-next'
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
@@ -45,6 +45,24 @@ const form = ref({
card_type_auth: {} as Record<string, boolean>, card_type_auth: {} as Record<string, boolean>,
}) })
const selectedAgent = computed(() => {
if (form.value.agent_id) {
return agents.value.find(a => String(a.id) === form.value.agent_id)
}
return null
})
const selectedApplication = computed(() => {
if (form.value.application_id) {
return applications.value.find(app => String(app.id) === form.value.application_id)
}
return null
})
const authorizedCount = computed(() => {
return Object.values(form.value.card_type_auth).filter(Boolean).length
})
async function fetchAgents() { async function fetchAgents() {
try { try {
const data = await api.get<{ agents?: Agent[] }>('/dev/agents') const data = await api.get<{ agents?: Agent[] }>('/dev/agents')
@@ -158,124 +176,176 @@ onMounted(() => {
<BasicPage <BasicPage
title="编辑授权" title="编辑授权"
description="修改代理的应用授权配置" description="修改代理的应用授权配置"
:breadcrumbs="[
{ title: '授权管理', href: '/admin/agent-apps' },
{ title: '编辑授权' },
]"
sticky sticky
> >
<div v-if="loading" class="flex items-center justify-center py-12"> <div v-if="loading" class="flex items-center justify-center py-12">
<Loader2 class="size-8 animate-spin text-muted-foreground" /> <Loader2 class="size-8 animate-spin text-muted-foreground" />
</div> </div>
<UiCard v-else class="max-w-2xl"> <div v-else class="space-y-6">
<UiCardHeader> <div class="grid gap-6 lg:grid-cols-3">
<UiCardTitle>基本信息</UiCardTitle> <div class="lg:col-span-2 space-y-6">
<UiCardDescription> <UiCard>
查看代理和应用信息 <UiCardHeader>
</UiCardDescription> <UiCardTitle class="flex items-center gap-2">
</UiCardHeader> <Info class="size-5" />
<UiCardContent class="space-y-6"> 基本信息
<div class="space-y-2"> </UiCardTitle>
<UiLabel>代理</UiLabel> <UiCardDescription>查看代理和应用信息</UiCardDescription>
<UiSelect </UiCardHeader>
v-model="form.agent_id" <UiCardContent class="space-y-6">
disabled <div class="space-y-2">
> <UiLabel>代理</UiLabel>
<UiSelectTrigger> <UiSelect v-model="form.agent_id" disabled>
<UiSelectValue placeholder="请选择代理" /> <UiSelectTrigger>
</UiSelectTrigger> <UiSelectValue placeholder="请选择代理" />
<UiSelectContent> </UiSelectTrigger>
<UiSelectItem v-for="agent in agents" :key="agent.id" :value="String(agent.id)"> <UiSelectContent>
{{ agent.username }} <UiSelectItem v-for="agent in agents" :key="agent.id" :value="String(agent.id)">
</UiSelectItem> {{ agent.username }}
</UiSelectContent> </UiSelectItem>
</UiSelect> </UiSelectContent>
<p class="text-xs text-muted-foreground">编辑时不可修改代理</p> </UiSelect>
<p class="text-xs text-muted-foreground">编辑时不可修改代理</p>
</div>
<div class="space-y-2">
<UiLabel>应用</UiLabel>
<UiSelect v-model="form.application_id" disabled>
<UiSelectTrigger>
<UiSelectValue placeholder="请选择应用" />
</UiSelectTrigger>
<UiSelectContent>
<UiSelectItem v-for="app in applications" :key="app.id" :value="String(app.id)">
{{ app.name }}
</UiSelectItem>
</UiSelectContent>
</UiSelect>
<p class="text-xs text-muted-foreground">编辑时不可修改应用</p>
</div>
<div class="space-y-2">
<UiLabel>备注</UiLabel>
<UiTextarea
v-model="form.remark"
placeholder="输入备注信息(可选)"
rows="3"
/>
</div>
</UiCardContent>
</UiCard>
<UiCard v-if="form.application_id && cardTypes.length > 0">
<UiCardHeader>
<UiCardTitle class="flex items-center gap-2">
<Shield class="size-5" />
卡类权限
</UiCardTitle>
<UiCardDescription>配置代理可以生成的卡类和价格</UiCardDescription>
</UiCardHeader>
<UiCardContent>
<div class="border rounded-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-muted/50">
<tr>
<th class="text-left p-3 font-medium w-12">授权</th>
<th class="text-left p-3 font-medium">卡类名称</th>
<th class="text-left p-3 font-medium">计费类型</th>
<th class="text-right p-3 font-medium w-32">代理价格</th>
</tr>
</thead>
<tbody>
<tr v-for="ct in cardTypes" :key="ct.id" class="border-t hover:bg-muted/30 transition-colors">
<td class="p-3">
<UiCheckbox
:checked="form.card_type_auth[String(ct.id)] ?? true"
@update:checked="form.card_type_auth[String(ct.id)] = $event"
/>
</td>
<td class="p-3 font-medium">{{ ct.name }}</td>
<td class="p-3 text-muted-foreground">{{ ct.billing_type }}</td>
<td class="p-3">
<div class="flex items-center justify-end gap-1">
<span class="text-muted-foreground">¥</span>
<UiInput
class="w-20 text-right"
:model-value="String(form.card_type_prices[String(ct.id)] ?? ct.price)"
@input="form.card_type_prices[String(ct.id)] = Number(($event.target as HTMLInputElement).value)"
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<p class="text-xs text-muted-foreground mt-3">
提示取消勾选"授权"列可以禁止代理生成该类型的卡密
</p>
</UiCardContent>
</UiCard>
</div> </div>
<div class="space-y-2"> <div class="space-y-6">
<UiLabel>应用</UiLabel> <UiCard>
<UiSelect <UiCardHeader>
v-model="form.application_id" <UiCardTitle class="flex items-center gap-2">
disabled <Eye class="size-5" />
> 预览
<UiSelectTrigger> </UiCardTitle>
<UiSelectValue placeholder="请选择应用" /> </UiCardHeader>
</UiSelectTrigger> <UiCardContent class="space-y-4">
<UiSelectContent> <div class="space-y-3">
<UiSelectItem v-for="app in applications" :key="app.id" :value="String(app.id)"> <div class="flex justify-between text-sm">
{{ app.name }} <span class="text-muted-foreground">代理</span>
</UiSelectItem> <span>{{ selectedAgent?.username || '-' }}</span>
</UiSelectContent> </div>
</UiSelect> <div class="flex justify-between text-sm">
<p class="text-xs text-muted-foreground">编辑时不可修改应用</p> <span class="text-muted-foreground">应用</span>
</div> <span>{{ selectedApplication?.name || '-' }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">授权卡类</span>
<span>{{ authorizedCount }} / {{ cardTypes.length }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">备注</span>
<span class="truncate max-w-[120px]">{{ form.remark || '-' }}</span>
</div>
</div>
</UiCardContent>
</UiCard>
<div class="space-y-2"> <UiCard>
<UiLabel>备注</UiLabel> <UiCardContent class="pt-6">
<UiTextarea <div class="flex flex-col gap-3">
v-model="form.remark" <UiButton
placeholder="输入备注信息(可选)" class="w-full"
rows="3" size="lg"
/> :disabled="saving || !form.agent_id || !form.application_id"
@click="handleSave"
>
<Loader2 v-if="saving" class="mr-2 h-4 w-4 animate-spin" />
<Check v-else class="mr-2 h-4 w-4" />
保存修改
</UiButton>
<UiButton
variant="outline"
class="w-full"
@click="router.back()"
>
取消
</UiButton>
</div>
</UiCardContent>
</UiCard>
</div> </div>
</div>
<div v-if="form.application_id && cardTypes.length > 0" class="space-y-3"> </div>
<UiLabel>卡类权限</UiLabel>
<div class="border rounded-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-muted/50">
<tr>
<th class="text-left p-3 font-medium w-12">授权</th>
<th class="text-left p-3 font-medium">卡类名称</th>
<th class="text-left p-3 font-medium">计费类型</th>
<th class="text-right p-3 font-medium w-32">代理价格</th>
</tr>
</thead>
<tbody>
<tr v-for="ct in cardTypes" :key="ct.id" class="border-t hover:bg-muted/30 transition-colors">
<td class="p-3">
<UiCheckbox
:checked="form.card_type_auth[String(ct.id)] ?? true"
@update:checked="form.card_type_auth[String(ct.id)] = $event"
/>
</td>
<td class="p-3 font-medium">{{ ct.name }}</td>
<td class="p-3 text-muted-foreground">{{ ct.billing_type }}</td>
<td class="p-3">
<div class="flex items-center justify-end gap-1">
<span class="text-muted-foreground">¥</span>
<UiInput
class="w-20 text-right"
:model-value="String(form.card_type_prices[String(ct.id)] ?? ct.price)"
@input="form.card_type_prices[String(ct.id)] = Number(($event.target as HTMLInputElement).value)"
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<p class="text-xs text-muted-foreground">
提示取消勾选"授权"列可以禁止代理生成该类型的卡密
</p>
</div>
<div class="flex gap-3">
<UiButton variant="outline" as-child>
<router-link to="/admin/agent-apps">
取消
</router-link>
</UiButton>
<UiButton
:disabled="saving || !form.agent_id || !form.application_id"
@click="handleSave"
>
<Loader2 v-if="saving" class="mr-2 h-4 w-4 animate-spin" />
<CheckCircle v-else class="mr-2 h-4 w-4" />
保存修改
</UiButton>
</div>
</UiCardContent>
</UiCard>
</BasicPage> </BasicPage>
</template> </template>
+178 -102
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { CheckCircle, Loader2 } from 'lucide-vue-next' import { CheckCircle, Eye, Info, Loader2, Plus, Shield } from 'lucide-vue-next'
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -41,6 +41,24 @@ const form = ref({
card_type_auth: {} as Record<string, boolean>, card_type_auth: {} as Record<string, boolean>,
}) })
const selectedAgent = computed(() => {
if (form.value.agent_id) {
return agents.value.find(a => String(a.id) === form.value.agent_id)
}
return null
})
const selectedApplication = computed(() => {
if (form.value.application_id) {
return applications.value.find(app => String(app.id) === form.value.application_id)
}
return null
})
const authorizedCount = computed(() => {
return Object.values(form.value.card_type_auth).filter(Boolean).length
})
async function fetchAgents() { async function fetchAgents() {
try { try {
const data = await api.get<{ agents?: Agent[] }>('/dev/agents') const data = await api.get<{ agents?: Agent[] }>('/dev/agents')
@@ -130,112 +148,170 @@ onMounted(() => {
<BasicPage <BasicPage
title="添加授权" title="添加授权"
description="为代理添加新的应用授权" description="为代理添加新的应用授权"
:breadcrumbs="[
{ title: '授权管理', href: '/admin/agent-apps' },
{ title: '添加授权' },
]"
sticky sticky
> >
<UiCard class="max-w-2xl"> <div class="space-y-6">
<UiCardHeader> <div class="grid gap-6 lg:grid-cols-3">
<UiCardTitle>基本信息</UiCardTitle> <div class="lg:col-span-2 space-y-6">
<UiCardDescription> <UiCard>
选择代理和应用 <UiCardHeader>
</UiCardDescription> <UiCardTitle class="flex items-center gap-2">
</UiCardHeader> <Info class="size-5" />
<UiCardContent class="space-y-6"> 基本信息
<div class="space-y-2"> </UiCardTitle>
<UiLabel>选择代理 <span class="text-destructive">*</span></UiLabel> <UiCardDescription>选择代理和应用</UiCardDescription>
<UiSelect v-model="form.agent_id"> </UiCardHeader>
<UiSelectTrigger> <UiCardContent class="space-y-6">
<UiSelectValue placeholder="请选择代理" /> <div class="space-y-2">
</UiSelectTrigger> <UiLabel>选择代理 <span class="text-destructive">*</span></UiLabel>
<UiSelectContent> <UiSelect v-model="form.agent_id">
<UiSelectItem v-for="agent in agents" :key="agent.id" :value="String(agent.id)"> <UiSelectTrigger>
{{ agent.username }} <UiSelectValue placeholder="请选择代理" />
</UiSelectItem> </UiSelectTrigger>
</UiSelectContent> <UiSelectContent>
</UiSelect> <UiSelectItem v-for="agent in agents" :key="agent.id" :value="String(agent.id)">
{{ agent.username }}
</UiSelectItem>
</UiSelectContent>
</UiSelect>
</div>
<div class="space-y-2">
<UiLabel>选择应用 <span class="text-destructive">*</span></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>备注</UiLabel>
<UiTextarea
v-model="form.remark"
placeholder="输入备注信息(可选)"
rows="3"
/>
</div>
</UiCardContent>
</UiCard>
<UiCard v-if="form.application_id && cardTypes.length > 0">
<UiCardHeader>
<UiCardTitle class="flex items-center gap-2">
<Shield class="size-5" />
卡类权限
</UiCardTitle>
<UiCardDescription>配置代理可以生成的卡类和价格</UiCardDescription>
</UiCardHeader>
<UiCardContent>
<div class="border rounded-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-muted/50">
<tr>
<th class="text-left p-3 font-medium w-12">授权</th>
<th class="text-left p-3 font-medium">卡类名称</th>
<th class="text-left p-3 font-medium">计费类型</th>
<th class="text-right p-3 font-medium w-32">代理价格</th>
</tr>
</thead>
<tbody>
<tr v-for="ct in cardTypes" :key="ct.id" class="border-t hover:bg-muted/30 transition-colors">
<td class="p-3">
<UiCheckbox
:checked="form.card_type_auth[String(ct.id)] ?? true"
@update:checked="form.card_type_auth[String(ct.id)] = $event"
/>
</td>
<td class="p-3 font-medium">{{ ct.name }}</td>
<td class="p-3 text-muted-foreground">{{ ct.billing_type }}</td>
<td class="p-3">
<div class="flex items-center justify-end gap-1">
<span class="text-muted-foreground">¥</span>
<UiInput
class="w-20 text-right"
:model-value="String(form.card_type_prices[String(ct.id)] ?? ct.price)"
@input="form.card_type_prices[String(ct.id)] = Number(($event.target as HTMLInputElement).value)"
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<p class="text-xs text-muted-foreground mt-3">
提示取消勾选"授权"列可以禁止代理生成该类型的卡密
</p>
</UiCardContent>
</UiCard>
</div> </div>
<div class="space-y-2"> <div class="space-y-6">
<UiLabel>选择应用 <span class="text-destructive">*</span></UiLabel> <UiCard>
<UiSelect v-model="form.application_id"> <UiCardHeader>
<UiSelectTrigger> <UiCardTitle class="flex items-center gap-2">
<UiSelectValue placeholder="请选择应用" /> <Eye class="size-5" />
</UiSelectTrigger> 预览
<UiSelectContent> </UiCardTitle>
<UiSelectItem v-for="app in applications" :key="app.id" :value="String(app.id)"> </UiCardHeader>
{{ app.name }} <UiCardContent class="space-y-4">
</UiSelectItem> <div class="space-y-3">
</UiSelectContent> <div class="flex justify-between text-sm">
</UiSelect> <span class="text-muted-foreground">代理</span>
</div> <span>{{ selectedAgent?.username || '-' }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">应用</span>
<span>{{ selectedApplication?.name || '-' }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">授权卡类</span>
<span>{{ authorizedCount }} / {{ cardTypes.length }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">备注</span>
<span class="truncate max-w-[120px]">{{ form.remark || '-' }}</span>
</div>
</div>
</UiCardContent>
</UiCard>
<div class="space-y-2"> <UiCard>
<UiLabel>备注</UiLabel> <UiCardContent class="pt-6">
<UiTextarea <div class="flex flex-col gap-3">
v-model="form.remark" <UiButton
placeholder="输入备注信息(可选)" class="w-full"
rows="3" size="lg"
/> :disabled="saving || !form.agent_id || !form.application_id"
@click="handleSave"
>
<Loader2 v-if="saving" class="mr-2 h-4 w-4 animate-spin" />
<Plus v-else class="mr-2 h-4 w-4" />
确认授权
</UiButton>
<UiButton
variant="outline"
class="w-full"
@click="router.back()"
>
取消
</UiButton>
</div>
</UiCardContent>
</UiCard>
</div> </div>
</div>
<div v-if="form.application_id && cardTypes.length > 0" class="space-y-3"> </div>
<UiLabel>卡类权限</UiLabel>
<div class="border rounded-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-muted/50">
<tr>
<th class="text-left p-3 font-medium w-12">授权</th>
<th class="text-left p-3 font-medium">卡类名称</th>
<th class="text-left p-3 font-medium">计费类型</th>
<th class="text-right p-3 font-medium w-32">代理价格</th>
</tr>
</thead>
<tbody>
<tr v-for="ct in cardTypes" :key="ct.id" class="border-t hover:bg-muted/30 transition-colors">
<td class="p-3">
<UiCheckbox
:checked="form.card_type_auth[String(ct.id)] ?? true"
@update:checked="form.card_type_auth[String(ct.id)] = $event"
/>
</td>
<td class="p-3 font-medium">{{ ct.name }}</td>
<td class="p-3 text-muted-foreground">{{ ct.billing_type }}</td>
<td class="p-3">
<div class="flex items-center justify-end gap-1">
<span class="text-muted-foreground">¥</span>
<UiInput
class="w-20 text-right"
:model-value="String(form.card_type_prices[String(ct.id)] ?? ct.price)"
@input="form.card_type_prices[String(ct.id)] = Number(($event.target as HTMLInputElement).value)"
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<p class="text-xs text-muted-foreground">
提示取消勾选"授权"列可以禁止代理生成该类型的卡密
</p>
</div>
<div class="flex gap-3">
<UiButton variant="outline" as-child>
<router-link to="/admin/agent-apps">
取消
</router-link>
</UiButton>
<UiButton
:disabled="saving || !form.agent_id || !form.application_id"
@click="handleSave"
>
<Loader2 v-if="saving" class="mr-2 h-4 w-4 animate-spin" />
<CheckCircle v-else class="mr-2 h-4 w-4" />
确认授权
</UiButton>
</div>
</UiCardContent>
</UiCard>
</BasicPage> </BasicPage>
</template> </template>
+17
View File
@@ -30,6 +30,7 @@ const form = ref({
password: '', password: '',
parent_agent_id: '', parent_agent_id: '',
can_create_agent: false, can_create_agent: false,
can_view_cloud_data: false,
balance: 0, balance: 0,
}) })
@@ -43,6 +44,7 @@ async function fetchAgent() {
form.value.email = agent.email || '' form.value.email = agent.email || ''
form.value.parent_agent_id = agent.parent_agent_id ? String(agent.parent_agent_id) : 'none' form.value.parent_agent_id = agent.parent_agent_id ? String(agent.parent_agent_id) : 'none'
form.value.can_create_agent = agent.can_create_agent || false form.value.can_create_agent = agent.can_create_agent || false
form.value.can_view_cloud_data = agent.can_view_cloud_data || false
form.value.balance = agent.balance || 0 form.value.balance = agent.balance || 0
} }
} }
@@ -83,6 +85,7 @@ async function handleSave() {
const payload: any = { const payload: any = {
username: form.value.username, username: form.value.username,
can_create_agent: form.value.can_create_agent, can_create_agent: form.value.can_create_agent,
can_view_cloud_data: form.value.can_view_cloud_data,
balance: form.value.balance, balance: form.value.balance,
} }
@@ -245,6 +248,16 @@ onMounted(() => {
</div> </div>
<UiSwitch v-model="form.can_create_agent" /> <UiSwitch v-model="form.can_create_agent" />
</div> </div>
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<UiLabel>云端数据权限</UiLabel>
<p class="text-xs text-muted-foreground">
允许代理查看云端数据只读
</p>
</div>
<UiSwitch v-model="form.can_view_cloud_data" />
</div>
</UiCardContent> </UiCardContent>
</UiCard> </UiCard>
</div> </div>
@@ -271,6 +284,10 @@ onMounted(() => {
<span class="text-muted-foreground">{{ t('admin.agents.form.canCreateAgent') }}</span> <span class="text-muted-foreground">{{ t('admin.agents.form.canCreateAgent') }}</span>
<span>{{ form.can_create_agent ? t('common.yes') : t('common.no') }}</span> <span>{{ form.can_create_agent ? t('common.yes') : t('common.no') }}</span>
</div> </div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">云端数据权限</span>
<span>{{ form.can_view_cloud_data ? t('common.yes') : t('common.no') }}</span>
</div>
<div class="flex justify-between text-sm"> <div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('admin.agents.form.parentAgent') }}</span> <span class="text-muted-foreground">{{ t('admin.agents.form.parentAgent') }}</span>
<span>{{ selectedParentAgent?.username || '-' }}</span> <span>{{ selectedParentAgent?.username || '-' }}</span>
+16 -1
View File
@@ -27,6 +27,7 @@ const form = ref({
password: '', password: '',
parent_agent_id: '', parent_agent_id: '',
can_create_agent: false, can_create_agent: false,
can_view_cloud_data: false,
balance: 0, balance: 0,
}) })
@@ -67,6 +68,7 @@ async function handleCreate() {
username: form.value.username, username: form.value.username,
password: form.value.password, password: form.value.password,
can_create_agent: form.value.can_create_agent, can_create_agent: form.value.can_create_agent,
can_view_cloud_data: form.value.can_view_cloud_data,
balance: form.value.balance, balance: form.value.balance,
} }
@@ -224,9 +226,18 @@ onMounted(() => {
{{ t('admin.agents.create.canCreateAgentHint') }} {{ t('admin.agents.create.canCreateAgentHint') }}
</p> </p>
</div> </div>
<!-- 注意UiSwitch 必须使用 v-model不能使用 v-model:checked -->
<UiSwitch v-model="form.can_create_agent" /> <UiSwitch v-model="form.can_create_agent" />
</div> </div>
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<UiLabel>云端数据权限</UiLabel>
<p class="text-xs text-muted-foreground">
允许代理查看云端数据只读
</p>
</div>
<UiSwitch v-model="form.can_view_cloud_data" />
</div>
</UiCardContent> </UiCardContent>
</UiCard> </UiCard>
</div> </div>
@@ -253,6 +264,10 @@ onMounted(() => {
<span class="text-muted-foreground">{{ t('admin.agents.form.canCreateAgent') }}</span> <span class="text-muted-foreground">{{ t('admin.agents.form.canCreateAgent') }}</span>
<span>{{ form.can_create_agent ? t('common.yes') : t('common.no') }}</span> <span>{{ form.can_create_agent ? t('common.yes') : t('common.no') }}</span>
</div> </div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">云端数据权限</span>
<span>{{ form.can_view_cloud_data ? t('common.yes') : t('common.no') }}</span>
</div>
<div class="flex justify-between text-sm"> <div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('admin.agents.form.parentAgent') }}</span> <span class="text-muted-foreground">{{ t('admin.agents.form.parentAgent') }}</span>
<span>{{ selectedParentAgent?.username || '-' }}</span> <span>{{ selectedParentAgent?.username || '-' }}</span>
+24 -4
View File
@@ -33,12 +33,28 @@ const totalPrice = computed(() => {
onMounted(async () => { onMounted(async () => {
try { try {
const data = await api.get<any[]>('/agent/apps') const data = await api.get<any>('/agent/apps')
apps.value = data || [] if (Array.isArray(data)) {
apps.value = data
} else if (data?.apps) {
apps.value = data.apps
} else if (data?.data) {
apps.value = Array.isArray(data.data) ? data.data : []
} else {
apps.value = []
}
if (apps.value.length > 0) { if (apps.value.length > 0) {
const typesData = await api.get<any[]>('/agent/card-types') const typesData = await api.get<any>('/agent/card-types')
cardTypes.value = typesData || [] if (Array.isArray(typesData)) {
cardTypes.value = typesData
} else if (typesData?.card_types) {
cardTypes.value = typesData.card_types
} else if (typesData?.data) {
cardTypes.value = Array.isArray(typesData.data) ? typesData.data : []
} else {
cardTypes.value = []
}
} }
} }
catch (error) { catch (error) {
@@ -90,6 +106,10 @@ async function handleGenerate() {
<BasicPage <BasicPage
title="生成卡密" title="生成卡密"
description="批量生成卡密" description="批量生成卡密"
:breadcrumbs="[
{ title: '卡密管理', href: '/agent/cards' },
{ title: '生成卡密' },
]"
sticky sticky
> >
<UiCard class="max-w-2xl"> <UiCard class="max-w-2xl">
+16 -3
View File
@@ -23,11 +23,20 @@ const cards = ref<Card[]>([])
onMounted(async () => { onMounted(async () => {
try { try {
const data = await api.get<Card[]>('/agent/cards') const data = await api.get<any>('/agent/cards')
cards.value = data || [] if (Array.isArray(data)) {
cards.value = data
} else if (data?.cards) {
cards.value = data.cards
} else if (data?.data) {
cards.value = Array.isArray(data.data) ? data.data : []
} else {
cards.value = []
}
} }
catch (error) { catch (error) {
console.error('Load cards failed:', error) console.error('Load cards failed:', error)
cards.value = []
} }
finally { finally {
loading.value = false loading.value = false
@@ -55,6 +64,10 @@ function getStatusBadge(status: string) {
<BasicPage <BasicPage
:title="t('nav.cards')" :title="t('nav.cards')"
description="管理生成的卡密" description="管理生成的卡密"
:breadcrumbs="[
{ title: '控制台', href: '/agent' },
{ title: '卡密管理' },
]"
sticky sticky
> >
<template #actions> <template #actions>
@@ -107,7 +120,7 @@ function getStatusBadge(status: string) {
</tr> </tr>
</thead> </thead>
<tbody class="divide-y"> <tbody class="divide-y">
<tr v-for="card in cards" :key="card.id" class="hover:bg-muted/30"> <tr v-for="card in cards.filter(c => c)" :key="card.id" class="hover:bg-muted/30">
<td class="px-4 py-3 font-mono text-sm"> <td class="px-4 py-3 font-mono text-sm">
{{ card.code }} {{ card.code }}
</td> </td>
@@ -0,0 +1,254 @@
<script setup lang="ts">
import { Cloud, Database, Eye, Globe, Variable } from 'lucide-vue-next'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { BasicPage } from '@/components/global-layout'
import api from '@/services/api'
const { t } = useI18n()
const router = useRouter()
interface Application {
id: number
name: string
app_key: string
}
const loading = ref(true)
const activeTab = ref('variables')
const applications = ref<Application[]>([])
const appFilter = ref<string>('')
const variables = ref<any[]>([])
const constants = ref<any[]>([])
const filteredVariables = computed(() => {
if (!appFilter.value) return variables.value
return variables.value.filter(v => String(v.app_id) === appFilter.value)
})
const filteredConstants = computed(() => {
if (!appFilter.value) return constants.value
return constants.value.filter(c => String(c.app_id) === appFilter.value)
})
const stats = computed(() => ({
variables: variables.value.length,
constants: constants.value.length,
applications: applications.value.length,
}))
async function fetchApplications() {
try {
const data = await api.get<{ applications: Application[] }>('/agent/applications')
applications.value = data?.applications || []
}
catch { /* ignore */ }
}
async function fetchVariables() {
try {
const data = await api.get<{ variables: any[] }>('/agent/cloud-variables')
variables.value = data?.variables || []
}
catch {
variables.value = []
}
}
async function fetchConstants() {
try {
const data = await api.get<{ constants: any[] }>('/agent/cloud-constants')
constants.value = data?.constants || []
}
catch {
constants.value = []
}
}
function getAppName(appId: number) {
const app = applications.value.find(a => a.id === appId)
return app?.name || String(appId)
}
function formatDate(dateStr: string) {
if (!dateStr) return '-'
return dateStr.replace('T', ' ').substring(0, 19)
}
onMounted(async () => {
loading.value = true
await Promise.all([fetchApplications(), fetchVariables(), fetchConstants()])
loading.value = false
})
</script>
<template>
<BasicPage
title="云端数据"
description="查看云端变量和常量数据(只读)"
:breadcrumbs="[
{ title: '控制台', href: '/agent' },
{ title: '云端数据' },
]"
>
<div class="space-y-6">
<div class="grid gap-4 md:grid-cols-3">
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<UiCardTitle class="text-sm font-medium">云端变量</UiCardTitle>
<Variable class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">{{ stats.variables }}</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<UiCardTitle class="text-sm font-medium">云端常量</UiCardTitle>
<Database class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">{{ stats.constants }}</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<UiCardTitle class="text-sm font-medium">应用数量</UiCardTitle>
<Globe class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">{{ stats.applications }}</div>
</UiCardContent>
</UiCard>
</div>
<UiCard>
<UiCardHeader>
<div class="flex items-center justify-between">
<div>
<UiCardTitle class="flex items-center gap-2">
<Cloud class="size-5" />
数据列表
</UiCardTitle>
<UiCardDescription>只读模式数据不可编辑</UiCardDescription>
</div>
<div class="flex items-center gap-3">
<UiSelect v-model="appFilter">
<UiSelectTrigger class="w-[180px]">
<UiSelectValue placeholder="全部应用" />
</UiSelectTrigger>
<UiSelectContent>
<UiSelectItem value="all">全部应用</UiSelectItem>
<UiSelectItem v-for="app in applications" :key="app.id" :value="String(app.id)">
{{ app.name }}
</UiSelectItem>
</UiSelectContent>
</UiSelect>
<div class="flex border rounded-lg overflow-hidden">
<button
class="px-4 py-2 text-sm font-medium transition-colors"
:class="activeTab === 'variables' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
@click="activeTab = 'variables'"
>
云端变量
</button>
<button
class="px-4 py-2 text-sm font-medium transition-colors"
:class="activeTab === 'constants' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
@click="activeTab = 'constants'"
>
云端常量
</button>
</div>
</div>
</div>
</UiCardHeader>
<UiCardContent>
<div v-if="loading" class="flex items-center justify-center py-12">
<div class="size-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
<template v-else>
<div v-if="activeTab === 'variables'">
<div v-if="filteredVariables.length === 0" class="text-center py-12 text-muted-foreground">
暂无云端变量数据
</div>
<div v-else class="border rounded-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-muted/50">
<tr>
<th class="text-left p-3 font-medium">名称</th>
<th class="text-left p-3 font-medium">应用</th>
<th class="text-left p-3 font-medium">作用域</th>
<th class="text-left p-3 font-medium">状态</th>
<th class="text-left p-3 font-medium">创建时间</th>
</tr>
</thead>
<tbody>
<tr v-for="v in filteredVariables" :key="v.id" class="border-t hover:bg-muted/30 transition-colors">
<td class="p-3 font-medium">{{ v.name }}</td>
<td class="p-3 text-muted-foreground">{{ getAppName(v.app_id) }}</td>
<td class="p-3">
<UiBadge variant="outline">{{ v.scope || 'global' }}</UiBadge>
</td>
<td class="p-3">
<UiBadge :variant="v.status === 1 ? 'default' : 'secondary'">
{{ v.status === 1 ? '启用' : '禁用' }}
</UiBadge>
</td>
<td class="p-3 text-muted-foreground">{{ formatDate(v.created_at) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div v-if="activeTab === 'constants'">
<div v-if="filteredConstants.length === 0" class="text-center py-12 text-muted-foreground">
暂无云端常量数据
</div>
<div v-else class="border rounded-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-muted/50">
<tr>
<th class="text-left p-3 font-medium">名称</th>
<th class="text-left p-3 font-medium">应用</th>
<th class="text-left p-3 font-medium"></th>
<th class="text-left p-3 font-medium">状态</th>
<th class="text-left p-3 font-medium">创建时间</th>
</tr>
</thead>
<tbody>
<tr v-for="c in filteredConstants" :key="c.id" class="border-t hover:bg-muted/30 transition-colors">
<td class="p-3 font-medium">{{ c.name }}</td>
<td class="p-3 text-muted-foreground">{{ getAppName(c.app_id) }}</td>
<td class="p-3 text-muted-foreground max-w-[200px] truncate">{{ c.value }}</td>
<td class="p-3">
<UiBadge :variant="c.status === 1 ? 'default' : 'secondary'">
{{ c.status === 1 ? '启用' : '禁用' }}
</UiBadge>
</td>
<td class="p-3 text-muted-foreground">{{ formatDate(c.created_at) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<div class="mt-4 flex items-center gap-2 text-sm text-muted-foreground">
<Eye class="size-4" />
<span>只读模式 - 当前数据仅供查看无法进行编辑操作</span>
</div>
</UiCardContent>
</UiCard>
</div>
</BasicPage>
</template>
+136 -67
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ArrowDownLeft, ArrowUpRight, Loader2, Receipt, RotateCcw } from 'lucide-vue-next' import { ArrowDownLeft, ArrowUpRight, CreditCard, Loader2, Receipt, RotateCcw, TrendingDown, TrendingUp, Wallet } from 'lucide-vue-next'
import { onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { BasicPage } from '@/components/global-layout' import { BasicPage } from '@/components/global-layout'
@@ -21,14 +21,35 @@ const loading = ref(true)
const balance = ref(0) const balance = ref(0)
const transactions = ref<Transaction[]>([]) const transactions = ref<Transaction[]>([])
const totalRecharge = computed(() => {
return transactions.value
.filter(tx => tx.type === 'recharge' || tx.type === 'refund')
.reduce((sum, tx) => sum + (tx.amount || 0), 0)
})
const totalConsume = computed(() => {
return transactions.value
.filter(tx => tx.type === 'consume')
.reduce((sum, tx) => sum + (tx.amount || 0), 0)
})
onMounted(async () => { onMounted(async () => {
try { try {
const data = await api.get<{ balance: number, transactions: Transaction[] }>('/agent/finance') const data = await api.get<any>('/agent/finance')
balance.value = data?.balance || 0 if (data) {
transactions.value = data?.transactions || [] balance.value = data.balance || 0
if (Array.isArray(data.transactions)) {
transactions.value = data.transactions
} else if (Array.isArray(data.data)) {
transactions.value = data.data
} else {
transactions.value = []
}
}
} }
catch (error) { catch (error) {
console.error('Load finance data failed:', error) console.error('Load finance data failed:', error)
transactions.value = []
} }
finally { finally {
loading.value = false loading.value = false
@@ -36,7 +57,8 @@ onMounted(async () => {
}) })
function formatDate(dateStr: string) { function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString() if (!dateStr) return '-'
return dateStr.replace('T', ' ').substring(0, 19)
} }
function getTypeLabel(type: string) { function getTypeLabel(type: string) {
@@ -56,80 +78,127 @@ function getTypeClass(type: string) {
} }
return map[type] || '' return map[type] || ''
} }
function getTypeIcon(type: string) {
if (type === 'recharge') return ArrowDownLeft
if (type === 'refund') return RotateCcw
return ArrowUpRight
}
function getTypeBgClass(type: string) {
if (type === 'recharge') return 'bg-green-500/10'
if (type === 'refund') return 'bg-blue-500/10'
return 'bg-red-500/10'
}
</script> </script>
<template> <template>
<BasicPage <BasicPage
:title="t('nav.finance')" :title="t('nav.finance')"
description="财务管理" description="查看财务信息和交易记录"
:breadcrumbs="[
{ title: '控制台', href: '/agent' },
{ title: '财务管理' },
]"
sticky sticky
> >
<div class="grid gap-4 md:grid-cols-3"> <div class="space-y-6">
<div class="grid gap-4 md:grid-cols-3">
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<UiCardTitle class="text-sm font-medium">账户余额</UiCardTitle>
<Wallet class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">¥{{ balance.toFixed(2) }}</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<UiCardTitle class="text-sm font-medium">累计收入</UiCardTitle>
<TrendingUp class="size-4 text-green-500" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold text-green-500">+¥{{ totalRecharge.toFixed(2) }}</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<UiCardTitle class="text-sm font-medium">累计支出</UiCardTitle>
<TrendingDown class="size-4 text-red-500" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold text-red-500">-¥{{ totalConsume.toFixed(2) }}</div>
</UiCardContent>
</UiCard>
</div>
<UiCard> <UiCard>
<UiCardHeader class="pb-2"> <UiCardHeader>
<UiCardTitle class="text-sm font-medium"> <UiCardTitle class="flex items-center gap-2">
账户余额 <Receipt class="size-5" />
交易记录
</UiCardTitle> </UiCardTitle>
<UiCardDescription>账户资金变动记录</UiCardDescription>
</UiCardHeader> </UiCardHeader>
<UiCardContent> <UiCardContent class="p-0">
<div class="text-3xl font-bold"> <div v-if="loading" class="flex items-center justify-center py-12">
¥{{ balance.toFixed(2) }} <Loader2 class="size-8 animate-spin text-muted-foreground" />
</div>
<div v-else-if="transactions.length === 0" class="text-center py-12 text-muted-foreground">
<Receipt class="size-16 mx-auto mb-4 opacity-30" />
<p>暂无交易记录</p>
</div>
<div v-else class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-muted/50">
<tr>
<th class="text-left p-3 font-medium">类型</th>
<th class="text-left p-3 font-medium">描述</th>
<th class="text-right p-3 font-medium">金额</th>
<th class="text-right p-3 font-medium">余额</th>
<th class="text-left p-3 font-medium">时间</th>
</tr>
</thead>
<tbody>
<tr v-for="tx in transactions.filter(t => t)" :key="tx.id" class="border-t hover:bg-muted/30 transition-colors">
<td class="p-3">
<div class="flex items-center gap-2">
<div
class="size-8 rounded-lg flex items-center justify-center"
:class="getTypeBgClass(tx.type)"
>
<component
:is="getTypeIcon(tx.type)"
:class="getTypeClass(tx.type)"
class="size-4"
/>
</div>
<span>{{ getTypeLabel(tx.type) }}</span>
</div>
</td>
<td class="p-3 text-muted-foreground">
{{ tx.description || getTypeLabel(tx.type) }}
</td>
<td class="p-3 text-right">
<span :class="getTypeClass(tx.type)" class="font-semibold">
{{ tx.type === 'recharge' || tx.type === 'refund' ? '+' : '-' }}¥{{ (tx.amount || 0).toFixed(2) }}
</span>
</td>
<td class="p-3 text-right text-muted-foreground">
¥{{ (tx.balance || 0).toFixed(2) }}
</td>
<td class="p-3 text-muted-foreground">
{{ formatDate(tx.created_at) }}
</td>
</tr>
</tbody>
</table>
</div> </div>
</UiCardContent> </UiCardContent>
</UiCard> </UiCard>
</div> </div>
<UiCard class="mt-6">
<UiCardHeader>
<UiCardTitle>交易记录</UiCardTitle>
</UiCardHeader>
<UiCardContent class="p-0">
<div v-if="loading" class="flex items-center justify-center py-12">
<Loader2 class="size-8 animate-spin text-muted-foreground" />
</div>
<div v-else-if="transactions.length === 0" class="text-center py-12 text-muted-foreground">
<Receipt class="size-16 mx-auto mb-4 opacity-30" />
<p>暂无交易记录</p>
</div>
<div v-else class="divide-y">
<div
v-for="tx in transactions"
:key="tx.id"
class="flex items-center justify-between p-4 hover:bg-muted/30"
>
<div class="flex items-center gap-4">
<div
class="size-10 rounded-xl flex items-center justify-center"
:class="tx.type === 'recharge' ? 'bg-green-500/10' : tx.type === 'refund' ? 'bg-blue-500/10' : 'bg-red-500/10'"
>
<component
:is="tx.type === 'recharge' ? ArrowDownLeft : tx.type === 'refund' ? RotateCcw : ArrowUpRight"
:class="getTypeClass(tx.type)"
class="size-5"
/>
</div>
<div>
<p class="font-medium">
{{ tx.description || getTypeLabel(tx.type) }}
</p>
<p class="text-xs text-muted-foreground">
{{ formatDate(tx.created_at) }}
</p>
</div>
</div>
<div class="text-right">
<p :class="getTypeClass(tx.type)" class="font-semibold text-lg">
{{ tx.type === 'recharge' || tx.type === 'refund' ? '+' : '-' }}¥{{ tx.amount.toFixed(2) }}
</p>
<p class="text-xs text-muted-foreground">
余额: ¥{{ tx.balance.toFixed(2) }}
</p>
</div>
</div>
</div>
</UiCardContent>
</UiCard>
</BasicPage> </BasicPage>
</template> </template>
+86 -55
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Loader2, Users } from 'lucide-vue-next' import { Loader2, Search, Users } from 'lucide-vue-next'
import { onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { BasicPage } from '@/components/global-layout' import { BasicPage } from '@/components/global-layout'
@@ -19,14 +19,36 @@ interface User {
const loading = ref(true) const loading = ref(true)
const users = ref<User[]>([]) const users = ref<User[]>([])
const searchQuery = ref('')
const filteredUsers = computed(() => {
let result = users.value.filter(u => u)
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result = result.filter(u =>
u.username?.toLowerCase().includes(query)
|| u.email?.toLowerCase().includes(query),
)
}
return result
})
onMounted(async () => { onMounted(async () => {
try { try {
const data = await api.get<User[]>('/agent/users') const data = await api.get<any>('/agent/users')
users.value = data || [] if (Array.isArray(data)) {
users.value = data
} else if (data?.users) {
users.value = data.users
} else if (data?.data) {
users.value = Array.isArray(data.data) ? data.data : []
} else {
users.value = []
}
} }
catch (error) { catch (error) {
console.error('Load users failed:', error) console.error('Load users failed:', error)
users.value = []
} }
finally { finally {
loading.value = false loading.value = false
@@ -34,18 +56,41 @@ onMounted(async () => {
}) })
function formatDate(dateStr: string | null) { function formatDate(dateStr: string | null) {
if (!dateStr) if (!dateStr) return '-'
return '-' return dateStr.replace('T', ' ').substring(0, 19)
return new Date(dateStr).toLocaleDateString() }
function getStatusInfo(status: string) {
const map: Record<string, { label: string, variant: string }> = {
active: { label: '正常', variant: 'default' },
disabled: { label: '禁用', variant: 'secondary' },
banned: { label: '封禁', variant: 'destructive' },
}
return map[status] || { label: status || '未知', variant: 'outline' }
} }
</script> </script>
<template> <template>
<BasicPage <BasicPage
:title="t('nav.users')" :title="t('nav.users')"
description="管理用户信息" description="查看和管理用户信息"
:breadcrumbs="[
{ title: '控制台', href: '/agent' },
{ title: '用户管理' },
]"
sticky sticky
> >
<template #actions>
<div class="relative">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<UiInput
v-model="searchQuery"
placeholder="搜索用户名或邮箱..."
class="pl-9 w-[250px]"
/>
</div>
</template>
<UiCard> <UiCard>
<UiCardContent class="p-0"> <UiCardContent class="p-0">
<div v-if="loading" class="flex items-center justify-center py-12"> <div v-if="loading" class="flex items-center justify-center py-12">
@@ -54,55 +99,41 @@ function formatDate(dateStr: string | null) {
<div v-else-if="users.length === 0" class="text-center py-12 text-muted-foreground"> <div v-else-if="users.length === 0" class="text-center py-12 text-muted-foreground">
<Users class="size-16 mx-auto mb-4 opacity-30" /> <Users class="size-16 mx-auto mb-4 opacity-30" />
<p>暂无用户</p> <p>暂无用户数据</p>
</div> </div>
<div v-else class="overflow-x-auto"> <div v-else>
<table class="w-full"> <div v-if="filteredUsers.length === 0" class="text-center py-12 text-muted-foreground">
<thead class="bg-muted/50"> <Search class="size-12 mx-auto mb-4 opacity-30" />
<tr> <p>未找到匹配的用户</p>
<th class="px-4 py-3 text-left text-sm font-medium"> </div>
用户名
</th> <div v-else class="overflow-x-auto">
<th class="px-4 py-3 text-left text-sm font-medium"> <table class="w-full text-sm">
邮箱 <thead class="bg-muted/50">
</th> <tr>
<th class="px-4 py-3 text-left text-sm font-medium"> <th class="text-left p-3 font-medium">用户名</th>
状态 <th class="text-left p-3 font-medium">邮箱</th>
</th> <th class="text-left p-3 font-medium">状态</th>
<th class="px-4 py-3 text-left text-sm font-medium"> <th class="text-left p-3 font-medium">注册时间</th>
注册时间 <th class="text-left p-3 font-medium">最后登录</th>
</th> </tr>
<th class="px-4 py-3 text-left text-sm font-medium"> </thead>
最后登录 <tbody>
</th> <tr v-for="user in filteredUsers" :key="user.id" class="border-t hover:bg-muted/30 transition-colors">
</tr> <td class="p-3 font-medium">{{ user.username }}</td>
</thead> <td class="p-3 text-muted-foreground">{{ user.email || '-' }}</td>
<tbody class="divide-y"> <td class="p-3">
<tr v-for="user in users" :key="user.id" class="hover:bg-muted/30"> <UiBadge :variant="getStatusInfo(user.status).variant as any">
<td class="px-4 py-3 font-medium"> {{ getStatusInfo(user.status).label }}
{{ user.username }} </UiBadge>
</td> </td>
<td class="px-4 py-3 text-sm text-muted-foreground"> <td class="p-3 text-muted-foreground">{{ formatDate(user.created_at) }}</td>
{{ user.email || '-' }} <td class="p-3 text-muted-foreground">{{ formatDate(user.last_login_at) }}</td>
</td> </tr>
<td class="px-4 py-3"> </tbody>
<UiBadge </table>
:class="user.status === 'active' ? 'bg-green-500/10 text-green-500' : ''" </div>
variant="outline"
>
{{ user.status === 'active' ? '正常' : user.status }}
</UiBadge>
</td>
<td class="px-4 py-3 text-sm text-muted-foreground">
{{ formatDate(user.created_at) }}
</td>
<td class="px-4 py-3 text-sm text-muted-foreground">
{{ formatDate(user.last_login_at) }}
</td>
</tr>
</tbody>
</table>
</div> </div>
</UiCardContent> </UiCardContent>
</UiCard> </UiCard>
+6
View File
@@ -450,6 +450,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/pages/agent/finance/index.vue'), component: () => import('@/pages/agent/finance/index.vue'),
meta: { title: '财务管理 - 代理商后台' }, meta: { title: '财务管理 - 代理商后台' },
}, },
{
path: 'cloud-data',
name: 'AgentCloudData',
component: () => import('@/pages/agent/cloud-data/index.vue'),
meta: { title: '云端数据 - 代理商后台' },
},
{ {
path: 'profile', path: 'profile',
name: 'AgentProfile', name: 'AgentProfile',
+21 -31
View File
@@ -14,6 +14,9 @@ import type {
ParamValueZeroOrMore, ParamValueZeroOrMore,
ParamValueZeroOrOne, ParamValueZeroOrOne,
} from 'vue-router' } from 'vue-router'
import type {
_ExtractParamParserType,
} from 'vue-router/experimental'
declare module 'vue-router' { declare module 'vue-router' {
interface TypesConfig { interface TypesConfig {
@@ -48,23 +51,9 @@ declare module 'vue-router/auto-routes' {
Record<never, never>, Record<never, never>,
| never | never
>, >,
'/admin/agent-apps/[id]/edit': RouteRecordInfo< '/admin/agent-apps/[id]': RouteRecordInfo<
'/admin/agent-apps/[id]/edit', '/admin/agent-apps/[id]',
'/admin/agent-apps/:id/edit', '/admin/agent-apps/:id',
{ id: ParamValue<true> },
{ id: ParamValue<false> },
| never
>,
'/admin/agent-apps/[id]/recharge': RouteRecordInfo<
'/admin/agent-apps/[id]/recharge',
'/admin/agent-apps/:id/recharge',
{ id: ParamValue<true> },
{ id: ParamValue<false> },
| never
>,
'/admin/agent-apps/[id]/view': RouteRecordInfo<
'/admin/agent-apps/[id]/view',
'/admin/agent-apps/:id/view',
{ id: ParamValue<true> }, { id: ParamValue<true> },
{ id: ParamValue<false> }, { id: ParamValue<false> },
| never | never
@@ -529,6 +518,13 @@ declare module 'vue-router/auto-routes' {
Record<never, never>, Record<never, never>,
| never | never
>, >,
'/agent/cloud-data/': RouteRecordInfo<
'/agent/cloud-data/',
'/agent/cloud-data',
Record<never, never>,
Record<never, never>,
| never
>,
'/agent/finance/': RouteRecordInfo< '/agent/finance/': RouteRecordInfo<
'/agent/finance/', '/agent/finance/',
'/agent/finance', '/agent/finance',
@@ -656,21 +652,9 @@ declare module 'vue-router/auto-routes' {
views: views:
| never | never
} }
'src/pages/admin/agent-apps/[id]/edit.vue': { 'src/pages/admin/agent-apps/[id].vue': {
routes: routes:
| '/admin/agent-apps/[id]/edit' | '/admin/agent-apps/[id]'
views:
| never
}
'src/pages/admin/agent-apps/[id]/recharge.vue': {
routes:
| '/admin/agent-apps/[id]/recharge'
views:
| never
}
'src/pages/admin/agent-apps/[id]/view.vue': {
routes:
| '/admin/agent-apps/[id]/view'
views: views:
| never | never
} }
@@ -1074,6 +1058,12 @@ declare module 'vue-router/auto-routes' {
views: views:
| never | never
} }
'src/pages/agent/cloud-data/index.vue': {
routes:
| '/agent/cloud-data/'
views:
| never
}
'src/pages/agent/finance/index.vue': { 'src/pages/agent/finance/index.vue': {
routes: routes:
| '/agent/finance/' | '/agent/finance/'