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

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-router": "^5.0.3",
"vue-sonner": "^2.0.9",
"vue3-flag-icons": "^0.1.4",
"zod": "^4.3.6"
},
"devDependencies": {
@@ -6198,12 +6197,6 @@
"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": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
@@ -10799,18 +10792,6 @@
"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": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
+21 -5
View File
@@ -1,5 +1,5 @@
<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 { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
@@ -18,6 +18,7 @@ const user = reactive({
email: 'agent@example.com',
avatar: '/avatars/agent.jpg',
role: 'agent',
can_view_cloud_data: false,
})
onMounted(() => {
@@ -29,6 +30,7 @@ onMounted(() => {
user.email = parsedUser.email || user.email
user.avatar = parsedUser.avatar || user.avatar
user.role = parsedUser.role || user.role
user.can_view_cloud_data = parsedUser.can_view_cloud_data || false
}
catch (e) {
console.error('Failed to parse user from localStorage', e)
@@ -44,7 +46,8 @@ const teams = [
},
]
const navMain = computed(() => [
const navMain = computed(() => {
const groups: any[] = [
{
title: t('nav.mainFeatures'),
items: [
@@ -65,7 +68,17 @@ const navMain = computed(() => [
},
],
},
{
]
if (user.can_view_cloud_data) {
groups[0].items.push({
title: '云端数据',
url: '/agent/cloud-data',
icon: Cloud,
})
}
groups.push({
title: t('nav.financeGroup'),
items: [
{
@@ -74,8 +87,10 @@ const navMain = computed(() => [
icon: CreditCard,
},
],
},
])
})
return groups
})
const breadcrumbs = computed(() => {
const path = router.currentRoute.value.path
@@ -86,6 +101,7 @@ const breadcrumbs = computed(() => {
cards: t('nav.cards'),
users: t('nav.users'),
finance: t('nav.finance'),
'cloud-data': '云端数据',
profile: t('nav.profile'),
create: t('nav.create'),
}
+94 -24
View File
@@ -1,5 +1,5 @@
<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 { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
@@ -45,6 +45,24 @@ const form = ref({
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() {
try {
const data = await api.get<{ agents?: Agent[] }>('/dev/agents')
@@ -158,26 +176,31 @@ onMounted(() => {
<BasicPage
title="编辑授权"
description="修改代理的应用授权配置"
:breadcrumbs="[
{ title: '授权管理', href: '/admin/agent-apps' },
{ title: '编辑授权' },
]"
sticky
>
<div v-if="loading" class="flex items-center justify-center py-12">
<Loader2 class="size-8 animate-spin text-muted-foreground" />
</div>
<UiCard v-else class="max-w-2xl">
<div v-else class="space-y-6">
<div class="grid gap-6 lg:grid-cols-3">
<div class="lg:col-span-2 space-y-6">
<UiCard>
<UiCardHeader>
<UiCardTitle>基本信息</UiCardTitle>
<UiCardDescription>
查看代理和应用信息
</UiCardDescription>
<UiCardTitle class="flex items-center gap-2">
<Info class="size-5" />
基本信息
</UiCardTitle>
<UiCardDescription>查看代理和应用信息</UiCardDescription>
</UiCardHeader>
<UiCardContent class="space-y-6">
<div class="space-y-2">
<UiLabel>代理</UiLabel>
<UiSelect
v-model="form.agent_id"
disabled
>
<UiSelect v-model="form.agent_id" disabled>
<UiSelectTrigger>
<UiSelectValue placeholder="请选择代理" />
</UiSelectTrigger>
@@ -192,10 +215,7 @@ onMounted(() => {
<div class="space-y-2">
<UiLabel>应用</UiLabel>
<UiSelect
v-model="form.application_id"
disabled
>
<UiSelect v-model="form.application_id" disabled>
<UiSelectTrigger>
<UiSelectValue placeholder="请选择应用" />
</UiSelectTrigger>
@@ -216,9 +236,18 @@ onMounted(() => {
rows="3"
/>
</div>
</UiCardContent>
</UiCard>
<div v-if="form.application_id && cardTypes.length > 0" class="space-y-3">
<UiLabel>卡类权限</UiLabel>
<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">
@@ -255,27 +284,68 @@ onMounted(() => {
</table>
</div>
</div>
<p class="text-xs text-muted-foreground">
<p class="text-xs text-muted-foreground mt-3">
提示取消勾选"授权"列可以禁止代理生成该类型的卡密
</p>
</UiCardContent>
</UiCard>
</div>
<div class="flex gap-3">
<UiButton variant="outline" as-child>
<router-link to="/admin/agent-apps">
取消
</router-link>
</UiButton>
<div class="space-y-6">
<UiCard>
<UiCardHeader>
<UiCardTitle class="flex items-center gap-2">
<Eye class="size-5" />
预览
</UiCardTitle>
</UiCardHeader>
<UiCardContent class="space-y-4">
<div class="space-y-3">
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">代理</span>
<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>
<UiCard>
<UiCardContent class="pt-6">
<div class="flex flex-col gap-3">
<UiButton
class="w-full"
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" />
<CheckCircle v-else class="mr-2 h-4 w-4" />
<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>
</BasicPage>
</template>
+92 -16
View File
@@ -1,5 +1,5 @@
<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 { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
@@ -41,6 +41,24 @@ const form = ref({
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() {
try {
const data = await api.get<{ agents?: Agent[] }>('/dev/agents')
@@ -130,14 +148,22 @@ onMounted(() => {
<BasicPage
title="添加授权"
description="为代理添加新的应用授权"
:breadcrumbs="[
{ title: '授权管理', href: '/admin/agent-apps' },
{ title: '添加授权' },
]"
sticky
>
<UiCard class="max-w-2xl">
<div class="space-y-6">
<div class="grid gap-6 lg:grid-cols-3">
<div class="lg:col-span-2 space-y-6">
<UiCard>
<UiCardHeader>
<UiCardTitle>基本信息</UiCardTitle>
<UiCardDescription>
选择代理和应用
</UiCardDescription>
<UiCardTitle class="flex items-center gap-2">
<Info class="size-5" />
基本信息
</UiCardTitle>
<UiCardDescription>选择代理和应用</UiCardDescription>
</UiCardHeader>
<UiCardContent class="space-y-6">
<div class="space-y-2">
@@ -176,9 +202,18 @@ onMounted(() => {
rows="3"
/>
</div>
</UiCardContent>
</UiCard>
<div v-if="form.application_id && cardTypes.length > 0" class="space-y-3">
<UiLabel>卡类权限</UiLabel>
<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">
@@ -215,27 +250,68 @@ onMounted(() => {
</table>
</div>
</div>
<p class="text-xs text-muted-foreground">
<p class="text-xs text-muted-foreground mt-3">
提示取消勾选"授权"列可以禁止代理生成该类型的卡密
</p>
</UiCardContent>
</UiCard>
</div>
<div class="flex gap-3">
<UiButton variant="outline" as-child>
<router-link to="/admin/agent-apps">
取消
</router-link>
</UiButton>
<div class="space-y-6">
<UiCard>
<UiCardHeader>
<UiCardTitle class="flex items-center gap-2">
<Eye class="size-5" />
预览
</UiCardTitle>
</UiCardHeader>
<UiCardContent class="space-y-4">
<div class="space-y-3">
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">代理</span>
<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>
<UiCard>
<UiCardContent class="pt-6">
<div class="flex flex-col gap-3">
<UiButton
class="w-full"
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" />
<CheckCircle v-else class="mr-2 h-4 w-4" />
<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>
</BasicPage>
</template>
+17
View File
@@ -30,6 +30,7 @@ const form = ref({
password: '',
parent_agent_id: '',
can_create_agent: false,
can_view_cloud_data: false,
balance: 0,
})
@@ -43,6 +44,7 @@ async function fetchAgent() {
form.value.email = agent.email || ''
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_view_cloud_data = agent.can_view_cloud_data || false
form.value.balance = agent.balance || 0
}
}
@@ -83,6 +85,7 @@ async function handleSave() {
const payload: any = {
username: form.value.username,
can_create_agent: form.value.can_create_agent,
can_view_cloud_data: form.value.can_view_cloud_data,
balance: form.value.balance,
}
@@ -245,6 +248,16 @@ onMounted(() => {
</div>
<UiSwitch v-model="form.can_create_agent" />
</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>
</UiCard>
</div>
@@ -271,6 +284,10 @@ onMounted(() => {
<span class="text-muted-foreground">{{ t('admin.agents.form.canCreateAgent') }}</span>
<span>{{ form.can_create_agent ? t('common.yes') : t('common.no') }}</span>
</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">
<span class="text-muted-foreground">{{ t('admin.agents.form.parentAgent') }}</span>
<span>{{ selectedParentAgent?.username || '-' }}</span>
+16 -1
View File
@@ -27,6 +27,7 @@ const form = ref({
password: '',
parent_agent_id: '',
can_create_agent: false,
can_view_cloud_data: false,
balance: 0,
})
@@ -67,6 +68,7 @@ async function handleCreate() {
username: form.value.username,
password: form.value.password,
can_create_agent: form.value.can_create_agent,
can_view_cloud_data: form.value.can_view_cloud_data,
balance: form.value.balance,
}
@@ -224,9 +226,18 @@ onMounted(() => {
{{ t('admin.agents.create.canCreateAgentHint') }}
</p>
</div>
<!-- 注意UiSwitch 必须使用 v-model不能使用 v-model:checked -->
<UiSwitch v-model="form.can_create_agent" />
</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>
</UiCard>
</div>
@@ -253,6 +264,10 @@ onMounted(() => {
<span class="text-muted-foreground">{{ t('admin.agents.form.canCreateAgent') }}</span>
<span>{{ form.can_create_agent ? t('common.yes') : t('common.no') }}</span>
</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">
<span class="text-muted-foreground">{{ t('admin.agents.form.parentAgent') }}</span>
<span>{{ selectedParentAgent?.username || '-' }}</span>
+24 -4
View File
@@ -33,12 +33,28 @@ const totalPrice = computed(() => {
onMounted(async () => {
try {
const data = await api.get<any[]>('/agent/apps')
apps.value = data || []
const data = await api.get<any>('/agent/apps')
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) {
const typesData = await api.get<any[]>('/agent/card-types')
cardTypes.value = typesData || []
const typesData = await api.get<any>('/agent/card-types')
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) {
@@ -90,6 +106,10 @@ async function handleGenerate() {
<BasicPage
title="生成卡密"
description="批量生成卡密"
:breadcrumbs="[
{ title: '卡密管理', href: '/agent/cards' },
{ title: '生成卡密' },
]"
sticky
>
<UiCard class="max-w-2xl">
+16 -3
View File
@@ -23,11 +23,20 @@ const cards = ref<Card[]>([])
onMounted(async () => {
try {
const data = await api.get<Card[]>('/agent/cards')
cards.value = data || []
const data = await api.get<any>('/agent/cards')
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) {
console.error('Load cards failed:', error)
cards.value = []
}
finally {
loading.value = false
@@ -55,6 +64,10 @@ function getStatusBadge(status: string) {
<BasicPage
:title="t('nav.cards')"
description="管理生成的卡密"
:breadcrumbs="[
{ title: '控制台', href: '/agent' },
{ title: '卡密管理' },
]"
sticky
>
<template #actions>
@@ -107,7 +120,7 @@ function getStatusBadge(status: string) {
</tr>
</thead>
<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">
{{ card.code }}
</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>
+112 -43
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ArrowDownLeft, ArrowUpRight, Loader2, Receipt, RotateCcw } from 'lucide-vue-next'
import { onMounted, ref } from 'vue'
import { ArrowDownLeft, ArrowUpRight, CreditCard, Loader2, Receipt, RotateCcw, TrendingDown, TrendingUp, Wallet } from 'lucide-vue-next'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { BasicPage } from '@/components/global-layout'
@@ -21,14 +21,35 @@ const loading = ref(true)
const balance = ref(0)
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 () => {
try {
const data = await api.get<{ balance: number, transactions: Transaction[] }>('/agent/finance')
balance.value = data?.balance || 0
transactions.value = data?.transactions || []
const data = await api.get<any>('/agent/finance')
if (data) {
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) {
console.error('Load finance data failed:', error)
transactions.value = []
}
finally {
loading.value = false
@@ -36,7 +57,8 @@ onMounted(async () => {
})
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString()
if (!dateStr) return '-'
return dateStr.replace('T', ' ').substring(0, 19)
}
function getTypeLabel(type: string) {
@@ -56,32 +78,68 @@ function getTypeClass(type: string) {
}
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>
<template>
<BasicPage
:title="t('nav.finance')"
description="财务管理"
description="查看财务信息和交易记录"
:breadcrumbs="[
{ title: '控制台', href: '/agent' },
{ title: '财务管理' },
]"
sticky
>
<div class="space-y-6">
<div class="grid gap-4 md:grid-cols-3">
<UiCard>
<UiCardHeader class="pb-2">
<UiCardTitle class="text-sm font-medium">
账户余额
</UiCardTitle>
<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-3xl font-bold">
¥{{ balance.toFixed(2) }}
</div>
<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 class="mt-6">
<UiCard>
<UiCardHeader>
<UiCardTitle>交易记录</UiCardTitle>
<UiCardTitle class="flex items-center gap-2">
<Receipt class="size-5" />
交易记录
</UiCardTitle>
<UiCardDescription>账户资金变动记录</UiCardDescription>
</UiCardHeader>
<UiCardContent class="p-0">
<div v-if="loading" class="flex items-center justify-center py-12">
@@ -93,43 +151,54 @@ function getTypeClass(type: string) {
<p>暂无交易记录</p>
</div>
<div v-else class="divide-y">
<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
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'"
class="size-8 rounded-lg flex items-center justify-center"
:class="getTypeBgClass(tx.type)"
>
<component
:is="tx.type === 'recharge' ? ArrowDownLeft : tx.type === 'refund' ? RotateCcw : ArrowUpRight"
:is="getTypeIcon(tx.type)"
:class="getTypeClass(tx.type)"
class="size-5"
class="size-4"
/>
</div>
<div>
<p class="font-medium">
<span>{{ getTypeLabel(tx.type) }}</span>
</div>
</td>
<td class="p-3 text-muted-foreground">
{{ tx.description || getTypeLabel(tx.type) }}
</p>
<p class="text-xs text-muted-foreground">
</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) }}
</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>
</td>
</tr>
</tbody>
</table>
</div>
</UiCardContent>
</UiCard>
</div>
</BasicPage>
</template>
+76 -45
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Loader2, Users } from 'lucide-vue-next'
import { onMounted, ref } from 'vue'
import { Loader2, Search, Users } from 'lucide-vue-next'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { BasicPage } from '@/components/global-layout'
@@ -19,14 +19,36 @@ interface User {
const loading = ref(true)
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 () => {
try {
const data = await api.get<User[]>('/agent/users')
users.value = data || []
const data = await api.get<any>('/agent/users')
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) {
console.error('Load users failed:', error)
users.value = []
}
finally {
loading.value = false
@@ -34,18 +56,41 @@ onMounted(async () => {
})
function formatDate(dateStr: string | null) {
if (!dateStr)
return '-'
return new Date(dateStr).toLocaleDateString()
if (!dateStr) return '-'
return dateStr.replace('T', ' ').substring(0, 19)
}
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>
<template>
<BasicPage
:title="t('nav.users')"
description="管理用户信息"
description="查看和管理用户信息"
:breadcrumbs="[
{ title: '控制台', href: '/agent' },
{ title: '用户管理' },
]"
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>
<UiCardContent class="p-0">
<div v-if="loading" class="flex items-center justify-center py-12">
@@ -54,56 +99,42 @@ function formatDate(dateStr: string | null) {
<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" />
<p>暂无用户</p>
<p>暂无用户数据</p>
</div>
<div v-else>
<div v-if="filteredUsers.length === 0" class="text-center py-12 text-muted-foreground">
<Search class="size-12 mx-auto mb-4 opacity-30" />
<p>未找到匹配的用户</p>
</div>
<div v-else class="overflow-x-auto">
<table class="w-full">
<table class="w-full text-sm">
<thead class="bg-muted/50">
<tr>
<th class="px-4 py-3 text-left text-sm font-medium">
用户名
</th>
<th class="px-4 py-3 text-left text-sm font-medium">
邮箱
</th>
<th class="px-4 py-3 text-left text-sm font-medium">
状态
</th>
<th class="px-4 py-3 text-left text-sm font-medium">
注册时间
</th>
<th class="px-4 py-3 text-left text-sm 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>
<th class="text-left p-3 font-medium">最后登录</th>
</tr>
</thead>
<tbody class="divide-y">
<tr v-for="user in users" :key="user.id" class="hover:bg-muted/30">
<td class="px-4 py-3 font-medium">
{{ user.username }}
</td>
<td class="px-4 py-3 text-sm text-muted-foreground">
{{ user.email || '-' }}
</td>
<td class="px-4 py-3">
<UiBadge
:class="user.status === 'active' ? 'bg-green-500/10 text-green-500' : ''"
variant="outline"
>
{{ user.status === 'active' ? '正常' : user.status }}
<tbody>
<tr v-for="user in filteredUsers" :key="user.id" class="border-t hover:bg-muted/30 transition-colors">
<td class="p-3 font-medium">{{ user.username }}</td>
<td class="p-3 text-muted-foreground">{{ user.email || '-' }}</td>
<td class="p-3">
<UiBadge :variant="getStatusInfo(user.status).variant as any">
{{ getStatusInfo(user.status).label }}
</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>
<td class="p-3 text-muted-foreground">{{ formatDate(user.created_at) }}</td>
<td class="p-3 text-muted-foreground">{{ formatDate(user.last_login_at) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</UiCardContent>
</UiCard>
</BasicPage>
+6
View File
@@ -450,6 +450,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/pages/agent/finance/index.vue'),
meta: { title: '财务管理 - 代理商后台' },
},
{
path: 'cloud-data',
name: 'AgentCloudData',
component: () => import('@/pages/agent/cloud-data/index.vue'),
meta: { title: '云端数据 - 代理商后台' },
},
{
path: 'profile',
name: 'AgentProfile',
+21 -31
View File
@@ -14,6 +14,9 @@ import type {
ParamValueZeroOrMore,
ParamValueZeroOrOne,
} from 'vue-router'
import type {
_ExtractParamParserType,
} from 'vue-router/experimental'
declare module 'vue-router' {
interface TypesConfig {
@@ -48,23 +51,9 @@ declare module 'vue-router/auto-routes' {
Record<never, never>,
| never
>,
'/admin/agent-apps/[id]/edit': RouteRecordInfo<
'/admin/agent-apps/[id]/edit',
'/admin/agent-apps/:id/edit',
{ 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',
'/admin/agent-apps/[id]': RouteRecordInfo<
'/admin/agent-apps/[id]',
'/admin/agent-apps/:id',
{ id: ParamValue<true> },
{ id: ParamValue<false> },
| never
@@ -529,6 +518,13 @@ declare module 'vue-router/auto-routes' {
Record<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/',
'/agent/finance',
@@ -656,21 +652,9 @@ declare module 'vue-router/auto-routes' {
views:
| never
}
'src/pages/admin/agent-apps/[id]/edit.vue': {
'src/pages/admin/agent-apps/[id].vue': {
routes:
| '/admin/agent-apps/[id]/edit'
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'
| '/admin/agent-apps/[id]'
views:
| never
}
@@ -1074,6 +1058,12 @@ declare module 'vue-router/auto-routes' {
views:
| never
}
'src/pages/agent/cloud-data/index.vue': {
routes:
| '/agent/cloud-data/'
views:
| never
}
'src/pages/agent/finance/index.vue': {
routes:
| '/agent/finance/'