refactor: 移除developer目录,统一使用admin角色;重命名developer-sidebar为admin-sidebar;修复路由守卫和header中的developer引用

This commit is contained in:
2026-05-01 12:18:58 +08:00
parent 9563c8fad6
commit c0edb32614
136 changed files with 26 additions and 26221 deletions
@@ -4,7 +4,7 @@ import { onMounted, reactive } from 'vue'
import NavTeam from '@/components/app-sidebar/nav-team.vue'
import TeamSwitcher from '@/components/app-sidebar/team-switcher.vue'
import NavFooter from '@/components/developer-sidebar/nav-footer.vue'
import NavFooter from '@/components/admin-sidebar/nav-footer.vue'
const user = reactive({
name: '管理员',
@@ -33,7 +33,7 @@ const teams = [
{
name: '管理后台',
logo: Code,
plan: 'Developer',
plan: 'Admin',
},
]
@@ -62,7 +62,7 @@ const logoSrc = computed(() => {
}
})
const developerMenus = computed(() => ({
const adminMenus = computed(() => ({
main: {
title: t('nav.mainFeatures'),
items: [
@@ -204,15 +204,15 @@ watch(route, () => {
<UiDropdownMenuTrigger as-child>
<button
class="transition-colors hover:text-foreground/80 px-3 py-2 rounded-lg flex items-center gap-1"
:class="isMenuActive(developerMenus.main.items) ? 'text-foreground bg-accent' : 'text-foreground/60'"
:class="isMenuActive(adminMenus.main.items) ? 'text-foreground bg-accent' : 'text-foreground/60'"
>
{{ developerMenus.main.title }}
{{ adminMenus.main.title }}
<Icon icon="lucide:chevron-down" class="h-4 w-4" />
</button>
</UiDropdownMenuTrigger>
<UiDropdownMenuContent align="start" class="w-40">
<UiDropdownMenuItem
v-for="item in developerMenus.main.items"
v-for="item in adminMenus.main.items"
:key="item.url"
:class="route.path === item.url ? 'bg-accent' : ''"
@click="router.push(item.url)"
@@ -227,15 +227,15 @@ watch(route, () => {
<UiDropdownMenuTrigger as-child>
<button
class="transition-colors hover:text-foreground/80 px-3 py-2 rounded-lg flex items-center gap-1"
:class="isMenuActive(developerMenus.business.items) ? 'text-foreground bg-accent' : 'text-foreground/60'"
:class="isMenuActive(adminMenus.business.items) ? 'text-foreground bg-accent' : 'text-foreground/60'"
>
{{ developerMenus.business.title }}
{{ adminMenus.business.title }}
<Icon icon="lucide:chevron-down" class="h-4 w-4" />
</button>
</UiDropdownMenuTrigger>
<UiDropdownMenuContent align="start" class="w-40">
<UiDropdownMenuItem
v-for="item in developerMenus.business.items"
v-for="item in adminMenus.business.items"
:key="item.url"
:class="route.path === item.url ? 'bg-accent' : ''"
@click="router.push(item.url)"
@@ -250,15 +250,15 @@ watch(route, () => {
<UiDropdownMenuTrigger as-child>
<button
class="transition-colors hover:text-foreground/80 px-3 py-2 rounded-lg flex items-center gap-1"
:class="isMenuActive(developerMenus.advanced.items) ? 'text-foreground bg-accent' : 'text-foreground/60'"
:class="isMenuActive(adminMenus.advanced.items) ? 'text-foreground bg-accent' : 'text-foreground/60'"
>
{{ developerMenus.advanced.title }}
{{ adminMenus.advanced.title }}
<Icon icon="lucide:chevron-down" class="h-4 w-4" />
</button>
</UiDropdownMenuTrigger>
<UiDropdownMenuContent align="start" class="w-40">
<UiDropdownMenuItem
v-for="item in developerMenus.advanced.items"
v-for="item in adminMenus.advanced.items"
:key="item.url"
:class="route.path === item.url ? 'bg-accent' : ''"
@click="router.push(item.url)"
@@ -336,7 +336,7 @@ watch(route, () => {
</UiDropdownMenuItem>
<UiDropdownMenuItem v-if="currentUser?.role === 'admin'" @click="router.push('/admin')">
<Icon icon="lucide:layout-dashboard" class="mr-2 h-4 w-4" />
<span>{{ t('nav.developerDashboard') }}</span>
<span>{{ t('nav.adminDashboard') }}</span>
</UiDropdownMenuItem>
<UiDropdownMenuItem v-if="currentUser?.role === 'agent'" @click="router.push('/agent')">
<Icon icon="lucide:layout-dashboard" class="mr-2 h-4 w-4" />
@@ -397,10 +397,10 @@ watch(route, () => {
<template v-if="isLoggedIn && currentUser?.role === 'admin'">
<div class="space-y-1">
<p class="text-xs font-medium text-muted-foreground px-3 mb-2">
{{ developerMenus.main.title }}
{{ adminMenus.main.title }}
</p>
<button
v-for="item in developerMenus.main.items"
v-for="item in adminMenus.main.items"
:key="item.url"
class="w-full flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors"
:class="isActive(item.url) ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground'"
@@ -413,10 +413,10 @@ watch(route, () => {
<div class="space-y-1">
<p class="text-xs font-medium text-muted-foreground px-3 mb-2">
{{ developerMenus.business.title }}
{{ adminMenus.business.title }}
</p>
<button
v-for="item in developerMenus.business.items"
v-for="item in adminMenus.business.items"
:key="item.url"
class="w-full flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors"
:class="isActive(item.url) ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground'"
@@ -429,10 +429,10 @@ watch(route, () => {
<div class="space-y-1">
<p class="text-xs font-medium text-muted-foreground px-3 mb-2">
{{ developerMenus.advanced.title }}
{{ adminMenus.advanced.title }}
</p>
<button
v-for="item in developerMenus.advanced.items"
v-for="item in adminMenus.advanced.items"
:key="item.url"
class="w-full flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors"
:class="isActive(item.url) ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground'"
+1 -1
View File
@@ -2,7 +2,7 @@
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import AdminSidebar from '@/components/developer-sidebar/index.vue'
import AdminSidebar from '@/components/admin-sidebar/index.vue'
import LanguageChange from '@/components/language-change.vue'
import ThemePopover from '@/components/custom-theme/theme-popover.vue'
import ToggleTheme from '@/components/toggle-theme.vue'
@@ -1,236 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import { BasicPage } from '@/components/global-layout'
interface CardType {
id: number
name: string
billing_type: string
price: number
}
interface CardTypePermission {
card_type_id: number
name: string
billing_type: string
price: number
can_generate: boolean
}
const route = useRoute()
const router = useRouter()
const API_BASE = 'http://localhost:8080/api/v1'
const loading = ref(true)
const saving = ref(false)
const agentAppName = ref('')
const cardTypeForm = ref<CardTypePermission[]>([])
function getBillingTypeText(type: string) {
const map: Record<string, string> = {
subscription: '订阅',
time: '计时',
point: '点卡',
}
return map[type] || type
}
async function fetchData() {
loading.value = true
try {
const token = localStorage.getItem('token')
const [agentAppRes, cardTypesRes] = await Promise.all([
fetch(`${API_BASE}/dev/agent-apps/${route.params.id}`, {
headers: { Authorization: `Bearer ${token}` },
}),
fetch(`${API_BASE}/dev/card-types`, {
headers: { Authorization: `Bearer ${token}` },
}),
])
const [agentAppData, cardTypesData] = await Promise.all([
agentAppRes.json(),
cardTypesRes.json(),
])
if (agentAppData.code === 200 && agentAppData.agent_app) {
agentAppName.value = `${agentAppData.agent_app.agent_name} - ${agentAppData.agent_app.app_name}`
const existingPerms = agentAppData.agent_app.card_types || []
const allCardTypes = cardTypesData.card_types || []
cardTypeForm.value = allCardTypes.map((ct: CardType) => {
const existing = existingPerms.find((p: any) => p.card_type_id === ct.id)
return {
card_type_id: ct.id,
name: ct.name,
billing_type: ct.billing_type,
price: ct.price,
can_generate: existing ? existing.can_generate : false,
}
})
}
else {
toast.error('获取授权信息失败')
router.push('/admin/agent-apps')
}
}
catch (error) {
console.error('获取数据失败:', error)
toast.error('获取数据失败')
router.push('/admin/agent-apps')
}
finally {
loading.value = false
}
}
function selectAll() {
cardTypeForm.value.forEach(ct => ct.can_generate = true)
}
function clearAll() {
cardTypeForm.value.forEach(ct => ct.can_generate = false)
}
async function handleSubmit() {
saving.value = true
try {
const token = localStorage.getItem('token')
const response = await fetch(`${API_BASE}/dev/agent-apps/${route.params.id}/card-types`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
card_types: cardTypeForm.value.map(ct => ({
card_type_id: ct.card_type_id,
can_generate: ct.can_generate,
})),
}),
})
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(() => {
fetchData()
})
</script>
<template>
<BasicPage
title="卡密权限配置"
description="配置代理商可生成的卡密类型"
>
<template #actions>
<UiButton variant="outline" @click="goBack">
<Icon icon="lucide:arrow-left" class="mr-2 h-4 w-4" />
返回
</UiButton>
</template>
<div v-if="loading" class="flex items-center justify-center py-12">
<Icon icon="lucide:loader-2" class="h-8 w-8 animate-spin text-muted-foreground" />
</div>
<div v-else class="space-y-6">
<UiCard>
<UiCardHeader>
<UiCardTitle>授权信息</UiCardTitle>
<UiCardDescription>
{{ agentAppName }}
</UiCardDescription>
</UiCardHeader>
<UiCardContent>
<div class="flex gap-2 mb-4">
<UiButton variant="outline" size="sm" @click="selectAll">
<Icon icon="lucide:check-check" class="mr-2 h-4 w-4" />
全选
</UiButton>
<UiButton variant="outline" size="sm" @click="clearAll">
<Icon icon="lucide:x" class="mr-2 h-4 w-4" />
清空
</UiButton>
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardContent class="p-0">
<UiTable>
<UiTableHeader>
<UiTableRow>
<UiTableHead class="w-12" />
<UiTableHead>卡类名称</UiTableHead>
<UiTableHead>计费类型</UiTableHead>
<UiTableHead>价格</UiTableHead>
</UiTableRow>
</UiTableHeader>
<UiTableBody>
<UiTableRow
v-for="cardType in cardTypeForm"
:key="cardType.card_type_id"
:class="cardType.can_generate ? 'bg-accent/50' : ''"
class="cursor-pointer"
@click="cardType.can_generate = !cardType.can_generate"
>
<UiTableCell>
<UiCheckbox
:checked="cardType.can_generate"
@update:checked="cardType.can_generate = $event"
@click.stop
/>
</UiTableCell>
<UiTableCell class="font-medium">
{{ cardType.name }}
</UiTableCell>
<UiTableCell>
<UiBadge variant="outline">
{{ getBillingTypeText(cardType.billing_type) }}
</UiBadge>
</UiTableCell>
<UiTableCell>
¥{{ cardType.price }}
</UiTableCell>
</UiTableRow>
</UiTableBody>
</UiTable>
</UiCardContent>
<UiCardFooter class="flex justify-end gap-2">
<UiButton variant="outline" @click="goBack">
取消
</UiButton>
<UiButton :disabled="saving" @click="handleSubmit">
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
保存
</UiButton>
</UiCardFooter>
</UiCard>
</div>
</BasicPage>
</template>
@@ -1,193 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import type { AgentApp } from '@/pages/admin/agent-apps/data/schema'
import { BasicPage } from '@/components/global-layout'
const route = useRoute()
const router = useRouter()
const API_BASE = 'http://localhost:8080/api/v1'
const loading = ref(true)
const saving = ref(false)
const agentApp = ref<AgentApp | null>(null)
const form = ref({
discount: 1.0,
status: 'active',
})
async function fetchAgentApp() {
loading.value = true
try {
const token = localStorage.getItem('token')
const response = await fetch(`${API_BASE}/dev/agent-apps/${route.params.id}`, {
headers: { Authorization: `Bearer ${token}` },
})
const data = await response.json()
if (data.code === 200 && data.agent_app) {
agentApp.value = data.agent_app
form.value = {
discount: data.agent_app.discount,
status: data.agent_app.status,
}
}
else {
toast.error('获取授权信息失败')
router.push('/admin/agent-apps')
}
}
catch (error) {
console.error('获取授权信息失败:', error)
toast.error('获取授权信息失败')
router.push('/admin/agent-apps')
}
finally {
loading.value = false
}
}
async function handleSubmit() {
if (form.value.discount < 0 || form.value.discount > 1) {
toast.error('折扣必须在0-1之间')
return
}
saving.value = true
try {
const token = localStorage.getItem('token')
const response = await fetch(`${API_BASE}/dev/agent-apps/${route.params.id}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(form.value),
})
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(() => {
fetchAgentApp()
})
</script>
<template>
<BasicPage
title="编辑授权"
description="修改代理商授权信息"
>
<template #actions>
<UiButton variant="outline" @click="goBack">
<Icon icon="lucide:arrow-left" class="mr-2 h-4 w-4" />
返回
</UiButton>
</template>
<div v-if="loading" class="flex items-center justify-center py-12">
<Icon icon="lucide:loader-2" class="h-8 w-8 animate-spin text-muted-foreground" />
</div>
<div v-else-if="agentApp" class="max-w-2xl">
<UiCard>
<UiCardHeader>
<UiCardTitle>授权信息</UiCardTitle>
<UiCardDescription>
代理商: {{ agentApp.agent_name }} | 应用: {{ agentApp.app_name }}
</UiCardDescription>
</UiCardHeader>
<UiCardContent class="space-y-6">
<div class="space-y-2">
<UiLabel for="status">
状态
</UiLabel>
<UiSelect v-model="form.status">
<UiSelectTrigger>
<UiSelectValue placeholder="请选择状态" />
</UiSelectTrigger>
<UiSelectContent>
<UiSelectItem value="active">
已授权
</UiSelectItem>
<UiSelectItem value="inactive">
已禁用
</UiSelectItem>
</UiSelectContent>
</UiSelect>
</div>
<div class="space-y-2">
<UiLabel for="discount">
折扣
</UiLabel>
<div class="flex items-center gap-4">
<UiInput
id="discount"
v-model.number="form.discount"
type="number"
step="0.01"
min="0"
max="1"
class="flex-1"
/>
<span class="text-sm text-muted-foreground w-20">
{{ (form.discount * 10).toFixed(1) }}
</span>
</div>
<p class="text-xs text-muted-foreground">
代理商购买卡密的折扣范围0-1
</p>
</div>
<div class="space-y-2">
<UiLabel>当前余额</UiLabel>
<div class="text-2xl font-bold">
¥{{ agentApp.balance.toFixed(2) }}
</div>
<UiButton
variant="outline"
size="sm"
@click="router.push(`/admin/agent-apps/${route.params.id}/recharge`)"
>
<Icon icon="lucide:wallet" class="mr-2 h-4 w-4" />
充值余额
</UiButton>
</div>
</UiCardContent>
<UiCardFooter class="flex justify-end gap-2">
<UiButton variant="outline" @click="goBack">
取消
</UiButton>
<UiButton :disabled="saving" @click="handleSubmit">
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
保存
</UiButton>
</UiCardFooter>
</UiCard>
</div>
</BasicPage>
</template>
@@ -1,185 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import { BasicPage } from '@/components/global-layout'
const route = useRoute()
const router = useRouter()
const API_BASE = 'http://localhost:8080/api/v1'
const loading = ref(true)
const saving = ref(false)
const currentBalance = ref(0)
const agentAppName = ref('')
const rechargeAmount = ref(0)
async function fetchAgentApp() {
loading.value = true
try {
const token = localStorage.getItem('token')
const response = await fetch(`${API_BASE}/dev/agent-apps/${route.params.id}`, {
headers: { Authorization: `Bearer ${token}` },
})
const data = await response.json()
if (data.code === 200 && data.agent_app) {
currentBalance.value = data.agent_app.balance
agentAppName.value = `${data.agent_app.agent_name} - ${data.agent_app.app_name}`
}
else {
toast.error('获取授权信息失败')
router.push('/admin/agent-apps')
}
}
catch (error) {
console.error('获取授权信息失败:', error)
toast.error('获取授权信息失败')
router.push('/admin/agent-apps')
}
finally {
loading.value = false
}
}
async function handleSubmit() {
if (rechargeAmount.value <= 0) {
toast.error('请输入有效的充值金额')
return
}
saving.value = true
try {
const token = localStorage.getItem('token')
const response = await fetch(`${API_BASE}/dev/agent-apps/${route.params.id}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
balance: currentBalance.value + rechargeAmount.value,
}),
})
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')
}
const quickAmounts = [100, 500, 1000, 5000]
function setAmount(amount: number) {
rechargeAmount.value = amount
}
onMounted(() => {
fetchAgentApp()
})
</script>
<template>
<BasicPage
title="充值余额"
description="为代理商充值余额"
>
<template #actions>
<UiButton variant="outline" @click="goBack">
<Icon icon="lucide:arrow-left" class="mr-2 h-4 w-4" />
返回
</UiButton>
</template>
<div v-if="loading" class="flex items-center justify-center py-12">
<Icon icon="lucide:loader-2" class="h-8 w-8 animate-spin text-muted-foreground" />
</div>
<div v-else class="max-w-2xl">
<UiCard>
<UiCardHeader>
<UiCardTitle>充值信息</UiCardTitle>
<UiCardDescription>
{{ agentAppName }}
</UiCardDescription>
</UiCardHeader>
<UiCardContent class="space-y-6">
<div class="space-y-2">
<UiLabel>当前余额</UiLabel>
<div class="text-3xl font-bold text-primary">
¥{{ currentBalance.toFixed(2) }}
</div>
</div>
<div class="space-y-2">
<UiLabel for="amount">
充值金额
</UiLabel>
<UiInput
id="amount"
v-model.number="rechargeAmount"
type="number"
min="0"
step="0.01"
placeholder="请输入充值金额"
/>
</div>
<div class="flex flex-wrap gap-2">
<UiButton
v-for="amount in quickAmounts"
:key="amount"
variant="outline"
size="sm"
@click="setAmount(amount)"
>
¥{{ amount }}
</UiButton>
</div>
<div v-if="rechargeAmount > 0" class="p-4 bg-muted rounded-lg">
<div class="flex justify-between text-sm">
<span>当前余额</span>
<span>¥{{ currentBalance.toFixed(2) }}</span>
</div>
<div class="flex justify-between text-sm mt-1">
<span>充值金额</span>
<span class="text-green-600">+¥{{ rechargeAmount.toFixed(2) }}</span>
</div>
<UiSeparator class="my-2" />
<div class="flex justify-between font-medium">
<span>充值后余额</span>
<span>¥{{ (currentBalance + rechargeAmount).toFixed(2) }}</span>
</div>
</div>
</UiCardContent>
<UiCardFooter class="flex justify-end gap-2">
<UiButton variant="outline" @click="goBack">
取消
</UiButton>
<UiButton :disabled="saving || rechargeAmount <= 0" @click="handleSubmit">
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
确认充值
</UiButton>
</UiCardFooter>
</UiCard>
</div>
</BasicPage>
</template>
@@ -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 +0,0 @@
import { z } from 'zod'
export const agentAppStatusSchema = z.enum(['active', 'inactive', 'pending', 'approved', 'rejected'])
export const agentAppSchema = z.object({
id: z.number(),
agent_id: z.number(),
agent_name: z.string(),
application_id: z.number(),
app_name: z.string(),
status: agentAppStatusSchema,
discount: z.number(),
balance: z.number(),
card_types: z.array(z.object({
card_type_id: z.number(),
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(),
developer_id: z.number(),
developer_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
}
@@ -1,389 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
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 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()
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 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 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 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 myRequestItems = myRequests.value
.filter(item => item.status === 'pending')
.map(item => ({
id: item.id,
agent_id: item.developer_id,
agent_name: item.developer_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()
})
})
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 || []
}
catch (error) {
console.error('获取数据失败:', error)
toast.error(t('common.fetchFailed'))
}
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) {
try {
await api.post(`/dev/agent-apps/requests/${item.id}/approve`)
toast.success(t('admin.agentApps.approveSuccess'))
fetchData()
}
catch (error: any) {
console.error('批准申请失败:', error)
toast.error(error.message || t('admin.agentApps.approveFailed'))
}
}
async function rejectRequest() {
if (!rejectingItem.value)
return
try {
await api.post(`/dev/agent-apps/requests/${rejectingItem.value.id}/reject`, { reason: '' })
toast.success(t('admin.agentApps.rejectSuccess'))
showRejectDialog.value = false
fetchData()
}
catch (error: any) {
console.error('拒绝申请失败:', error)
toast.error(error.message || t('admin.agentApps.rejectFailed'))
}
}
function confirmReject(item: CombinedItem) {
rejectingItem.value = item
showRejectDialog.value = true
}
async function removeItem() {
if (!removingItem.value)
return
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
fetchData()
}
catch (error: any) {
console.error('操作失败:', error)
toast.error(error.message || t('common.failed'))
}
}
function confirmRemove(item: CombinedItem) {
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)
return
try {
await api.post('/dev/agent-apps/requests/batch-reject', { ids: batchRejectIds.value, reason: '' })
toast.success(t('admin.agentApps.batchRejectSuccess'))
showBatchRejectDialog.value = false
fetchData()
}
catch (error: any) {
console.error('批量拒绝失败:', error)
toast.error(error.message || t('admin.agentApps.batchRejectFailed'))
}
}
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()
}
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
}
onMounted(() => {
fetchData()
})
</script>
<template>
<BasicPage
:title="t('admin.agentApps.title')"
:description="t('admin.agentApps.description')"
sticky
>
<template #actions>
<UiButton variant="outline" @click="goToRequest">
<Icon icon="lucide:send" class="mr-2 h-4 w-4" />
{{ t('admin.agentApps.requestAuth') }}
</UiButton>
<UiButton @click="goToInvite">
<Icon icon="lucide:user-plus" class="mr-2 h-4 w-4" />
{{ t('admin.agentApps.inviteAuth') }}
</UiButton>
</template>
<div class="space-y-6">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<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>
<Icon icon="lucide:share-2" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ agentApps.length }}
</div>
</UiCardContent>
</UiCard>
<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>
<Icon icon="lucide:check-circle" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ activeCount }}
</div>
</UiCardContent>
</UiCard>
<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>
<Icon icon="lucide:clock" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ pendingCount }}
</div>
</UiCardContent>
</UiCard>
<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>
<Icon icon="lucide:wallet" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
¥{{ totalBalance.toFixed(2) }}
</div>
</UiCardContent>
</UiCard>
</div>
<UiCard>
<UiCardContent class="p-6">
<DataTable
ref="tableRef"
:loading
:data="allItems"
:on-approve="approveRequest"
:on-reject="confirmReject"
:on-remove="confirmRemove"
@refresh="fetchData"
/>
</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))"
>
<Icon icon="lucide: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))"
>
<Icon icon="lucide: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))"
>
<Icon icon="lucide:trash-2" class="mr-2 h-4 w-4" />
{{ t('admin.agentApps.batchRemove') }}
</UiButton>
</BulkActions>
<ConfirmDialog
v-model:open="showRejectDialog"
:title="t('admin.agentApps.reject')"
:description="t('admin.agentApps.rejectConfirm')"
confirm-text="确定"
@confirm="rejectRequest"
/>
<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"
/>
</BasicPage>
</template>
@@ -1,170 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import { BasicPage } from '@/components/global-layout'
const router = useRouter()
const API_BASE = 'http://localhost:8080/api/v1'
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">
<Icon icon="lucide:arrow-left" 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">
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
发送邀请
</UiButton>
</UiCardFooter>
</UiCard>
</div>
</BasicPage>
</template>
@@ -1,170 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import { BasicPage } from '@/components/global-layout'
const router = useRouter()
const API_BASE = 'http://localhost:8080/api/v1'
const saving = ref(false)
const applications = ref<Array<{ id: number, name: string }>>([])
const form = ref({
developer_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.developer_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({
developer_id: Number(form.value.developer_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">
<Icon icon="lucide:arrow-left" 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="developer_id">
管理员ID
</UiLabel>
<UiInput
id="developer_id"
v-model="form.developer_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">
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
提交申请
</UiButton>
</UiCardFooter>
</UiCard>
</div>
</BasicPage>
</template>
@@ -1,307 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import type { Agent } from './data/schema'
import ConfirmDialog from '@/components/confirm-dialog.vue'
import SingleFilter from '@/components/data-table/single-filter.vue'
import { BasicPage } from '@/components/global-layout'
import DataTable from './components/data-table.vue'
import api from '@/services/api'
const { t } = useI18n()
const router = useRouter()
const loading = ref(true)
const agents = ref<Agent[]>([])
const _tableRef = ref<InstanceType<typeof DataTable> | null>(null)
const searchFilter = ref('')
const statusFilter = ref<string>('')
const viewMode = ref<'tree' | 'list'>('tree')
const deleteDialogOpen = ref(false)
const deleteTarget = ref<Agent | null>(null)
const filteredAgents = computed(() => {
let result = agents.value
if (statusFilter.value) {
const filterByStatus = (items: Agent[]): Agent[] => {
return items.reduce((acc: Agent[], item) => {
const matches = item.status === statusFilter.value
const filteredChildren = item.children ? filterByStatus(item.children) : []
if (matches || filteredChildren.length > 0) {
acc.push({
...item,
children: filteredChildren.length > 0 ? filteredChildren : item.children,
})
}
return acc
}, [])
}
result = filterByStatus(result)
}
if (searchFilter.value) {
const search = searchFilter.value.toLowerCase()
const filterTree = (items: Agent[]): Agent[] => {
return items.reduce((acc: Agent[], item) => {
const matchesSearch =
item.username?.toLowerCase().includes(search) ||
item.email?.toLowerCase().includes(search) ||
item.parent_agent_name?.toLowerCase().includes(search)
const filteredChildren = item.children ? filterTree(item.children) : []
if (matchesSearch || filteredChildren.length > 0) {
acc.push({
...item,
children: filteredChildren.length > 0 ? filteredChildren : item.children,
})
}
return acc
}, [])
}
result = filterTree(result)
}
return result
})
const totalAgents = computed(() => {
const countNodes = (items: Agent[]): number => {
return items.reduce((acc, item) => {
return acc + 1 + (item.children ? countNodes(item.children) : 0)
}, 0)
}
return countNodes(agents.value)
})
const activeCount = computed(() => {
const countByStatus = (items: Agent[], status: string): number => {
return items.reduce((acc, item) => {
const self = item.status === status ? 1 : 0
const children = item.children ? countByStatus(item.children, status) : 0
return acc + self + children
}, 0)
}
return countByStatus(agents.value, 'active')
})
const inactiveCount = computed(() => {
const countByStatus = (items: Agent[], status: string): number => {
return items.reduce((acc, item) => {
const self = item.status === status ? 1 : 0
const children = item.children ? countByStatus(item.children, status) : 0
return acc + self + children
}, 0)
}
return countByStatus(agents.value, 'inactive')
})
const bannedCount = computed(() => {
const countByStatus = (items: Agent[], status: string): number => {
return items.reduce((acc, item) => {
const self = item.status === status ? 1 : 0
const children = item.children ? countByStatus(item.children, status) : 0
return acc + self + children
}, 0)
}
return countByStatus(agents.value, 'banned')
})
const statusOptions = computed(() => [
{ label: t('admin.agents.status.active'), value: 'active' },
{ label: t('admin.agents.status.inactive'), value: 'inactive' },
{ label: t('admin.agents.status.banned'), value: 'banned' },
])
async function fetchAgents() {
loading.value = true
try {
const endpoint = viewMode.value === 'tree' ? '/dev/agents/tree' : '/dev/agents'
const data = await api.get<{ tree?: Agent[], agents?: Agent[], total: number }>(endpoint)
if (viewMode.value === 'tree') {
agents.value = data?.tree || []
} else {
agents.value = data?.agents || []
}
} catch (error) {
console.error('获取代理列表失败:', error)
agents.value = []
} finally {
loading.value = false
}
}
function goToCreate() {
router.push('/admin/agents/create')
}
function toggleViewMode() {
viewMode.value = viewMode.value === 'tree' ? 'list' : 'tree'
fetchAgents()
}
function handleEdit(agent: Agent) {
router.push(`/admin/agents/${agent.id}/edit`)
}
async function handleToggleStatus(agent: Agent) {
try {
const newStatus = agent.status === 'banned' ? 'active' : 'banned'
await api.put(`/dev/agents/${agent.id}/status`, { status: newStatus })
toast.success(t('admin.agents.statusUpdateSuccess'))
fetchAgents()
} catch (error: any) {
console.error('切换代理状态失败:', error)
toast.error(error.message || t('admin.agents.statusUpdateFailed'))
}
}
function confirmDelete(agent: Agent) {
deleteTarget.value = agent
deleteDialogOpen.value = true
}
async function handleDelete() {
if (!deleteTarget.value) return
try {
await api.delete(`/dev/agents/${deleteTarget.value.id}`)
toast.success(t('admin.agents.deleteSuccess'))
fetchAgents()
} catch (error: any) {
console.error('删除代理失败:', error)
toast.error(error.message || t('admin.agents.deleteFailed'))
} finally {
deleteTarget.value = null
}
}
onMounted(() => {
fetchAgents()
})
</script>
<template>
<BasicPage
:title="t('admin.agents.title')"
:description="t('admin.agents.description')"
sticky
>
<template #actions>
<UiButton @click="goToCreate">
<Icon icon="lucide:plus" class="mr-2 h-4 w-4" />
{{ t('admin.agents.addAgent') }}
</UiButton>
</template>
<div class="space-y-6">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.agents.totalAgents') }}
</UiCardTitle>
<Icon icon="lucide:users" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ totalAgents }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.agents.status.active') }}
</UiCardTitle>
<Icon icon="lucide:check-circle" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ activeCount }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.agents.status.inactive') }}
</UiCardTitle>
<Icon icon="lucide:clock" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ inactiveCount }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.agents.status.banned') }}
</UiCardTitle>
<Icon icon="lucide:ban" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ bannedCount }}
</div>
</UiCardContent>
</UiCard>
</div>
<UiCard>
<UiCardContent class="p-6">
<DataTable
ref="tableRef"
:loading="loading"
:data="filteredAgents"
:search-filter="searchFilter"
:view-mode="viewMode"
:on-toggle-status="handleToggleStatus"
:on-edit="handleEdit"
:on-delete="confirmDelete"
@refresh="fetchAgents"
@toggle-view="toggleViewMode"
@update:search-filter="searchFilter = $event"
>
<template #filters>
<SingleFilter
v-model="statusFilter"
:title="t('admin.agents.columns.status')"
:options="statusOptions"
/>
</template>
</DataTable>
</UiCardContent>
</UiCard>
</div>
<ConfirmDialog
v-model:open="deleteDialogOpen"
destructive
:confirm-button-text="t('common.delete')"
:cancel-button-text="t('common.cancel')"
@confirm="handleDelete"
>
<template #title>
{{ t('admin.agents.deleteAgent') }}
</template>
<template #description>
{{ t('admin.agents.deleteAgentConfirm', { username: deleteTarget?.username }) }}
</template>
</ConfirmDialog>
</BasicPage>
</template>
@@ -1,186 +0,0 @@
import type { ColumnDef, Row } from '@tanstack/vue-table'
import type { Composer } from 'vue-i18n'
import { Ban, Check, MoreHorizontal, Pencil, Trash2, X } from 'lucide-vue-next'
import { h } from 'vue'
import type { Agent } from '../data/schema'
import Badge from '@/components/ui/badge/Badge.vue'
import Button from '@/components/ui/button/Button.vue'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
function getIndentStyle(row: Row<Agent>): string {
const depth = row.depth
if (depth === 0) return ''
return `padding-left: ${depth * 24}px;`
}
export function getColumns(actions: {
onToggleStatus: (row: Agent) => void
onEdit: (row: Agent) => void
onDelete: (row: Agent) => void
}, t: Composer['t']): ColumnDef<Agent>[] {
return [
{
accessorKey: 'username',
header: () => t('admin.agents.columns.username'),
cell: ({ row }) => {
const agent = row.original
const indentStyle = getIndentStyle(row)
return h('div', { class: 'flex items-center space-x-3', style: indentStyle }, [
h(Avatar, { class: 'h-8 w-8' }, () => [
h(AvatarImage, { src: agent.avatar, alt: agent.username }),
h(AvatarFallback, {}, () => agent.username?.charAt(0).toUpperCase() || 'U'),
]),
h('div', { class: 'flex flex-col' }, [
h('span', { class: 'font-medium' }, agent.username),
agent.email ? h('span', { class: 'text-xs text-muted-foreground' }, agent.email) : null,
]),
])
},
},
{
accessorKey: 'status',
header: () => t('admin.agents.columns.status'),
cell: ({ row }) => {
const status = row.getValue('status') as string
const statusMap: Record<string, { text: string, variant: any }> = {
active: { text: t('admin.agents.status.active'), variant: 'default' },
inactive: { text: t('admin.agents.status.inactive'), variant: 'secondary' },
banned: { text: t('admin.agents.status.banned'), variant: 'destructive' },
}
const { text, variant } = statusMap[status] || { text: status, variant: 'default' }
return h(Badge, { variant }, () => text)
},
},
{
accessorKey: 'can_create_agent',
header: () => t('admin.agents.columns.canCreateAgent'),
cell: ({ row }) => {
const canCreate = row.getValue('can_create_agent') as boolean
return h('div', { class: 'flex items-center justify-center' }, [
canCreate
? h(Check, { class: 'h-4 w-4 text-green-500' })
: h(X, { class: 'h-4 w-4 text-red-500' }),
])
},
},
{
accessorKey: 'cards_count',
header: () => t('admin.agents.columns.cardsCount'),
cell: ({ row }) => {
const count = row.getValue('cards_count') as number
return h(Badge, { variant: 'outline' }, () => String(count))
},
},
{
accessorKey: 'child_agents_count',
header: () => t('admin.agents.columns.childAgentsCount'),
cell: ({ row }) => {
const count = row.getValue('child_agents_count') as number
return h(Badge, { variant: 'secondary' }, () => String(count))
},
},
{
accessorKey: 'balance',
header: () => t('admin.agents.columns.balance'),
cell: ({ row }) => {
const balance = row.getValue('balance') as number
return `¥${balance.toFixed(2)}`
},
},
{
accessorKey: 'created_at',
header: () => t('admin.agents.columns.createdAt'),
cell: ({ row }) => {
const createdAt = row.getValue('created_at') as string
if (!createdAt) return '-'
try {
const date = new Date(createdAt)
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 '-'
}
},
},
{
accessorKey: 'last_login_at',
header: () => t('admin.agents.columns.lastLoginAt'),
cell: ({ row }) => {
const lastLoginAt = row.getValue('last_login_at') as string
if (!lastLoginAt) return '-'
try {
const date = new Date(lastLoginAt)
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' }, t('common.actions')),
cell: ({ row }) => {
const agent = row.original
const isBanned = agent.status === 'banned'
return h(
DropdownMenu,
{},
{
default: () => [
h(DropdownMenuTrigger, { asChild: true }, () =>
h(Button, { variant: 'ghost', class: 'h-8 w-8 p-0' }, () => [
h(MoreHorizontal, { class: 'h-4 w-4' }),
h('span', { class: 'sr-only' }, t('common.openMenu')),
])),
h(
DropdownMenuContent,
{ align: 'end' },
() => [
h(DropdownMenuItem, { onClick: () => actions.onEdit(agent) }, () => [
h(Pencil, { class: 'mr-2 h-4 w-4' }),
t('common.edit'),
]),
h(DropdownMenuItem, { onClick: () => actions.onToggleStatus(agent) }, () => [
h(Ban, { class: 'mr-2 h-4 w-4' }),
isBanned ? t('admin.agents.enable') : t('admin.agents.disable'),
]),
h(DropdownMenuSeparator),
h(DropdownMenuItem, { class: 'text-destructive', onClick: () => actions.onDelete(agent) }, () => [
h(Trash2, { class: 'mr-2 h-4 w-4' }),
t('common.delete'),
]),
],
),
],
},
)
},
enableSorting: false,
enableHiding: false,
},
]
}
@@ -1,95 +0,0 @@
<script setup lang="ts">
import type { ColumnDef } from '@tanstack/vue-table'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { DataTableProps } from '@/components/data-table/types'
import type { Agent } from '../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 './columns'
import { Icon } from '@iconify/vue'
const props = defineProps<Omit<DataTableProps<Agent>, 'columns'> & {
searchFilter?: string
viewMode?: 'tree' | 'list'
onToggleStatus: (row: Agent) => void
onEdit: (row: Agent) => void
onDelete: (row: Agent) => void
}>()
const emit = defineEmits<{
'refresh': []
'toggleView': []
'update:searchFilter': [value: string]
}>()
const { t } = useI18n()
const columns = computed(() => [
SelectColumn as ColumnDef<Agent>,
...getColumns({
onToggleStatus: props.onToggleStatus,
onEdit: props.onEdit,
onDelete: props.onDelete,
}, t),
])
const table = generateVueTable<Agent>({
get data() { return props.data },
get loading() { return props.loading },
columns: columns.value,
getSubRows: props.viewMode === 'tree' ? (row: Agent) => row.children || [] : undefined,
}, columns.value)
const columnLabels: Record<string, string> = {
select: 'admin.agents.select',
username: 'admin.agents.columns.username',
status: 'admin.agents.columns.status',
can_create_agent: 'admin.agents.columns.canCreateAgent',
cards_count: 'admin.agents.columns.cardsCount',
child_agents_count: 'admin.agents.columns.childAgentsCount',
balance: 'admin.agents.columns.balance',
created_at: 'admin.agents.columns.createdAt',
last_login_at: 'admin.agents.columns.lastLoginAt',
}
defineExpose({
table,
})
</script>
<template>
<DataTable :columns="columns" :data :loading :table @refresh="emit('refresh')">
<template #toolbar>
<div class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-2">
<div class="relative">
<Icon icon="lucide:search" class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<UiInput
:model-value="searchFilter"
type="search"
:placeholder="t('admin.agents.searchPlaceholder')"
class="h-9 w-[250px] pl-8"
@update:model-value="emit('update:searchFilter', String($event))"
/>
</div>
<slot name="filters" />
</div>
<div class="flex items-center gap-2">
<UiButton variant="outline" size="sm" @click="emit('toggleView')">
<Icon :icon="viewMode === 'tree' ? 'lucide:list' : 'lucide:git-branch'" class="mr-2 h-4 w-4" />
{{ viewMode === 'tree' ? t('admin.agents.listView') : t('admin.agents.treeView') }}
</UiButton>
<DataTableViewOptions :table="table" :column-labels="columnLabels" />
</div>
</div>
</div>
</template>
</DataTable>
</template>
@@ -1,295 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import { BasicPage } from '@/components/global-layout'
import api from '@/services/api'
const router = useRouter()
const { t } = useI18n()
interface Agent {
id: number
username: string
email: string
status: string
}
const saving = ref(false)
const agents = ref<Agent[]>([])
const form = ref({
username: '',
email: '',
password: '',
parent_agent_id: '',
can_create_agent: false,
balance: 0,
})
async function fetchAgents() {
try {
const data = await api.get<{ agents?: Agent[], total: number }>('/dev/agents')
agents.value = data?.agents || []
}
catch (error) {
console.error('获取代理列表失败:', error)
}
}
const selectedParentAgent = computed(() => {
if (form.value.parent_agent_id) {
return agents.value.find(a => String(a.id) === form.value.parent_agent_id)
}
return null
})
async function handleCreate() {
if (!form.value.username) {
toast.error(t('admin.agents.create.usernameRequired'))
return
}
if (!form.value.password) {
toast.error(t('admin.agents.create.passwordRequired'))
return
}
if (form.value.password.length < 6) {
toast.error(t('admin.agents.create.passwordMinLength'))
return
}
saving.value = true
try {
const payload: any = {
username: form.value.username,
password: form.value.password,
can_create_agent: form.value.can_create_agent,
balance: form.value.balance,
}
if (form.value.email) {
payload.email = form.value.email
}
if (form.value.parent_agent_id && form.value.parent_agent_id !== 'none') {
payload.parent_agent_id = parseInt(form.value.parent_agent_id)
}
await api.post('/dev/agents', payload)
toast.success(t('admin.agents.create.createSuccess'))
router.push('/admin/agents')
}
catch (error: any) {
console.error('创建代理失败:', error)
toast.error(error.message || t('admin.agents.create.createFailed'))
}
finally {
saving.value = false
}
}
onMounted(() => {
fetchAgents()
})
</script>
<template>
<BasicPage
:title="t('admin.agents.create.title')"
:description="t('admin.agents.create.description')"
:breadcrumbs="[
{ title: t('admin.agents.title'), href: '/admin/agents' },
{ title: t('admin.agents.create.title') },
]"
sticky
>
<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 class="flex items-center gap-2">
<Icon icon="lucide:user-plus" class="size-5" />
{{ t('admin.agents.create.basicInfo') }}
</UiCardTitle>
<UiCardDescription>{{ t('admin.agents.create.basicInfoDesc') }}</UiCardDescription>
</UiCardHeader>
<UiCardContent class="space-y-6">
<div class="space-y-2">
<UiLabel for="username">
{{ t('admin.agents.form.username') }}
</UiLabel>
<UiInput
id="username"
v-model="form.username"
:placeholder="t('admin.agents.form.usernamePlaceholder')"
/>
</div>
<div class="space-y-2">
<UiLabel for="email">
{{ t('admin.agents.form.email') }}
</UiLabel>
<UiInput
id="email"
v-model="form.email"
type="email"
:placeholder="t('admin.agents.form.emailPlaceholder')"
/>
</div>
<div class="space-y-2">
<UiLabel for="password">
{{ t('admin.agents.form.password') }}
</UiLabel>
<UiInput
id="password"
v-model="form.password"
type="password"
:placeholder="t('admin.agents.form.passwordPlaceholder')"
/>
<p class="text-xs text-muted-foreground">
{{ t('admin.agents.create.passwordHint') }}
</p>
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader>
<UiCardTitle class="flex items-center gap-2">
<Icon icon="lucide:settings-2" class="size-5" />
{{ t('admin.agents.create.agentConfig') }}
</UiCardTitle>
<UiCardDescription>{{ t('admin.agents.create.agentConfigDesc') }}</UiCardDescription>
</UiCardHeader>
<UiCardContent class="space-y-6">
<div class="space-y-2">
<UiLabel for="parent_agent">
{{ t('admin.agents.form.parentAgent') }}
</UiLabel>
<UiSelect v-model="form.parent_agent_id">
<UiSelectTrigger>
<UiSelectValue :placeholder="t('admin.agents.form.selectParentAgent')" />
</UiSelectTrigger>
<UiSelectContent>
<UiSelectItem value="none">
{{ t('admin.agents.form.noParent') }}
</UiSelectItem>
<UiSelectItem
v-for="agent in agents"
:key="agent.id"
:value="String(agent.id)"
>
{{ agent.username }}
</UiSelectItem>
</UiSelectContent>
</UiSelect>
<p class="text-xs text-muted-foreground">
{{ t('admin.agents.create.parentAgentHint') }}
</p>
</div>
<div class="space-y-2">
<UiLabel for="balance">
{{ t('admin.agents.form.balance') }}
</UiLabel>
<div class="flex items-center gap-4">
<UiNumberField
v-model="form.balance"
:min="0"
:step="0.01"
class="flex-1 max-w-[200px]"
>
<UiNumberFieldContent>
<UiNumberFieldDecrement />
<UiNumberFieldInput />
<UiNumberFieldIncrement />
</UiNumberFieldContent>
</UiNumberField>
<span class="text-sm text-muted-foreground">¥</span>
</div>
<p class="text-xs text-muted-foreground">
{{ t('admin.agents.form.balancePlaceholder') }}
</p>
</div>
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<UiLabel>{{ t('admin.agents.form.canCreateAgent') }}</UiLabel>
<p class="text-xs text-muted-foreground">
{{ t('admin.agents.create.canCreateAgentHint') }}
</p>
</div>
<!-- 注意UiSwitch 必须使用 v-model不能使用 v-model:checked -->
<UiSwitch v-model="form.can_create_agent" />
</div>
</UiCardContent>
</UiCard>
</div>
<div class="space-y-6">
<UiCard>
<UiCardHeader>
<UiCardTitle class="flex items-center gap-2">
<Icon icon="lucide:receipt" class="size-5" />
{{ t('admin.agents.create.preview') }}
</UiCardTitle>
</UiCardHeader>
<UiCardContent class="space-y-4">
<div class="space-y-3">
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('admin.agents.form.username') }}</span>
<span>{{ form.username || '-' }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('admin.agents.form.email') }}</span>
<span>{{ form.email || '-' }}</span>
</div>
<div class="flex justify-between text-sm">
<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">{{ t('admin.agents.form.parentAgent') }}</span>
<span>{{ selectedParentAgent?.username || '-' }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('admin.agents.form.balance') }}</span>
<span>¥{{ form.balance.toFixed(2) }}</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.username || !form.password"
@click="handleCreate"
>
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
<Icon v-else icon="lucide:user-plus" class="mr-2 h-4 w-4" />
{{ t('admin.agents.form.create') }}
</UiButton>
<UiButton
variant="outline"
class="w-full"
@click="router.back()"
>
{{ t('admin.agents.create.cancel') }}
</UiButton>
</div>
</UiCardContent>
</UiCard>
</div>
</div>
</div>
</BasicPage>
</template>
@@ -1,17 +0,0 @@
export interface Agent {
id: number
username: string
email: string
avatar: string
status: string
role: string
parent_agent_id: number | null
parent_agent_name: string
created_at: string
last_login_at: string
balance: number
can_create_agent: boolean
cards_count: number
child_agents_count: number
children?: Agent[]
}
@@ -1,307 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import type { Agent } from './data/schema'
import ConfirmDialog from '@/components/confirm-dialog.vue'
import SingleFilter from '@/components/data-table/single-filter.vue'
import { BasicPage } from '@/components/global-layout'
import DataTable from './components/data-table.vue'
import api from '@/services/api'
const { t } = useI18n()
const router = useRouter()
const loading = ref(true)
const agents = ref<Agent[]>([])
const _tableRef = ref<InstanceType<typeof DataTable> | null>(null)
const searchFilter = ref('')
const statusFilter = ref<string>('')
const viewMode = ref<'tree' | 'list'>('tree')
const deleteDialogOpen = ref(false)
const deleteTarget = ref<Agent | null>(null)
const filteredAgents = computed(() => {
let result = agents.value
if (statusFilter.value) {
const filterByStatus = (items: Agent[]): Agent[] => {
return items.reduce((acc: Agent[], item) => {
const matches = item.status === statusFilter.value
const filteredChildren = item.children ? filterByStatus(item.children) : []
if (matches || filteredChildren.length > 0) {
acc.push({
...item,
children: filteredChildren.length > 0 ? filteredChildren : item.children,
})
}
return acc
}, [])
}
result = filterByStatus(result)
}
if (searchFilter.value) {
const search = searchFilter.value.toLowerCase()
const filterTree = (items: Agent[]): Agent[] => {
return items.reduce((acc: Agent[], item) => {
const matchesSearch =
item.username?.toLowerCase().includes(search) ||
item.email?.toLowerCase().includes(search) ||
item.parent_agent_name?.toLowerCase().includes(search)
const filteredChildren = item.children ? filterTree(item.children) : []
if (matchesSearch || filteredChildren.length > 0) {
acc.push({
...item,
children: filteredChildren.length > 0 ? filteredChildren : item.children,
})
}
return acc
}, [])
}
result = filterTree(result)
}
return result
})
const totalAgents = computed(() => {
const countNodes = (items: Agent[]): number => {
return items.reduce((acc, item) => {
return acc + 1 + (item.children ? countNodes(item.children) : 0)
}, 0)
}
return countNodes(agents.value)
})
const activeCount = computed(() => {
const countByStatus = (items: Agent[], status: string): number => {
return items.reduce((acc, item) => {
const self = item.status === status ? 1 : 0
const children = item.children ? countByStatus(item.children, status) : 0
return acc + self + children
}, 0)
}
return countByStatus(agents.value, 'active')
})
const inactiveCount = computed(() => {
const countByStatus = (items: Agent[], status: string): number => {
return items.reduce((acc, item) => {
const self = item.status === status ? 1 : 0
const children = item.children ? countByStatus(item.children, status) : 0
return acc + self + children
}, 0)
}
return countByStatus(agents.value, 'inactive')
})
const bannedCount = computed(() => {
const countByStatus = (items: Agent[], status: string): number => {
return items.reduce((acc, item) => {
const self = item.status === status ? 1 : 0
const children = item.children ? countByStatus(item.children, status) : 0
return acc + self + children
}, 0)
}
return countByStatus(agents.value, 'banned')
})
const statusOptions = computed(() => [
{ label: t('admin.agents.status.active'), value: 'active' },
{ label: t('admin.agents.status.inactive'), value: 'inactive' },
{ label: t('admin.agents.status.banned'), value: 'banned' },
])
async function fetchAgents() {
loading.value = true
try {
const endpoint = viewMode.value === 'tree' ? '/dev/agents/tree' : '/dev/agents'
const data = await api.get<{ tree?: Agent[], agents?: Agent[], total: number }>(endpoint)
if (viewMode.value === 'tree') {
agents.value = data?.tree || []
} else {
agents.value = data?.agents || []
}
} catch (error) {
console.error('获取代理列表失败:', error)
agents.value = []
} finally {
loading.value = false
}
}
function goToCreate() {
router.push('/admin/agents/create')
}
function toggleViewMode() {
viewMode.value = viewMode.value === 'tree' ? 'list' : 'tree'
fetchAgents()
}
function handleEdit(agent: Agent) {
router.push(`/admin/agents/${agent.id}/edit`)
}
async function handleToggleStatus(agent: Agent) {
try {
const newStatus = agent.status === 'banned' ? 'active' : 'banned'
await api.put(`/dev/agents/${agent.id}/status`, { status: newStatus })
toast.success(t('admin.agents.statusUpdateSuccess'))
fetchAgents()
} catch (error: any) {
console.error('切换代理状态失败:', error)
toast.error(error.message || t('admin.agents.statusUpdateFailed'))
}
}
function confirmDelete(agent: Agent) {
deleteTarget.value = agent
deleteDialogOpen.value = true
}
async function handleDelete() {
if (!deleteTarget.value) return
try {
await api.delete(`/dev/agents/${deleteTarget.value.id}`)
toast.success(t('admin.agents.deleteSuccess'))
fetchAgents()
} catch (error: any) {
console.error('删除代理失败:', error)
toast.error(error.message || t('admin.agents.deleteFailed'))
} finally {
deleteTarget.value = null
}
}
onMounted(() => {
fetchAgents()
})
</script>
<template>
<BasicPage
:title="t('admin.agents.title')"
:description="t('admin.agents.description')"
sticky
>
<template #actions>
<UiButton @click="goToCreate">
<Icon icon="lucide:plus" class="mr-2 h-4 w-4" />
{{ t('admin.agents.addAgent') }}
</UiButton>
</template>
<div class="space-y-6">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.agents.totalAgents') }}
</UiCardTitle>
<Icon icon="lucide:users" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ totalAgents }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.agents.status.active') }}
</UiCardTitle>
<Icon icon="lucide:check-circle" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ activeCount }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.agents.status.inactive') }}
</UiCardTitle>
<Icon icon="lucide:clock" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ inactiveCount }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.agents.status.banned') }}
</UiCardTitle>
<Icon icon="lucide:ban" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ bannedCount }}
</div>
</UiCardContent>
</UiCard>
</div>
<UiCard>
<UiCardContent class="p-6">
<DataTable
ref="tableRef"
:loading="loading"
:data="filteredAgents"
:search-filter="searchFilter"
:view-mode="viewMode"
:on-toggle-status="handleToggleStatus"
:on-edit="handleEdit"
:on-delete="confirmDelete"
@refresh="fetchAgents"
@toggle-view="toggleViewMode"
@update:search-filter="searchFilter = $event"
>
<template #filters>
<SingleFilter
v-model="statusFilter"
:title="t('admin.agents.columns.status')"
:options="statusOptions"
/>
</template>
</DataTable>
</UiCardContent>
</UiCard>
</div>
<ConfirmDialog
v-model:open="deleteDialogOpen"
destructive
:confirm-button-text="t('common.delete')"
:cancel-button-text="t('common.cancel')"
@confirm="handleDelete"
>
<template #title>
{{ t('admin.agents.deleteAgent') }}
</template>
<template #description>
{{ t('admin.agents.deleteAgentConfirm', { username: deleteTarget?.username }) }}
</template>
</ConfirmDialog>
</BasicPage>
</template>
@@ -1,354 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import type { Announcement } from '@/pages/admin/announcements/data/schema'
import ConfirmDialog from '@/components/confirm-dialog.vue'
import SingleFilter from '@/components/data-table/single-filter.vue'
import { BasicPage } from '@/components/global-layout'
import DataTable from '@/pages/admin/announcements/components/data-table.vue'
import api, { BASE_URL } from '@/services/api'
const { t } = useI18n()
interface Application {
id: number
name: string
}
const route = useRoute()
const router = useRouter()
const loading = ref(true)
const announcements = ref<Announcement[]>([])
const applications = ref<Application[]>([])
const tableRef = ref()
const appFilter = ref<string>('')
const typeFilter = ref<string>('')
const statusFilter = ref<string>('')
const searchFilter = ref<string>('')
const deleteDialogOpen = ref(false)
const deleteTarget = ref<Announcement | null>(null)
const batchDeleteDialogOpen = ref(false)
const batchDeleteIds = ref<number[]>([])
const applicationOptions = computed(() => {
return applications.value.map(app => ({
label: app.name,
value: String(app.id),
}))
})
const typeOptions = computed(() => [
{ label: t('admin.announcements.types.info'), value: 'info' },
{ label: t('admin.announcements.types.warning'), value: 'warning' },
{ label: t('admin.announcements.types.error'), value: 'error' },
{ label: t('admin.announcements.types.success'), value: 'success' },
])
const statusOptions = computed(() => [
{ label: t('admin.announcements.statuses.active'), value: 'active' },
{ label: t('admin.announcements.statuses.inactive'), value: 'inactive' },
])
const filteredAnnouncements = computed(() => {
let result = announcements.value
if (appFilter.value) {
result = result.filter(a => String(a.application_id) === appFilter.value)
}
if (typeFilter.value) {
result = result.filter(a => a.type === typeFilter.value)
}
if (statusFilter.value) {
result = result.filter(a => a.status === statusFilter.value)
}
if (searchFilter.value) {
const search = searchFilter.value.toLowerCase()
result = result.filter(a =>
a.title?.toLowerCase().includes(search)
|| a.content?.toLowerCase().includes(search)
|| a.application_name?.toLowerCase().includes(search),
)
}
return result
})
const activeCount = computed(() => filteredAnnouncements.value.filter(a => a.status === 'active').length)
const inactiveCount = computed(() => filteredAnnouncements.value.filter(a => a.status === 'inactive').length)
const topCount = computed(() => filteredAnnouncements.value.filter(a => a.is_top).length)
async function fetchApplications() {
try {
const data = await api.get<{ applications: Application[] }>('/dev/applications')
applications.value = data?.applications || []
}
catch (error) {
console.error('获取应用列表失败:', error)
}
}
async function fetchAnnouncements() {
loading.value = true
try {
const data = await api.get<{ announcements: Announcement[], total: number }>('/dev/announcements')
announcements.value = data?.announcements || []
}
catch (error) {
console.error('加载公告失败:', error)
announcements.value = []
}
finally {
loading.value = false
}
}
function goToCreate() {
router.push('/admin/announcements/create')
}
function goToEdit(announcement: Announcement) {
router.push(`/admin/announcements/${announcement.id}`)
}
async function toggleTop(announcement: Announcement) {
try {
await api.put(`/dev/applications/${announcement.application_id}/announcements/${announcement.id}/top`)
toast.success(announcement.is_top ? t('admin.announcements.unpinned') : t('admin.announcements.pinned'))
fetchAnnouncements()
}
catch (error: any) {
console.error('切换置顶失败:', error)
toast.error(error.message || t('common.error'))
}
}
function confirmDelete(announcement: Announcement) {
deleteTarget.value = announcement
deleteDialogOpen.value = true
}
async function handleDelete() {
if (!deleteTarget.value)
return
try {
await api.delete(`/dev/applications/${deleteTarget.value.application_id}/announcements/${deleteTarget.value.id}`)
toast.success(t('admin.announcements.deleteSuccess'))
fetchAnnouncements()
}
catch (error: any) {
console.error('删除公告失败:', error)
toast.error(error.message || t('common.error'))
}
finally {
deleteTarget.value = null
}
}
function handleExport() {
const token = localStorage.getItem('token')
const params = new URLSearchParams()
if (appFilter.value)
params.append('application_id', appFilter.value)
if (typeFilter.value)
params.append('type', typeFilter.value)
if (statusFilter.value)
params.append('status', statusFilter.value)
const queryString = params.toString()
const url = `${BASE_URL}/dev/announcements/export?${queryString}&token=${token}`
window.open(url, '_blank')
}
function handleBatchExport(ids: number[]) {
if (!ids.length)
return
const token = localStorage.getItem('token')
const params = new URLSearchParams()
params.append('ids', ids.join(','))
const url = `${BASE_URL}/dev/announcements/export?${params.toString()}&token=${token}`
window.open(url, '_blank')
}
function confirmBatchDelete(ids: number[]) {
batchDeleteIds.value = ids
batchDeleteDialogOpen.value = true
}
async function handleBatchDelete() {
try {
await api.delete('/dev/announcements/batch', { ids: batchDeleteIds.value } as any)
toast.success(t('admin.announcements.batchDeleteSuccess'))
fetchAnnouncements()
}
catch (error: any) {
console.error('批量删除失败:', error)
toast.error(error.message || t('common.error'))
}
finally {
batchDeleteIds.value = []
}
}
onMounted(() => {
fetchApplications()
fetchAnnouncements()
const appParam = route.query.app as string
if (appParam) {
appFilter.value = appParam
}
})
</script>
<template>
<BasicPage
:title="t('admin.announcements.title')"
:description="t('admin.announcements.description')"
sticky
>
<template #actions>
<UiButton @click="goToCreate">
<Icon icon="lucide:plus" class="mr-2 h-4 w-4" />
{{ t('admin.announcements.create') }}
</UiButton>
</template>
<div class="space-y-6">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.announcements.totalAnnouncements') }}
</UiCardTitle>
<Icon icon="lucide:megaphone" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ filteredAnnouncements.length }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.announcements.activeCount') }}
</UiCardTitle>
<Icon icon="lucide:check-circle" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ activeCount }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.announcements.inactiveCount') }}
</UiCardTitle>
<Icon icon="lucide:x-circle" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ inactiveCount }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.announcements.topCount') }}
</UiCardTitle>
<Icon icon="lucide:pin" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ topCount }}
</div>
</UiCardContent>
</UiCard>
</div>
<UiCard>
<UiCardContent class="p-6">
<DataTable
ref="tableRef"
:loading
:data="filteredAnnouncements"
:on-toggle-top="toggleTop"
:on-edit="goToEdit"
:on-delete="confirmDelete"
:search-filter="searchFilter"
@refresh="fetchAnnouncements"
@update:search-filter="searchFilter = $event"
@export="handleExport"
@batch-delete="confirmBatchDelete(tableRef?.table?.getSelectedRowModel().rows.map((r: any) => r.original.id) || [])"
@batch-export="handleBatchExport(tableRef?.table?.getSelectedRowModel().rows.map((r: any) => r.original.id) || [])"
>
<template #filters>
<SingleFilter
v-model="appFilter"
:title="t('admin.announcements.application')"
:options="applicationOptions"
/>
<SingleFilter
v-model="typeFilter"
:title="t('admin.announcements.type')"
:options="typeOptions"
/>
<SingleFilter
v-model="statusFilter"
:title="t('admin.announcements.status')"
:options="statusOptions"
/>
</template>
</DataTable>
</UiCardContent>
</UiCard>
</div>
<ConfirmDialog
v-model:open="deleteDialogOpen"
destructive
:confirm-button-text="t('common.delete')"
:cancel-button-text="t('common.cancel')"
@confirm="handleDelete"
>
<template #title>
{{ t('admin.announcements.deleteAnnouncement') }}
</template>
<template #description>
{{ t('admin.announcements.deleteConfirm', { title: deleteTarget?.title }) }}
</template>
</ConfirmDialog>
<ConfirmDialog
v-model:open="batchDeleteDialogOpen"
destructive
:confirm-button-text="t('common.delete')"
:cancel-button-text="t('common.cancel')"
@confirm="handleBatchDelete"
>
<template #title>
{{ t('admin.announcements.batchDelete') }}
</template>
<template #description>
{{ t('admin.announcements.batchDeleteConfirm', { count: batchDeleteIds.length }) }}
</template>
</ConfirmDialog>
</BasicPage>
</template>
@@ -1,117 +0,0 @@
import type { ColumnDef } from '@tanstack/vue-table'
import { Icon } from '@iconify/vue'
import { h } from 'vue'
import type { Announcement } from '@/pages/admin/announcements/data/schema'
import Badge from '@/components/ui/badge/Badge.vue'
import Button from '@/components/ui/button/Button.vue'
interface ColumnOptions {
onToggleTop: (row: Announcement) => void
onEdit: (row: Announcement) => void
onDelete: (row: Announcement) => void
}
export function getColumns(options: ColumnOptions, t: (key: string) => string): ColumnDef<Announcement>[] {
return [
{
accessorKey: 'title',
header: () => t('admin.announcements.columns.title'),
cell: ({ row }) => {
return h('div', { class: 'space-y-1' }, [
h('div', { class: 'font-medium' }, row.original.title),
h('div', { class: 'text-xs text-muted-foreground truncate max-w-xs' }, row.original.content),
])
},
},
{
accessorKey: 'application_name',
header: () => t('admin.announcements.columns.application'),
cell: ({ row }) => {
const appName = row.original.application_name
return appName ? h(Badge, { variant: 'secondary' }, () => appName) : '-'
},
},
{
accessorKey: 'type',
header: () => t('admin.announcements.columns.type'),
cell: ({ row }) => {
const typeLabels: Record<string, string> = {
info: t('admin.announcements.types.info'),
warning: t('admin.announcements.types.warning'),
error: t('admin.announcements.types.error'),
success: t('admin.announcements.types.success'),
}
const typeClasses: Record<string, string> = {
info: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
warning: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
error: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
success: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
}
return h(Badge, { class: typeClasses[row.original.type] }, () => typeLabels[row.original.type])
},
},
{
accessorKey: 'status',
header: () => t('admin.announcements.columns.status'),
cell: ({ row }) => {
const statusLabels: Record<string, string> = {
active: t('admin.announcements.statuses.active'),
inactive: t('admin.announcements.statuses.inactive'),
}
const statusClasses: Record<string, string> = {
active: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
inactive: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400',
}
return h(Badge, { class: statusClasses[row.original.status] }, () => statusLabels[row.original.status])
},
},
{
accessorKey: 'is_top',
header: () => t('admin.announcements.columns.isTop'),
cell: ({ row }) => {
return h(Button, {
variant: 'ghost',
size: 'sm',
onClick: () => options.onToggleTop(row.original),
}, () => [
h(Icon, {
icon: row.original.is_top ? 'lucide:pin' : 'lucide:pin-off',
class: `h-4 w-4 ${row.original.is_top ? 'text-primary' : 'text-muted-foreground'}`,
}),
])
},
},
{
accessorKey: 'created_at',
header: () => t('admin.announcements.columns.createdAt'),
cell: ({ row }) => {
return h('span', { class: 'text-sm text-muted-foreground' }, new Date(row.original.created_at).toLocaleString('zh-CN'))
},
},
{
id: 'actions',
header: () => t('admin.announcements.columns.actions'),
cell: ({ row }) => {
return h('div', { class: 'flex items-center justify-end gap-1' }, [
h(Button, {
variant: 'ghost',
size: 'sm',
onClick: () => options.onEdit(row.original),
}, () => [
h(Icon, { icon: 'lucide:edit', class: 'h-4 w-4' }),
]),
h(Button, {
variant: 'ghost',
size: 'sm',
onClick: () => options.onDelete(row.original),
}, () => [
h(Icon, { icon: 'lucide:trash-2', class: 'h-4 w-4 text-destructive' }),
]),
])
},
},
]
}
@@ -1,52 +0,0 @@
<script setup lang="ts">
import type { Table } from '@tanstack/vue-table'
import { X } from 'lucide-vue-next'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Announcement } from '@/pages/admin/announcements/data/schema'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
const props = defineProps<DataTableToolbarProps>()
const emit = defineEmits<{
'update:searchFilter': [value: string]
}>()
const { t } = useI18n()
interface DataTableToolbarProps {
table: Table<Announcement>
searchFilter?: string
}
const isFiltered = computed(() => !!props.searchFilter)
function resetFilters() {
emit('update:searchFilter', '')
}
</script>
<template>
<div class="flex items-center flex-1 space-x-2">
<Input
:placeholder="t('admin.announcements.searchAll')"
:model-value="searchFilter"
class="h-8 w-[200px] lg:w-[300px]"
@update:model-value="emit('update:searchFilter', $event as string)"
/>
<Button
v-if="isFiltered"
variant="ghost"
class="h-8 px-2 lg:px-3"
@click="resetFilters"
>
{{ t('admin.announcements.reset') }}
<X class="size-4 ml-2" />
</Button>
</div>
</template>
@@ -1,96 +0,0 @@
<script setup lang="ts">
import type { ColumnDef } from '@tanstack/vue-table'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { DataTableProps } from '@/components/data-table/types'
import type { Announcement } from '@/pages/admin/announcements/data/schema'
import BulkActions from '@/components/data-table/bulk-actions.vue'
import DataTable from '@/components/data-table/data-table.vue'
import { SelectColumn } from '@/components/data-table/table-columns'
import { generateVueTable } from '@/components/data-table/use-generate-vue-table'
import DataTableViewOptions from '@/components/data-table/view-options.vue'
import { getColumns } from '@/pages/admin/announcements/components/columns'
import DataTableToolbar from '@/pages/admin/announcements/components/data-table-toolbar.vue'
const props = defineProps<Omit<DataTableProps<Announcement>, 'columns'> & {
onToggleTop: (row: Announcement) => void
onEdit: (row: Announcement) => void
onDelete: (row: Announcement) => void
searchFilter?: string
}>()
const emit = defineEmits<{
'refresh': []
'batchDelete': []
'batchExport': []
'export': []
'update:searchFilter': [value: string]
}>()
const { t } = useI18n()
const columns = computed<ColumnDef<Announcement>[]>(() => [
SelectColumn as ColumnDef<Announcement>,
...getColumns({
onToggleTop: props.onToggleTop,
onEdit: props.onEdit,
onDelete: props.onDelete,
}, t),
])
const table = generateVueTable<Announcement>({
get data() { return props.data },
get loading() { return props.loading },
columns: columns.value,
}, columns.value)
const columnLabels: Record<string, string> = {
select: 'admin.announcements.select',
title: 'admin.announcements.columns.title',
application_name: 'admin.announcements.columns.application',
type: 'admin.announcements.columns.type',
status: 'admin.announcements.columns.status',
is_top: 'admin.announcements.columns.isTop',
created_at: 'admin.announcements.columns.createdAt',
}
defineExpose({
table,
})
</script>
<template>
<DataTable :columns="columns" :data :loading :table @refresh="emit('refresh')">
<template #toolbar>
<div class="space-y-3">
<BulkActions :table="table" entity-name="announcements">
<UiButton variant="outline" size="sm" @click="emit('batchExport')">
{{ t('admin.announcements.batchExportBtn') }}
</UiButton>
<UiButton variant="destructive" size="sm" @click="emit('batchDelete')">
{{ t('admin.announcements.batchDeleteBtn') }}
</UiButton>
</BulkActions>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-2">
<DataTableToolbar
:table
:search-filter="searchFilter"
@update:search-filter="emit('update:searchFilter', $event)"
/>
<slot name="filters" />
</div>
<div class="flex items-center gap-2">
<UiButton variant="outline" size="sm" @click="emit('export')">
{{ t('admin.announcements.exportBtn') }}
</UiButton>
<DataTableViewOptions :table="table" :column-labels="columnLabels" />
</div>
</div>
</div>
</template>
</DataTable>
</template>
@@ -1,292 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import { BasicPage } from '@/components/global-layout'
import api from '@/services/api'
interface Application {
id: number
name: string
}
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const saving = ref(false)
const applications = ref<Application[]>([])
const formData = ref({
application_id: '',
title: '',
content: '',
type: 'info' as 'info' | 'warning' | 'error' | 'success',
status: 'active' as 'active' | 'inactive',
is_top: false,
})
const typeOptions = [
{ value: 'info', label: '信息', color: 'bg-blue-500', desc: '普通通知信息' },
{ value: 'warning', label: '警告', color: 'bg-yellow-500', desc: '需要注意的内容' },
{ value: 'error', label: '错误', color: 'bg-red-500', desc: '错误或故障通知' },
{ value: 'success', label: '成功', color: 'bg-green-500', desc: '成功状态通知' },
]
const selectedApplication = computed(() => {
return applications.value.find(app => String(app.id) === formData.value.application_id)
})
const selectedType = computed(() => {
return typeOptions.find(t => t.value === formData.value.type)
})
async function fetchApplications() {
loading.value = true
try {
const data = await api.get<{ applications: Application[] }>('/dev/applications')
applications.value = data?.applications || []
const appParam = route.query.app as string
if (appParam) {
formData.value.application_id = appParam
}
}
catch (error) {
console.error('获取应用列表失败:', error)
}
finally {
loading.value = false
}
}
async function handleSubmit() {
if (!formData.value.application_id) {
toast.error('请选择应用')
return
}
if (!formData.value.title) {
toast.error('请输入公告标题')
return
}
if (!formData.value.content) {
toast.error('请输入公告内容')
return
}
saving.value = true
try {
await api.post(`/dev/applications/${formData.value.application_id}/announcements`, {
title: formData.value.title,
content: formData.value.content,
type: formData.value.type,
status: formData.value.status,
is_top: formData.value.is_top,
})
toast.success('公告创建成功')
router.push('/admin/announcements')
}
catch (error: any) {
console.error('创建公告失败:', error)
toast.error(error.message || '创建失败')
}
finally {
saving.value = false
}
}
onMounted(() => {
fetchApplications()
})
</script>
<template>
<BasicPage
title="创建公告"
description="创建新的系统公告"
:breadcrumbs="[
{ title: '公告管理', href: '/admin/announcements' },
{ title: '创建公告' },
]"
sticky
>
<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 class="flex items-center gap-2">
<Icon icon="lucide:megaphone" class="size-5" />
公告配置
</UiCardTitle>
<UiCardDescription>填写公告的基本信息和内容</UiCardDescription>
</UiCardHeader>
<UiCardContent class="space-y-6">
<div class="space-y-2">
<UiLabel>选择应用 <span class="text-destructive">*</span></UiLabel>
<UiSelect v-model="formData.application_id" :disabled="saving || loading">
<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="title">
公告标题 <span class="text-destructive">*</span>
</UiLabel>
<UiInput
id="title"
v-model="formData.title"
placeholder="输入公告标题"
:disabled="saving"
/>
</div>
<div class="space-y-2">
<UiLabel for="content">
公告内容 <span class="text-destructive">*</span>
</UiLabel>
<textarea
id="content"
v-model="formData.content"
class="flex min-h-[180px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder="输入公告详细内容..."
:disabled="saving"
/>
</div>
<div class="space-y-2">
<UiLabel>公告类型</UiLabel>
<UiSelect v-model="formData.type" :disabled="saving">
<UiSelectTrigger>
<UiSelectValue placeholder="选择类型" />
</UiSelectTrigger>
<UiSelectContent>
<UiSelectItem v-for="type in typeOptions" :key="type.value" :value="type.value">
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full" :class="type.color" />
{{ type.label }}
</div>
</UiSelectItem>
</UiSelectContent>
</UiSelect>
</div>
<div class="flex items-center justify-between rounded-lg border p-4">
<div class="space-y-0.5">
<UiLabel class="text-base">
发布状态
</UiLabel>
<p class="text-sm text-muted-foreground">
启用后公告将对用户可见
</p>
</div>
<UiSwitch
:checked="formData.status === 'active'"
:disabled="saving"
@update:checked="formData.status = $event ? 'active' : 'inactive'"
/>
</div>
<div class="flex items-center justify-between rounded-lg border p-4">
<div class="space-y-0.5">
<UiLabel class="text-base">
置顶公告
</UiLabel>
<p class="text-sm text-muted-foreground">
置顶的公告将在应用内优先显示
</p>
</div>
<UiSwitch v-model:checked="formData.is_top" :disabled="saving" />
</div>
</UiCardContent>
</UiCard>
</div>
<div class="space-y-6">
<UiCard>
<UiCardHeader>
<UiCardTitle class="flex items-center gap-2">
<Icon icon="lucide: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>{{ selectedApplication?.name || '-' }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">公告标题</span>
<span class="truncate max-w-[120px]">{{ formData.title || '-' }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">公告类型</span>
<div class="flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full" :class="selectedType?.color" />
<span>{{ selectedType?.label || '-' }}</span>
</div>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">发布状态</span>
<span>{{ formData.status === 'active' ? '启用' : '禁用' }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">是否置顶</span>
<span>{{ formData.is_top ? '是' : '否' }}</span>
</div>
</div>
<div class="border-t pt-4 mt-4">
<p class="text-sm text-muted-foreground mb-2">
内容预览
</p>
<div class="rounded-lg bg-muted/50 p-3 min-h-[80px]">
<p class="text-sm whitespace-pre-wrap">
{{ formData.content || '暂无内容' }}
</p>
</div>
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardContent class="pt-6">
<div class="flex flex-col gap-3">
<UiButton
class="w-full"
size="lg"
:disabled="saving || !formData.application_id || !formData.title || !formData.content"
@click="handleSubmit"
>
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
<Icon v-else icon="lucide:send" 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>
@@ -1,19 +0,0 @@
import { z } from 'zod'
export const announcementStatusSchema = z.enum(['active', 'inactive'])
export const announcementTypeSchema = z.enum(['info', 'warning', 'error', 'success'])
export const announcementSchema = z.object({
id: z.number(),
application_id: z.number(),
application_name: z.string(),
title: z.string(),
content: z.string(),
type: announcementTypeSchema,
status: announcementStatusSchema,
is_top: z.boolean(),
created_at: z.string(),
updated_at: z.string(),
})
export type Announcement = z.infer<typeof announcementSchema>
@@ -1,354 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import type { Announcement } from '@/pages/admin/announcements/data/schema'
import ConfirmDialog from '@/components/confirm-dialog.vue'
import SingleFilter from '@/components/data-table/single-filter.vue'
import { BasicPage } from '@/components/global-layout'
import DataTable from '@/pages/admin/announcements/components/data-table.vue'
import api, { BASE_URL } from '@/services/api'
const { t } = useI18n()
interface Application {
id: number
name: string
}
const route = useRoute()
const router = useRouter()
const loading = ref(true)
const announcements = ref<Announcement[]>([])
const applications = ref<Application[]>([])
const tableRef = ref()
const appFilter = ref<string>('')
const typeFilter = ref<string>('')
const statusFilter = ref<string>('')
const searchFilter = ref<string>('')
const deleteDialogOpen = ref(false)
const deleteTarget = ref<Announcement | null>(null)
const batchDeleteDialogOpen = ref(false)
const batchDeleteIds = ref<number[]>([])
const applicationOptions = computed(() => {
return applications.value.map(app => ({
label: app.name,
value: String(app.id),
}))
})
const typeOptions = computed(() => [
{ label: t('admin.announcements.types.info'), value: 'info' },
{ label: t('admin.announcements.types.warning'), value: 'warning' },
{ label: t('admin.announcements.types.error'), value: 'error' },
{ label: t('admin.announcements.types.success'), value: 'success' },
])
const statusOptions = computed(() => [
{ label: t('admin.announcements.statuses.active'), value: 'active' },
{ label: t('admin.announcements.statuses.inactive'), value: 'inactive' },
])
const filteredAnnouncements = computed(() => {
let result = announcements.value
if (appFilter.value) {
result = result.filter(a => String(a.application_id) === appFilter.value)
}
if (typeFilter.value) {
result = result.filter(a => a.type === typeFilter.value)
}
if (statusFilter.value) {
result = result.filter(a => a.status === statusFilter.value)
}
if (searchFilter.value) {
const search = searchFilter.value.toLowerCase()
result = result.filter(a =>
a.title?.toLowerCase().includes(search)
|| a.content?.toLowerCase().includes(search)
|| a.application_name?.toLowerCase().includes(search),
)
}
return result
})
const activeCount = computed(() => filteredAnnouncements.value.filter(a => a.status === 'active').length)
const inactiveCount = computed(() => filteredAnnouncements.value.filter(a => a.status === 'inactive').length)
const topCount = computed(() => filteredAnnouncements.value.filter(a => a.is_top).length)
async function fetchApplications() {
try {
const data = await api.get<{ applications: Application[] }>('/dev/applications')
applications.value = data?.applications || []
}
catch (error) {
console.error('获取应用列表失败:', error)
}
}
async function fetchAnnouncements() {
loading.value = true
try {
const data = await api.get<{ announcements: Announcement[], total: number }>('/dev/announcements')
announcements.value = data?.announcements || []
}
catch (error) {
console.error('加载公告失败:', error)
announcements.value = []
}
finally {
loading.value = false
}
}
function goToCreate() {
router.push('/admin/announcements/create')
}
function goToEdit(announcement: Announcement) {
router.push(`/admin/announcements/${announcement.id}`)
}
async function toggleTop(announcement: Announcement) {
try {
await api.put(`/dev/applications/${announcement.application_id}/announcements/${announcement.id}/top`)
toast.success(announcement.is_top ? t('admin.announcements.unpinned') : t('admin.announcements.pinned'))
fetchAnnouncements()
}
catch (error: any) {
console.error('切换置顶失败:', error)
toast.error(error.message || t('common.error'))
}
}
function confirmDelete(announcement: Announcement) {
deleteTarget.value = announcement
deleteDialogOpen.value = true
}
async function handleDelete() {
if (!deleteTarget.value)
return
try {
await api.delete(`/dev/applications/${deleteTarget.value.application_id}/announcements/${deleteTarget.value.id}`)
toast.success(t('admin.announcements.deleteSuccess'))
fetchAnnouncements()
}
catch (error: any) {
console.error('删除公告失败:', error)
toast.error(error.message || t('common.error'))
}
finally {
deleteTarget.value = null
}
}
function handleExport() {
const token = localStorage.getItem('token')
const params = new URLSearchParams()
if (appFilter.value)
params.append('application_id', appFilter.value)
if (typeFilter.value)
params.append('type', typeFilter.value)
if (statusFilter.value)
params.append('status', statusFilter.value)
const queryString = params.toString()
const url = `${BASE_URL}/dev/announcements/export?${queryString}&token=${token}`
window.open(url, '_blank')
}
function handleBatchExport(ids: number[]) {
if (!ids.length)
return
const token = localStorage.getItem('token')
const params = new URLSearchParams()
params.append('ids', ids.join(','))
const url = `${BASE_URL}/dev/announcements/export?${params.toString()}&token=${token}`
window.open(url, '_blank')
}
function confirmBatchDelete(ids: number[]) {
batchDeleteIds.value = ids
batchDeleteDialogOpen.value = true
}
async function handleBatchDelete() {
try {
await api.delete('/dev/announcements/batch', { ids: batchDeleteIds.value } as any)
toast.success(t('admin.announcements.batchDeleteSuccess'))
fetchAnnouncements()
}
catch (error: any) {
console.error('批量删除失败:', error)
toast.error(error.message || t('common.error'))
}
finally {
batchDeleteIds.value = []
}
}
onMounted(() => {
fetchApplications()
fetchAnnouncements()
const appParam = route.query.app as string
if (appParam) {
appFilter.value = appParam
}
})
</script>
<template>
<BasicPage
:title="t('admin.announcements.title')"
:description="t('admin.announcements.description')"
sticky
>
<template #actions>
<UiButton @click="goToCreate">
<Icon icon="lucide:plus" class="mr-2 h-4 w-4" />
{{ t('admin.announcements.create') }}
</UiButton>
</template>
<div class="space-y-6">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.announcements.totalAnnouncements') }}
</UiCardTitle>
<Icon icon="lucide:megaphone" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ filteredAnnouncements.length }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.announcements.activeCount') }}
</UiCardTitle>
<Icon icon="lucide:check-circle" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ activeCount }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.announcements.inactiveCount') }}
</UiCardTitle>
<Icon icon="lucide:x-circle" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ inactiveCount }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.announcements.topCount') }}
</UiCardTitle>
<Icon icon="lucide:pin" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ topCount }}
</div>
</UiCardContent>
</UiCard>
</div>
<UiCard>
<UiCardContent class="p-6">
<DataTable
ref="tableRef"
:loading
:data="filteredAnnouncements"
:on-toggle-top="toggleTop"
:on-edit="goToEdit"
:on-delete="confirmDelete"
:search-filter="searchFilter"
@refresh="fetchAnnouncements"
@update:search-filter="searchFilter = $event"
@export="handleExport"
@batch-delete="confirmBatchDelete(tableRef?.table?.getSelectedRowModel().rows.map((r: any) => r.original.id) || [])"
@batch-export="handleBatchExport(tableRef?.table?.getSelectedRowModel().rows.map((r: any) => r.original.id) || [])"
>
<template #filters>
<SingleFilter
v-model="appFilter"
:title="t('admin.announcements.application')"
:options="applicationOptions"
/>
<SingleFilter
v-model="typeFilter"
:title="t('admin.announcements.type')"
:options="typeOptions"
/>
<SingleFilter
v-model="statusFilter"
:title="t('admin.announcements.status')"
:options="statusOptions"
/>
</template>
</DataTable>
</UiCardContent>
</UiCard>
</div>
<ConfirmDialog
v-model:open="deleteDialogOpen"
destructive
:confirm-button-text="t('common.delete')"
:cancel-button-text="t('common.cancel')"
@confirm="handleDelete"
>
<template #title>
{{ t('admin.announcements.deleteAnnouncement') }}
</template>
<template #description>
{{ t('admin.announcements.deleteConfirm', { title: deleteTarget?.title }) }}
</template>
</ConfirmDialog>
<ConfirmDialog
v-model:open="batchDeleteDialogOpen"
destructive
:confirm-button-text="t('common.delete')"
:cancel-button-text="t('common.cancel')"
@confirm="handleBatchDelete"
>
<template #title>
{{ t('admin.announcements.batchDelete') }}
</template>
<template #description>
{{ t('admin.announcements.batchDeleteConfirm', { count: batchDeleteIds.length }) }}
</template>
</ConfirmDialog>
</BasicPage>
</template>
@@ -1,248 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import type { App } from './applications/data/schema'
import ConfirmDialog from '@/components/confirm-dialog.vue'
import { BasicPage } from '@/components/global-layout'
import DataTable from '@/pages/admin/applications/components/data-table.vue'
import api from '@/services/api'
const { t } = useI18n()
const router = useRouter()
const loading = ref(true)
const apps = ref<App[]>([])
const searchFilter = ref('')
const deleteDialogOpen = ref(false)
const deleteTarget = ref<App | null>(null)
const activeAppsCount = computed(() => apps.value.filter(app => app.status === 'active').length)
const totalUsers = computed(() => apps.value.reduce((sum, app) => sum + (app.users || 0), 0))
const totalVerifyCount = computed(() => apps.value.reduce((sum, app) => sum + (app.verify_count || 0), 0))
const filteredApps = computed(() => {
if (!searchFilter.value)
return apps.value
const query = searchFilter.value.toLowerCase()
return apps.value.filter(app =>
app.name.toLowerCase().includes(query)
|| app.app_key?.toLowerCase().includes(query)
|| app.description?.toLowerCase().includes(query),
)
})
async function fetchApps() {
loading.value = true
try {
const data = await api.get<{ applications: App[] }>('/dev/applications')
const applications = data?.applications || []
apps.value = applications.map((app: any) => ({
id: app.id,
name: app.name,
description: app.description,
app_key: app.app_key || '',
billing_type: app.billing_type || 'free',
login_policy: app.login_policy || 'loose',
enable_trial: app.enable_trial || false,
trial_balance: app.trial_balance || 0,
enable_free_period: app.enable_free_period || false,
free_period_start: app.free_period_start || '',
free_period_end: app.free_period_end || '',
status: app.status || 'active',
users: app.users || 0,
devices: app.devices || 0,
verify_count: app.verify_count || 0,
created_at: app.created_at || new Date(),
icon_url: app.icon_url || '',
encrypt_type: app.encrypt_type || 'none',
secret_key: app.secret_key || '',
bind_type: app.bind_type || 'device',
max_devices: app.max_devices || 1,
multi_open: app.multi_open || false,
deduction_cycle: app.deduction_cycle || 'per_use',
deduction_amount: app.deduction_amount || 1,
heartbeat_interval: app.heartbeat_interval || 60,
heartbeat_timeout: app.heartbeat_timeout || 300,
max_attempts: app.max_attempts || 5,
lock_duration: app.lock_duration || 30,
disabled_by_package: app.disabled_by_package || false,
}))
}
catch (error) {
console.error('获取应用列表失败:', error)
}
finally {
loading.value = false
}
}
function goToCreate() {
router.push('/admin/applications/create')
}
function goToSettings(app: App) {
router.push(`/admin/applications/${app.id}/settings`)
}
function goToSecurity(app: App) {
router.push(`/admin/applications/${app.id}/security`)
}
function goToEmail(app: App) {
router.push(`/admin/applications/${app.id}/email`)
}
async function toggleStatus(app: App) {
try {
const newStatus = app.status === 'active' ? 'inactive' : 'active'
await api.put(`/dev/applications/${app.id}`, { status: newStatus })
toast.success(t('admin.applications.statusUpdateSuccess'))
fetchApps()
}
catch (error: any) {
console.error('切换应用状态失败:', error)
toast.error(error.message || t('admin.applications.statusUpdateFailed'))
}
}
function confirmDeleteApp(app: App) {
deleteTarget.value = app
deleteDialogOpen.value = true
}
async function handleDeleteApp() {
if (!deleteTarget.value)
return
try {
await api.delete(`/dev/applications/${deleteTarget.value.id}`)
toast.success(t('admin.applications.deleteSuccess'))
fetchApps()
}
catch (error: any) {
console.error('删除应用失败:', error)
toast.error(error.message || t('admin.applications.deleteFailed'))
}
finally {
deleteTarget.value = null
}
}
onMounted(() => {
fetchApps()
})
</script>
<template>
<BasicPage
:title="t('admin.applications.title')"
:description="t('admin.applications.description')"
sticky
>
<template #actions>
<UiButton @click="goToCreate">
<Icon icon="lucide:plus" class="mr-2 h-4 w-4" />
{{ t('admin.applications.createApp') }}
</UiButton>
</template>
<div class="space-y-6">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.applications.totalApps') }}
</UiCardTitle>
<Icon icon="lucide:layout-grid" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ apps.length }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.applications.activeApps') }}
</UiCardTitle>
<Icon icon="lucide:check-circle" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ activeAppsCount }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.applications.totalUsers') }}
</UiCardTitle>
<Icon icon="lucide:users" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ totalUsers }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.applications.totalVerifyCount') }}
</UiCardTitle>
<Icon icon="lucide:activity" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ totalVerifyCount }}
</div>
</UiCardContent>
</UiCard>
</div>
<UiCard>
<UiCardContent class="p-6">
<DataTable
:loading
:data="filteredApps"
:search-filter="searchFilter"
@refresh="fetchApps"
@go-to-settings="goToSettings"
@go-to-security="goToSecurity"
@go-to-email="goToEmail"
@toggle-status="toggleStatus"
@delete="confirmDeleteApp"
@update:search-filter="searchFilter = $event"
/>
</UiCardContent>
</UiCard>
</div>
<ConfirmDialog
v-model:open="deleteDialogOpen"
destructive
:confirm-button-text="t('admin.applications.delete')"
:cancel-button-text="t('admin.applications.create.cancelBtn')"
@confirm="handleDeleteApp"
>
<template #title>
{{ t('admin.applications.deleteApp') }}
</template>
<template #description>
{{ t('admin.applications.deleteAppConfirm', { name: deleteTarget?.name }) }}
</template>
</ConfirmDialog>
</BasicPage>
</template>
@@ -1,484 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { toast } from 'vue-sonner'
import { BasicPage } from '@/components/global-layout'
const route = useRoute()
const API_BASE = 'http://localhost:8080/api/v1'
const loading = ref(false)
const saving = ref(false)
const testing = ref(false)
const sendingCode = ref(false)
interface Permission {
allow_email_verify: boolean
allow_password_reset: boolean
allow_custom_smtp: boolean
allow_custom_template: boolean
}
interface SMTPConfig {
id: number
host: string
port: number
user: string
from_name: string
from_email: string
use_ssl: boolean
status: string
}
interface EmailConfig {
enable_email_verify: boolean
require_email_verify: boolean
enable_password_reset: boolean
permission: Permission
smtp_config: SMTPConfig | null
}
interface EmailTemplate {
id: number
type: string
name: string
subject: string
content: string
is_default: boolean
status: string
}
const config = ref<EmailConfig | null>(null)
const templates = ref<EmailTemplate[]>([])
const form = ref({
enable_email_verify: false,
require_email_verify: false,
enable_password_reset: false,
smtp_host: '',
smtp_port: 465,
smtp_user: '',
smtp_password: '',
smtp_from_name: '',
smtp_from_email: '',
smtp_use_ssl: true,
})
const testEmail = ref('')
const sendCodeEmail = ref('')
const sendCodePurpose = ref('register')
const canUseEmailVerify = computed(() => config.value?.permission?.allow_email_verify)
const canUsePasswordReset = computed(() => config.value?.permission?.allow_password_reset)
const canUseCustomSMTP = computed(() => config.value?.permission?.allow_custom_smtp)
const canUseCustomTemplate = computed(() => config.value?.permission?.allow_custom_template)
async function fetchConfig() {
loading.value = true
try {
const token = localStorage.getItem('token')
const response = await fetch(`${API_BASE}/dev/applications/${route.params.id}/email-config`, {
headers: {
Authorization: `Bearer ${token}`,
},
})
const data = await response.json()
if (data.code === 200) {
config.value = data.data
form.value.enable_email_verify = data.data.enable_email_verify || false
form.value.require_email_verify = data.data.require_email_verify || false
form.value.enable_password_reset = data.data.enable_password_reset || false
if (data.data.smtp_config) {
form.value.smtp_host = data.data.smtp_config.host || ''
form.value.smtp_port = data.data.smtp_config.port || 465
form.value.smtp_user = data.data.smtp_config.user || ''
form.value.smtp_from_name = data.data.smtp_config.from_name || ''
form.value.smtp_from_email = data.data.smtp_config.from_email || ''
form.value.smtp_use_ssl = data.data.smtp_config.use_ssl ?? true
}
}
else {
toast.error(data.message || '获取配置失败')
}
}
catch (error) {
console.error('获取配置失败:', error)
toast.error('获取配置失败')
}
finally {
loading.value = false
}
}
async function fetchTemplates() {
try {
const token = localStorage.getItem('token')
const response = await fetch(`${API_BASE}/dev/applications/${route.params.id}/email-templates`, {
headers: {
Authorization: `Bearer ${token}`,
},
})
const data = await response.json()
if (data.code === 200) {
templates.value = data.data || []
}
}
catch (error) {
console.error('获取模板失败:', error)
}
}
async function handleSave() {
saving.value = true
try {
const token = localStorage.getItem('token')
const response = await fetch(`${API_BASE}/dev/applications/${route.params.id}/email-config`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
enable_email_verify: form.value.enable_email_verify,
require_email_verify: form.value.require_email_verify,
enable_password_reset: form.value.enable_password_reset,
smtp_config: {
host: form.value.smtp_host,
port: form.value.smtp_port,
user: form.value.smtp_user,
password: form.value.smtp_password,
from_name: form.value.smtp_from_name,
from_email: form.value.smtp_from_email,
use_ssl: form.value.smtp_use_ssl,
},
}),
})
const data = await response.json()
if (data.code === 200) {
toast.success('保存成功')
fetchConfig()
}
else {
toast.error(data.message || '保存失败')
}
}
catch (error) {
console.error('保存失败:', error)
toast.error('保存失败')
}
finally {
saving.value = false
}
}
async function handleTestEmail() {
if (!testEmail.value) {
toast.error('请输入测试邮箱地址')
return
}
testing.value = true
try {
const token = localStorage.getItem('token')
const response = await fetch(`${API_BASE}/dev/applications/${route.params.id}/email-config/test`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: testEmail.value,
}),
})
const data = await response.json()
if (data.code === 200) {
toast.success('测试邮件已发送,请检查收件箱')
}
else {
toast.error(data.message || '发送失败')
}
}
catch (error) {
console.error('发送失败:', error)
toast.error('发送失败')
}
finally {
testing.value = false
}
}
async function handleSendVerifyCode() {
if (!sendCodeEmail.value) {
toast.error('请输入邮箱地址')
return
}
sendingCode.value = true
try {
const token = localStorage.getItem('token')
const response = await fetch(`${API_BASE}/dev/applications/${route.params.id}/send-verify-code`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: sendCodeEmail.value,
purpose: sendCodePurpose.value,
}),
})
const data = await response.json()
if (data.code === 200) {
toast.success('验证码已发送')
}
else {
toast.error(data.message || '发送失败')
}
}
catch (error) {
console.error('发送失败:', error)
toast.error('发送失败')
}
finally {
sendingCode.value = false
}
}
onMounted(() => {
fetchConfig()
fetchTemplates()
})
</script>
<template>
<BasicPage
title="邮箱设置"
description="配置应用的邮箱验证相关设置"
:breadcrumbs="[
{ title: '应用管理', href: '/admin/applications' },
{ title: '邮箱设置' },
]"
sticky
>
<div v-if="loading" class="flex items-center justify-center py-12">
<Icon icon="lucide:loader-2" class="size-8 animate-spin text-muted-foreground" />
</div>
<div v-else class="space-y-6">
<UiCard>
<UiCardHeader>
<UiCardTitle>邮箱验证设置</UiCardTitle>
<UiCardDescription>配置应用的邮箱验证功能</UiCardDescription>
</UiCardHeader>
<UiCardContent class="space-y-4">
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<UiLabel>启用邮箱验证</UiLabel>
<p class="text-sm text-muted-foreground">
允许用户使用邮箱注册和验证
</p>
</div>
<UiSwitch v-model:checked="form.enable_email_verify" />
</div>
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<UiLabel>强制邮箱验证</UiLabel>
<p class="text-sm text-muted-foreground">
注册时必须验证邮箱才能完成注册
</p>
</div>
<UiSwitch v-model:checked="form.require_email_verify" :disabled="!form.enable_email_verify" />
</div>
<div v-if="canUsePasswordReset" class="flex items-center justify-between">
<div class="space-y-0.5">
<UiLabel>启用密码重置</UiLabel>
<p class="text-sm text-muted-foreground">
允许用户通过邮箱验证码重置密码
</p>
</div>
<UiSwitch v-model:checked="form.enable_password_reset" />
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader>
<UiCardTitle>SMTP 配置</UiCardTitle>
<UiCardDescription>配置邮件发送服务器</UiCardDescription>
</UiCardHeader>
<UiCardContent class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2">
<UiLabel for="smtp_host">
SMTP 服务器
</UiLabel>
<UiInput id="smtp_host" v-model="form.smtp_host" placeholder="smtp.example.com" />
</div>
<div class="space-y-2">
<UiLabel for="smtp_port">
端口
</UiLabel>
<UiInput id="smtp_port" v-model.number="form.smtp_port" type="number" placeholder="465" />
</div>
<div class="space-y-2">
<UiLabel for="smtp_user">
用户名
</UiLabel>
<UiInput id="smtp_user" v-model="form.smtp_user" placeholder="your@email.com" />
</div>
<div class="space-y-2">
<UiLabel for="smtp_password">
密码/授权码
</UiLabel>
<UiInput id="smtp_password" v-model="form.smtp_password" type="password" placeholder="••••••••" />
</div>
<div class="space-y-2">
<UiLabel for="smtp_from_name">
发件人名称
</UiLabel>
<UiInput id="smtp_from_name" v-model="form.smtp_from_name" placeholder="应用名称" />
</div>
<div class="space-y-2">
<UiLabel for="smtp_from_email">
发件人邮箱
</UiLabel>
<UiInput id="smtp_from_email" v-model="form.smtp_from_email" placeholder="noreply@example.com" />
</div>
</div>
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<UiLabel>使用 SSL</UiLabel>
<p class="text-sm text-muted-foreground">
推荐开启端口 465 通常需要 SSL
</p>
</div>
<UiSwitch v-model:checked="form.smtp_use_ssl" />
</div>
<div class="flex items-end gap-4 pt-4 border-t">
<div class="flex-1 space-y-2">
<UiLabel for="test_email">
测试邮箱
</UiLabel>
<UiInput id="test_email" v-model="testEmail" type="email" placeholder="test@example.com" />
</div>
<UiButton :disabled="testing" @click="handleTestEmail">
<Icon v-if="testing" icon="lucide:loader-2" class="mr-2 size-4 animate-spin" />
发送测试邮件
</UiButton>
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader>
<UiCardTitle>发送验证码测试</UiCardTitle>
<UiCardDescription>测试验证码发送功能</UiCardDescription>
</UiCardHeader>
<UiCardContent class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="space-y-2">
<UiLabel for="send_code_email">
邮箱地址
</UiLabel>
<UiInput id="send_code_email" v-model="sendCodeEmail" type="email" placeholder="user@example.com" />
</div>
<div class="space-y-2">
<UiLabel for="send_code_purpose">
用途
</UiLabel>
<UiSelect v-model="sendCodePurpose">
<UiSelectTrigger>
<UiSelectValue placeholder="选择用途" />
</UiSelectTrigger>
<UiSelectContent>
<UiSelectItem value="register">
注册验证
</UiSelectItem>
<UiSelectItem value="reset_password">
重置密码
</UiSelectItem>
<UiSelectItem value="change_email">
更换邮箱
</UiSelectItem>
</UiSelectContent>
</UiSelect>
</div>
<div class="flex items-end">
<UiButton :disabled="sendingCode" @click="handleSendVerifyCode">
<Icon v-if="sendingCode" icon="lucide:loader-2" class="mr-2 size-4 animate-spin" />
发送验证码
</UiButton>
</div>
</div>
</UiCardContent>
</UiCard>
<UiCard v-if="canUseCustomTemplate">
<UiCardHeader>
<div class="flex items-center justify-between">
<div>
<UiCardTitle>邮件模板</UiCardTitle>
<UiCardDescription>自定义邮件模板</UiCardDescription>
</div>
<UiButton size="sm">
<Icon icon="lucide:plus" class="mr-2 size-4" />
新建模板
</UiButton>
</div>
</UiCardHeader>
<UiCardContent>
<div v-if="templates.length === 0" class="text-center py-8 text-muted-foreground">
<Icon icon="lucide:mail" class="size-12 mx-auto mb-4 opacity-50" />
<p>暂无自定义模板将使用系统默认模板</p>
</div>
<div v-else class="space-y-4">
<div
v-for="template in templates"
:key="template.id"
class="flex items-center justify-between p-4 rounded-lg border"
>
<div>
<p class="font-medium">
{{ template.name }}
</p>
<p class="text-sm text-muted-foreground">
{{ template.subject }}
</p>
</div>
<div class="flex items-center gap-2">
<UiBadge v-if="template.is_default" variant="secondary">
默认
</UiBadge>
<UiButton variant="ghost" size="icon">
<Icon icon="lucide:pencil" class="size-4" />
</UiButton>
</div>
</div>
</div>
</UiCardContent>
</UiCard>
<div class="flex justify-end">
<UiButton :disabled="saving" @click="handleSave">
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 size-4 animate-spin" />
保存设置
</UiButton>
</div>
</div>
</BasicPage>
</template>
@@ -1,557 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { toast } from 'vue-sonner'
import { BasicPage } from '@/components/global-layout'
const route = useRoute()
const API_BASE = 'http://localhost:8080/api/v1'
const loading = ref(false)
const saving = ref(false)
interface AppData {
id: number
encrypt_type: string
secret_key: string
bind_type: string
max_devices: number
change_limit: number
change_interval: number
change_exceed_action: string
change_deduct_amount: number
multi_open_mode: string
max_instances: number
heartbeat_interval: number
heartbeat_timeout: number
max_attempts: number
lock_duration: number
}
const app = ref<AppData | null>(null)
const form = ref({
encrypt_type: 'none',
secret_key: '',
bind_type: 'none',
max_devices: 1,
change_limit: 3,
change_interval: 7,
change_exceed_action: 'deny',
change_deduct_amount: 1,
multi_open_mode: 'forbidden',
max_instances: 1,
heartbeat_interval: 60,
heartbeat_timeout: 300,
max_attempts: 5,
lock_duration: 30,
})
async function fetchApp() {
loading.value = true
try {
const token = localStorage.getItem('token')
const appId = route.params.id
const response = await fetch(`${API_BASE}/dev/applications/${appId}`, {
headers: {
Authorization: `Bearer ${token}`,
},
})
const data = await response.json()
if (data.code === 200) {
const appData = data.data.application || data.data
app.value = appData
form.value = {
encrypt_type: appData.encrypt_type || 'none',
secret_key: appData.secret_key || '',
bind_type: appData.bind_type || 'none',
max_devices: appData.max_devices || 1,
change_limit: appData.change_limit || 3,
change_interval: appData.change_interval || 7,
change_exceed_action: appData.change_exceed_action || 'deny',
change_deduct_amount: appData.change_deduct_amount || 1,
multi_open_mode: appData.multi_open_mode || 'forbidden',
max_instances: appData.max_instances || 1,
heartbeat_interval: appData.heartbeat_interval || 60,
heartbeat_timeout: appData.heartbeat_timeout || 300,
max_attempts: appData.max_attempts || 5,
lock_duration: appData.lock_duration || 30,
}
}
}
catch (error) {
console.error('获取应用详情失败:', error)
toast.error('获取应用详情失败')
}
finally {
loading.value = false
}
}
function generateSecretKey() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
let result = ''
for (let i = 0; i < 32; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
form.value.secret_key = result
}
async function handleSave() {
saving.value = true
try {
const token = localStorage.getItem('token')
const response = await fetch(`${API_BASE}/dev/applications/${route.params.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify(form.value),
})
const data = await response.json()
if (data.code === 200) {
toast.success('保存成功')
fetchApp()
}
else {
toast.error(data.message || '保存失败')
}
}
catch (error) {
console.error('保存失败:', error)
toast.error('保存失败')
}
finally {
saving.value = false
}
}
onMounted(() => {
fetchApp()
})
</script>
<template>
<BasicPage
title="安全设置"
description="配置应用的安全相关设置,包括加密方式和登录策略"
:breadcrumbs="[
{ title: '应用管理', href: '/admin/applications' },
{ title: '安全设置' },
]"
sticky
>
<div v-if="loading" class="flex items-center justify-center py-12">
<Icon icon="lucide:loader-2" class="size-8 animate-spin text-muted-foreground" />
</div>
<div v-else class="space-y-6">
<UiCard>
<UiCardHeader>
<UiCardTitle>加密设置</UiCardTitle>
<UiCardDescription>配置数据传输加密方式</UiCardDescription>
</UiCardHeader>
<UiCardContent class="space-y-4">
<div class="space-y-2">
<UiLabel>加密方式</UiLabel>
<UiRadioGroup v-model="form.encrypt_type" class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
:class="form.encrypt_type === 'none' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="form.encrypt_type = 'none'"
>
<UiRadioGroupItem value="none" class="mt-0.5" />
<div>
<p class="font-medium">
不加密
</p>
<p class="text-sm text-muted-foreground">
数据明文传输
</p>
</div>
</div>
<div
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
:class="form.encrypt_type === 'aes' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="form.encrypt_type = 'aes'"
>
<UiRadioGroupItem value="aes" class="mt-0.5" />
<div>
<p class="font-medium">
AES 加密
</p>
<p class="text-sm text-muted-foreground">
对称加密速度快
</p>
</div>
</div>
<div
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
:class="form.encrypt_type === 'rsa' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="form.encrypt_type = 'rsa'"
>
<UiRadioGroupItem value="rsa" class="mt-0.5" />
<div>
<p class="font-medium">
RSA 加密
</p>
<p class="text-sm text-muted-foreground">
非对称加密更安全
</p>
</div>
</div>
</UiRadioGroup>
</div>
<div v-if="form.encrypt_type !== 'none'" class="space-y-2 pt-4 border-t">
<div class="flex items-center justify-between">
<UiLabel for="secret_key">
加密密钥
</UiLabel>
<UiButton variant="outline" size="sm" @click="generateSecretKey">
<Icon icon="lucide:refresh-cw" class="mr-2 h-4 w-4" />
自动生成
</UiButton>
</div>
<UiInput id="secret_key" v-model="form.secret_key" placeholder="请输入加密密钥" />
<p class="text-xs text-muted-foreground">
请妥善保管密钥丢失后无法恢复
</p>
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader>
<UiCardTitle>绑定策略</UiCardTitle>
<UiCardDescription>配置应用的绑定策略</UiCardDescription>
</UiCardHeader>
<UiCardContent class="space-y-4">
<div class="space-y-2">
<UiLabel>绑定模式</UiLabel>
<UiRadioGroup v-model="form.bind_type" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
:class="form.bind_type === 'none' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="form.bind_type = 'none'"
>
<UiRadioGroupItem value="none" class="mt-0.5" />
<div>
<p class="font-medium">
不绑定
</p>
<p class="text-sm text-muted-foreground">
不限制设备或IP
</p>
</div>
</div>
<div
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
:class="form.bind_type === 'device' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="form.bind_type = 'device'"
>
<UiRadioGroupItem value="device" class="mt-0.5" />
<div>
<p class="font-medium">
绑定设备
</p>
<p class="text-sm text-muted-foreground">
绑定用户设备硬件信息
</p>
</div>
</div>
<div
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
:class="form.bind_type === 'ip' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="form.bind_type = 'ip'"
>
<UiRadioGroupItem value="ip" class="mt-0.5" />
<div>
<p class="font-medium">
绑定IP
</p>
<p class="text-sm text-muted-foreground">
绑定用户IP地址
</p>
</div>
</div>
<div
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
:class="form.bind_type === 'mixed' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="form.bind_type = 'mixed'"
>
<UiRadioGroupItem value="mixed" class="mt-0.5" />
<div>
<p class="font-medium">
混合绑定
</p>
<p class="text-sm text-muted-foreground">
同时绑定设备和IP
</p>
</div>
</div>
</UiRadioGroup>
</div>
<div v-if="form.bind_type !== 'none'" class="space-y-4 pt-4 border-t">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="space-y-2">
<UiLabel for="max_devices">
最大设备数
</UiLabel>
<UiInput id="max_devices" v-model.number="form.max_devices" type="number" min="0" />
<p class="text-xs text-muted-foreground">
用户最多可绑定的设备数量
</p>
</div>
<div class="space-y-2">
<UiLabel for="change_limit">
更换次数限制
</UiLabel>
<UiInput id="change_limit" v-model.number="form.change_limit" type="number" min="0" />
<p class="text-xs text-muted-foreground">
允许更换绑定的次数
</p>
</div>
<div class="space-y-2">
<UiLabel for="change_interval">
更换间隔
</UiLabel>
<UiInput id="change_interval" v-model.number="form.change_interval" type="number" min="0" />
<p class="text-xs text-muted-foreground">
两次更换之间的最小间隔
</p>
</div>
</div>
<div class="space-y-3 pt-4 border-t">
<UiLabel>超出更换次数处理</UiLabel>
<UiRadioGroup v-model="form.change_exceed_action" class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
:class="form.change_exceed_action === 'deny' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="form.change_exceed_action = 'deny'"
>
<UiRadioGroupItem value="deny" class="mt-0.5" />
<div>
<p class="font-medium">
拒绝更换
</p>
<p class="text-sm text-muted-foreground">
超出次数后禁止更换绑定
</p>
</div>
</div>
<div
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
:class="form.change_exceed_action === 'deduct_time' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="form.change_exceed_action = 'deduct_time'"
>
<UiRadioGroupItem value="deduct_time" class="mt-0.5" />
<div>
<p class="font-medium">
扣除时长
</p>
<p class="text-sm text-muted-foreground">
超出次数后扣除使用时长
</p>
</div>
</div>
<div
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
:class="form.change_exceed_action === 'deduct_points' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="form.change_exceed_action = 'deduct_points'"
>
<UiRadioGroupItem value="deduct_points" class="mt-0.5" />
<div>
<p class="font-medium">
扣除点数
</p>
<p class="text-sm text-muted-foreground">
超出次数后扣除点数
</p>
</div>
</div>
</UiRadioGroup>
<div v-if="form.change_exceed_action !== 'deny'" class="space-y-2 pt-2">
<UiLabel for="change_deduct_amount">
{{ form.change_exceed_action === 'deduct_time' ? '扣除时长(天)' : '扣除点数' }}
</UiLabel>
<UiInput
id="change_deduct_amount"
v-model.number="form.change_deduct_amount"
type="number"
min="1"
class="max-w-xs"
/>
<p class="text-xs text-muted-foreground">
{{ form.change_exceed_action === 'deduct_time' ? '每次超出更换将扣除的时长' : '每次超出更换将扣除的点数' }}
</p>
</div>
</div>
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader>
<UiCardTitle>多开控制</UiCardTitle>
<UiCardDescription>配置应用的多开限制</UiCardDescription>
</UiCardHeader>
<UiCardContent class="space-y-4">
<div class="space-y-2">
<UiLabel>多开模式</UiLabel>
<UiRadioGroup v-model="form.multi_open_mode" class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
:class="form.multi_open_mode === 'forbidden' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="form.multi_open_mode = 'forbidden'"
>
<UiRadioGroupItem value="forbidden" class="mt-0.5" />
<div>
<p class="font-medium">
禁止多开
</p>
<p class="text-sm text-muted-foreground">
只允许运行一个实例
</p>
</div>
</div>
<div
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
:class="form.multi_open_mode === 'limited' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="form.multi_open_mode = 'limited'"
>
<UiRadioGroupItem value="limited" class="mt-0.5" />
<div>
<p class="font-medium">
有限多开
</p>
<p class="text-sm text-muted-foreground">
限制同时运行的实例数
</p>
</div>
</div>
<div
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
:class="form.multi_open_mode === 'unlimited' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="form.multi_open_mode = 'unlimited'"
>
<UiRadioGroupItem value="unlimited" class="mt-0.5" />
<div>
<p class="font-medium">
无限多开
</p>
<p class="text-sm text-muted-foreground">
不限制实例数量
</p>
</div>
</div>
</UiRadioGroup>
</div>
<div v-if="form.multi_open_mode === 'limited'" class="space-y-2 pt-4 border-t">
<UiLabel for="max_instances">
最大实例数
</UiLabel>
<UiInput id="max_instances" v-model.number="form.max_instances" type="number" min="1" class="max-w-xs" />
<p class="text-xs text-muted-foreground">
同一账号最多可同时运行的实例数量
</p>
</div>
<div v-if="form.multi_open_mode === 'unlimited'" class="p-4 rounded-lg bg-muted mt-4">
<p class="text-sm text-muted-foreground">
允许多开模式将不限制应用实例数量请谨慎使用此选项
</p>
</div>
<div v-if="form.multi_open_mode === 'forbidden'" class="p-4 rounded-lg bg-muted mt-4">
<p class="text-sm text-muted-foreground">
禁止多开模式将只允许应用运行一个实例尝试启动第二个实例将被阻止
</p>
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader>
<UiCardTitle>心跳设置</UiCardTitle>
<UiCardDescription>配置客户端心跳检测参数</UiCardDescription>
</UiCardHeader>
<UiCardContent>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2">
<UiLabel for="heartbeat_interval">
心跳间隔
</UiLabel>
<UiInput id="heartbeat_interval" v-model.number="form.heartbeat_interval" type="number" min="10" max="300" />
<p class="text-xs text-muted-foreground">
客户端发送心跳的间隔时间
</p>
</div>
<div class="space-y-2">
<UiLabel for="heartbeat_timeout">
心跳超时
</UiLabel>
<UiInput id="heartbeat_timeout" v-model.number="form.heartbeat_timeout" type="number" min="30" max="600" />
<p class="text-xs text-muted-foreground">
超过此时间未收到心跳则判定离线
</p>
</div>
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader>
<UiCardTitle>安全防护</UiCardTitle>
<UiCardDescription>配置登录安全策略</UiCardDescription>
</UiCardHeader>
<UiCardContent>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2">
<UiLabel for="max_attempts">
最大尝试次数
</UiLabel>
<UiInput id="max_attempts" v-model.number="form.max_attempts" type="number" min="1" max="10" />
<p class="text-xs text-muted-foreground">
连续登录失败超过此次数将锁定
</p>
</div>
<div class="space-y-2">
<UiLabel for="lock_duration">
锁定时长分钟
</UiLabel>
<UiInput id="lock_duration" v-model.number="form.lock_duration" type="number" min="1" max="1440" />
<p class="text-xs text-muted-foreground">
账号被锁定后的解锁时间
</p>
</div>
</div>
</UiCardContent>
</UiCard>
<div class="flex justify-end">
<UiButton :disabled="saving" @click="handleSave">
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
保存设置
</UiButton>
</div>
</div>
</BasicPage>
</template>
@@ -1,881 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { toast } from 'vue-sonner'
import { BasicPage } from '@/components/global-layout'
const route = useRoute()
const API_BASE = 'http://localhost:8080/api/v1'
const loading = ref(false)
const saving = ref(false)
interface AppData {
id: number
name: string
description: string
icon_url: string
billing_type: string
login_policy: string
allow_register: boolean
register_methods: string
enable_trial: boolean
trial_balance: number
enable_free_period: boolean
free_period_type: string
free_period_start: string
free_period_end: string
free_period_weekdays: string
free_period_start_time: string
free_period_end_time: string
status: string
deduction_cycle: string
deduction_amount: number
}
const app = ref<AppData | null>(null)
const form = ref({
name: '',
description: '',
icon_url: '',
iconFile: null as File | null,
iconPreview: '',
billing_type: 'free',
login_policy: 'loose',
allow_register: true,
register_methods: [] as string[],
enable_trial: false,
trial_balance: 0,
trial_days: 0,
enable_free_period: false,
free_period_type: 'range',
free_period_start: '',
free_period_end: '',
free_period_weekdays: [] as number[],
free_period_start_time: '',
free_period_end_time: '',
status: 'active',
deduction_mode: 'auto',
deduction_type: 'login',
deduction_interval: 1,
deduction_unit: 'minute',
deduction_amount: 1,
})
function parseWeekdays(str: string): number[] {
if (!str)
return []
try {
const parsed = JSON.parse(str)
return Array.isArray(parsed) ? parsed : []
}
catch {
return []
}
}
function weekdaysToString(arr: number[]): string {
return JSON.stringify(arr)
}
function parseRegisterMethods(str: string): string[] {
if (!str)
return ['username']
try {
const parsed = JSON.parse(str)
return Array.isArray(parsed) ? parsed : ['username']
}
catch {
return ['username']
}
}
function registerMethodsToString(arr: string[]): string {
return JSON.stringify(arr)
}
function getIconUrl(url: string): string {
if (!url)
return ''
if (url.startsWith('http'))
return url
return `${API_BASE.replace('/api/v1', '')}${url}`
}
function handleIconUpload(event: Event) {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (file) {
form.value.iconFile = file
form.value.iconPreview = URL.createObjectURL(file)
}
}
function clearIcon() {
form.value.iconFile = null
form.value.iconPreview = ''
form.value.icon_url = ''
}
async function fetchApp() {
loading.value = true
try {
const token = localStorage.getItem('token')
const appId = route.params.id
const response = await fetch(`${API_BASE}/dev/applications/${appId}`, {
headers: {
Authorization: `Bearer ${token}`,
},
})
const data = await response.json()
if (data.code === 200) {
const appData = data.data.application || data.data
app.value = appData
form.value = {
name: appData.name || '',
description: appData.description || '',
icon_url: appData.icon_url || '',
iconFile: null,
iconPreview: getIconUrl(appData.icon_url),
billing_type: appData.billing_type || 'free',
login_policy: appData.login_policy || 'loose',
allow_register: appData.allow_register ?? true,
register_methods: parseRegisterMethods(appData.register_methods),
enable_trial: appData.enable_trial || false,
trial_balance: appData.trial_balance || 0,
trial_days: appData.trial_days || 0,
enable_free_period: appData.enable_free_period || false,
free_period_type: appData.free_period_type || 'range',
free_period_start: appData.free_period_start || '',
free_period_end: appData.free_period_end || '',
free_period_weekdays: parseWeekdays(appData.free_period_weekdays),
free_period_start_time: appData.free_period_start_time || '',
free_period_end_time: appData.free_period_end_time || '',
status: appData.status || 'active',
deduction_mode: appData.deduction_mode || 'auto',
deduction_type: appData.deduction_type || 'login',
deduction_interval: appData.deduction_interval || 1,
deduction_unit: appData.deduction_unit || 'minute',
deduction_amount: appData.deduction_amount || 1,
}
}
}
catch (error) {
console.error('获取应用详情失败:', error)
toast.error('获取应用详情失败')
}
finally {
loading.value = false
}
}
async function handleSave() {
saving.value = true
try {
const token = localStorage.getItem('token')
const formDataToSend = new FormData()
formDataToSend.append('name', form.value.name)
formDataToSend.append('description', form.value.description)
formDataToSend.append('billing_type', form.value.billing_type)
formDataToSend.append('login_policy', form.value.login_policy)
formDataToSend.append('allow_register', form.value.allow_register.toString())
formDataToSend.append('register_methods', registerMethodsToString(form.value.register_methods))
formDataToSend.append('enable_trial', form.value.enable_trial.toString())
formDataToSend.append('trial_balance', form.value.trial_balance.toString())
formDataToSend.append('trial_days', form.value.trial_days.toString())
formDataToSend.append('enable_free_period', form.value.enable_free_period.toString())
formDataToSend.append('free_period_type', form.value.free_period_type)
formDataToSend.append('free_period_start', form.value.free_period_start)
formDataToSend.append('free_period_end', form.value.free_period_end)
formDataToSend.append('free_period_weekdays', weekdaysToString(form.value.free_period_weekdays))
formDataToSend.append('free_period_start_time', form.value.free_period_start_time)
formDataToSend.append('free_period_end_time', form.value.free_period_end_time)
formDataToSend.append('status', form.value.status)
formDataToSend.append('deduction_mode', form.value.deduction_mode)
formDataToSend.append('deduction_type', form.value.deduction_type)
formDataToSend.append('deduction_interval', form.value.deduction_interval.toString())
formDataToSend.append('deduction_unit', form.value.deduction_unit)
formDataToSend.append('deduction_amount', form.value.deduction_amount.toString())
if (form.value.iconFile) {
formDataToSend.append('icon', form.value.iconFile)
}
const response = await fetch(`${API_BASE}/dev/applications/${route.params.id}`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${token}`,
},
body: formDataToSend,
})
const data = await response.json()
if (data.code === 200) {
toast.success('保存成功')
fetchApp()
}
else {
toast.error(data.message || '保存失败')
}
}
catch (error) {
console.error('保存失败:', error)
toast.error('保存失败')
}
finally {
saving.value = false
}
}
onMounted(() => {
fetchApp()
})
function toggleWeekday(index: number) {
const idx = form.value.free_period_weekdays.indexOf(index)
if (idx === -1) {
form.value.free_period_weekdays.push(index)
form.value.free_period_weekdays.sort()
}
else {
form.value.free_period_weekdays.splice(idx, 1)
}
}
</script>
<template>
<BasicPage
title="基本设置"
description="修改应用的基本信息,包括名称、描述和图标等"
:breadcrumbs="[
{ title: '应用管理', href: '/admin/applications' },
{ title: '基本设置' },
]"
sticky
>
<div v-if="loading" class="flex items-center justify-center py-12">
<Icon icon="lucide:loader-2" class="size-8 animate-spin text-muted-foreground" />
</div>
<div v-else class="space-y-6">
<UiCard>
<UiCardHeader>
<UiCardTitle>基本信息</UiCardTitle>
<UiCardDescription>修改应用的基本信息</UiCardDescription>
</UiCardHeader>
<UiCardContent class="space-y-4">
<div class="space-y-2">
<UiLabel for="name">
应用名称
</UiLabel>
<UiInput id="name" v-model="form.name" placeholder="请输入应用名称" />
</div>
<div class="space-y-2">
<UiLabel>运营状态</UiLabel>
<UiRadioGroup v-model="form.status" class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
:class="form.status === 'active' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="form.status = 'active'"
>
<UiRadioGroupItem value="active" class="mt-0.5" />
<div>
<p class="font-medium">
正常
</p>
<p class="text-sm text-muted-foreground">
应用正常运行
</p>
</div>
</div>
<div
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
:class="form.status === 'maintenance' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="form.status = 'maintenance'"
>
<UiRadioGroupItem value="maintenance" class="mt-0.5" />
<div>
<p class="font-medium">
维护
</p>
<p class="text-sm text-muted-foreground">
应用维护中
</p>
</div>
</div>
<div
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
:class="form.status === 'stopped' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="form.status = 'stopped'"
>
<UiRadioGroupItem value="stopped" class="mt-0.5" />
<div>
<p class="font-medium">
停止
</p>
<p class="text-sm text-muted-foreground">
应用已停止
</p>
</div>
</div>
</UiRadioGroup>
</div>
<div class="space-y-2">
<UiLabel>应用图标</UiLabel>
<div class="flex items-center gap-4">
<div v-if="form.iconPreview" class="relative group">
<img
:src="form.iconPreview"
alt="应用图标"
class="w-16 h-16 rounded-lg object-cover border"
>
<UiButton
variant="destructive"
size="icon"
class="absolute -top-1.5 -right-1.5 h-5 w-5 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
@click="clearIcon"
>
<Icon icon="lucide:x" class="h-3 w-3" />
</UiButton>
</div>
<label
for="appIcon"
class="flex items-center justify-center h-16 px-4 border-2 border-dashed rounded-lg cursor-pointer hover:bg-accent/50 transition-colors"
>
<Icon icon="lucide:upload" class="w-4 h-4 mr-2 text-muted-foreground" />
<span class="text-sm text-muted-foreground">{{ form.iconPreview ? '更换图标' : '上传图标' }}</span>
<input
id="appIcon"
type="file"
accept="image/*"
class="hidden"
@change="handleIconUpload"
>
</label>
</div>
<p class="text-xs text-muted-foreground">
支持 JPGPNGGIF 格式建议尺寸 512x512
</p>
</div>
<div class="space-y-2">
<UiLabel for="description">
应用描述
</UiLabel>
<UiTextarea id="description" v-model="form.description" placeholder="请输入应用描述" rows="3" />
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader>
<UiCardTitle>运营模式</UiCardTitle>
<UiCardDescription>选择应用的计费方式</UiCardDescription>
</UiCardHeader>
<UiCardContent>
<UiRadioGroup v-model="form.billing_type" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
:class="form.billing_type === 'free' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="form.billing_type = 'free'"
>
<UiRadioGroupItem value="free" class="mt-0.5" />
<div>
<p class="font-medium">
免费模式
</p>
<p class="text-sm text-muted-foreground">
用户无需付费即可使用所有功能
</p>
</div>
</div>
<div
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
:class="form.billing_type === 'subscription' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="form.billing_type = 'subscription'"
>
<UiRadioGroupItem value="subscription" class="mt-0.5" />
<div>
<p class="font-medium">
订阅模式
</p>
<p class="text-sm text-muted-foreground">
用户按周期付费订阅使用
</p>
</div>
</div>
<div
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
:class="form.billing_type === 'balance' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="form.billing_type = 'balance'"
>
<UiRadioGroupItem value="balance" class="mt-0.5" />
<div>
<p class="font-medium">
余额模式
</p>
<p class="text-sm text-muted-foreground">
用户按使用量计费从余额扣除
</p>
</div>
</div>
</UiRadioGroup>
<div v-if="form.billing_type === 'balance'" class="space-y-4 pt-4 border-t mt-4">
<div class="space-y-2">
<UiLabel>扣费方式</UiLabel>
<UiRadioGroup v-model="form.deduction_mode" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
:class="form.deduction_mode === 'auto' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="form.deduction_mode = 'auto'"
>
<UiRadioGroupItem value="auto" class="mt-0.5" />
<div>
<p class="font-medium">
自动扣费
</p>
<p class="text-sm text-muted-foreground">
系统自动触发扣费
</p>
</div>
</div>
<div
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
:class="form.deduction_mode === 'manual' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="form.deduction_mode = 'manual'"
>
<UiRadioGroupItem value="manual" class="mt-0.5" />
<div>
<p class="font-medium">
🔧 手动扣费
</p>
<p class="text-sm text-muted-foreground">
通过 API 自行控制扣费
</p>
</div>
</div>
</UiRadioGroup>
</div>
<div v-if="form.deduction_mode === 'auto'" class="space-y-4 pt-4 border-t">
<div class="space-y-2">
<UiLabel>自动扣费类型</UiLabel>
<UiRadioGroup v-model="form.deduction_type" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
:class="form.deduction_type === 'login' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="form.deduction_type = 'login'"
>
<UiRadioGroupItem value="login" class="mt-0.5" />
<div>
<p class="font-medium">
登录扣费
</p>
<p class="text-sm text-muted-foreground">
每次登录验证时扣费
</p>
</div>
</div>
<div
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
:class="form.deduction_type === 'timer' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="form.deduction_type = 'timer'"
>
<UiRadioGroupItem value="timer" class="mt-0.5" />
<div>
<p class="font-medium">
计时扣费
</p>
<p class="text-sm text-muted-foreground">
按设定的时间间隔自动扣费
</p>
</div>
</div>
</UiRadioGroup>
</div>
<div v-if="form.deduction_type === 'timer'" class="space-y-2">
<UiLabel>扣费间隔</UiLabel>
<div class="flex items-center gap-3">
<span class="text-sm text-muted-foreground"></span>
<UiNumberField
v-model="form.deduction_interval"
:min="1"
:max="9999"
:step="1"
class="w-24"
>
<UiNumberFieldContent>
<UiNumberFieldDecrement />
<UiNumberFieldInput />
<UiNumberFieldIncrement />
</UiNumberFieldContent>
</UiNumberField>
<UiSelect v-model="form.deduction_unit">
<UiSelectTrigger class="w-24">
<UiSelectValue placeholder="单位" />
</UiSelectTrigger>
<UiSelectContent>
<UiSelectItem value="minute">
分钟
</UiSelectItem>
<UiSelectItem value="hour">
小时
</UiSelectItem>
<UiSelectItem value="day">
</UiSelectItem>
</UiSelectContent>
</UiSelect>
<span class="text-sm text-muted-foreground">扣费一次</span>
</div>
</div>
<div class="space-y-2">
<UiLabel>每次扣费金额</UiLabel>
<UiNumberField
v-model="form.deduction_amount"
:min="0.01"
:max="10000"
:step="0.01"
class="max-w-xs"
>
<UiNumberFieldContent>
<UiNumberFieldDecrement />
<UiNumberFieldInput />
<UiNumberFieldIncrement />
</UiNumberFieldContent>
</UiNumberField>
<p class="text-xs text-muted-foreground">
每次扣费时扣除的余额数量
</p>
</div>
</div>
<div v-else class="p-4 rounded-lg bg-muted/50 border">
<div class="flex items-start gap-3">
<Icon icon="lucide:info" class="size-5 text-blue-500 mt-0.5" />
<div class="space-y-2">
<p class="font-medium text-sm">
手动扣费说明
</p>
<p class="text-xs text-muted-foreground">
选择手动扣费后系统不会自动扣除用户余额您需要通过 API 接口自行控制扣费时机和金额
</p>
<div class="mt-2 p-2 rounded bg-background border text-xs font-mono">
POST /api/v1/dev/applications/:id/deduct
</div>
</div>
</div>
</div>
</div>
</UiCardContent>
</UiCard>
<UiCard v-if="form.billing_type === 'subscription'">
<UiCardHeader>
<UiCardTitle>登录策略</UiCardTitle>
<UiCardDescription>设置用户登录验证策略</UiCardDescription>
</UiCardHeader>
<UiCardContent>
<UiRadioGroup v-model="form.login_policy" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
:class="form.login_policy === 'loose' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="form.login_policy = 'loose'"
>
<UiRadioGroupItem value="loose" class="mt-0.5" />
<div>
<p class="font-medium">
宽松模式
</p>
<p class="text-sm text-muted-foreground">
用户到期后仍可登录由客户端控制功能
</p>
</div>
</div>
<div
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
:class="form.login_policy === 'strict' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="form.login_policy = 'strict'"
>
<UiRadioGroupItem value="strict" class="mt-0.5" />
<div>
<p class="font-medium">
严格模式
</p>
<p class="text-sm text-muted-foreground">
用户到期后无法登录必须续费
</p>
</div>
</div>
</UiRadioGroup>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader>
<UiCardTitle>注册设置</UiCardTitle>
<UiCardDescription>配置用户注册方式和权限</UiCardDescription>
</UiCardHeader>
<UiCardContent class="space-y-4">
<div class="flex items-center justify-between">
<div>
<p class="font-medium">
允许注册
</p>
<p class="text-sm text-muted-foreground">
是否允许新用户注册账号
</p>
</div>
<UiSwitch v-model="form.allow_register" />
</div>
<div v-if="form.allow_register" class="space-y-4 pt-4 border-t">
<div class="space-y-2">
<UiLabel>注册方式</UiLabel>
<p class="text-sm text-muted-foreground mb-3">
选择允许用户使用的注册方式
</p>
<div class="space-y-3">
<div class="flex items-center space-x-3">
<UiCheckbox
id="register-username"
:checked="form.register_methods.includes('username')"
@update:checked="(checked: boolean) => {
if (checked) {
form.register_methods.push('username')
}
else {
form.register_methods = form.register_methods.filter(m => m !== 'username')
}
}"
/>
<UiLabel for="register-username" class="font-normal cursor-pointer">
用户名注册
</UiLabel>
</div>
<div class="flex items-center space-x-3">
<UiCheckbox
id="register-email"
:checked="form.register_methods.includes('email')"
@update:checked="(checked: boolean) => {
if (checked) {
form.register_methods.push('email')
}
else {
form.register_methods = form.register_methods.filter(m => m !== 'email')
}
}"
/>
<UiLabel for="register-email" class="font-normal cursor-pointer">
邮箱注册
</UiLabel>
</div>
<div class="flex items-center space-x-3">
<UiCheckbox
id="register-phone"
:checked="form.register_methods.includes('phone')"
@update:checked="(checked: boolean) => {
if (checked) {
form.register_methods.push('phone')
}
else {
form.register_methods = form.register_methods.filter(m => m !== 'phone')
}
}"
/>
<UiLabel for="register-phone" class="font-normal cursor-pointer">
手机号注册
</UiLabel>
</div>
</div>
<p class="text-xs text-muted-foreground mt-2">
至少选择一种注册方式
</p>
</div>
</div>
</UiCardContent>
</UiCard>
<UiCard v-if="form.billing_type !== 'free'">
<UiCardHeader>
<UiCardTitle>试用功能</UiCardTitle>
<UiCardDescription>为新用户提供试用体验</UiCardDescription>
</UiCardHeader>
<UiCardContent class="space-y-4">
<div class="flex items-center justify-between">
<div>
<p class="font-medium">
启用试用
</p>
<p class="text-sm text-muted-foreground">
新用户注册后自动获得试用资格
</p>
</div>
<UiSwitch v-model="form.enable_trial" />
</div>
<div v-if="form.enable_trial" class="space-y-2 pt-4 border-t">
<template v-if="form.billing_type === 'balance'">
<UiLabel>试用余额</UiLabel>
<UiNumberField
v-model="form.trial_balance"
:min="0"
:step="0.01"
class="max-w-xs"
>
<UiNumberFieldContent>
<UiNumberFieldDecrement />
<UiNumberFieldInput />
<UiNumberFieldIncrement />
</UiNumberFieldContent>
</UiNumberField>
<p class="text-xs text-muted-foreground">
新用户注册后自动获得的余额数量
</p>
</template>
<template v-else-if="form.billing_type === 'subscription'">
<UiLabel>试用天数</UiLabel>
<UiNumberField
v-model="form.trial_days"
:min="1"
:max="365"
:step="1"
class="max-w-xs"
>
<UiNumberFieldContent>
<UiNumberFieldDecrement />
<UiNumberFieldInput />
<UiNumberFieldIncrement />
</UiNumberFieldContent>
</UiNumberField>
<p class="text-xs text-muted-foreground">
新用户注册后自动获得的试用天数
</p>
</template>
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader>
<UiCardTitle>免费时段</UiCardTitle>
<UiCardDescription>在指定时间段内所有用户免费使用</UiCardDescription>
</UiCardHeader>
<UiCardContent class="space-y-4">
<div class="flex items-center justify-between">
<div>
<p class="font-medium">
启用免费时段
</p>
<p class="text-sm text-muted-foreground">
在设定的时间段内所有用户可免费使用
</p>
</div>
<UiSwitch v-model="form.enable_free_period" />
</div>
<div v-if="form.enable_free_period" class="space-y-4 pt-4 border-t">
<div class="space-y-2">
<UiLabel>免费时段类型</UiLabel>
<UiRadioGroup v-model="form.free_period_type" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
class="flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors"
:class="form.free_period_type === 'range' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="form.free_period_type = 'range'"
>
<UiRadioGroupItem value="range" class="mt-0.5" />
<div>
<p class="font-medium text-sm">
日期范围
</p>
<p class="text-xs text-muted-foreground">
指定开始和结束日期
</p>
</div>
</div>
<div
class="flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors"
:class="form.free_period_type === 'weekly' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="form.free_period_type = 'weekly'"
>
<UiRadioGroupItem value="weekly" class="mt-0.5" />
<div>
<p class="font-medium text-sm">
每周重复
</p>
<p class="text-xs text-muted-foreground">
每周指定日期和时间
</p>
</div>
</div>
</UiRadioGroup>
</div>
<div v-if="form.free_period_type === 'range'" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2">
<UiLabel>开始时间</UiLabel>
<UiInput v-model="form.free_period_start" type="datetime-local" />
</div>
<div class="space-y-2">
<UiLabel>结束时间</UiLabel>
<UiInput v-model="form.free_period_end" type="datetime-local" />
</div>
</div>
<div v-if="form.free_period_type === 'weekly'" class="space-y-4">
<div class="space-y-2">
<UiLabel>选择日期</UiLabel>
<div class="flex flex-wrap gap-2">
<UiButton
v-for="(day, index) in ['日', '一', '二', '三', '四', '五', '六']"
:key="index"
:variant="form.free_period_weekdays.includes(index) ? 'default' : 'outline'"
size="sm"
@click="toggleWeekday(index)"
>
{{ day }}
</UiButton>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2">
<UiLabel>开始时间</UiLabel>
<UiInput v-model="form.free_period_start_time" type="time" />
</div>
<div class="space-y-2">
<UiLabel>结束时间</UiLabel>
<UiInput v-model="form.free_period_end_time" type="time" />
</div>
</div>
</div>
</div>
</UiCardContent>
</UiCard>
<div class="flex justify-end">
<UiButton :disabled="saving" @click="handleSave">
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
保存设置
</UiButton>
</div>
</div>
</BasicPage>
</template>
@@ -1,206 +0,0 @@
import type { ColumnDef } from '@tanstack/vue-table'
import type { Composer } from 'vue-i18n'
import {
Ban,
CheckCircle,
Mail,
MoreHorizontal,
Settings,
Shield,
Trash2,
} from 'lucide-vue-next'
import { h } from 'vue'
import type { App } from '../data/schema'
import { Copy } from '@/components/sva-ui/copy'
import Button from '@/components/ui/button/Button.vue'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
export function getColumns(actions: {
onGoToSettings: (app: App) => void
onGoToSecurity: (app: App) => void
onGoToEmail: (app: App) => void
onToggleStatus: (app: App) => void
onDelete: (app: App) => void
}, t: Composer['t']): ColumnDef<App>[] {
const billingTypeMap: Record<string, { label: string, class: string }> = {
free: { label: '免费', class: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' },
subscription: { label: '订阅', class: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300' },
time: { label: '计时', class: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300' },
count: { label: '计次', class: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300' },
}
const loginPolicyMap: Record<string, { label: string, class: string }> = {
loose: { label: '宽松', class: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' },
strict: { label: '严格', class: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300' },
hybrid: { label: '混合', class: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300' },
}
const statusMap: Record<string, { label: string, class: string }> = {
active: { label: '启用', class: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' },
inactive: { label: '禁用', class: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300' },
}
return [
{
accessorKey: 'name',
header: () => '应用名称',
cell: ({ row }) => h('div', { class: 'flex items-center space-x-3' }, [
h('div', { class: 'h-8 w-8 rounded bg-primary/10 flex items-center justify-center flex-shrink-0 overflow-hidden' }, [
row.original.icon_url
? h('img', {
src: `http://localhost:8080${row.original.icon_url}`,
alt: row.getValue('name') as string,
class: 'w-full h-full object-cover',
})
: h('span', { class: 'text-primary font-bold text-sm' }, (row.getValue('name') as string).charAt(0).toUpperCase()),
]),
h('div', { class: 'flex flex-col' }, [
h('p', { class: 'font-medium' }, row.getValue('name') as string),
h('p', { class: 'text-xs text-muted-foreground truncate max-w-[200px]' }, row.original.description),
]),
]),
},
{
accessorKey: 'app_key',
header: () => 'App Key',
cell: ({ row }) => {
const appKey = row.getValue('app_key') as string
return h('div', { class: 'flex items-center space-x-2' }, [
h('code', { class: 'text-xs bg-muted px-2 py-1 rounded font-mono' }, appKey || '-'),
appKey && h(Copy, { class: 'h-4 w-4', size: 'sm', content: appKey }),
])
},
},
{
accessorKey: 'billing_type',
header: () => '运营模式',
cell: ({ row }) => {
const billingType = row.getValue('billing_type') as string
const info = billingTypeMap[billingType]
if (!info)
return h('span', {}, billingType)
return h('span', { class: `px-2 py-1 rounded text-xs ${info.class}` }, info.label)
},
},
{
accessorKey: 'login_policy',
header: () => '登录策略',
cell: ({ row }) => {
const loginPolicy = row.getValue('login_policy') as string
const info = loginPolicyMap[loginPolicy]
if (!info)
return h('span', {}, loginPolicy)
return h('span', { class: `px-2 py-1 rounded text-xs ${info.class}` }, info.label)
},
},
{
accessorKey: 'enable_trial',
header: () => '试用',
cell: ({ row }) => {
const enableTrial = row.getValue('enable_trial') as boolean
return h('span', {
class: `px-2 py-1 rounded text-xs ${enableTrial ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' : 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300'}`,
}, enableTrial ? `${row.original.trial_balance}余额` : '无')
},
},
{
accessorKey: 'status',
header: () => '状态',
cell: ({ row }) => {
const status = row.getValue('status') as string
const info = statusMap[status]
if (!info)
return h('span', {}, status)
return h('span', { class: `px-2 py-1 rounded text-xs ${info.class}` }, info.label)
},
},
{
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.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
}
catch {
return '-'
}
},
},
{
id: 'actions',
header: () => h('span', { class: 'sr-only' }, t('common.actions')),
cell: ({ row }) => {
const app = row.original
const isActive = app.status === 'active'
return h(
DropdownMenu,
{},
{
default: () => [
h(DropdownMenuTrigger, { asChild: true }, () =>
h(Button, { variant: 'ghost', class: 'h-8 w-8 p-0' }, () => [
h(MoreHorizontal, { class: 'h-4 w-4' }),
h('span', { class: 'sr-only' }, t('common.openMenu')),
])),
h(
DropdownMenuContent,
{ align: 'end' },
() => [
h(DropdownMenuItem, { onClick: () => actions.onGoToSettings(app) }, () => [
h(Settings, { class: 'mr-2 h-4 w-4' }),
'基本设置',
]),
h(DropdownMenuItem, { onClick: () => actions.onGoToSecurity(app) }, () => [
h(Shield, { class: 'mr-2 h-4 w-4' }),
'安全设置',
]),
h(DropdownMenuItem, { onClick: () => actions.onGoToEmail(app) }, () => [
h(Mail, { class: 'mr-2 h-4 w-4' }),
'邮箱设置',
]),
h(DropdownMenuSeparator),
h(DropdownMenuItem, { onClick: () => actions.onToggleStatus(app) }, () => [
isActive ? h(Ban, { class: 'mr-2 h-4 w-4' }) : h(CheckCircle, { class: 'mr-2 h-4 w-4' }),
isActive ? '禁用' : '启用',
]),
h(DropdownMenuSeparator),
h(DropdownMenuItem, { class: 'text-destructive', onClick: () => actions.onDelete(app) }, () => [
h(Trash2, { class: 'mr-2 h-4 w-4' }),
t('admin.applications.delete'),
]),
],
),
],
},
)
},
},
]
}
@@ -1,81 +0,0 @@
<script setup lang="ts">
import type { ColumnDef } from '@tanstack/vue-table'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { DataTableProps } from '@/components/data-table/types'
import type { App } from '@/pages/admin/applications/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/applications/components/columns'
const props = defineProps<Omit<DataTableProps<App>, 'columns'> & {
searchFilter?: string
}>()
const emit = defineEmits<{
'refresh': []
'goToSettings': [app: App]
'goToSecurity': [app: App]
'goToEmail': [app: App]
'toggleStatus': [app: App]
'delete': [app: App]
'update:searchFilter': [value: string]
}>()
const { t } = useI18n()
const columns = computed(() => [
SelectColumn as ColumnDef<App>,
...getColumns({
onGoToSettings: (app: App) => emit('goToSettings', app),
onGoToSecurity: (app: App) => emit('goToSecurity', app),
onGoToEmail: (app: App) => emit('goToEmail', app),
onToggleStatus: (app: App) => emit('toggleStatus', app),
onDelete: (app: App) => emit('delete', app),
}, t),
])
const table = generateVueTable<App>({
get data() { return props.data },
get loading() { return props.loading },
columns: columns.value,
}, columns.value)
const columnLabels: Record<string, string> = {
select: 'admin.applications.select',
name: 'admin.applications.columns.name',
app_key: 'admin.applications.columns.appKey',
billing_type: 'admin.applications.columns.billingType',
login_policy: 'admin.applications.columns.loginPolicy',
enable_trial: 'admin.applications.columns.trial',
status: 'admin.applications.columns.status',
created_at: 'admin.applications.columns.createdAt',
}
defineExpose({
table,
})
</script>
<template>
<DataTable :columns="columns" :data :loading :table @refresh="emit('refresh')">
<template #toolbar>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-2">
<UiInput
:model-value="searchFilter"
:placeholder="t('admin.applications.searchPlaceholder')"
class="h-9 w-40 lg:w-[250px]"
@update:model-value="emit('update:searchFilter', String($event))"
/>
</div>
<DataTableViewOptions :table="table" :column-labels="columnLabels" />
</div>
</template>
</DataTable>
</template>
@@ -1,652 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import { BasicPage } from '@/components/global-layout'
const { t } = useI18n()
const router = useRouter()
const API_BASE = 'http://localhost:8080/api/v1'
const loading = ref(false)
const formData = ref({
name: '',
description: '',
billing_type: 'free' as 'free' | 'subscription' | 'time' | 'count',
login_policy: 'loose' as 'loose' | 'strict' | 'hybrid',
deduction_cycle: 'per_use' as 'per_use' | 'per_minute' | 'per_hour' | 'per_day',
deduction_amount: 1,
iconFile: null as File | null,
iconPreview: '',
encrypt_type: 'none' as 'none' | 'aes' | 'rsa' | 'rc4',
secret_key: '',
bind_type: 'device' as 'device' | 'account' | 'none',
max_devices: 1,
multi_open: false,
enable_trial: false,
trial_balance: 0,
enable_free_period: false,
free_period_type: 'range' as 'range' | 'weekly',
free_period_start: '',
free_period_end: '',
free_period_weekdays: [] as number[],
free_period_start_time: '',
free_period_end_time: '',
})
function handleIconUpload(event: Event) {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (file) {
formData.value.iconFile = file
formData.value.iconPreview = URL.createObjectURL(file)
}
}
function clearIcon() {
formData.value.iconFile = null
formData.value.iconPreview = ''
}
function generateSecretKey() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
let result = ''
for (let i = 0; i < 32; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
formData.value.secret_key = result
}
function toggleWeekday(index: number) {
const idx = formData.value.free_period_weekdays.indexOf(index)
if (idx === -1) {
formData.value.free_period_weekdays.push(index)
formData.value.free_period_weekdays.sort()
}
else {
formData.value.free_period_weekdays.splice(idx, 1)
}
}
async function handleCreate() {
if (!formData.value.name.trim()) {
toast.error(t('admin.applications.appNameRequired'))
return
}
loading.value = true
try {
const token = localStorage.getItem('token')
const formDataToSend = new FormData()
formDataToSend.append('name', formData.value.name)
formDataToSend.append('description', formData.value.description)
formDataToSend.append('billing_type', formData.value.billing_type)
formDataToSend.append('login_policy', formData.value.login_policy)
formDataToSend.append('encrypt_type', formData.value.encrypt_type)
formDataToSend.append('secret_key', formData.value.secret_key)
formDataToSend.append('bind_type', formData.value.bind_type)
formDataToSend.append('max_devices', formData.value.max_devices.toString())
formDataToSend.append('multi_open', formData.value.multi_open.toString())
formDataToSend.append('enable_trial', formData.value.enable_trial.toString())
formDataToSend.append('trial_balance', formData.value.trial_balance.toString())
formDataToSend.append('enable_free_period', formData.value.enable_free_period.toString())
formDataToSend.append('free_period_type', formData.value.free_period_type)
formDataToSend.append('free_period_start', formData.value.free_period_start)
formDataToSend.append('free_period_end', formData.value.free_period_end)
formDataToSend.append('free_period_weekdays', JSON.stringify(formData.value.free_period_weekdays))
formDataToSend.append('free_period_start_time', formData.value.free_period_start_time)
formDataToSend.append('free_period_end_time', formData.value.free_period_end_time)
if (formData.value.billing_type !== 'free') {
formDataToSend.append('deduction_cycle', formData.value.deduction_cycle)
formDataToSend.append('deduction_amount', formData.value.deduction_amount.toString())
}
if (formData.value.iconFile) {
formDataToSend.append('icon', formData.value.iconFile)
}
const response = await fetch(`${API_BASE}/dev/applications`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
body: formDataToSend,
})
const data = await response.json()
if (data.code === 200) {
toast.success(t('admin.applications.createSuccess'))
router.push('/admin/applications')
}
else {
toast.error(data.message || t('admin.applications.createFailed'))
}
}
catch (error) {
console.error('创建应用失败:', error)
toast.error(t('admin.applications.createFailed'))
}
finally {
loading.value = false
}
}
</script>
<template>
<BasicPage
:title="t('admin.applications.createApp')"
:description="t('admin.applications.createAppDesc')"
:breadcrumbs="[
{ title: t('admin.applications.title'), href: '/admin/applications' },
{ title: t('admin.applications.createApp') },
]"
sticky
>
<div class="space-y-6">
<UiCard>
<UiCardHeader>
<UiCardTitle>{{ t('admin.applications.basicInfo') }}</UiCardTitle>
<UiCardDescription>{{ t('admin.applications.basicInfoDesc') }}</UiCardDescription>
</UiCardHeader>
<UiCardContent class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2">
<UiLabel for="name">
{{ t('admin.applications.appName') }} *
</UiLabel>
<UiInput id="name" v-model="formData.name" :placeholder="t('admin.applications.appNamePlaceholder')" />
</div>
<div class="space-y-2">
<UiLabel>{{ t('admin.applications.appIcon') }}</UiLabel>
<div class="flex items-center gap-4">
<div v-if="formData.iconPreview" class="relative group">
<img
:src="formData.iconPreview"
alt="应用图标"
class="w-12 h-12 rounded-lg object-cover border"
>
<UiButton
variant="destructive"
size="icon"
class="absolute -top-1.5 -right-1.5 h-5 w-5 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
@click="clearIcon"
>
<Icon icon="lucide:x" class="h-3 w-3" />
</UiButton>
</div>
<label
for="appIcon"
class="flex items-center justify-center h-12 px-4 border-2 border-dashed rounded-lg cursor-pointer hover:bg-accent/50 transition-colors"
>
<Icon icon="lucide:upload" class="w-4 h-4 mr-2 text-muted-foreground" />
<span class="text-sm text-muted-foreground">{{ formData.iconPreview ? t('admin.applications.change') : t('admin.applications.upload') }}</span>
<input
id="appIcon"
type="file"
accept="image/*"
class="hidden"
@change="handleIconUpload"
>
</label>
</div>
</div>
</div>
<div class="space-y-2">
<UiLabel for="description">
{{ t('admin.applications.appDescription') }}
</UiLabel>
<UiTextarea id="description" v-model="formData.description" :placeholder="t('admin.applications.appDescriptionPlaceholder')" rows="3" />
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader>
<UiCardTitle>{{ t('admin.applications.operationMode') }}</UiCardTitle>
<UiCardDescription>{{ t('admin.applications.operationModeDesc') }}</UiCardDescription>
</UiCardHeader>
<UiCardContent>
<UiRadioGroup v-model="formData.billing_type" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
:class="formData.billing_type === 'free' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="formData.billing_type = 'free'"
>
<UiRadioGroupItem value="free" class="mt-0.5" />
<div>
<p class="font-medium">
{{ t('admin.applications.freeMode') }}
</p>
<p class="text-sm text-muted-foreground">
{{ t('admin.applications.freeModeDesc') }}
</p>
</div>
</div>
<div
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
:class="formData.billing_type === 'subscription' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="formData.billing_type = 'subscription'"
>
<UiRadioGroupItem value="subscription" class="mt-0.5" />
<div>
<p class="font-medium">
{{ t('admin.applications.subscriptionMode') }}
</p>
<p class="text-sm text-muted-foreground">
{{ t('admin.applications.subscriptionModeDesc') }}
</p>
</div>
</div>
<div
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
:class="formData.billing_type === 'time' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="formData.billing_type = 'time'"
>
<UiRadioGroupItem value="time" class="mt-0.5" />
<div>
<p class="font-medium">
{{ t('admin.applications.timeMode') }}
</p>
<p class="text-sm text-muted-foreground">
{{ t('admin.applications.timeModeDesc') }}
</p>
</div>
</div>
<div
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
:class="formData.billing_type === 'count' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="formData.billing_type = 'count'"
>
<UiRadioGroupItem value="count" class="mt-0.5" />
<div>
<p class="font-medium">
{{ t('admin.applications.countMode') }}
</p>
<p class="text-sm text-muted-foreground">
{{ t('admin.applications.countModeDesc') }}
</p>
</div>
</div>
</UiRadioGroup>
<div v-if="formData.billing_type !== 'free'" class="mt-4 pt-4 border-t space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2">
<UiLabel>{{ t('admin.applications.deductionCycle') }}</UiLabel>
<UiSelect v-model="formData.deduction_cycle">
<UiSelectTrigger>
<UiSelectValue :placeholder="t('admin.applications.selectDeductionCycle')" />
</UiSelectTrigger>
<UiSelectContent>
<UiSelectItem value="per_use">
{{ t('admin.applications.perUse') }}
</UiSelectItem>
<UiSelectItem value="per_minute">
{{ t('admin.applications.perMinute') }}
</UiSelectItem>
<UiSelectItem value="per_hour">
{{ t('admin.applications.perHour') }}
</UiSelectItem>
<UiSelectItem value="per_day">
{{ t('admin.applications.perDay') }}
</UiSelectItem>
</UiSelectContent>
</UiSelect>
</div>
<div class="space-y-2">
<UiLabel>{{ t('admin.applications.deductionAmount') }}</UiLabel>
<UiNumberField v-model="formData.deduction_amount" :min="0" :step="0.01">
<UiNumberFieldContent>
<UiNumberFieldDecrement />
<UiNumberFieldInput />
<UiNumberFieldIncrement />
</UiNumberFieldContent>
</UiNumberField>
</div>
</div>
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader>
<UiCardTitle>{{ t('admin.applications.loginPolicy') }}</UiCardTitle>
<UiCardDescription>{{ t('admin.applications.loginPolicyDesc') }}</UiCardDescription>
</UiCardHeader>
<UiCardContent>
<UiRadioGroup v-model="formData.login_policy" class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
:class="formData.login_policy === 'loose' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="formData.login_policy = 'loose'"
>
<UiRadioGroupItem value="loose" class="mt-0.5" />
<div>
<p class="font-medium">
{{ t('admin.applications.looseMode') }}
</p>
<p class="text-sm text-muted-foreground">
{{ t('admin.applications.looseModeDesc') }}
</p>
</div>
</div>
<div
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
:class="formData.login_policy === 'strict' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="formData.login_policy = 'strict'"
>
<UiRadioGroupItem value="strict" class="mt-0.5" />
<div>
<p class="font-medium">
{{ t('admin.applications.strictMode') }}
</p>
<p class="text-sm text-muted-foreground">
{{ t('admin.applications.strictModeDesc') }}
</p>
</div>
</div>
<div
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
:class="formData.login_policy === 'hybrid' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="formData.login_policy = 'hybrid'"
>
<UiRadioGroupItem value="hybrid" class="mt-0.5" />
<div>
<p class="font-medium">
{{ t('admin.applications.hybridMode') }}
</p>
<p class="text-sm text-muted-foreground">
{{ t('admin.applications.hybridModeDesc') }}
</p>
</div>
</div>
</UiRadioGroup>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader>
<UiCardTitle>{{ t('admin.applications.trialSettings') }}</UiCardTitle>
<UiCardDescription>{{ t('admin.applications.trialSettings') }}</UiCardDescription>
</UiCardHeader>
<UiCardContent class="space-y-4">
<div class="flex items-center justify-between">
<div>
<p class="font-medium">
{{ t('admin.applications.enableTrial') }}
</p>
<p class="text-sm text-muted-foreground">
新用户注册后自动获得试用权益
</p>
</div>
<UiSwitch v-model="formData.enable_trial" />
</div>
<div v-if="formData.enable_trial" class="space-y-4 pt-4 border-t">
<div class="space-y-2">
<UiLabel>{{ t('admin.applications.trialBalance') }}</UiLabel>
<UiNumberField
v-model="formData.trial_balance"
:min="0"
:step="0.01"
class="max-w-xs"
>
<UiNumberFieldContent>
<UiNumberFieldDecrement />
<UiNumberFieldInput />
<UiNumberFieldIncrement />
</UiNumberFieldContent>
</UiNumberField>
<p class="text-xs text-muted-foreground">
新用户注册后获得的余额
</p>
</div>
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader>
<UiCardTitle>{{ t('admin.applications.freePeriodSettings') }}</UiCardTitle>
<UiCardDescription>{{ t('admin.applications.freePeriodSettings') }}</UiCardDescription>
</UiCardHeader>
<UiCardContent class="space-y-4">
<div class="flex items-center justify-between">
<div>
<p class="font-medium">
{{ t('admin.applications.enableFreePeriod') }}
</p>
<p class="text-sm text-muted-foreground">
在设定的时间段内所有用户可免费使用
</p>
</div>
<UiSwitch v-model="formData.enable_free_period" />
</div>
<div v-if="formData.enable_free_period" class="space-y-4 pt-4 border-t">
<div class="space-y-2">
<UiLabel>{{ t('admin.applications.periodType') }}</UiLabel>
<UiRadioGroup v-model="formData.free_period_type" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
:class="formData.free_period_type === 'range' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="formData.free_period_type = 'range'"
>
<UiRadioGroupItem value="range" class="mt-0.5" />
<div>
<p class="font-medium">
{{ t('admin.applications.dateRange') }}
</p>
<p class="text-sm text-muted-foreground">
指定开始和结束日期时间
</p>
</div>
</div>
<div
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
:class="formData.free_period_type === 'weekly' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="formData.free_period_type = 'weekly'"
>
<UiRadioGroupItem value="weekly" class="mt-0.5" />
<div>
<p class="font-medium">
{{ t('admin.applications.weekly') }}
</p>
<p class="text-sm text-muted-foreground">
每周固定日期和时间段
</p>
</div>
</div>
</UiRadioGroup>
</div>
<div v-if="formData.free_period_type === 'range'" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2">
<UiLabel>{{ t('admin.applications.startTime') }}</UiLabel>
<UiDatePickerDateTimePicker v-model="formData.free_period_start" :placeholder="t('admin.applications.startTime')" />
</div>
<div class="space-y-2">
<UiLabel>{{ t('admin.applications.endTime') }}</UiLabel>
<UiDatePickerDateTimePicker v-model="formData.free_period_end" :placeholder="t('admin.applications.endTime')" />
</div>
</div>
<div v-if="formData.free_period_type === 'weekly'" class="space-y-4">
<div class="space-y-2">
<UiLabel>{{ t('admin.applications.weekdays') }}</UiLabel>
<div class="flex flex-wrap gap-2">
<template v-for="(day, index) in ['周一', '周二', '周三', '周四', '周五', '周六', '周日']" :key="index">
<UiButton
variant="outline"
size="sm"
:class="formData.free_period_weekdays.includes(index) ? 'bg-primary text-primary-foreground hover:bg-primary/90' : ''"
@click="toggleWeekday(index)"
>
{{ day }}
</UiButton>
</template>
</div>
<p class="text-xs text-muted-foreground">
选择每周哪些日期免费
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2">
<UiLabel>{{ t('admin.applications.startTime') }}</UiLabel>
<UiDatePickerTimePicker v-model="formData.free_period_start_time" :placeholder="t('admin.applications.startTime')" />
<p class="text-xs text-muted-foreground">
每天免费开始时间
</p>
</div>
<div class="space-y-2">
<UiLabel>{{ t('admin.applications.endTime') }}</UiLabel>
<UiDatePickerTimePicker v-model="formData.free_period_end_time" :placeholder="t('admin.applications.endTime')" />
<p class="text-xs text-muted-foreground">
每天免费结束时间
</p>
</div>
</div>
</div>
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader>
<UiCardTitle>{{ t('admin.applications.securitySettings') }}</UiCardTitle>
<UiCardDescription>{{ t('admin.applications.securitySettingsDesc') }}</UiCardDescription>
</UiCardHeader>
<UiCardContent class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2">
<UiLabel>{{ t('admin.applications.encryptionType') }}</UiLabel>
<UiSelect v-model="formData.encrypt_type">
<UiSelectTrigger>
<UiSelectValue :placeholder="t('admin.applications.encryptionType')" />
</UiSelectTrigger>
<UiSelectContent>
<UiSelectItem value="none">
{{ t('admin.applications.noEncryption') }}
</UiSelectItem>
<UiSelectItem value="aes">
{{ t('admin.applications.aesEncryption') }}
</UiSelectItem>
<UiSelectItem value="rsa">
{{ t('admin.applications.rsaEncryption') }}
</UiSelectItem>
<UiSelectItem value="rc4">
{{ t('admin.applications.rc4Encryption') }}
</UiSelectItem>
</UiSelectContent>
</UiSelect>
<p class="text-xs text-muted-foreground">
<span v-if="formData.encrypt_type === 'none'">数据明文传输仅用于测试环境</span>
<span v-else-if="formData.encrypt_type === 'aes'">使用AES-GCM算法加密安全性高</span>
<span v-else-if="formData.encrypt_type === 'rsa'">非对称加密安全性最高</span>
<span v-else-if="formData.encrypt_type === 'rc4'">使用RC4流加密性能较好</span>
</p>
</div>
<div class="space-y-2">
<UiLabel>{{ t('admin.applications.secretKey') }}</UiLabel>
<div class="relative">
<UiInput
v-model="formData.secret_key"
type="password"
placeholder="留空则自动生成"
class="pr-10"
/>
<UiButton
variant="ghost"
size="icon"
type="button"
class="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7"
@click="generateSecretKey"
>
<Icon icon="lucide:refresh-cw" class="h-4 w-4" />
</UiButton>
</div>
<p class="text-xs text-muted-foreground">
用于加密通信数据的密钥留空则自动生成
</p>
</div>
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader>
<UiCardTitle>{{ t('admin.applications.deviceBind') }}</UiCardTitle>
<UiCardDescription>控制用户登录时是否需要绑定设备</UiCardDescription>
</UiCardHeader>
<UiCardContent class="space-y-4">
<div class="flex items-center justify-between">
<div>
<p class="font-medium">
{{ t('admin.applications.deviceBind') }}
</p>
<p class="text-sm text-muted-foreground">
启用后用户登录时需要绑定设备可限制同时登录的设备数量
</p>
</div>
<UiSwitch
:model-value="formData.bind_type === 'device'"
@update:model-value="(val: boolean) => formData.bind_type = val ? 'device' : 'none'"
/>
</div>
<div v-if="formData.bind_type === 'device'" class="space-y-2 pt-4 border-t">
<UiLabel>{{ t('admin.applications.maxDevices') }}</UiLabel>
<UiNumberField v-model="formData.max_devices" :min="1" :max="10">
<UiNumberFieldContent>
<UiNumberFieldDecrement />
<UiNumberFieldInput />
<UiNumberFieldIncrement />
</UiNumberFieldContent>
</UiNumberField>
<p class="text-xs text-muted-foreground">
每个账号最多绑定的设备数量
</p>
</div>
<div class="flex items-center justify-between pt-4 border-t">
<div>
<p class="font-medium">
{{ t('admin.applications.allowMultiOpen') }}
</p>
<p class="text-sm text-muted-foreground">
允许同一账号在同一设备上同时运行多个实例
</p>
</div>
<UiSwitch v-model="formData.multi_open" />
</div>
</UiCardContent>
</UiCard>
<div class="flex justify-end gap-3">
<UiButton variant="outline" @click="router.push('/admin/applications')">
取消
</UiButton>
<UiButton :disabled="loading" @click="handleCreate">
<Icon v-if="loading" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
{{ t('admin.applications.createApp') }}
</UiButton>
</div>
</div>
</BasicPage>
</template>
@@ -1,54 +0,0 @@
import { z } from 'zod'
export const appStatusSchema = z.enum(['active', 'inactive'])
export type AppStatus = z.infer<typeof appStatusSchema>
export const billingMethodSchema = z.enum(['subscription', 'time', 'count'])
export type BillingMethod = z.infer<typeof billingMethodSchema>
export const operationModeSchema = z.enum(['free', 'paid'])
export type OperationMode = z.infer<typeof operationModeSchema>
export const deductionCycleSchema = z.enum(['per_use', 'per_minute', 'per_hour', 'per_day'])
export type DeductionCycle = z.infer<typeof deductionCycleSchema>
export const loginPolicySchema = z.enum(['loose', 'strict', 'hybrid'])
export type LoginPolicy = z.infer<typeof loginPolicySchema>
export const appSchema = z.object({
id: z.union([z.string(), z.number()]),
name: z.string(),
description: z.string(),
app_key: z.string(),
billing_type: z.enum(['free', 'subscription', 'time', 'count']),
login_policy: loginPolicySchema.default('loose'),
encrypt_type: z.enum(['none', 'aes', 'rsa', 'rc4']),
secret_key: z.string(),
bind_type: z.enum(['device', 'account', 'none']),
max_devices: z.number().default(1),
change_limit: z.number().optional(),
change_interval: z.number().optional(),
multi_open: z.boolean().default(false),
max_instances: z.number().optional(),
enable_trial: z.boolean().default(false),
trial_balance: z.number().default(0),
enable_free_period: z.boolean().default(false),
free_period_start: z.string().optional(),
free_period_end: z.string().optional(),
deduction_cycle: deductionCycleSchema.optional(),
deduction_amount: z.number().optional(),
heartbeat_interval: z.number().default(60),
heartbeat_timeout: z.number().default(300),
max_attempts: z.number().default(5),
lock_duration: z.number().default(30),
status: appStatusSchema,
disabled_by_package: z.boolean().default(false),
icon_url: z.string().optional(),
users: z.number().optional(),
verify_count: z.number().optional(),
created_at: z.string(),
updated_at: z.string().optional(),
})
export type App = z.infer<typeof appSchema>
export const appListSchema = z.array(appSchema)
@@ -1,409 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import type { CardType } from '@/pages/admin/card-types/data/schema'
import ConfirmDialog from '@/components/confirm-dialog.vue'
import SingleFilter from '@/components/data-table/single-filter.vue'
import { BasicPage } from '@/components/global-layout'
import DataTable from '@/pages/admin/card-types/components/data-table.vue'
const { t } = useI18n()
const router = useRouter()
const API_BASE = 'http://localhost:8080/api/v1'
interface Application {
id: string
name: string
}
const loading = ref(true)
const cardTypes = ref<CardType[]>([])
const applications = ref<Application[]>([])
const tableRef = ref()
const appFilter = ref<string>('')
const statusFilter = ref<string>('')
const deleteDialogOpen = ref(false)
const deleteTarget = ref<CardType | null>(null)
const batchDeleteDialogOpen = ref(false)
const batchDeleteIds = ref<(string | number)[]>([])
const applicationOptions = computed(() => {
return applications.value.map(app => ({
label: app.name,
value: String(app.id),
}))
})
const statusOptions = computed(() => [
{ label: t('admin.cardTypes.statusOptions.active'), value: 'active' },
{ label: t('admin.cardTypes.statusOptions.inactive'), value: 'inactive' },
])
const filteredCardTypes = computed(() => {
let result = cardTypes.value
if (appFilter.value) {
result = result.filter(type => String(type.application_id) === appFilter.value)
}
if (statusFilter.value) {
result = result.filter(type => type.status === statusFilter.value)
}
return result
})
const activeTypesCount = computed(() => filteredCardTypes.value.filter(type => type.status === 'active').length)
const totalGeneratedCount = computed(() => filteredCardTypes.value.reduce((sum, type) => sum + (type.generatedCount || 0), 0))
const totalValue = computed(() => filteredCardTypes.value.reduce((sum, type) => sum + (type.price * (type.generatedCount || 0)), 0))
async function fetchCardTypes() {
loading.value = true
try {
const token = localStorage.getItem('token')
const response = await fetch(`${API_BASE}/dev/card-types`, {
headers: {
Authorization: `Bearer ${token}`,
},
})
const data = await response.json()
if (data.code === 200) {
cardTypes.value = Array.isArray(data.data?.card_types) ? data.data.card_types : []
}
else {
cardTypes.value = []
}
}
catch (error) {
console.error('获取卡密类型失败:', error)
cardTypes.value = []
}
finally {
loading.value = false
}
}
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 = Array.isArray(data.data?.applications) ? data.data.applications : []
}
else {
applications.value = []
}
}
catch (error) {
console.error('获取应用列表失败:', error)
applications.value = []
}
}
function goToCreate() {
router.push('/admin/card-types/create')
}
function goToEdit(type: CardType) {
router.push(`/admin/card-types/${type.id}`)
}
async function toggleStatus(type: CardType) {
try {
const token = localStorage.getItem('token')
const newStatus = type.status === 'active' ? 'inactive' : 'active'
const response = await fetch(`${API_BASE}/dev/card-types/${type.id}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ status: newStatus }),
})
const data = await response.json()
if (data.code === 200) {
toast.success(t('admin.cardTypes.statusUpdateSuccess'))
fetchCardTypes()
}
else {
toast.error(data.message || t('admin.cardTypes.statusUpdateFailed'))
}
}
catch (error) {
console.error('切换卡密类型状态失败:', error)
toast.error(t('admin.cardTypes.statusUpdateFailed'))
}
}
function confirmDeleteType(type: CardType) {
deleteTarget.value = type
deleteDialogOpen.value = true
}
async function handleDeleteType() {
if (!deleteTarget.value)
return
try {
const token = localStorage.getItem('token')
const response = await fetch(`${API_BASE}/dev/card-types/${deleteTarget.value.id}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
},
})
const data = await response.json()
if (data.code === 200) {
toast.success(t('admin.cardTypes.deleteSuccess'))
fetchCardTypes()
}
else {
toast.error(data.message || t('admin.cardTypes.deleteFailed'))
}
}
catch (error) {
console.error('删除卡密类型失败:', error)
toast.error(t('admin.cardTypes.deleteFailed'))
}
finally {
deleteTarget.value = null
}
}
function confirmBatchDelete(ids: (string | number)[]) {
batchDeleteIds.value = ids
batchDeleteDialogOpen.value = true
}
async function handleBatchDelete() {
try {
const token = localStorage.getItem('token')
let successCount = 0
let failCount = 0
for (const id of batchDeleteIds.value) {
const response = await fetch(`${API_BASE}/dev/card-types/${id}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
},
})
const data = await response.json()
if (data.code === 200) {
successCount++
}
else {
failCount++
}
}
if (successCount > 0) {
toast.success(t('admin.cardTypes.deleteSuccess'))
fetchCardTypes()
}
if (failCount > 0) {
toast.error(t('admin.cardTypes.deleteFailed'))
}
}
catch (error) {
console.error('批量删除失败:', error)
toast.error(t('admin.cardTypes.deleteFailed'))
}
finally {
batchDeleteIds.value = []
}
}
async function batchToggleStatus(ids: (string | number)[], status: string) {
try {
const token = localStorage.getItem('token')
let successCount = 0
let failCount = 0
for (const id of ids) {
const response = await fetch(`${API_BASE}/dev/card-types/${id}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ status }),
})
const data = await response.json()
if (data.code === 200) {
successCount++
}
else {
failCount++
}
}
if (successCount > 0) {
toast.success(t('admin.cardTypes.batchUpdateSuccess'))
fetchCardTypes()
}
if (failCount > 0) {
toast.error(t('admin.cardTypes.batchUpdateFailed'))
}
}
catch (error) {
console.error('批量更新状态失败:', error)
toast.error(t('admin.cardTypes.batchUpdateFailed'))
}
}
onMounted(() => {
fetchCardTypes()
fetchApplications()
})
</script>
<template>
<BasicPage
:title="t('admin.cardTypes.title')"
:description="t('admin.cardTypes.description')"
sticky
>
<template #actions>
<UiButton @click="goToCreate">
<Icon icon="lucide:plus" class="mr-2 h-4 w-4" />
{{ t('admin.cardTypes.createCardType') }}
</UiButton>
</template>
<div class="space-y-6">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.cardTypes.totalTypes') }}
</UiCardTitle>
<Icon icon="lucide:layers" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ filteredCardTypes.length }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.cardTypes.activeTypes') }}
</UiCardTitle>
<Icon icon="lucide:check-circle" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ activeTypesCount }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.cardTypes.generatedCards') }}
</UiCardTitle>
<Icon icon="lucide:hash" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ totalGeneratedCount }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.cardTypes.totalValue') }}
</UiCardTitle>
<Icon icon="lucide:dollar-sign" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
¥{{ totalValue }}
</div>
</UiCardContent>
</UiCard>
</div>
<UiCard>
<UiCardContent class="p-6">
<DataTable
ref="tableRef"
:loading
:data="filteredCardTypes"
:on-edit="goToEdit"
:on-toggle-status="toggleStatus"
:on-delete="confirmDeleteType"
@refresh="fetchCardTypes"
@batch-enable="batchToggleStatus(tableRef?.table?.getSelectedRowModel().rows.filter((r: any) => r.original.status === 'inactive').map((r: any) => r.original.id) || [], 'active')"
@batch-disable="batchToggleStatus(tableRef?.table?.getSelectedRowModel().rows.filter((r: any) => r.original.status === 'active').map((r: any) => r.original.id) || [], 'inactive')"
@batch-delete="confirmBatchDelete(tableRef?.table?.getSelectedRowModel().rows.map((r: any) => r.original.id) || [])"
>
<template #filters>
<SingleFilter
v-model="appFilter"
:title="t('admin.cardTypes.columns.application')"
:options="applicationOptions"
/>
<SingleFilter
v-model="statusFilter"
:title="t('admin.cardTypes.columns.status')"
:options="statusOptions"
/>
</template>
</DataTable>
</UiCardContent>
</UiCard>
</div>
<ConfirmDialog
v-model:open="deleteDialogOpen"
destructive
:confirm-button-text="t('admin.cardTypes.delete')"
:cancel-button-text="t('admin.cards.create.cancelBtn')"
@confirm="handleDeleteType"
>
<template #title>
{{ t('admin.cardTypes.delete') }}
</template>
<template #description>
{{ t('admin.cardTypes.deleteConfirm', { name: deleteTarget?.name }) }}
</template>
</ConfirmDialog>
<ConfirmDialog
v-model:open="batchDeleteDialogOpen"
destructive
:confirm-button-text="t('admin.cardTypes.delete')"
:cancel-button-text="t('admin.cards.create.cancelBtn')"
@confirm="handleBatchDelete"
>
<template #title>
{{ t('admin.cardTypes.batchDelete') }}
</template>
<template #description>
{{ t('admin.cardTypes.batchDeleteConfirm', { count: batchDeleteIds.length }) }}
</template>
</ConfirmDialog>
</BasicPage>
</template>
@@ -1,176 +0,0 @@
import type { ColumnDef } from '@tanstack/vue-table'
import type { Composer } from 'vue-i18n'
import { Ban, MoreHorizontal, Pencil, Trash2 } from 'lucide-vue-next'
import { h } from 'vue'
import type { CardType } from '@/pages/admin/card-types/data/schema'
import Badge from '@/components/ui/badge/Badge.vue'
import Button from '@/components/ui/button/Button.vue'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
export function getColumns(actions: {
onEdit: (row: CardType) => void
onToggleStatus: (row: CardType) => void
onDelete: (row: CardType) => void
}, t: Composer['t']): ColumnDef<CardType>[] {
return [
{
accessorKey: 'name',
header: () => t('admin.cardTypes.columns.name'),
cell: ({ row }) => {
return row.getValue('name') as string
},
},
{
accessorKey: 'description',
header: () => t('admin.cardTypes.columns.description'),
cell: ({ row }) => {
const description = row.getValue('description') as string
return description || '-'
},
},
{
accessorKey: 'application.name',
header: () => t('admin.cardTypes.columns.application'),
cell: ({ row }) => {
const appName = row.original.application?.name
return appName ? h(Badge, { variant: 'secondary' }, () => appName) : '-'
},
},
{
accessorKey: 'recharge_type',
header: () => '充值类型',
cell: ({ row }) => {
const rechargeType = row.original.recharge_type
const isBalance = rechargeType === 'balance'
return h(Badge, { variant: isBalance ? 'outline' : 'default' }, () => isBalance ? '余额充值' : '订阅充值')
},
},
{
accessorKey: 'price',
header: () => t('admin.cardTypes.columns.price'),
cell: ({ row }) => {
const price = row.getValue('price') as number
return `¥${price}`
},
},
{
accessorKey: 'value',
header: () => t('admin.cardTypes.columns.value'),
cell: ({ row }) => {
const value = row.getValue('value') as number
const rechargeType = row.original.recharge_type
const valueUnit = row.original.value_unit
if (value === -1) {
return h(Badge, { variant: 'default' }, () => t('admin.cardTypes.units.permanent'))
}
if (rechargeType === 'subscription' && valueUnit) {
const unitMap: Record<string, string> = {
minute: '分钟',
hour: '小时',
day: '天',
month: '月',
year: '年',
}
return `${value} ${unitMap[valueUnit] || valueUnit}`
}
return `${value}`
},
},
{
accessorKey: 'generatedCount',
header: () => t('admin.cardTypes.columns.generatedCount'),
cell: ({ row }) => {
const count = row.original.generatedCount ?? 0
return `${count} ${t('admin.cardTypes.units.cards')}`
},
},
{
accessorKey: 'status',
header: () => t('admin.cardTypes.columns.status'),
cell: ({ row }) => {
const status = row.getValue('status') as string
const isActive = status === 'active'
return h(Badge, { variant: isActive ? 'default' : 'destructive' }, () => t(`admin.cardTypes.statusOptions.${status}`))
},
},
{
accessorKey: 'created_at',
header: () => t('admin.cardTypes.columns.createdAt'),
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' }, t('common.actions')),
cell: ({ row }) => {
const cardType = row.original
const isActive = cardType.status === 'active'
return h(
DropdownMenu,
{},
{
default: () => [
h(DropdownMenuTrigger, { asChild: true }, () =>
h(Button, { variant: 'ghost', class: 'h-8 w-8 p-0' }, () => [
h(MoreHorizontal, { class: 'h-4 w-4' }),
h('span', { class: 'sr-only' }, t('common.openMenu')),
])),
h(
DropdownMenuContent,
{ align: 'end' },
() => [
h(DropdownMenuItem, { onClick: () => actions.onEdit(cardType) }, () => [
h(Pencil, { class: 'mr-2 h-4 w-4' }),
t('admin.cardTypes.edit'),
]),
h(DropdownMenuItem, { onClick: () => actions.onToggleStatus(cardType) }, () => [
h(Ban, { class: 'mr-2 h-4 w-4' }),
isActive ? t('admin.cardTypes.disable') : t('admin.cardTypes.enable'),
]),
h(DropdownMenuSeparator),
h(DropdownMenuItem, { class: 'text-destructive', onClick: () => actions.onDelete(cardType) }, () => [
h(Trash2, { class: 'mr-2 h-4 w-4' }),
t('admin.cardTypes.delete'),
]),
],
),
],
},
)
},
enableSorting: false,
enableHiding: false,
},
]
}
@@ -1,43 +0,0 @@
<script setup lang="ts">
import type { Table } from '@tanstack/vue-table'
import { X } from 'lucide-vue-next'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { CardType } from '@/pages/admin/card-types/data/schema'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
const props = defineProps<DataTableToolbarProps>()
const { t } = useI18n()
interface DataTableToolbarProps {
table: Table<CardType>
}
const isFiltered = computed(() => props.table.getState().columnFilters.length > 0)
</script>
<template>
<div class="flex items-center flex-1 space-x-2">
<Input
:placeholder="t('admin.cardTypes.searchName')"
:model-value="(table.getColumn('name')?.getFilterValue() as string) ?? ''"
class="h-8 w-[150px] lg:w-[250px]"
@input="table.getColumn('name')?.setFilterValue($event.target.value)"
/>
<Button
v-if="isFiltered"
variant="ghost"
class="h-8 px-2 lg:px-3"
@click="table.resetColumnFilters()"
>
{{ t('admin.cardTypes.reset') }}
<X class="size-4 ml-2" />
</Button>
</div>
</template>
@@ -1,94 +0,0 @@
<script setup lang="ts">
import type { ColumnDef } from '@tanstack/vue-table'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { DataTableProps } from '@/components/data-table/types'
import type { CardType } from '@/pages/admin/card-types/data/schema'
import BulkActions from '@/components/data-table/bulk-actions.vue'
import DataTable from '@/components/data-table/data-table.vue'
import { SelectColumn } from '@/components/data-table/table-columns'
import { generateVueTable } from '@/components/data-table/use-generate-vue-table'
import DataTableViewOptions from '@/components/data-table/view-options.vue'
import { getColumns } from '@/pages/admin/card-types/components/columns'
import DataTableToolbar from '@/pages/admin/card-types/components/data-table-toolbar.vue'
const props = defineProps<Omit<DataTableProps<CardType>, 'columns'> & {
onEdit: (row: CardType) => void
onToggleStatus: (row: CardType) => void
onDelete: (row: CardType) => void
}>()
const emit = defineEmits<{
refresh: []
batchEnable: []
batchDisable: []
batchDelete: []
}>()
const { t } = useI18n()
const columns = computed(() => [
SelectColumn as ColumnDef<CardType>,
...getColumns({
onEdit: props.onEdit,
onToggleStatus: props.onToggleStatus,
onDelete: props.onDelete,
}, t),
])
const table = generateVueTable<CardType>({
get data() { return props.data },
get loading() { return props.loading },
columns: columns.value,
}, columns.value)
const columnLabels: Record<string, string> = {
select: 'admin.cardTypes.select',
name: 'admin.cardTypes.columns.name',
description: 'admin.cardTypes.columns.description',
application_name: 'admin.cardTypes.columns.application',
billing_type: 'admin.cardTypes.columns.billingType',
price: 'admin.cardTypes.columns.price',
value: 'admin.cardTypes.columns.value',
generatedCount: 'admin.cardTypes.columns.generatedCount',
status: 'admin.cardTypes.columns.status',
created_at: 'admin.cardTypes.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>
<BulkActions :table="table" entity-name="cardTypes">
<UiButton variant="outline" size="sm" @click="emit('batchEnable')">
{{ t('admin.cardTypes.batchEnableBtn') }}
</UiButton>
<UiButton variant="outline" size="sm" @click="emit('batchDisable')">
{{ t('admin.cardTypes.batchDisableBtn') }}
</UiButton>
<UiButton variant="destructive" size="sm" @click="emit('batchDelete')">
{{ t('admin.cardTypes.batchDeleteBtn') }}
</UiButton>
</BulkActions>
<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,372 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import { BasicPage } from '@/components/global-layout'
import api from '@/services/api'
const router = useRouter()
const { t } = useI18n()
interface Application {
id: string
name: string
billing_type: string
}
const saving = ref(false)
const applications = ref<Application[]>([])
const form = ref({
name: '',
description: '',
application_id: '',
price: 0,
recharge_type: 'balance' as 'balance' | 'subscription',
value: 1,
value_unit: 'day' as 'day' | 'month' | 'year',
is_permanent: false,
})
async function fetchApplications() {
try {
const data = await api.get<{ applications: Application[] }>('/dev/applications')
applications.value = Array.isArray(data?.applications) ? data.applications : []
}
catch (error) {
console.error('获取应用列表失败:', error)
}
}
const selectedApplication = computed(() => {
if (form.value.application_id) {
return applications.value.find(app => String(app.id) === form.value.application_id)
}
return null
})
watch(selectedApplication, (app) => {
if (app) {
if (app.billing_type === 'subscription') {
form.value.recharge_type = 'subscription'
}
else if (app.billing_type === 'time' || app.billing_type === 'count') {
form.value.recharge_type = 'balance'
}
}
})
const displayValue = computed(() => {
if (form.value.is_permanent) {
return t('admin.cardTypes.units.permanent')
}
if (form.value.recharge_type === 'subscription') {
const unitMap: Record<string, string> = {
minute: t('admin.cardTypes.units.minute'),
hour: t('admin.cardTypes.units.hour'),
day: t('admin.cardTypes.units.day'),
month: t('admin.cardTypes.units.month'),
year: t('admin.cardTypes.units.year'),
}
return `${form.value.value} ${unitMap[form.value.value_unit]}`
}
return `${form.value.value}`
})
async function handleCreate() {
if (!form.value.name) {
toast.error(t('admin.cardTypes.create.nameRequired'))
return
}
if (!form.value.application_id) {
toast.error(t('admin.cardTypes.create.applicationRequired'))
return
}
if (!form.value.is_permanent && form.value.value <= 0) {
toast.error(t('admin.cardTypes.create.valueRequired'))
return
}
saving.value = true
try {
await api.post('/dev/card-types', {
application_id: Number(form.value.application_id),
name: form.value.name,
description: form.value.description,
recharge_type: form.value.recharge_type,
value: form.value.is_permanent ? -1 : form.value.value,
value_unit: form.value.recharge_type === 'subscription' ? form.value.value_unit : '',
price: form.value.price,
})
toast.success(t('admin.cardTypes.create.createSuccess'))
router.push('/admin/card-types')
}
catch (error: any) {
console.error('创建卡密类型失败:', error)
toast.error(error.message || t('admin.cardTypes.create.createFailed'))
}
finally {
saving.value = false
}
}
onMounted(() => {
fetchApplications()
})
</script>
<template>
<BasicPage
:title="t('admin.cardTypes.create.title')"
:description="t('admin.cardTypes.create.description')"
:breadcrumbs="[
{ title: t('admin.cardTypes.title'), href: '/admin/card-types' },
{ title: t('admin.cardTypes.create.title') },
]"
sticky
>
<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 class="flex items-center gap-2">
<Icon icon="lucide:info" class="size-5" />
{{ t('admin.cardTypes.create.basicInfo') }}
</UiCardTitle>
<UiCardDescription>{{ t('admin.cardTypes.create.basicInfoDesc') }}</UiCardDescription>
</UiCardHeader>
<UiCardContent class="space-y-6">
<div class="space-y-2">
<UiLabel for="name">
{{ t('admin.cardTypes.create.name') }} *
</UiLabel>
<UiInput id="name" v-model="form.name" :placeholder="t('admin.cardTypes.create.namePlaceholder')" />
</div>
<div class="space-y-2">
<UiLabel for="description">
{{ t('admin.cardTypes.create.description') }}
</UiLabel>
<UiTextarea id="description" v-model="form.description" :placeholder="t('admin.cardTypes.create.descriptionPlaceholder')" rows="3" />
</div>
<div class="space-y-2">
<UiLabel for="application">
{{ t('admin.cardTypes.create.application') }} *
</UiLabel>
<UiSelect v-model="form.application_id">
<UiSelectTrigger>
<UiSelectValue :placeholder="t('admin.cardTypes.create.selectApplication')" />
</UiSelectTrigger>
<UiSelectContent>
<UiSelectItem v-for="app in applications" :key="app.id" :value="String(app.id)">
{{ app.name }}
</UiSelectItem>
</UiSelectContent>
</UiSelect>
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader>
<UiCardTitle class="flex items-center gap-2">
<Icon icon="lucide:credit-card" class="size-5" />
充值配置
</UiCardTitle>
<UiCardDescription>设置卡密的充值类型和金额</UiCardDescription>
</UiCardHeader>
<UiCardContent class="space-y-6">
<div class="space-y-2">
<UiLabel>充值类型</UiLabel>
<UiRadioGroup v-model="form.recharge_type" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
:class="form.recharge_type === 'balance' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="form.recharge_type = 'balance'"
>
<UiRadioGroupItem value="balance" class="mt-0.5" />
<div>
<p class="font-medium">
余额充值
</p>
<p class="text-sm text-muted-foreground">
充值账户余额适用于计时/计次模式
</p>
</div>
</div>
<div
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
:class="form.recharge_type === 'subscription' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="form.recharge_type = 'subscription'"
>
<UiRadioGroupItem value="subscription" class="mt-0.5" />
<div>
<p class="font-medium">
订阅充值
</p>
<p class="text-sm text-muted-foreground">
充值会员时长适用于订阅模式
</p>
</div>
</div>
</UiRadioGroup>
</div>
<div class="space-y-2">
<UiLabel for="price">
{{ t('admin.cardTypes.create.price') }}
</UiLabel>
<div class="flex items-center gap-2">
<UiNumberField v-model="form.price" :min="0" :step="0.01" class="max-w-[200px]">
<UiNumberFieldContent>
<UiNumberFieldDecrement />
<UiNumberFieldInput />
<UiNumberFieldIncrement />
</UiNumberFieldContent>
</UiNumberField>
<span class="text-sm text-muted-foreground">{{ t('admin.cardTypes.create.priceUnit') }}</span>
</div>
</div>
<div class="flex items-center justify-between pt-4 border-t">
<div>
<p class="font-medium">
永久会员
</p>
<p class="text-sm text-muted-foreground">
开启后使用此卡密的用户将成为永久会员
</p>
</div>
<UiSwitch v-model="form.is_permanent" />
</div>
<div v-if="!form.is_permanent" class="space-y-4 pt-4 border-t">
<div v-if="form.recharge_type === 'balance'" class="space-y-2">
<UiLabel>充值金额</UiLabel>
<UiNumberField v-model="form.value" :min="0.01" :step="0.01" class="max-w-[200px]">
<UiNumberFieldContent>
<UiNumberFieldDecrement />
<UiNumberFieldInput />
<UiNumberFieldIncrement />
</UiNumberFieldContent>
</UiNumberField>
<p class="text-xs text-muted-foreground">
用户充值后获得的余额数量
</p>
</div>
<div v-if="form.recharge_type === 'subscription'" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<UiLabel>充值时长</UiLabel>
<UiNumberField v-model="form.value" :min="1" :step="1">
<UiNumberFieldContent>
<UiNumberFieldDecrement />
<UiNumberFieldInput />
<UiNumberFieldIncrement />
</UiNumberFieldContent>
</UiNumberField>
</div>
<div class="space-y-2">
<UiLabel>时长单位</UiLabel>
<UiSelect v-model="form.value_unit">
<UiSelectTrigger>
<UiSelectValue />
</UiSelectTrigger>
<UiSelectContent>
<UiSelectItem value="minute">
分钟
</UiSelectItem>
<UiSelectItem value="hour">
小时
</UiSelectItem>
<UiSelectItem value="day">
</UiSelectItem>
<UiSelectItem value="month">
</UiSelectItem>
<UiSelectItem value="year">
</UiSelectItem>
</UiSelectContent>
</UiSelect>
</div>
</div>
</div>
</div>
</UiCardContent>
</UiCard>
</div>
<div class="space-y-6">
<UiCard>
<UiCardHeader>
<UiCardTitle class="flex items-center gap-2">
<Icon icon="lucide:eye" class="size-5" />
{{ t('admin.cardTypes.create.preview') }}
</UiCardTitle>
</UiCardHeader>
<UiCardContent class="space-y-4">
<div class="space-y-3">
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('admin.cardTypes.create.previewName') }}</span>
<span>{{ form.name || '-' }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('admin.cardTypes.create.previewApplication') }}</span>
<span>{{ selectedApplication?.name || '-' }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">充值类型</span>
<span>{{ form.recharge_type === 'balance' ? '余额充值' : '订阅充值' }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('admin.cardTypes.create.previewPrice') }}</span>
<span>¥{{ form.price.toFixed(2) }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('admin.cardTypes.create.previewValue') }}</span>
<span>{{ displayValue }}</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.name || !form.application_id"
@click="handleCreate"
>
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
<Icon v-else icon="lucide:plus" class="mr-2 h-4 w-4" />
{{ t('admin.cardTypes.create.createNow') }}
</UiButton>
<UiButton
variant="outline"
class="w-full"
@click="router.back()"
>
{{ t('admin.cardTypes.create.cancel') }}
</UiButton>
</div>
</UiCardContent>
</UiCard>
</div>
</div>
</div>
</BasicPage>
</template>
@@ -1,25 +0,0 @@
import type { Composer } from 'vue-i18n'
import type { CardTypeStatus } from './schema'
export function getCardTypeStatuses(t: Composer['t']): { label: string, value: CardTypeStatus }[] {
return [
{ label: t('admin.cardTypes.statusOptions.active'), value: 'active' },
{ label: t('admin.cardTypes.statusOptions.inactive'), value: 'inactive' },
]
}
export function getValueLabel(): string {
return '充值金额'
}
export function getValueUnit(): string {
return ''
}
export function formatValue(value: number): string {
if (value === -1) {
return '永久'
}
return `${value}`
}
@@ -1,29 +0,0 @@
import { z } from 'zod'
export const cardTypeStatusSchema = z.enum(['active', 'inactive'])
export const rechargeTypeSchema = z.enum(['balance', 'subscription'])
export const cardTypeSchema = z.object({
id: z.union([z.string(), z.number()]),
user_id: z.union([z.string(), z.number()]),
application_id: z.union([z.string(), z.number()]).nullable(),
name: z.string(),
description: z.string(),
recharge_type: rechargeTypeSchema.default('balance'),
value: z.number(),
value_unit: z.string().optional(),
price: z.number(),
generatedCount: z.number().default(0),
status: cardTypeStatusSchema,
created_at: z.string(),
updated_at: z.string().optional(),
application: z.object({
id: z.union([z.string(), z.number()]),
name: z.string(),
}).optional().nullable(),
})
export type CardType = z.infer<typeof cardTypeSchema>
export type CardTypeStatus = z.infer<typeof cardTypeStatusSchema>
export type RechargeType = z.infer<typeof rechargeTypeSchema>
@@ -1,409 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import type { CardType } from '@/pages/admin/card-types/data/schema'
import ConfirmDialog from '@/components/confirm-dialog.vue'
import SingleFilter from '@/components/data-table/single-filter.vue'
import { BasicPage } from '@/components/global-layout'
import DataTable from '@/pages/admin/card-types/components/data-table.vue'
const { t } = useI18n()
const router = useRouter()
const API_BASE = 'http://localhost:8080/api/v1'
interface Application {
id: string
name: string
}
const loading = ref(true)
const cardTypes = ref<CardType[]>([])
const applications = ref<Application[]>([])
const tableRef = ref()
const appFilter = ref<string>('')
const statusFilter = ref<string>('')
const deleteDialogOpen = ref(false)
const deleteTarget = ref<CardType | null>(null)
const batchDeleteDialogOpen = ref(false)
const batchDeleteIds = ref<(string | number)[]>([])
const applicationOptions = computed(() => {
return applications.value.map(app => ({
label: app.name,
value: String(app.id),
}))
})
const statusOptions = computed(() => [
{ label: t('admin.cardTypes.statusOptions.active'), value: 'active' },
{ label: t('admin.cardTypes.statusOptions.inactive'), value: 'inactive' },
])
const filteredCardTypes = computed(() => {
let result = cardTypes.value
if (appFilter.value) {
result = result.filter(type => String(type.application_id) === appFilter.value)
}
if (statusFilter.value) {
result = result.filter(type => type.status === statusFilter.value)
}
return result
})
const activeTypesCount = computed(() => filteredCardTypes.value.filter(type => type.status === 'active').length)
const totalGeneratedCount = computed(() => filteredCardTypes.value.reduce((sum, type) => sum + (type.generatedCount || 0), 0))
const totalValue = computed(() => filteredCardTypes.value.reduce((sum, type) => sum + (type.price * (type.generatedCount || 0)), 0))
async function fetchCardTypes() {
loading.value = true
try {
const token = localStorage.getItem('token')
const response = await fetch(`${API_BASE}/dev/card-types`, {
headers: {
Authorization: `Bearer ${token}`,
},
})
const data = await response.json()
if (data.code === 200) {
cardTypes.value = Array.isArray(data.data?.card_types) ? data.data.card_types : []
}
else {
cardTypes.value = []
}
}
catch (error) {
console.error('获取卡密类型失败:', error)
cardTypes.value = []
}
finally {
loading.value = false
}
}
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 = Array.isArray(data.data?.applications) ? data.data.applications : []
}
else {
applications.value = []
}
}
catch (error) {
console.error('获取应用列表失败:', error)
applications.value = []
}
}
function goToCreate() {
router.push('/admin/card-types/create')
}
function goToEdit(type: CardType) {
router.push(`/admin/card-types/${type.id}`)
}
async function toggleStatus(type: CardType) {
try {
const token = localStorage.getItem('token')
const newStatus = type.status === 'active' ? 'inactive' : 'active'
const response = await fetch(`${API_BASE}/dev/card-types/${type.id}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ status: newStatus }),
})
const data = await response.json()
if (data.code === 200) {
toast.success(t('admin.cardTypes.statusUpdateSuccess'))
fetchCardTypes()
}
else {
toast.error(data.message || t('admin.cardTypes.statusUpdateFailed'))
}
}
catch (error) {
console.error('切换卡密类型状态失败:', error)
toast.error(t('admin.cardTypes.statusUpdateFailed'))
}
}
function confirmDeleteType(type: CardType) {
deleteTarget.value = type
deleteDialogOpen.value = true
}
async function handleDeleteType() {
if (!deleteTarget.value)
return
try {
const token = localStorage.getItem('token')
const response = await fetch(`${API_BASE}/dev/card-types/${deleteTarget.value.id}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
},
})
const data = await response.json()
if (data.code === 200) {
toast.success(t('admin.cardTypes.deleteSuccess'))
fetchCardTypes()
}
else {
toast.error(data.message || t('admin.cardTypes.deleteFailed'))
}
}
catch (error) {
console.error('删除卡密类型失败:', error)
toast.error(t('admin.cardTypes.deleteFailed'))
}
finally {
deleteTarget.value = null
}
}
function confirmBatchDelete(ids: (string | number)[]) {
batchDeleteIds.value = ids
batchDeleteDialogOpen.value = true
}
async function handleBatchDelete() {
try {
const token = localStorage.getItem('token')
let successCount = 0
let failCount = 0
for (const id of batchDeleteIds.value) {
const response = await fetch(`${API_BASE}/dev/card-types/${id}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
},
})
const data = await response.json()
if (data.code === 200) {
successCount++
}
else {
failCount++
}
}
if (successCount > 0) {
toast.success(t('admin.cardTypes.deleteSuccess'))
fetchCardTypes()
}
if (failCount > 0) {
toast.error(t('admin.cardTypes.deleteFailed'))
}
}
catch (error) {
console.error('批量删除失败:', error)
toast.error(t('admin.cardTypes.deleteFailed'))
}
finally {
batchDeleteIds.value = []
}
}
async function batchToggleStatus(ids: (string | number)[], status: string) {
try {
const token = localStorage.getItem('token')
let successCount = 0
let failCount = 0
for (const id of ids) {
const response = await fetch(`${API_BASE}/dev/card-types/${id}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ status }),
})
const data = await response.json()
if (data.code === 200) {
successCount++
}
else {
failCount++
}
}
if (successCount > 0) {
toast.success(t('admin.cardTypes.batchUpdateSuccess'))
fetchCardTypes()
}
if (failCount > 0) {
toast.error(t('admin.cardTypes.batchUpdateFailed'))
}
}
catch (error) {
console.error('批量更新状态失败:', error)
toast.error(t('admin.cardTypes.batchUpdateFailed'))
}
}
onMounted(() => {
fetchCardTypes()
fetchApplications()
})
</script>
<template>
<BasicPage
:title="t('admin.cardTypes.title')"
:description="t('admin.cardTypes.description')"
sticky
>
<template #actions>
<UiButton @click="goToCreate">
<Icon icon="lucide:plus" class="mr-2 h-4 w-4" />
{{ t('admin.cardTypes.createCardType') }}
</UiButton>
</template>
<div class="space-y-6">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.cardTypes.totalTypes') }}
</UiCardTitle>
<Icon icon="lucide:layers" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ filteredCardTypes.length }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.cardTypes.activeTypes') }}
</UiCardTitle>
<Icon icon="lucide:check-circle" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ activeTypesCount }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.cardTypes.generatedCards') }}
</UiCardTitle>
<Icon icon="lucide:hash" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ totalGeneratedCount }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.cardTypes.totalValue') }}
</UiCardTitle>
<Icon icon="lucide:dollar-sign" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
¥{{ totalValue }}
</div>
</UiCardContent>
</UiCard>
</div>
<UiCard>
<UiCardContent class="p-6">
<DataTable
ref="tableRef"
:loading
:data="filteredCardTypes"
:on-edit="goToEdit"
:on-toggle-status="toggleStatus"
:on-delete="confirmDeleteType"
@refresh="fetchCardTypes"
@batch-enable="batchToggleStatus(tableRef?.table?.getSelectedRowModel().rows.filter((r: any) => r.original.status === 'inactive').map((r: any) => r.original.id) || [], 'active')"
@batch-disable="batchToggleStatus(tableRef?.table?.getSelectedRowModel().rows.filter((r: any) => r.original.status === 'active').map((r: any) => r.original.id) || [], 'inactive')"
@batch-delete="confirmBatchDelete(tableRef?.table?.getSelectedRowModel().rows.map((r: any) => r.original.id) || [])"
>
<template #filters>
<SingleFilter
v-model="appFilter"
:title="t('admin.cardTypes.columns.application')"
:options="applicationOptions"
/>
<SingleFilter
v-model="statusFilter"
:title="t('admin.cardTypes.columns.status')"
:options="statusOptions"
/>
</template>
</DataTable>
</UiCardContent>
</UiCard>
</div>
<ConfirmDialog
v-model:open="deleteDialogOpen"
destructive
:confirm-button-text="t('admin.cardTypes.delete')"
:cancel-button-text="t('admin.cards.create.cancelBtn')"
@confirm="handleDeleteType"
>
<template #title>
{{ t('admin.cardTypes.delete') }}
</template>
<template #description>
{{ t('admin.cardTypes.deleteConfirm', { name: deleteTarget?.name }) }}
</template>
</ConfirmDialog>
<ConfirmDialog
v-model:open="batchDeleteDialogOpen"
destructive
:confirm-button-text="t('admin.cardTypes.delete')"
:cancel-button-text="t('admin.cards.create.cancelBtn')"
@confirm="handleBatchDelete"
>
<template #title>
{{ t('admin.cardTypes.batchDelete') }}
</template>
<template #description>
{{ t('admin.cardTypes.batchDeleteConfirm', { count: batchDeleteIds.length }) }}
</template>
</ConfirmDialog>
</BasicPage>
</template>
@@ -1,202 +0,0 @@
import type { ColumnDef } from '@tanstack/vue-table'
import type { Composer } from 'vue-i18n'
import { Ban, MoreHorizontal, Trash2 } from 'lucide-vue-next'
import { h } from 'vue'
import type { Card } from '@/pages/admin/cards/data/schema'
import { Copy } from '@/components/sva-ui/copy'
import Badge from '@/components/ui/badge/Badge.vue'
import Button from '@/components/ui/button/Button.vue'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
export function getColumns(actions: {
onToggleStatus: (row: Card) => void
onDelete: (row: Card) => void
}, t: Composer['t']): ColumnDef<Card>[] {
return [
{
accessorKey: 'card_key',
header: () => t('admin.cards.columns.cardKey'),
cell: ({ row }) => {
const cardKey = row.getValue('card_key') as string
if (!cardKey)
return '-'
return h('div', { class: 'flex items-center space-x-2' }, [
h('code', { class: 'text-xs bg-muted px-2 py-1 rounded font-mono' }, cardKey),
h(Copy, { class: 'h-4 w-4', size: 'sm', content: cardKey }),
])
},
},
{
accessorKey: 'application.name',
header: () => t('admin.cards.columns.application'),
cell: ({ row }) => {
const appName = row.original.application?.name
return appName ? h(Badge, { variant: 'secondary' }, () => appName) : '-'
},
},
{
accessorKey: 'card_type.name',
header: () => t('admin.cards.columns.type'),
cell: ({ row }) => {
const typeName = row.original.card_type?.name
return typeName ? h(Badge, { variant: 'outline' }, () => typeName) : '-'
},
},
{
accessorKey: 'card_type.price',
header: () => t('admin.cards.columns.faceValue'),
cell: ({ row }) => {
const price = row.original.card_type?.price
return price != null ? `¥${price}` : '-'
},
},
{
accessorKey: 'card_type.value',
header: () => t('admin.cards.columns.duration'),
cell: ({ row }) => {
const value = row.original.card_type?.value
const billingType = row.original.card_type?.billing_type
if (value == null)
return '-'
if (value === -1)
return t('admin.cards.columns.permanent')
if (billingType === 'subscription')
return `${value}${t('admin.cards.columns.hours')}`
if (billingType === 'timer')
return `${value}${t('admin.cards.columns.minutes')}`
if (billingType === 'points')
return `${value}${t('admin.cards.columns.points')}`
return String(value)
},
},
{
accessorKey: 'status',
header: () => t('admin.cards.columns.status'),
cell: ({ row }) => {
const status = row.getValue('status') as string
if (!status || status === '') {
return h(Badge, { variant: 'default' }, () => t('admin.cards.unused'))
}
const statusMap: Record<string, { text: string, variant: any }> = {
unused: { text: t('admin.cards.unused'), variant: 'default' },
used: { text: t('admin.cards.used'), variant: 'secondary' },
banned: { text: t('admin.cards.banned'), variant: 'destructive' },
}
const { text, variant } = statusMap[status] || { text: status, variant: 'default' }
return h(Badge, { variant }, () => text)
},
},
{
accessorKey: 'app_user.username',
header: () => t('admin.cards.columns.usedUser'),
cell: ({ row }) => {
const username = row.original.app_user?.username
return username || '-'
},
},
{
accessorKey: 'used_at',
header: () => t('admin.cards.columns.usedTime'),
cell: ({ row }) => {
const usedAt = row.getValue('used_at')
if (!usedAt)
return '-'
try {
const date = new Date(usedAt 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 '-'
}
},
},
{
accessorKey: 'creator.username',
header: () => t('admin.cards.columns.creator'),
cell: ({ row }) => {
const username = row.original.creator?.username
return username || '-'
},
},
{
accessorKey: 'created_at',
header: () => t('admin.cards.columns.createdAt'),
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' }, t('common.actions')),
cell: ({ row }) => {
const card = row.original
const isBanned = card.status === 'banned'
return h(
DropdownMenu,
{},
{
default: () => [
h(DropdownMenuTrigger, { asChild: true }, () =>
h(Button, { variant: 'ghost', class: 'h-8 w-8 p-0' }, () => [
h(MoreHorizontal, { class: 'h-4 w-4' }),
h('span', { class: 'sr-only' }, t('common.openMenu')),
])),
h(
DropdownMenuContent,
{ align: 'end' },
() => [
h(DropdownMenuItem, { onClick: () => actions.onToggleStatus(card) }, () => [
h(Ban, { class: 'mr-2 h-4 w-4' }),
isBanned ? t('admin.cards.enable') : t('admin.cards.disable'),
]),
h(DropdownMenuSeparator),
h(DropdownMenuItem, { class: 'text-destructive', onClick: () => actions.onDelete(card) }, () => [
h(Trash2, { class: 'mr-2 h-4 w-4' }),
t('admin.cards.delete'),
]),
],
),
],
},
)
},
enableSorting: false,
enableHiding: false,
},
]
}
@@ -1,52 +0,0 @@
<script setup lang="ts">
import type { Table } from '@tanstack/vue-table'
import { X } from 'lucide-vue-next'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Card } from '@/pages/admin/cards/data/schema'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
const props = defineProps<DataTableToolbarProps>()
const emit = defineEmits<{
'update:searchFilter': [value: string]
}>()
const { t } = useI18n()
interface DataTableToolbarProps {
table: Table<Card>
searchFilter?: string
}
const isFiltered = computed(() => !!props.searchFilter)
function resetFilters() {
emit('update:searchFilter', '')
}
</script>
<template>
<div class="flex items-center flex-1 space-x-2">
<Input
:placeholder="t('admin.cards.searchAll')"
:model-value="searchFilter"
class="h-8 w-[200px] lg:w-[300px]"
@update:model-value="emit('update:searchFilter', $event as string)"
/>
<Button
v-if="isFiltered"
variant="ghost"
class="h-8 px-2 lg:px-3"
@click="resetFilters"
>
{{ t('admin.cards.reset') }}
<X class="size-4 ml-2" />
</Button>
</div>
</template>
@@ -1,109 +0,0 @@
<script setup lang="ts">
import type { ColumnDef } from '@tanstack/vue-table'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { DataTableProps } from '@/components/data-table/types'
import type { Card } from '@/pages/admin/cards/data/schema'
import BulkActions from '@/components/data-table/bulk-actions.vue'
import DataTable from '@/components/data-table/data-table.vue'
import { SelectColumn } from '@/components/data-table/table-columns'
import { generateVueTable } from '@/components/data-table/use-generate-vue-table'
import DataTableViewOptions from '@/components/data-table/view-options.vue'
import { getColumns } from '@/pages/admin/cards/components/columns'
import DataTableToolbar from '@/pages/admin/cards/components/data-table-toolbar.vue'
const props = defineProps<Omit<DataTableProps<Card>, 'columns'> & {
onToggleStatus: (row: Card) => void
onDelete: (row: Card) => void
searchFilter?: string
}>()
const emit = defineEmits<{
'refresh': []
'batchDisable': []
'batchEnable': []
'batchDelete': []
'batchExport': []
'export': []
'update:searchFilter': [value: string]
}>()
const { t } = useI18n()
const columns = computed(() => [
SelectColumn as ColumnDef<Card>,
...getColumns({
onToggleStatus: props.onToggleStatus,
onDelete: props.onDelete,
}, t),
])
const table = generateVueTable<Card>({
get data() { return props.data },
get loading() { return props.loading },
columns: columns.value,
}, columns.value)
const columnLabels: Record<string, string> = {
select: 'admin.cards.select',
card_key: 'admin.cards.columns.cardKey',
application_name: 'admin.cards.columns.application',
card_type_name: 'admin.cards.columns.type',
card_type_price: 'admin.cards.columns.faceValue',
card_type_value: 'admin.cards.columns.duration',
status: 'admin.cards.columns.status',
app_user_username: 'admin.cards.columns.usedUser',
used_at: 'admin.cards.columns.usedTime',
creator_username: 'admin.cards.columns.creator',
created_at: 'admin.cards.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>
<BulkActions :table="table" entity-name="cards">
<UiButton variant="outline" size="sm" @click="emit('batchEnable')">
{{ t('admin.cards.batchEnableBtn') }}
</UiButton>
<UiButton variant="outline" size="sm" @click="emit('batchDisable')">
{{ t('admin.cards.batchDisableBtn') }}
</UiButton>
<UiButton variant="outline" size="sm" @click="emit('batchExport')">
{{ t('admin.cards.batchExportBtn') }}
</UiButton>
<UiButton variant="destructive" size="sm" @click="emit('batchDelete')">
{{ t('admin.cards.batchDeleteBtn') }}
</UiButton>
</BulkActions>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-2">
<DataTableToolbar
:table
:search-filter="searchFilter"
@update:search-filter="emit('update:searchFilter', $event)"
/>
<slot name="filters" />
</div>
<div class="flex items-center gap-2">
<UiButton variant="outline" size="sm" @click="emit('export')">
{{ t('admin.cards.exportBtn') }}
</UiButton>
<DataTableViewOptions :table="table" :column-labels="columnLabels" />
</div>
</div>
</div>
</template>
</DataTable>
</template>
@@ -1,279 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import { BasicPage } from '@/components/global-layout'
import api from '@/services/api'
const router = useRouter()
const { t } = useI18n()
interface CardType {
id: string
name: string
billing_type: string
price: number
value: number
application_id: string
}
interface Application {
id: string
name: string
}
const saving = ref(false)
const applications = ref<Application[]>([])
const cardTypes = ref<CardType[]>([])
const form = ref({
application_id: '',
card_type_id: '',
count: 1,
})
async function fetchApplications() {
try {
const data = await api.get<{ applications: Application[] }>('/dev/applications')
applications.value = Array.isArray(data?.applications) ? data.applications : []
}
catch (error) {
console.error('获取应用列表失败:', error)
}
}
async function fetchCardTypes() {
try {
const data = await api.get<{ card_types: CardType[] }>('/dev/card-types')
cardTypes.value = Array.isArray(data?.card_types) ? data.card_types : []
}
catch (error) {
console.error('获取卡类列表失败:', error)
}
}
const filteredCardTypes = computed(() => {
if (form.value.application_id) {
return cardTypes.value.filter(
ct => String(ct.application_id) === form.value.application_id,
)
}
return []
})
const selectedCardType = computed(() => {
if (form.value.card_type_id) {
return cardTypes.value.find(ct => String(ct.id) === form.value.card_type_id)
}
return null
})
const totalCost = computed(() => {
if (selectedCardType.value) {
return selectedCardType.value.price * form.value.count
}
return 0
})
function onApplicationChange() {
form.value.card_type_id = ''
}
async function handleGenerate() {
if (!form.value.application_id) {
toast.error(t('admin.cards.create.selectAppFirst'))
return
}
if (!form.value.card_type_id) {
toast.error(t('admin.cards.create.selectCardTypeFirst'))
return
}
if (form.value.count < 1 || form.value.count > 1000) {
toast.error(t('admin.cards.create.countRange'))
return
}
saving.value = true
try {
await api.post('/dev/cards/batch', {
application_id: Number(form.value.application_id),
card_type_id: Number(form.value.card_type_id),
count: form.value.count,
})
toast.success(t('admin.cards.create.generateSuccess', { count: form.value.count }))
router.push('/admin/cards')
}
catch (error: any) {
console.error('生成卡密失败:', error)
toast.error(error.message || t('admin.cards.create.generateFailed'))
}
finally {
saving.value = false
}
}
onMounted(() => {
fetchApplications()
fetchCardTypes()
})
</script>
<template>
<BasicPage
:title="t('admin.cards.create.title')"
:description="t('admin.cards.create.description')"
:breadcrumbs="[
{ title: t('admin.cards.title'), href: '/admin/cards' },
{ title: t('admin.cards.create.title') },
]"
sticky
>
<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 class="flex items-center gap-2">
<Icon icon="lucide:settings-2" class="size-5" />
{{ t('admin.cards.create.config') }}
</UiCardTitle>
<UiCardDescription>{{ t('admin.cards.create.configDesc') }}</UiCardDescription>
</UiCardHeader>
<UiCardContent class="space-y-6">
<div class="space-y-2">
<UiLabel for="application">
{{ t('admin.cards.create.application') }}
</UiLabel>
<UiSelect v-model="form.application_id" @update:model-value="onApplicationChange">
<UiSelectTrigger>
<UiSelectValue :placeholder="t('admin.cards.create.selectApplication')" />
</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="cardType">
{{ t('admin.cards.create.cardType') }}
</UiLabel>
<UiSelect v-model="form.card_type_id" :disabled="!form.application_id">
<UiSelectTrigger>
<UiSelectValue :placeholder="t('admin.cards.create.selectCardType')" />
</UiSelectTrigger>
<UiSelectContent>
<UiSelectItem v-for="type in filteredCardTypes" :key="type.id" :value="String(type.id)">
{{ type.name }}
</UiSelectItem>
</UiSelectContent>
</UiSelect>
<p v-if="form.application_id && filteredCardTypes.length === 0" class="text-xs text-muted-foreground">
{{ t('admin.cards.create.noCardType') }}
</p>
</div>
<div class="space-y-2">
<UiLabel for="count">
{{ t('admin.cards.create.count') }}
</UiLabel>
<div class="flex items-center gap-4">
<UiNumberField v-model="form.count" :min="1" :max="1000" class="flex-1 max-w-[200px]">
<UiNumberFieldContent>
<UiNumberFieldDecrement />
<UiNumberFieldInput />
<UiNumberFieldIncrement />
</UiNumberFieldContent>
</UiNumberField>
<div class="flex gap-2">
<UiButton variant="outline" size="sm" @click="form.count = 10">
10
</UiButton>
<UiButton variant="outline" size="sm" @click="form.count = 50">
50
</UiButton>
<UiButton variant="outline" size="sm" @click="form.count = 100">
100
</UiButton>
<UiButton variant="outline" size="sm" @click="form.count = 500">
500
</UiButton>
</div>
</div>
<p class="text-xs text-muted-foreground">
{{ t('admin.cards.create.maxCount') }}
</p>
</div>
</UiCardContent>
</UiCard>
</div>
<div class="space-y-6">
<UiCard>
<UiCardHeader>
<UiCardTitle class="flex items-center gap-2">
<Icon icon="lucide:receipt" class="size-5" />
{{ t('admin.cards.create.preview') }}
</UiCardTitle>
</UiCardHeader>
<UiCardContent class="space-y-4">
<div class="space-y-3">
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('admin.cards.create.app') }}</span>
<span>{{ applications.find(a => String(a.id) === form.application_id)?.name || '-' }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('admin.cards.create.type') }}</span>
<span>{{ selectedCardType?.name || '-' }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('admin.cards.create.unitPrice') }}</span>
<span>¥{{ selectedCardType?.price?.toFixed(2) || '0.00' }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('admin.cards.create.quantity') }}</span>
<span>{{ form.count }} {{ t('admin.cards.create.units') }}</span>
</div>
<div class="border-t pt-3 mt-3">
<div class="flex justify-between items-center">
<span class="text-muted-foreground">{{ t('admin.cards.create.totalAmount') }}</span>
<span class="text-xl font-bold text-primary">¥{{ totalCost.toFixed(2) }}</span>
</div>
</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.application_id || !form.card_type_id"
@click="handleGenerate"
>
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
<Icon v-else icon="lucide:sparkles" class="mr-2 h-4 w-4" />
{{ t('admin.cards.create.generateNow') }}
</UiButton>
<UiButton
variant="outline"
class="w-full"
@click="router.back()"
>
{{ t('admin.cards.create.cancel') }}
</UiButton>
</div>
</UiCardContent>
</UiCard>
</div>
</div>
</div>
</BasicPage>
</template>
@@ -1,32 +0,0 @@
import type { Composer } from 'vue-i18n'
import { Ban, CheckCircle2, Users } from 'lucide-vue-next'
import { h } from 'vue'
import type { FacetedFilterOption } from '@/components/data-table/types'
export function getCardStatuses(t: Composer['t']): (FacetedFilterOption & { style: string, variant?: string })[] {
return [
{
label: t('admin.cards.unused'),
value: 'unused',
icon: h(CheckCircle2),
style: 'bg-green-500/10 text-green-500',
variant: 'default',
},
{
label: t('admin.cards.used'),
value: 'used',
icon: h(Users),
style: 'bg-blue-500/10 text-blue-500',
variant: 'secondary',
},
{
label: t('admin.cards.banned'),
value: 'banned',
icon: h(Ban),
style: 'bg-gray-500/10 text-gray-500',
variant: 'destructive',
},
]
}
@@ -1,36 +0,0 @@
import { z } from 'zod'
export const cardStatusSchema = z.enum(['unused', 'used', 'expired', 'banned'])
export const cardSchema = z.object({
id: z.union([z.string(), z.number()]),
card_key: z.string(),
application_id: z.union([z.string(), z.number()]),
card_type_id: z.union([z.string(), z.number()]),
status: cardStatusSchema,
used_at: z.string().optional().nullable(),
expire_at: z.string().optional().nullable(),
created_at: z.string(),
updated_at: z.string().optional(),
application: z.object({
id: z.union([z.string(), z.number()]),
name: z.string(),
}).optional(),
card_type: z.object({
id: z.union([z.string(), z.number()]),
name: z.string(),
price: z.number(),
value: z.number(),
billing_type: z.string(),
}).optional(),
creator: z.object({
id: z.union([z.string(), z.number()]),
username: z.string(),
}).optional(),
app_user: z.object({
id: z.union([z.string(), z.number()]),
username: z.string(),
}).optional().nullable(),
})
export type Card = z.infer<typeof cardSchema>
@@ -1,423 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import type { Card } from '@/pages/admin/cards/data/schema'
import ConfirmDialog from '@/components/confirm-dialog.vue'
import DateRangeFilter from '@/components/data-table/date-range-filter.vue'
import SingleFilter from '@/components/data-table/single-filter.vue'
import { BasicPage } from '@/components/global-layout'
import DataTable from '@/pages/admin/cards/components/data-table.vue'
import api, { BASE_URL } from '@/services/api'
const { t } = useI18n()
interface Application {
id: number
name: string
}
interface CardType {
id: number
name: string
application_id: number
}
const router = useRouter()
const loading = ref(true)
const cards = ref<Card[]>([])
const tableRef = ref()
const applications = ref<Application[]>([])
const cardTypes = ref<CardType[]>([])
const appFilter = ref<string>('')
const cardTypeFilter = ref<string>('')
const statusFilter = ref<string>('')
const startDate = ref<string>('')
const endDate = ref<string>('')
const searchFilter = ref<string>('')
const deleteDialogOpen = ref(false)
const deleteTarget = ref<Card | null>(null)
const batchDeleteDialogOpen = ref(false)
const batchDeleteIds = ref<(string | number)[]>([])
const filteredCardTypes = computed(() => {
if (appFilter.value) {
return cardTypes.value.filter(
ct => String(ct.application_id) === appFilter.value,
)
}
return cardTypes.value
})
const filteredCards = computed(() => {
let result = cards.value
if (appFilter.value) {
result = result.filter(card => String(card.application_id) === appFilter.value)
}
if (cardTypeFilter.value) {
result = result.filter(card => String(card.card_type_id) === cardTypeFilter.value)
}
if (statusFilter.value) {
result = result.filter(card => card.status === statusFilter.value)
}
if (startDate.value) {
const fromDateTime = startDate.value.includes('T')
? startDate.value.replace('T', ' ')
: `${startDate.value} 00:00`
result = result.filter(card => card.created_at >= fromDateTime)
}
if (endDate.value) {
const toDateTime = endDate.value.includes('T')
? endDate.value.replace('T', ' ')
: `${endDate.value} 23:59`
result = result.filter(card => card.created_at <= toDateTime)
}
if (searchFilter.value) {
const search = searchFilter.value.toLowerCase()
result = result.filter(card =>
card.card_key?.toLowerCase().includes(search)
|| card.application?.name?.toLowerCase().includes(search)
|| card.card_type?.name?.toLowerCase().includes(search)
|| card.creator?.username?.toLowerCase().includes(search)
|| card.app_user?.username?.toLowerCase().includes(search),
)
}
return result
})
const unusedCount = computed(() => filteredCards.value.filter(card => card.status === 'unused').length)
const usedCount = computed(() => filteredCards.value.filter(card => card.status === 'used').length)
const bannedCount = computed(() => filteredCards.value.filter(card => card.status === 'banned').length)
const applicationOptions = computed(() => {
return applications.value.map(app => ({
label: app.name,
value: String(app.id),
}))
})
const cardTypeOptions = computed(() => {
if (appFilter.value) {
return filteredCardTypes.value.map(ct => ({
label: ct.name,
value: String(ct.id),
}))
}
return []
})
const statusOptions = computed(() => [
{ label: t('admin.cards.unused'), value: 'unused' },
{ label: t('admin.cards.used'), value: 'used' },
{ label: t('admin.cards.banned'), value: 'banned' },
])
async function fetchApplications() {
try {
const data = await api.get<{ applications: Application[] }>('/dev/applications')
applications.value = data?.applications || []
}
catch (error) {
console.error('获取应用列表失败:', error)
}
}
async function fetchCardTypes() {
try {
const data = await api.get<{ card_types: CardType[] }>('/dev/card-types')
cardTypes.value = data?.card_types || []
}
catch (error) {
console.error('获取卡类列表失败:', error)
}
}
async function fetchCards() {
loading.value = true
try {
const data = await api.get<Card[]>('/dev/cards')
cards.value = Array.isArray(data) ? data : []
}
catch (error) {
console.error('获取卡密列表失败:', error)
cards.value = []
}
finally {
loading.value = false
}
}
function goToCreate() {
router.push('/admin/cards/create')
}
async function toggleStatus(card: Card) {
try {
const newStatus = card.status === 'banned' ? 'unused' : 'banned'
await api.put(`/dev/cards/${card.id}/status`, { status: newStatus })
toast.success(t('admin.cards.statusUpdateSuccess'))
fetchCards()
}
catch (error: any) {
console.error('切换卡密状态失败:', error)
toast.error(error.message || t('admin.cards.statusUpdateFailed'))
}
}
function confirmDeleteCard(card: Card) {
deleteTarget.value = card
deleteDialogOpen.value = true
}
async function handleDeleteCard() {
if (!deleteTarget.value)
return
try {
await api.delete(`/dev/cards/${deleteTarget.value.id}`)
toast.success(t('admin.cards.deleteSuccess'))
fetchCards()
}
catch (error: any) {
console.error('删除卡密失败:', error)
toast.error(error.message || t('admin.cards.deleteFailed'))
}
finally {
deleteTarget.value = null
}
}
function confirmBatchDelete(ids: (string | number)[]) {
batchDeleteIds.value = ids
batchDeleteDialogOpen.value = true
}
async function handleBatchDelete() {
try {
await api.delete('/dev/cards/batch', { ids: batchDeleteIds.value } as any)
toast.success(t('admin.cards.deleteSuccess'))
fetchCards()
}
catch (error: any) {
console.error('批量删除失败:', error)
toast.error(error.message || t('admin.cards.deleteFailed'))
}
finally {
batchDeleteIds.value = []
}
}
async function batchToggleStatus(ids: (string | number)[], status: string) {
try {
await api.put('/dev/cards/batch/status', { ids, status })
toast.success(t('admin.cards.batchUpdateSuccess'))
fetchCards()
}
catch (error: any) {
console.error('批量更新状态失败:', error)
toast.error(error.message || t('admin.cards.batchUpdateFailed'))
}
}
function handleExport() {
const token = localStorage.getItem('token')
const params = new URLSearchParams()
if (appFilter.value)
params.append('application_id', appFilter.value)
if (cardTypeFilter.value)
params.append('card_type_id', cardTypeFilter.value)
if (startDate.value) {
const startDateOnly = startDate.value.includes('T') ? startDate.value.split('T')[0] : startDate.value
params.append('start_date', startDateOnly)
}
if (endDate.value) {
const endDateOnly = endDate.value.includes('T') ? endDate.value.split('T')[0] : endDate.value
params.append('end_date', endDateOnly)
}
const queryString = params.toString()
const url = `${BASE_URL}/dev/cards/export?${queryString}&token=${token}`
window.open(url, '_blank')
}
function handleBatchExport(ids: (string | number)[]) {
if (!ids.length)
return
const token = localStorage.getItem('token')
const params = new URLSearchParams()
params.append('ids', ids.join(','))
const url = `${BASE_URL}/dev/cards/export?${params.toString()}&token=${token}`
window.open(url, '_blank')
}
watch(() => appFilter.value, () => {
cardTypeFilter.value = ''
})
onMounted(() => {
fetchApplications()
fetchCardTypes()
fetchCards()
})
</script>
<template>
<BasicPage
:title="t('admin.cards.title')"
:description="t('admin.cards.description')"
sticky
>
<template #actions>
<UiButton @click="goToCreate">
<Icon icon="lucide:plus" class="mr-2 h-4 w-4" />
{{ t('admin.cards.generateCard') }}
</UiButton>
</template>
<div class="space-y-6">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.cards.totalCards') }}
</UiCardTitle>
<Icon icon="lucide:key" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ filteredCards.length }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.cards.unused') }}
</UiCardTitle>
<Icon icon="lucide:check-circle" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ unusedCount }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.cards.used') }}
</UiCardTitle>
<Icon icon="lucide:users" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ usedCount }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.cards.banned') }}
</UiCardTitle>
<Icon icon="lucide:ban" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ bannedCount }}
</div>
</UiCardContent>
</UiCard>
</div>
<UiCard>
<UiCardContent class="p-6">
<DataTable
ref="tableRef"
:loading
:data="filteredCards"
:on-toggle-status="toggleStatus"
:on-delete="confirmDeleteCard"
:search-filter="searchFilter"
@refresh="fetchCards"
@batch-disable="batchToggleStatus(tableRef?.table?.getSelectedRowModel().rows.filter((r: any) => r.original.status === 'unused').map((r: any) => r.original.id) || [], 'banned')"
@batch-enable="batchToggleStatus(tableRef?.table?.getSelectedRowModel().rows.filter((r: any) => r.original.status === 'banned').map((r: any) => r.original.id) || [], 'unused')"
@batch-delete="confirmBatchDelete(tableRef?.table?.getSelectedRowModel().rows.map((r: any) => r.original.id) || [])"
@batch-export="handleBatchExport(tableRef?.table?.getSelectedRowModel().rows.map((r: any) => r.original.id) || [])"
@export="handleExport"
@update:search-filter="searchFilter = $event"
>
<template #filters>
<SingleFilter
v-model="appFilter"
:title="t('admin.cards.application')"
:options="applicationOptions"
/>
<SingleFilter
v-model="cardTypeFilter"
:title="t('admin.cards.cardType')"
:options="cardTypeOptions"
/>
<SingleFilter
v-model="statusFilter"
:title="t('admin.cards.status')"
:options="statusOptions"
/>
<DateRangeFilter
v-model:start-model-value="startDate"
v-model:end-model-value="endDate"
/>
</template>
</DataTable>
</UiCardContent>
</UiCard>
</div>
<ConfirmDialog
v-model:open="deleteDialogOpen"
destructive
:confirm-button-text="t('admin.cards.delete')"
:cancel-button-text="t('admin.cards.create.cancelBtn')"
@confirm="handleDeleteCard"
>
<template #title>
{{ t('admin.cards.deleteCard') }}
</template>
<template #description>
{{ t('admin.cards.deleteCardConfirm', { cardKey: deleteTarget?.card_key }) }}
</template>
</ConfirmDialog>
<ConfirmDialog
v-model:open="batchDeleteDialogOpen"
destructive
:confirm-button-text="t('admin.cards.delete')"
:cancel-button-text="t('admin.cards.create.cancelBtn')"
@confirm="handleBatchDelete"
>
<template #title>
{{ t('admin.cards.batchDeleteCards') }}
</template>
<template #description>
{{ t('admin.cards.batchDeleteConfirm', { count: batchDeleteIds.length }) }}
</template>
</ConfirmDialog>
</BasicPage>
</template>
@@ -1,337 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import ConfirmDialog from '@/components/confirm-dialog.vue'
import DateRangeFilter from '@/components/data-table/date-range-filter.vue'
import SingleFilter from '@/components/data-table/single-filter.vue'
import { BasicPage } from '@/components/global-layout'
import api from '@/services/api'
import type { CloudConstant } from './data/schema'
import DataTable from './components/data-table.vue'
const { t } = useI18n()
const router = useRouter()
interface Application {
id: number
name: string
app_key: string
}
const loading = ref(true)
const constants = ref<CloudConstant[]>([])
const applications = ref<Application[]>([])
const appFilter = ref<string>('')
const startDate = ref<string>('')
const endDate = ref<string>('')
const deleteDialogOpen = ref(false)
const deleteTarget = ref<CloudConstant | null>(null)
const batchDialogOpen = ref(false)
const batchAction = ref<'enable' | 'disable' | 'delete' | null>(null)
const selectedRows = ref<CloudConstant[]>([])
const filteredConstants = computed(() => {
let result = [...constants.value]
if (appFilter.value) {
result = result.filter(c => String(c.app_id) === appFilter.value)
}
if (startDate.value) {
const fromDateTime = startDate.value.includes('T')
? startDate.value.replace('T', ' ')
: `${startDate.value} 00:00`
result = result.filter(c => c.created_at >= fromDateTime)
}
if (endDate.value) {
const toDateTime = endDate.value.includes('T')
? endDate.value.replace('T', ' ')
: `${endDate.value} 23:59`
result = result.filter(c => c.created_at <= toDateTime)
}
return result
})
const linkedCount = computed(() => constants.value.filter(c => c.app_id).length)
const unlinkedCount = computed(() => constants.value.filter(c => !c.app_id).length)
const applicationOptions = computed(() => {
return applications.value.map(app => ({
label: app.name,
value: String(app.id),
}))
})
async function fetchApplications() {
try {
const data = await api.get<{ applications: Application[] }>('/dev/applications')
applications.value = data?.applications || []
}
catch (error) {
console.error('获取应用列表失败:', error)
}
}
async function fetchConstants() {
loading.value = true
try {
const data = await api.get<{ constants: CloudConstant[] }>('/dev/cloud-constants')
const consts = data?.constants || []
constants.value = consts.map((c: any) => ({
...c,
application_name: applications.value.find(app => app.id === c.app_id)?.name || '',
}))
}
catch (error) {
console.error('获取云端常量失败:', error)
constants.value = []
}
finally {
loading.value = false
}
}
function openCreatePage() {
router.push('/admin/cloud-constants/create')
}
function openEditPage(constant: CloudConstant) {
router.push(`/admin/cloud-constants/${constant.id}`)
}
function confirmDelete(constant: CloudConstant) {
deleteTarget.value = constant
deleteDialogOpen.value = true
}
async function handleDelete() {
if (!deleteTarget.value)
return
try {
await api.delete(`/dev/cloud-constants/${deleteTarget.value.id}`)
toast.success(t('admin.cloudConstants.deleteSuccess'))
fetchConstants()
}
catch (error: any) {
console.error('删除云端常量失败:', error)
toast.error(error.message || t('admin.cloudConstants.deleteFailed'))
}
finally {
deleteTarget.value = null
}
}
async function handleToggleStatus(constant: CloudConstant) {
const newStatus = constant.status === 'active' ? 'inactive' : 'active'
try {
await api.put(`/dev/cloud-constants/${constant.id}`, {
key: constant.key,
value: constant.value,
var_type: constant.var_type,
description: constant.description,
status: newStatus,
})
toast.success(newStatus === 'active' ? t('admin.cloudConstants.enableSuccess') : t('admin.cloudConstants.disableSuccess'))
fetchConstants()
}
catch (error: any) {
console.error('更新状态失败:', error)
toast.error(error.message || t('admin.cloudConstants.updateFailed'))
}
}
function handleDownload(constant: CloudConstant) {
if (constant.var_type !== 'binary')
return
const token = localStorage.getItem('token')
const url = `/api/v1/dev/cloud-constants/${constant.id}/download?token=${token}`
window.open(url, '_blank')
}
function openBatchDialog(action: 'enable' | 'disable' | 'delete', rows: CloudConstant[]) {
batchAction.value = action
selectedRows.value = rows
batchDialogOpen.value = true
}
async function handleBatchAction() {
if (!batchAction.value || selectedRows.value.length === 0)
return
const ids = selectedRows.value.map(c => c.id)
try {
if (batchAction.value === 'delete') {
await Promise.all(ids.map(id => api.delete(`/dev/cloud-constants/${id}`)))
toast.success(t('admin.cloudConstants.batchDeleteSuccess'))
}
else {
const status = batchAction.value === 'enable' ? 'active' : 'inactive'
await Promise.all(selectedRows.value.map(c => api.put(`/dev/cloud-constants/${c.id}`, {
key: c.key,
value: c.value,
var_type: c.var_type,
description: c.description,
status,
})))
toast.success(batchAction.value === 'enable' ? t('admin.cloudConstants.batchEnableSuccess') : t('admin.cloudConstants.batchDisableSuccess'))
}
fetchConstants()
}
catch (error: any) {
console.error('批量操作失败:', error)
toast.error(error.message || t('admin.cloudConstants.batchFailed'))
}
finally {
batchAction.value = null
selectedRows.value = []
}
}
onMounted(async () => {
await fetchApplications()
fetchConstants()
})
</script>
<template>
<BasicPage
:title="t('admin.cloudConstants.title')"
:description="t('admin.cloudConstants.description')"
sticky
>
<template #actions>
<UiButton size="sm" @click="openCreatePage">
<Icon icon="lucide:plus" class="mr-2 h-4 w-4" />
{{ t('admin.cloudConstants.addConstant') }}
</UiButton>
</template>
<div class="space-y-6">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.cloudConstants.totalConstants') }}
</UiCardTitle>
<Icon icon="lucide:database" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ filteredConstants.length }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.cloudConstants.linkedApps') }}
</UiCardTitle>
<Icon icon="lucide:link" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ linkedCount }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.cloudConstants.unlinked') }}
</UiCardTitle>
<Icon icon="lucide:unlink" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ unlinkedCount }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.cloudConstants.appCount') }}
</UiCardTitle>
<Icon icon="lucide:layout-grid" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ applications.length }}
</div>
</UiCardContent>
</UiCard>
</div>
<UiCard>
<UiCardContent class="p-6">
<DataTable
:loading
:data="filteredConstants"
:on-edit="openEditPage"
:on-delete="confirmDelete"
:on-toggle-status="handleToggleStatus"
:on-download="handleDownload"
@refresh="fetchConstants"
@batch-action="openBatchDialog"
>
<template #filters>
<SingleFilter
v-model="appFilter"
:title="t('admin.cloudConstants.application')"
:options="applicationOptions"
/>
<DateRangeFilter
v-model:start-model-value="startDate"
v-model:end-model-value="endDate"
/>
</template>
</DataTable>
</UiCardContent>
</UiCard>
</div>
<ConfirmDialog
v-model:open="deleteDialogOpen"
destructive
:confirm-button-text="t('admin.cloudConstants.delete')"
:cancel-button-text="t('common.reset')"
@confirm="handleDelete"
>
<template #title>
{{ t('admin.cloudConstants.deleteConstant') }}
</template>
<template #description>
{{ t('admin.cloudConstants.deleteConfirm', { key: deleteTarget?.key }) }}
</template>
</ConfirmDialog>
<ConfirmDialog
v-model:open="batchDialogOpen"
:destructive="batchAction === 'delete'"
:confirm-button-text="batchAction === 'delete' ? t('admin.cloudConstants.delete') : t('common.confirm')"
:cancel-button-text="t('common.cancel')"
@confirm="handleBatchAction"
>
<template #title>
{{ batchAction === 'delete' ? t('admin.cloudConstants.batchDelete') : batchAction === 'enable' ? t('admin.cloudConstants.batchEnable') : t('admin.cloudConstants.batchDisable') }}
</template>
<template #description>
{{ batchAction === 'delete' ? t('admin.cloudConstants.batchDeleteConfirm', { count: selectedRows.length }) : batchAction === 'enable' ? t('admin.cloudConstants.batchEnableConfirm', { count: selectedRows.length }) : t('admin.cloudConstants.batchDisableConfirm', { count: selectedRows.length }) }}
</template>
</ConfirmDialog>
</BasicPage>
</template>
@@ -1,185 +0,0 @@
import type { ColumnDef } from '@tanstack/vue-table'
import type { Composer } from 'vue-i18n'
import { Download, File, MoreHorizontal, Pencil, Power, PowerOff, Trash2 } from 'lucide-vue-next'
import { h } from 'vue'
import type { CloudConstant } from '@/pages/admin/cloud-constants/data/schema'
import { Copy } from '@/components/sva-ui/copy'
import Badge from '@/components/ui/badge/Badge.vue'
import Button from '@/components/ui/button/Button.vue'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
export function getColumns(actions: {
onEdit: (row: CloudConstant) => void
onDelete: (row: CloudConstant) => void
onToggleStatus: (row: CloudConstant) => void
onDownload: (row: CloudConstant) => void
}, t: Composer['t']): ColumnDef<CloudConstant>[] {
return [
{
accessorKey: 'key',
header: () => t('admin.cloudConstants.columns.key'),
cell: ({ row }) => {
const key = row.getValue('key') as string
if (!key)
return '-'
return h('div', { class: 'flex items-center space-x-2' }, [
h('code', { class: 'text-xs bg-muted px-2 py-1 rounded font-mono' }, key),
h(Copy, { class: 'h-4 w-4', size: 'sm', content: key }),
])
},
},
{
accessorKey: 'value',
header: () => t('admin.cloudConstants.columns.value'),
cell: ({ row }) => {
const value = row.getValue('value') as string
const varType = row.original.var_type
if (!value)
return '-'
if (varType === 'binary') {
const originalName = row.original.original_name || value
return h('div', { class: 'flex items-center gap-2' }, [
h(File, { class: 'h-4 w-4 text-muted-foreground' }),
h('span', { class: 'max-w-xs truncate', title: originalName }, originalName),
])
}
return h('div', { class: 'max-w-xs truncate', title: value }, value)
},
},
{
accessorKey: 'var_type',
header: () => t('admin.cloudConstants.columns.type'),
cell: ({ row }) => {
const type = row.getValue('var_type') as string
const typeMap: Record<string, string> = {
integer: t('admin.cloudConstants.types.integer'),
decimal: t('admin.cloudConstants.types.decimal'),
string: t('admin.cloudConstants.types.string'),
binary: t('admin.cloudConstants.types.binary'),
}
return h(Badge, { variant: 'secondary' }, () => typeMap[type || 'string'] || type || 'string')
},
},
{
accessorKey: 'description',
header: () => t('admin.cloudConstants.columns.description'),
cell: ({ row }) => {
const description = row.getValue('description') as string
if (!description)
return '-'
return h('div', { class: 'max-w-xs truncate', title: description }, description)
},
},
{
accessorKey: 'application_name',
header: () => t('admin.cloudConstants.columns.application'),
cell: ({ row }) => {
const appName = row.getValue('application_name') as string
const appId = row.original.app_id
if (!appId)
return h(Badge, { variant: 'outline' }, () => t('admin.cloudConstants.notLinked'))
if (!appName)
return '-'
return h(Badge, { variant: 'outline' }, () => appName)
},
},
{
accessorKey: 'status',
header: () => t('admin.cloudConstants.columns.status'),
cell: ({ row }) => {
const status = row.getValue('status') as string
const isActive = status === 'active'
return h(Badge, { variant: isActive ? 'default' : 'destructive' }, () => isActive ? t('admin.cloudConstants.statusActive') : t('admin.cloudConstants.statusInactive'))
},
},
{
accessorKey: 'created_at',
header: () => t('admin.cloudConstants.columns.createdAt'),
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' }, t('common.actions')),
cell: ({ row }) => {
const constant = row.original
return h(
DropdownMenu,
{},
{
default: () => [
h(DropdownMenuTrigger, { asChild: true }, () =>
h(Button, { variant: 'ghost', class: 'h-8 w-8 p-0' }, () => [
h(MoreHorizontal, { class: 'h-4 w-4' }),
h('span', { class: 'sr-only' }, t('common.openMenu')),
])),
h(
DropdownMenuContent,
{ align: 'end' },
() => {
const items = [
h(DropdownMenuItem, { onClick: () => actions.onEdit(constant) }, () => [
h(Pencil, { class: 'mr-2 h-4 w-4' }),
t('admin.cloudConstants.edit'),
]),
]
if (constant.var_type === 'binary') {
items.push(
h(DropdownMenuItem, { onClick: () => actions.onDownload(constant) }, () => [
h(Download, { class: 'mr-2 h-4 w-4' }),
'下载文件',
]),
)
}
items.push(
h(DropdownMenuItem, { onClick: () => actions.onToggleStatus(constant) }, () => [
constant.status === 'active' ? h(PowerOff, { class: 'mr-2 h-4 w-4' }) : h(Power, { class: 'mr-2 h-4 w-4' }),
constant.status === 'active' ? t('admin.cloudConstants.disable') : t('admin.cloudConstants.enable'),
]),
h(DropdownMenuItem, { class: 'text-destructive', onClick: () => actions.onDelete(constant) }, () => [
h(Trash2, { class: 'mr-2 h-4 w-4' }),
t('admin.cloudConstants.delete'),
]),
)
return items
},
),
],
},
)
},
enableSorting: false,
enableHiding: false,
},
]
}
@@ -1,44 +0,0 @@
<script setup lang="ts">
import type { Row } from '@tanstack/vue-table'
import { Ellipsis, Pencil, Trash2 } from 'lucide-vue-next'
import type { CloudConstant } from '../data/schema'
interface DataTableRowActionsProps {
row: Row<CloudConstant>
}
const props = defineProps<DataTableRowActionsProps>()
const emit = defineEmits<{
edit: [constant: CloudConstant]
delete: [constant: CloudConstant]
}>()
const constant = props.row.original
</script>
<template>
<UiDropdownMenu>
<UiDropdownMenuTrigger as-child>
<UiButton
variant="ghost"
class="flex h-8 w-8 p-0 data-[state=open]:bg-muted"
>
<Ellipsis class="size-4" />
<span class="sr-only">打开菜单</span>
</UiButton>
</UiDropdownMenuTrigger>
<UiDropdownMenuContent align="end" class="w-[160px]">
<UiDropdownMenuItem @click="emit('edit', constant)">
<Pencil class="mr-2 h-4 w-4" />
编辑
</UiDropdownMenuItem>
<UiDropdownMenuSeparator />
<UiDropdownMenuItem class="text-destructive" @click="emit('delete', constant)">
<Trash2 class="mr-2 h-4 w-4" />
删除
</UiDropdownMenuItem>
</UiDropdownMenuContent>
</UiDropdownMenu>
</template>
@@ -1,43 +0,0 @@
<script setup lang="ts">
import type { Table } from '@tanstack/vue-table'
import { X } from 'lucide-vue-next'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { CloudConstant } from '@/pages/admin/cloud-constants/data/schema'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
const props = defineProps<DataTableToolbarProps>()
const { t } = useI18n()
interface DataTableToolbarProps {
table: Table<CloudConstant>
}
const isFiltered = computed(() => props.table.getState().columnFilters.length > 0)
</script>
<template>
<div class="flex items-center flex-1 space-x-2">
<Input
:placeholder="t('admin.cloudConstants.searchPlaceholder')"
:model-value="(table.getColumn('key')?.getFilterValue() as string) ?? ''"
class="h-8 w-[150px] lg:w-[250px]"
@input="table.getColumn('key')?.setFilterValue($event.target.value)"
/>
<Button
v-if="isFiltered"
variant="ghost"
class="h-8 px-2 lg:px-3"
@click="table.resetColumnFilters()"
>
{{ t('common.reset') }}
<X class="size-4 ml-2" />
</Button>
</div>
</template>
@@ -1,98 +0,0 @@
<script setup lang="ts">
import type { ColumnDef } from '@tanstack/vue-table'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { DataTableProps } from '@/components/data-table/types'
import type { CloudConstant } from '@/pages/admin/cloud-constants/data/schema'
import BulkActions from '@/components/data-table/bulk-actions.vue'
import DataTable from '@/components/data-table/data-table.vue'
import { SelectColumn } from '@/components/data-table/table-columns'
import { generateVueTable } from '@/components/data-table/use-generate-vue-table'
import DataTableViewOptions from '@/components/data-table/view-options.vue'
import { getColumns } from '@/pages/admin/cloud-constants/components/columns'
import DataTableToolbar from '@/pages/admin/cloud-constants/components/data-table-toolbar.vue'
const props = defineProps<Omit<DataTableProps<CloudConstant>, 'columns'> & {
onEdit: (row: CloudConstant) => void
onDelete: (row: CloudConstant) => void
onToggleStatus: (row: CloudConstant) => void
onDownload: (row: CloudConstant) => void
}>()
const emit = defineEmits<{
refresh: []
batchAction: [action: 'enable' | 'disable' | 'delete', rows: CloudConstant[]]
}>()
const { t } = useI18n()
const columns = computed(() => [
SelectColumn as ColumnDef<CloudConstant>,
...getColumns({
onEdit: props.onEdit,
onDelete: props.onDelete,
onToggleStatus: props.onToggleStatus,
onDownload: props.onDownload,
}, t),
])
const table = generateVueTable<CloudConstant>({
get data() { return props.data },
get loading() { return props.loading },
columns: columns.value,
}, columns.value)
const columnLabels: Record<string, string> = {
select: 'admin.cloudConstants.select',
key: 'admin.cloudConstants.columns.key',
value: 'admin.cloudConstants.columns.value',
var_type: 'admin.cloudConstants.columns.type',
description: 'admin.cloudConstants.columns.description',
application_name: 'admin.cloudConstants.columns.application',
status: 'admin.cloudConstants.columns.status',
created_at: 'admin.cloudConstants.columns.createdAt',
}
defineExpose({
table,
})
function handleBatchAction(action: 'enable' | 'disable' | 'delete') {
const selectedRows = table.getSelectedRowModel().rows.map(row => row.original)
emit('batchAction', action, selectedRows)
table.resetRowSelection()
}
</script>
<template>
<DataTable :columns="columns" :data :loading :table @refresh="emit('refresh')">
<template #toolbar>
<div class="space-y-3">
<div class="flex items-center justify-end">
<slot name="actions" />
</div>
<BulkActions :table="table" entity-name="constants">
<UiButton variant="outline" size="sm" @click="handleBatchAction('enable')">
{{ t('admin.cloudConstants.batchEnableBtn') }}
</UiButton>
<UiButton variant="outline" size="sm" @click="handleBatchAction('disable')">
{{ t('admin.cloudConstants.batchDisableBtn') }}
</UiButton>
<UiButton variant="destructive" size="sm" @click="handleBatchAction('delete')">
{{ t('admin.cloudConstants.batchDeleteBtn') }}
</UiButton>
</BulkActions>
<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,354 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import { BasicPage } from '@/components/global-layout'
import api from '@/services/api'
const router = useRouter()
const { t } = useI18n()
interface Application {
id: number
name: string
}
const saving = ref(false)
const applications = ref<Application[]>([])
const fileInput = ref<HTMLInputElement | null>(null)
const selectedFile = ref<File | null>(null)
const uploading = ref(false)
const form = ref({
app_id: '',
key: '',
value: '',
var_type: 'string',
description: '',
status: 'active',
})
async function fetchApplications() {
try {
const data = await api.get<{ applications: Application[] }>('/dev/applications')
applications.value = Array.isArray(data?.applications) ? data.applications : []
}
catch (error) {
console.error('获取应用列表失败:', error)
}
}
const selectedApplication = computed(() => {
if (form.value.app_id) {
return applications.value.find(app => String(app.id) === form.value.app_id)
}
return null
})
function handleFileSelect(event: Event) {
const target = event.target as HTMLInputElement
if (target.files && target.files[0]) {
selectedFile.value = target.files[0]
if (!form.value.key) {
form.value.key = selectedFile.value.name.replace(/\.[^/.]+$/, '')
}
}
}
function clearFile() {
selectedFile.value = null
if (fileInput.value) {
fileInput.value.value = ''
}
}
async function handleSave() {
if (!form.value.key) {
toast.error(t('admin.cloudConstants.create.keyRequired'))
return
}
if (!form.value.app_id) {
toast.error(t('admin.cloudConstants.create.appRequired'))
return
}
if (form.value.var_type === 'binary') {
if (!selectedFile.value) {
toast.error('请选择要上传的文件')
return
}
await uploadFile()
return
}
if (!form.value.value) {
toast.error(t('admin.cloudConstants.create.valueRequired'))
return
}
saving.value = true
try {
await api.post('/dev/cloud-constants', {
app_id: Number(form.value.app_id),
key: form.value.key,
value: form.value.value,
var_type: form.value.var_type,
description: form.value.description,
status: form.value.status,
})
toast.success(t('admin.cloudConstants.create.success'))
router.push('/admin/cloud-constants')
}
catch (error: any) {
console.error('创建云端常量失败:', error)
toast.error(error.message || t('admin.cloudConstants.create.failed'))
}
finally {
saving.value = false
}
}
async function uploadFile() {
if (!selectedFile.value || !form.value.app_id || !form.value.key)
return
uploading.value = true
try {
const formData = new FormData()
formData.append('file', selectedFile.value)
formData.append('app_id', form.value.app_id)
formData.append('key', form.value.key)
formData.append('description', form.value.description)
formData.append('status', form.value.status)
await api.postFormData('/dev/cloud-constants/upload', formData)
toast.success(t('admin.cloudConstants.create.success'))
router.push('/admin/cloud-constants')
}
catch (error: any) {
console.error('上传文件失败:', error)
toast.error(error.message || '上传文件失败')
}
finally {
uploading.value = false
}
}
function formatFileSize(bytes: number): string {
if (bytes === 0)
return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`
}
onMounted(() => {
fetchApplications()
})
</script>
<template>
<BasicPage
:title="t('admin.cloudConstants.addConstant')"
:description="t('admin.cloudConstants.createDescription')"
:breadcrumbs="[
{ title: t('admin.cloudConstants.title'), href: '/admin/cloud-constants' },
{ title: t('admin.cloudConstants.addConstant') },
]"
sticky
>
<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 class="flex items-center gap-2">
<Icon icon="lucide:settings-2" class="size-5" />
{{ t('admin.cloudConstants.create.config') }}
</UiCardTitle>
<UiCardDescription>{{ t('admin.cloudConstants.create.configDesc') }}</UiCardDescription>
</UiCardHeader>
<UiCardContent class="space-y-6">
<div class="space-y-2">
<UiLabel for="application">
{{ t('admin.cloudConstants.create.application') }}
</UiLabel>
<UiSelect v-model="form.app_id">
<UiSelectTrigger>
<UiSelectValue :placeholder="t('admin.cloudConstants.create.selectApp')" />
</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="varType">
{{ t('admin.cloudConstants.create.type') }}
</UiLabel>
<UiSelect id="varType" v-model="form.var_type">
<UiSelectTrigger>
<UiSelectValue :placeholder="t('admin.cloudConstants.create.selectType')" />
</UiSelectTrigger>
<UiSelectContent>
<UiSelectItem value="integer">
{{ t('admin.cloudConstants.types.integer') }}
</UiSelectItem>
<UiSelectItem value="decimal">
{{ t('admin.cloudConstants.types.decimal') }}
</UiSelectItem>
<UiSelectItem value="string">
{{ t('admin.cloudConstants.types.string') }}
</UiSelectItem>
<UiSelectItem value="binary">
二进制
</UiSelectItem>
</UiSelectContent>
</UiSelect>
</div>
<div class="space-y-2">
<UiLabel for="key">
{{ t('admin.cloudConstants.create.key') }}
</UiLabel>
<UiInput id="key" v-model="form.key" :placeholder="t('admin.cloudConstants.create.keyPlaceholder')" />
<p class="text-xs text-muted-foreground">
{{ t('admin.cloudConstants.create.keyHint') }}
</p>
</div>
<div v-if="form.var_type === 'binary'" class="space-y-2">
<UiLabel>选择文件</UiLabel>
<div v-if="!selectedFile" class="border-2 border-dashed rounded-lg p-6 text-center">
<input
ref="fileInput"
type="file"
class="hidden"
@change="handleFileSelect"
>
<Icon icon="lucide:upload" class="mx-auto h-12 w-12 text-muted-foreground mb-4" />
<p class="text-sm text-muted-foreground mb-2">
拖拽文件到此处或点击选择
</p>
<UiButton variant="outline" @click="fileInput?.click()">
选择文件
</UiButton>
</div>
<div v-else class="border rounded-lg p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<Icon icon="lucide:file" class="h-8 w-8 text-muted-foreground" />
<div>
<p class="font-medium">
{{ selectedFile.name }}
</p>
<p class="text-sm text-muted-foreground">
{{ formatFileSize(selectedFile.size) }}
</p>
</div>
</div>
<UiButton variant="ghost" size="icon" @click="clearFile">
<Icon icon="lucide:x" class="h-4 w-4" />
</UiButton>
</div>
</div>
</div>
<div v-else class="space-y-2">
<UiLabel for="value">
{{ t('admin.cloudConstants.create.value') }}
</UiLabel>
<UiTextarea id="value" v-model="form.value" :placeholder="t('admin.cloudConstants.create.valuePlaceholder')" rows="3" />
</div>
<div class="space-y-2">
<UiLabel for="description">
{{ t('admin.cloudConstants.create.description') }}
</UiLabel>
<UiInput id="description" v-model="form.description" :placeholder="t('admin.cloudConstants.create.descriptionPlaceholder')" />
</div>
</UiCardContent>
</UiCard>
</div>
<div class="space-y-6">
<UiCard>
<UiCardHeader>
<UiCardTitle class="flex items-center gap-2">
<Icon icon="lucide:eye" class="size-5" />
{{ t('admin.cloudConstants.create.preview') }}
</UiCardTitle>
</UiCardHeader>
<UiCardContent class="space-y-4">
<div class="space-y-3">
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('admin.cloudConstants.create.application') }}</span>
<span>{{ selectedApplication?.name || '-' }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('admin.cloudConstants.create.type') }}</span>
<span>{{ t(`admin.cloudConstants.types.${form.var_type}`) }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('admin.cloudConstants.create.key') }}</span>
<span>{{ form.key || '-' }}</span>
</div>
<div class="flex justify-between text-sm items-center">
<span class="text-muted-foreground">{{ t('admin.cloudConstants.create.status') }}</span>
<UiSwitch
:checked="form.status === 'active'"
@update:checked="form.status = $event ? 'active' : 'inactive'"
/>
</div>
<div class="border-t pt-3 mt-3">
<div class="text-sm">
<div class="text-muted-foreground mb-2">
{{ t('admin.cloudConstants.create.value') }}
</div>
<div v-if="form.var_type === 'binary'" class="bg-muted p-2 rounded text-xs">
{{ selectedFile ? `${selectedFile.name} (${formatFileSize(selectedFile.size)})` : '未选择文件' }}
</div>
<div v-else class="bg-muted p-2 rounded max-h-[100px] overflow-y-auto text-xs">
{{ form.value || '-' }}
</div>
</div>
</div>
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardContent class="pt-6">
<div class="flex flex-col gap-3">
<UiButton
class="w-full"
size="lg"
:disabled="(saving || uploading) || !form.app_id || !form.key || (form.var_type !== 'binary' && !form.value) || (form.var_type === 'binary' && !selectedFile)"
@click="handleSave"
>
<Icon v-if="saving || uploading" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
<Icon v-else icon="lucide:check" class="mr-2 h-4 w-4" />
{{ uploading ? '上传中...' : t('admin.cloudConstants.create.submit') }}
</UiButton>
<UiButton
variant="outline"
class="w-full"
@click="router.back()"
>
{{ t('admin.cloudConstants.create.cancel') }}
</UiButton>
</div>
</UiCardContent>
</UiCard>
</div>
</div>
</div>
</BasicPage>
</template>
@@ -1,9 +0,0 @@
import type { CloudConstant } from './schema'
export const constantAppOptions: { label: string, value: string }[] = []
export function filterConstantsByApp(constants: CloudConstant[], appId: string): CloudConstant[] {
if (!appId || appId === 'all')
return constants
return constants.filter(c => String(c.app_id) === appId)
}
@@ -1,21 +0,0 @@
import { z } from 'zod'
export const cloudConstantSchema = z.object({
id: z.union([z.string(), z.number()]),
user_id: z.union([z.string(), z.number()]),
app_id: z.union([z.string(), z.number()]).nullable().optional(),
application_name: z.string().optional().nullable(),
key: z.string(),
value: z.string(),
var_type: z.string().optional().nullable(),
file_path: z.string().optional().nullable(),
file_size: z.union([z.string(), z.number()]).optional().nullable(),
mime_type: z.string().optional().nullable(),
original_name: z.string().optional().nullable(),
description: z.string().optional().nullable(),
status: z.string().optional().nullable(),
created_at: z.string(),
updated_at: z.string().optional().nullable(),
})
export type CloudConstant = z.infer<typeof cloudConstantSchema>
@@ -1,337 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import ConfirmDialog from '@/components/confirm-dialog.vue'
import DateRangeFilter from '@/components/data-table/date-range-filter.vue'
import SingleFilter from '@/components/data-table/single-filter.vue'
import { BasicPage } from '@/components/global-layout'
import api from '@/services/api'
import type { CloudConstant } from './data/schema'
import DataTable from './components/data-table.vue'
const { t } = useI18n()
const router = useRouter()
interface Application {
id: number
name: string
app_key: string
}
const loading = ref(true)
const constants = ref<CloudConstant[]>([])
const applications = ref<Application[]>([])
const appFilter = ref<string>('')
const startDate = ref<string>('')
const endDate = ref<string>('')
const deleteDialogOpen = ref(false)
const deleteTarget = ref<CloudConstant | null>(null)
const batchDialogOpen = ref(false)
const batchAction = ref<'enable' | 'disable' | 'delete' | null>(null)
const selectedRows = ref<CloudConstant[]>([])
const filteredConstants = computed(() => {
let result = [...constants.value]
if (appFilter.value) {
result = result.filter(c => String(c.app_id) === appFilter.value)
}
if (startDate.value) {
const fromDateTime = startDate.value.includes('T')
? startDate.value.replace('T', ' ')
: `${startDate.value} 00:00`
result = result.filter(c => c.created_at >= fromDateTime)
}
if (endDate.value) {
const toDateTime = endDate.value.includes('T')
? endDate.value.replace('T', ' ')
: `${endDate.value} 23:59`
result = result.filter(c => c.created_at <= toDateTime)
}
return result
})
const linkedCount = computed(() => constants.value.filter(c => c.app_id).length)
const unlinkedCount = computed(() => constants.value.filter(c => !c.app_id).length)
const applicationOptions = computed(() => {
return applications.value.map(app => ({
label: app.name,
value: String(app.id),
}))
})
async function fetchApplications() {
try {
const data = await api.get<{ applications: Application[] }>('/dev/applications')
applications.value = data?.applications || []
}
catch (error) {
console.error('获取应用列表失败:', error)
}
}
async function fetchConstants() {
loading.value = true
try {
const data = await api.get<{ constants: CloudConstant[] }>('/dev/cloud-constants')
const consts = data?.constants || []
constants.value = consts.map((c: any) => ({
...c,
application_name: applications.value.find(app => app.id === c.app_id)?.name || '',
}))
}
catch (error) {
console.error('获取云端常量失败:', error)
constants.value = []
}
finally {
loading.value = false
}
}
function openCreatePage() {
router.push('/admin/cloud-constants/create')
}
function openEditPage(constant: CloudConstant) {
router.push(`/admin/cloud-constants/${constant.id}`)
}
function confirmDelete(constant: CloudConstant) {
deleteTarget.value = constant
deleteDialogOpen.value = true
}
async function handleDelete() {
if (!deleteTarget.value)
return
try {
await api.delete(`/dev/cloud-constants/${deleteTarget.value.id}`)
toast.success(t('admin.cloudConstants.deleteSuccess'))
fetchConstants()
}
catch (error: any) {
console.error('删除云端常量失败:', error)
toast.error(error.message || t('admin.cloudConstants.deleteFailed'))
}
finally {
deleteTarget.value = null
}
}
async function handleToggleStatus(constant: CloudConstant) {
const newStatus = constant.status === 'active' ? 'inactive' : 'active'
try {
await api.put(`/dev/cloud-constants/${constant.id}`, {
key: constant.key,
value: constant.value,
var_type: constant.var_type,
description: constant.description,
status: newStatus,
})
toast.success(newStatus === 'active' ? t('admin.cloudConstants.enableSuccess') : t('admin.cloudConstants.disableSuccess'))
fetchConstants()
}
catch (error: any) {
console.error('更新状态失败:', error)
toast.error(error.message || t('admin.cloudConstants.updateFailed'))
}
}
function handleDownload(constant: CloudConstant) {
if (constant.var_type !== 'binary')
return
const token = localStorage.getItem('token')
const url = `/api/v1/dev/cloud-constants/${constant.id}/download?token=${token}`
window.open(url, '_blank')
}
function openBatchDialog(action: 'enable' | 'disable' | 'delete', rows: CloudConstant[]) {
batchAction.value = action
selectedRows.value = rows
batchDialogOpen.value = true
}
async function handleBatchAction() {
if (!batchAction.value || selectedRows.value.length === 0)
return
const ids = selectedRows.value.map(c => c.id)
try {
if (batchAction.value === 'delete') {
await Promise.all(ids.map(id => api.delete(`/dev/cloud-constants/${id}`)))
toast.success(t('admin.cloudConstants.batchDeleteSuccess'))
}
else {
const status = batchAction.value === 'enable' ? 'active' : 'inactive'
await Promise.all(selectedRows.value.map(c => api.put(`/dev/cloud-constants/${c.id}`, {
key: c.key,
value: c.value,
var_type: c.var_type,
description: c.description,
status,
})))
toast.success(batchAction.value === 'enable' ? t('admin.cloudConstants.batchEnableSuccess') : t('admin.cloudConstants.batchDisableSuccess'))
}
fetchConstants()
}
catch (error: any) {
console.error('批量操作失败:', error)
toast.error(error.message || t('admin.cloudConstants.batchFailed'))
}
finally {
batchAction.value = null
selectedRows.value = []
}
}
onMounted(async () => {
await fetchApplications()
fetchConstants()
})
</script>
<template>
<BasicPage
:title="t('admin.cloudConstants.title')"
:description="t('admin.cloudConstants.description')"
sticky
>
<template #actions>
<UiButton size="sm" @click="openCreatePage">
<Icon icon="lucide:plus" class="mr-2 h-4 w-4" />
{{ t('admin.cloudConstants.addConstant') }}
</UiButton>
</template>
<div class="space-y-6">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.cloudConstants.totalConstants') }}
</UiCardTitle>
<Icon icon="lucide:database" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ filteredConstants.length }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.cloudConstants.linkedApps') }}
</UiCardTitle>
<Icon icon="lucide:link" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ linkedCount }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.cloudConstants.unlinked') }}
</UiCardTitle>
<Icon icon="lucide:unlink" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ unlinkedCount }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.cloudConstants.appCount') }}
</UiCardTitle>
<Icon icon="lucide:layout-grid" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ applications.length }}
</div>
</UiCardContent>
</UiCard>
</div>
<UiCard>
<UiCardContent class="p-6">
<DataTable
:loading
:data="filteredConstants"
:on-edit="openEditPage"
:on-delete="confirmDelete"
:on-toggle-status="handleToggleStatus"
:on-download="handleDownload"
@refresh="fetchConstants"
@batch-action="openBatchDialog"
>
<template #filters>
<SingleFilter
v-model="appFilter"
:title="t('admin.cloudConstants.application')"
:options="applicationOptions"
/>
<DateRangeFilter
v-model:start-model-value="startDate"
v-model:end-model-value="endDate"
/>
</template>
</DataTable>
</UiCardContent>
</UiCard>
</div>
<ConfirmDialog
v-model:open="deleteDialogOpen"
destructive
:confirm-button-text="t('admin.cloudConstants.delete')"
:cancel-button-text="t('common.reset')"
@confirm="handleDelete"
>
<template #title>
{{ t('admin.cloudConstants.deleteConstant') }}
</template>
<template #description>
{{ t('admin.cloudConstants.deleteConfirm', { key: deleteTarget?.key }) }}
</template>
</ConfirmDialog>
<ConfirmDialog
v-model:open="batchDialogOpen"
:destructive="batchAction === 'delete'"
:confirm-button-text="batchAction === 'delete' ? t('admin.cloudConstants.delete') : t('common.confirm')"
:cancel-button-text="t('common.cancel')"
@confirm="handleBatchAction"
>
<template #title>
{{ batchAction === 'delete' ? t('admin.cloudConstants.batchDelete') : batchAction === 'enable' ? t('admin.cloudConstants.batchEnable') : t('admin.cloudConstants.batchDisable') }}
</template>
<template #description>
{{ batchAction === 'delete' ? t('admin.cloudConstants.batchDeleteConfirm', { count: selectedRows.length }) : batchAction === 'enable' ? t('admin.cloudConstants.batchEnableConfirm', { count: selectedRows.length }) : t('admin.cloudConstants.batchDisableConfirm', { count: selectedRows.length }) }}
</template>
</ConfirmDialog>
</BasicPage>
</template>
@@ -1,361 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import ConfirmDialog from '@/components/confirm-dialog.vue'
import DateRangeFilter from '@/components/data-table/date-range-filter.vue'
import SingleFilter from '@/components/data-table/single-filter.vue'
import { BasicPage } from '@/components/global-layout'
import api from '@/services/api'
import type { CloudVariable } from './data/schema'
import DataTable from './components/data-table.vue'
const { t } = useI18n()
const router = useRouter()
interface Application {
id: number
name: string
app_key: string
}
const loading = ref(true)
const variables = ref<CloudVariable[]>([])
const applications = ref<Application[]>([])
const appFilter = ref<string>('')
const scopeFilter = ref<string>('')
const startDate = ref<string>('')
const endDate = ref<string>('')
const deleteDialogOpen = ref(false)
const deleteTarget = ref<CloudVariable | null>(null)
const batchDialogOpen = ref(false)
const batchAction = ref<'enable' | 'disable' | 'delete' | null>(null)
const selectedRows = ref<CloudVariable[]>([])
const filteredVariables = computed(() => {
let result = [...variables.value]
if (appFilter.value) {
result = result.filter(v => String(v.app_id) === appFilter.value)
}
if (scopeFilter.value) {
result = result.filter(v => v.scope === scopeFilter.value)
}
if (startDate.value) {
const fromDateTime = startDate.value.includes('T')
? startDate.value.replace('T', ' ')
: `${startDate.value} 00:00`
result = result.filter(v => v.created_at >= fromDateTime)
}
if (endDate.value) {
const toDateTime = endDate.value.includes('T')
? endDate.value.replace('T', ' ')
: `${endDate.value} 23:59`
result = result.filter(v => v.created_at <= toDateTime)
}
return result
})
const appScopeCount = computed(() => variables.value.filter(v => v.scope === 'app').length)
const userScopeCount = computed(() => variables.value.filter(v => v.scope === 'user').length)
const applicationOptions = computed(() => {
return applications.value.map(app => ({
label: app.name,
value: String(app.id),
}))
})
const scopeOptions = computed(() => [
{ label: t('admin.cloudVariables.appScope'), value: 'app' },
{ label: t('admin.cloudVariables.userScope'), value: 'user' },
])
async function fetchApplications() {
try {
const data = await api.get<{ applications: Application[] }>('/dev/applications')
applications.value = data?.applications || []
}
catch (error) {
console.error('获取应用列表失败:', error)
}
}
async function fetchVariables() {
loading.value = true
try {
const data = await api.get<{ variables: CloudVariable[] }>('/dev/cloud-variables')
const vars = data?.variables || []
variables.value = vars.map((v: any) => ({
...v,
application_name: applications.value.find(app => app.id === v.app_id)?.name || '',
}))
}
catch (error) {
console.error('获取云端变量失败:', error)
variables.value = []
}
finally {
loading.value = false
}
}
function openCreatePage() {
router.push('/admin/cloud-variables/create')
}
function openEditPage(variable: CloudVariable) {
router.push(`/admin/cloud-variables/${variable.id}`)
}
function confirmDelete(variable: CloudVariable) {
deleteTarget.value = variable
deleteDialogOpen.value = true
}
async function handleDelete() {
if (!deleteTarget.value)
return
try {
await api.delete(`/dev/cloud-variables/${deleteTarget.value.id}`)
toast.success(t('admin.cloudVariables.deleteSuccess'))
fetchVariables()
}
catch (error: any) {
console.error('删除云端变量失败:', error)
toast.error(error.message || t('admin.cloudVariables.deleteFailed'))
}
finally {
deleteTarget.value = null
}
}
async function handleToggleStatus(variable: CloudVariable) {
const newStatus = variable.status === 'active' ? 'inactive' : 'active'
try {
await api.put(`/dev/cloud-variables/${variable.id}`, {
key: variable.key,
default_value: variable.default_value,
var_type: variable.var_type,
scope: variable.scope,
write_permission: variable.write_permission,
description: variable.description,
status: newStatus,
})
toast.success(newStatus === 'active' ? t('admin.cloudVariables.enableSuccess') : t('admin.cloudVariables.disableSuccess'))
fetchVariables()
}
catch (error: any) {
console.error('更新状态失败:', error)
toast.error(error.message || t('admin.cloudVariables.updateFailed'))
}
}
function handleDownload(variable: CloudVariable) {
if (variable.var_type !== 'binary')
return
const token = localStorage.getItem('token')
const url = `/api/v1/dev/cloud-variables/${variable.id}/download?token=${token}`
window.open(url, '_blank')
}
function handleViewRecords(variable: CloudVariable) {
router.push(`/admin/cloud-variables/${variable.id}/records?app_id=${variable.app_id}`)
}
function openBatchDialog(action: 'enable' | 'disable' | 'delete', rows: CloudVariable[]) {
batchAction.value = action
selectedRows.value = rows
batchDialogOpen.value = true
}
async function handleBatchAction() {
if (!batchAction.value || selectedRows.value.length === 0)
return
const ids = selectedRows.value.map(v => v.id)
try {
if (batchAction.value === 'delete') {
await Promise.all(ids.map(id => api.delete(`/dev/cloud-variables/${id}`)))
toast.success(t('admin.cloudVariables.batchDeleteSuccess'))
}
else {
const status = batchAction.value === 'enable' ? 'active' : 'inactive'
await Promise.all(selectedRows.value.map(v => api.put(`/dev/cloud-variables/${v.id}`, {
key: v.key,
default_value: v.default_value,
var_type: v.var_type,
scope: v.scope,
write_permission: v.write_permission,
description: v.description,
status,
})))
toast.success(batchAction.value === 'enable' ? t('admin.cloudVariables.batchEnableSuccess') : t('admin.cloudVariables.batchDisableSuccess'))
}
fetchVariables()
}
catch (error: any) {
console.error('批量操作失败:', error)
toast.error(error.message || t('admin.cloudVariables.batchFailed'))
}
finally {
batchAction.value = null
selectedRows.value = []
}
}
onMounted(async () => {
await fetchApplications()
fetchVariables()
})
</script>
<template>
<BasicPage
:title="t('admin.cloudVariables.title')"
:description="t('admin.cloudVariables.description')"
sticky
>
<template #actions>
<UiButton size="sm" @click="openCreatePage">
<Icon icon="lucide:plus" class="mr-2 h-4 w-4" />
{{ t('admin.cloudVariables.addVariable') }}
</UiButton>
</template>
<div class="space-y-6">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.cloudVariables.totalVariables') }}
</UiCardTitle>
<Icon icon="lucide:variable" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ filteredVariables.length }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.cloudVariables.appScopeVariables') }}
</UiCardTitle>
<Icon icon="lucide:globe" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ appScopeCount }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.cloudVariables.userScopeVariables') }}
</UiCardTitle>
<Icon icon="lucide:user" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ userScopeCount }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.cloudVariables.appCount') }}
</UiCardTitle>
<Icon icon="lucide:layout-grid" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ applications.length }}
</div>
</UiCardContent>
</UiCard>
</div>
<UiCard>
<UiCardContent class="p-6">
<DataTable
:loading
:data="filteredVariables"
:on-edit="openEditPage"
:on-delete="confirmDelete"
:on-toggle-status="handleToggleStatus"
:on-download="handleDownload"
:on-view-records="handleViewRecords"
@refresh="fetchVariables"
@batch-action="openBatchDialog"
>
<template #filters>
<SingleFilter
v-model="appFilter"
:title="t('admin.cloudVariables.application')"
:options="applicationOptions"
/>
<SingleFilter
v-model="scopeFilter"
:title="t('admin.cloudVariables.scope')"
:options="scopeOptions"
/>
<DateRangeFilter
v-model:start-model-value="startDate"
v-model:end-model-value="endDate"
/>
</template>
</DataTable>
</UiCardContent>
</UiCard>
</div>
<ConfirmDialog
v-model:open="deleteDialogOpen"
destructive
:confirm-button-text="t('admin.cloudVariables.delete')"
:cancel-button-text="t('common.reset')"
@confirm="handleDelete"
>
<template #title>
{{ t('admin.cloudVariables.deleteVariable') }}
</template>
<template #description>
{{ t('admin.cloudVariables.deleteConfirm', { key: deleteTarget?.key }) }}
</template>
</ConfirmDialog>
<ConfirmDialog
v-model:open="batchDialogOpen"
:destructive="batchAction === 'delete'"
:confirm-button-text="batchAction === 'delete' ? t('admin.cloudVariables.delete') : t('common.confirm')"
:cancel-button-text="t('common.cancel')"
@confirm="handleBatchAction"
>
<template #title>
{{ batchAction === 'delete' ? t('admin.cloudVariables.batchDelete') : batchAction === 'enable' ? t('admin.cloudVariables.batchEnable') : t('admin.cloudVariables.batchDisable') }}
</template>
<template #description>
{{ batchAction === 'delete' ? t('admin.cloudVariables.batchDeleteConfirm', { count: selectedRows.length }) : batchAction === 'enable' ? t('admin.cloudVariables.batchEnableConfirm', { count: selectedRows.length }) : t('admin.cloudVariables.batchDisableConfirm', { count: selectedRows.length }) }}
</template>
</ConfirmDialog>
</BasicPage>
</template>
@@ -1,274 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import { BasicPage } from '@/components/global-layout'
import api from '@/services/api'
interface Variable {
id: number
key: string
var_type: string
scope: string
max_records: number
}
interface VariableRecord {
id: number
data: Record<string, any>
user_id?: number
user?: {
id: number
username: string
}
created_at: string
}
const route = useRoute()
const router = useRouter()
const loading = ref(true)
const variable = ref<Variable | null>(null)
const records = ref<VariableRecord[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const startDate = ref('')
const endDate = ref('')
const selectedIds = ref<number[]>([])
async function fetchVariable() {
try {
const data = await api.get<{ variables: Variable[] }>(`/dev/cloud-variables?app_id=${route.query.app_id}`)
const vars = data?.variables || []
variable.value = vars.find((v: Variable) => String(v.id) === route.params.id) || null
if (variable.value && variable.value.var_type !== 'stream') {
toast.error('该变量不是记录类型')
router.back()
}
}
catch (error) {
console.error('获取变量信息失败:', error)
}
}
async function fetchRecords() {
if (!variable.value)
return
loading.value = true
try {
const params = new URLSearchParams()
params.append('page', String(page.value))
params.append('page_size', String(pageSize.value))
if (startDate.value)
params.append('start_date', startDate.value)
if (endDate.value)
params.append('end_date', endDate.value)
const data = await api.get<{ records: VariableRecord[], total: number }>(`/dev/cloud-variables/${variable.value.id}/records?${params}`)
records.value = data?.records || []
total.value = data?.total || 0
}
catch (error) {
console.error('获取记录失败:', error)
}
finally {
loading.value = false
}
}
async function handleDeleteSelected() {
if (selectedIds.value.length === 0) {
toast.error('请选择要删除的记录')
return
}
try {
await api.delete(`/dev/cloud-variables/${variable.value?.id}/records`, {
ids: selectedIds.value,
})
toast.success('删除成功')
selectedIds.value = []
fetchRecords()
}
catch (error: any) {
toast.error(error.message || '删除失败')
}
}
function toggleSelectAll() {
if (selectedIds.value.length === records.value.length) {
selectedIds.value = []
}
else {
selectedIds.value = records.value.map(r => r.id)
}
}
function toggleSelect(id: number) {
const index = selectedIds.value.indexOf(id)
if (index > -1) {
selectedIds.value.splice(index, 1)
}
else {
selectedIds.value.push(id)
}
}
function formatDate(date: string) {
return new Date(date).toLocaleString('zh-CN')
}
function formatData(data: Record<string, any>) {
try {
return JSON.stringify(data, null, 2)
}
catch {
return String(data)
}
}
onMounted(async () => {
await fetchVariable()
if (variable.value) {
fetchRecords()
}
})
</script>
<template>
<BasicPage
title="变量记录"
description="查看云端变量的记录数据"
:breadcrumbs="[
{ title: '云端变量', href: '/admin/cloud-variables' },
{ title: '变量记录' },
]"
>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<UiLabel class="text-sm">开始日期</UiLabel>
<UiInput
v-model="startDate"
type="date"
class="w-40"
/>
</div>
<div class="flex items-center gap-2">
<UiLabel class="text-sm">结束日期</UiLabel>
<UiInput
v-model="endDate"
type="date"
class="w-40"
/>
</div>
<UiButton @click="fetchRecords">
<Icon icon="lucide:search" class="h-4 w-4 mr-2" />
查询
</UiButton>
</div>
<div class="flex items-center gap-2">
<UiButton
v-if="selectedIds.length > 0"
variant="destructive"
@click="handleDeleteSelected"
>
<Icon icon="lucide:trash-2" class="h-4 w-4 mr-2" />
删除选中 ({{ selectedIds.length }})
</UiButton>
</div>
</div>
<div class="border rounded-lg">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-muted/50">
<tr>
<th class="w-10 p-3 text-left">
<UiCheckbox
:checked="selectedIds.length === records.length && records.length > 0"
@update:checked="toggleSelectAll"
/>
</th>
<th class="p-3 text-left font-medium">
ID
</th>
<th v-if="variable?.scope === 'user'" class="p-3 text-left font-medium">
用户
</th>
<th class="p-3 text-left font-medium">
数据
</th>
<th class="p-3 text-left font-medium">
创建时间
</th>
</tr>
</thead>
<tbody>
<tr
v-for="record in records"
:key="record.id"
class="border-t hover:bg-muted/30"
>
<td class="p-3">
<UiCheckbox
:checked="selectedIds.includes(record.id)"
@update:checked="toggleSelect(record.id)"
/>
</td>
<td class="p-3 text-muted-foreground">
{{ record.id }}
</td>
<td v-if="variable?.scope === 'user'" class="p-3">
<span v-if="record.user">{{ record.user.username }}</span>
<span v-else class="text-muted-foreground">-</span>
</td>
<td class="p-3">
<pre class="text-xs bg-muted p-2 rounded max-w-md overflow-auto max-h-32">{{ formatData(record.data) }}</pre>
</td>
<td class="p-3 text-muted-foreground">
{{ formatDate(record.created_at) }}
</td>
</tr>
<tr v-if="records.length === 0">
<td :colspan="variable?.scope === 'user' ? 5 : 4" class="p-8 text-center text-muted-foreground">
暂无记录
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="flex items-center justify-between">
<p class="text-sm text-muted-foreground">
{{ total }} 条记录
</p>
<div class="flex items-center gap-2">
<UiButton
variant="outline"
size="sm"
:disabled="page <= 1"
@click="page--; fetchRecords()"
>
上一页
</UiButton>
<span class="text-sm"> {{ page }} </span>
<UiButton
variant="outline"
size="sm"
:disabled="page * pageSize >= total"
@click="page++; fetchRecords()"
>
下一页
</UiButton>
</div>
</div>
</div>
</BasicPage>
</template>
@@ -1,221 +0,0 @@
import type { ColumnDef } from '@tanstack/vue-table'
import type { Composer } from 'vue-i18n'
import { Download, File, List, MoreHorizontal, Pencil, Power, PowerOff, Trash2 } from 'lucide-vue-next'
import { h } from 'vue'
import type { CloudVariable } from '@/pages/admin/cloud-variables/data/schema'
import { Copy } from '@/components/sva-ui/copy'
import Badge from '@/components/ui/badge/Badge.vue'
import Button from '@/components/ui/button/Button.vue'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
export function getColumns(actions: {
onEdit: (row: CloudVariable) => void
onDelete: (row: CloudVariable) => void
onToggleStatus: (row: CloudVariable) => void
onDownload: (row: CloudVariable) => void
onViewRecords: (row: CloudVariable) => void
}, t: Composer['t']): ColumnDef<CloudVariable>[] {
return [
{
accessorKey: 'key',
header: () => t('admin.cloudVariables.columns.key'),
cell: ({ row }) => {
const key = row.getValue('key') as string
if (!key)
return '-'
return h('div', { class: 'flex items-center space-x-2' }, [
h('code', { class: 'text-xs bg-muted px-2 py-1 rounded font-mono' }, key),
h(Copy, { class: 'h-4 w-4', size: 'sm', content: key }),
])
},
},
{
accessorKey: 'default_value',
header: () => t('admin.cloudVariables.columns.defaultValue'),
cell: ({ row }) => {
const value = row.getValue('default_value') as string
const varType = row.original.var_type
if (!value)
return '-'
if (varType === 'binary') {
const originalName = row.original.original_name || value
return h('div', { class: 'flex items-center gap-2' }, [
h(File, { class: 'h-4 w-4 text-muted-foreground' }),
h('span', { class: 'max-w-xs truncate', title: originalName }, originalName),
])
}
return h('div', { class: 'max-w-xs truncate', title: value }, value)
},
},
{
accessorKey: 'scope',
header: () => t('admin.cloudVariables.columns.scope'),
cell: ({ row }) => {
const scope = row.getValue('scope') as string
if (scope === 'app') {
return h(Badge, { variant: 'default' }, () => t('admin.cloudVariables.appScope'))
}
return h(Badge, { variant: 'secondary' }, () => t('admin.cloudVariables.userScope'))
},
},
{
accessorKey: 'write_permission',
header: () => t('admin.cloudVariables.create.writePermission'),
cell: ({ row }) => {
const permission = row.getValue('write_permission') as string
if (permission === 'user') {
return h(Badge, { variant: 'outline', class: 'bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-300' }, () => t('admin.cloudVariables.create.userWritable'))
}
return h(Badge, { variant: 'outline' }, () => t('admin.cloudVariables.create.developerOnly'))
},
},
{
accessorKey: 'var_type',
header: () => t('admin.cloudVariables.columns.type'),
cell: ({ row }) => {
const type = row.getValue('var_type') as string
const typeMap: Record<string, string> = {
integer: t('admin.cloudVariables.types.integer'),
decimal: t('admin.cloudVariables.types.decimal'),
string: t('admin.cloudVariables.types.string'),
binary: t('admin.cloudVariables.types.binary'),
stream: '记录',
}
return h(Badge, { variant: 'outline' }, () => typeMap[type || 'string'] || type || 'string')
},
},
{
accessorKey: 'description',
header: () => t('admin.cloudVariables.columns.description'),
cell: ({ row }) => {
const description = row.getValue('description') as string
if (!description)
return '-'
return h('div', { class: 'max-w-xs truncate', title: description }, description)
},
},
{
accessorKey: 'application_name',
header: () => t('admin.cloudVariables.columns.application'),
cell: ({ row }) => {
const appName = row.getValue('application_name') as string
if (!appName) {
const appId = row.original.app_id
if (!appId)
return h(Badge, { variant: 'outline' }, () => t('admin.cloudVariables.notLinked'))
return '-'
}
return h(Badge, { variant: 'outline' }, () => appName)
},
},
{
accessorKey: 'status',
header: () => t('admin.cloudVariables.columns.status'),
cell: ({ row }) => {
const status = row.getValue('status') as string
const isActive = status === 'active'
return h(Badge, { variant: isActive ? 'default' : 'destructive' }, () => isActive ? t('admin.cloudVariables.statusActive') : t('admin.cloudVariables.statusInactive'))
},
},
{
accessorKey: 'created_at',
header: () => t('admin.cloudVariables.columns.createdAt'),
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' }, t('common.actions')),
cell: ({ row }) => {
const variable = row.original
return h(
DropdownMenu,
{},
{
default: () => [
h(DropdownMenuTrigger, { asChild: true }, () =>
h(Button, { variant: 'ghost', class: 'h-8 w-8 p-0' }, () => [
h(MoreHorizontal, { class: 'h-4 w-4' }),
h('span', { class: 'sr-only' }, t('common.openMenu')),
])),
h(
DropdownMenuContent,
{ align: 'end' },
() => {
const items = []
if (variable.var_type === 'stream') {
items.push(
h(DropdownMenuItem, { onClick: () => actions.onViewRecords(variable) }, () => [
h(List, { class: 'mr-2 h-4 w-4' }),
'查看记录',
]),
)
}
items.push(
h(DropdownMenuItem, { onClick: () => actions.onEdit(variable) }, () => [
h(Pencil, { class: 'mr-2 h-4 w-4' }),
t('admin.cloudVariables.edit'),
]),
)
if (variable.var_type === 'binary') {
items.push(
h(DropdownMenuItem, { onClick: () => actions.onDownload(variable) }, () => [
h(Download, { class: 'mr-2 h-4 w-4' }),
'下载文件',
]),
)
}
items.push(
h(DropdownMenuItem, { onClick: () => actions.onToggleStatus(variable) }, () => [
variable.status === 'active' ? h(PowerOff, { class: 'mr-2 h-4 w-4' }) : h(Power, { class: 'mr-2 h-4 w-4' }),
variable.status === 'active' ? t('admin.cloudVariables.disable') : t('admin.cloudVariables.enable'),
]),
h(DropdownMenuItem, { class: 'text-destructive', onClick: () => actions.onDelete(variable) }, () => [
h(Trash2, { class: 'mr-2 h-4 w-4' }),
t('admin.cloudVariables.delete'),
]),
)
return items
},
),
],
},
)
},
enableSorting: false,
enableHiding: false,
},
]
}
@@ -1,54 +0,0 @@
<script setup lang="ts">
import type { Row } from '@tanstack/vue-table'
import { Ellipsis, Pencil, Trash2, List } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import type { CloudVariable } from '../data/schema'
interface DataTableRowActionsProps {
row: Row<CloudVariable>
}
const props = defineProps<DataTableRowActionsProps>()
const emit = defineEmits<{
edit: [variable: CloudVariable]
delete: [variable: CloudVariable]
}>()
const router = useRouter()
const variable = props.row.original
function viewRecords() {
router.push(`/admin/cloud-variables/${variable.id}/records?app_id=${variable.app_id}`)
}
</script>
<template>
<UiDropdownMenu>
<UiDropdownMenuTrigger as-child>
<UiButton
variant="ghost"
class="flex h-8 w-8 p-0 data-[state=open]:bg-muted"
>
<Ellipsis class="size-4" />
<span class="sr-only">打开菜单</span>
</UiButton>
</UiDropdownMenuTrigger>
<UiDropdownMenuContent align="end" class="w-[160px]">
<UiDropdownMenuItem v-if="variable.var_type === 'stream'" @click="viewRecords">
<List class="mr-2 h-4 w-4" />
查看记录
</UiDropdownMenuItem>
<UiDropdownMenuItem @click="emit('edit', variable)">
<Pencil class="mr-2 h-4 w-4" />
编辑
</UiDropdownMenuItem>
<UiDropdownMenuSeparator />
<UiDropdownMenuItem class="text-destructive" @click="emit('delete', variable)">
<Trash2 class="mr-2 h-4 w-4" />
删除
</UiDropdownMenuItem>
</UiDropdownMenuContent>
</UiDropdownMenu>
</template>
@@ -1,43 +0,0 @@
<script setup lang="ts">
import type { Table } from '@tanstack/vue-table'
import { X } from 'lucide-vue-next'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { CloudVariable } from '@/pages/admin/cloud-variables/data/schema'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
const props = defineProps<DataTableToolbarProps>()
const { t } = useI18n()
interface DataTableToolbarProps {
table: Table<CloudVariable>
}
const isFiltered = computed(() => props.table.getState().columnFilters.length > 0)
</script>
<template>
<div class="flex items-center flex-1 space-x-2">
<Input
:placeholder="t('admin.cloudVariables.searchPlaceholder')"
:model-value="(table.getColumn('key')?.getFilterValue() as string) ?? ''"
class="h-8 w-[150px] lg:w-[250px]"
@input="table.getColumn('key')?.setFilterValue($event.target.value)"
/>
<Button
v-if="isFiltered"
variant="ghost"
class="h-8 px-2 lg:px-3"
@click="table.resetColumnFilters()"
>
{{ t('common.reset') }}
<X class="size-4 ml-2" />
</Button>
</div>
</template>
@@ -1,102 +0,0 @@
<script setup lang="ts">
import type { ColumnDef } from '@tanstack/vue-table'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { DataTableProps } from '@/components/data-table/types'
import type { CloudVariable } from '@/pages/admin/cloud-variables/data/schema'
import BulkActions from '@/components/data-table/bulk-actions.vue'
import DataTable from '@/components/data-table/data-table.vue'
import { SelectColumn } from '@/components/data-table/table-columns'
import { generateVueTable } from '@/components/data-table/use-generate-vue-table'
import DataTableViewOptions from '@/components/data-table/view-options.vue'
import { getColumns } from '@/pages/admin/cloud-variables/components/columns'
import DataTableToolbar from '@/pages/admin/cloud-variables/components/data-table-toolbar.vue'
const props = defineProps<Omit<DataTableProps<CloudVariable>, 'columns'> & {
onEdit: (row: CloudVariable) => void
onDelete: (row: CloudVariable) => void
onToggleStatus: (row: CloudVariable) => void
onDownload: (row: CloudVariable) => void
onViewRecords: (row: CloudVariable) => void
}>()
const emit = defineEmits<{
refresh: []
batchAction: [action: 'enable' | 'disable' | 'delete', rows: CloudVariable[]]
}>()
const { t } = useI18n()
const columns = computed(() => [
SelectColumn as ColumnDef<CloudVariable>,
...getColumns({
onEdit: props.onEdit,
onDelete: props.onDelete,
onToggleStatus: props.onToggleStatus,
onDownload: props.onDownload,
onViewRecords: props.onViewRecords,
}, t),
])
const table = generateVueTable<CloudVariable>({
get data() { return props.data },
get loading() { return props.loading },
columns: columns.value,
}, columns.value)
const columnLabels: Record<string, string> = {
select: 'admin.cloudVariables.select',
key: 'admin.cloudVariables.columns.key',
default_value: 'admin.cloudVariables.columns.defaultValue',
scope: 'admin.cloudVariables.columns.scope',
write_permission: 'admin.cloudVariables.create.writePermission',
var_type: 'admin.cloudVariables.columns.type',
description: 'admin.cloudVariables.columns.description',
application_name: 'admin.cloudVariables.columns.application',
status: 'admin.cloudVariables.columns.status',
created_at: 'admin.cloudVariables.columns.createdAt',
}
defineExpose({
table,
})
function handleBatchAction(action: 'enable' | 'disable' | 'delete') {
const selectedRows = table.getSelectedRowModel().rows.map(row => row.original)
emit('batchAction', action, selectedRows)
table.resetRowSelection()
}
</script>
<template>
<DataTable :columns="columns" :data :loading :table @refresh="emit('refresh')">
<template #toolbar>
<div class="space-y-3">
<div class="flex items-center justify-end">
<slot name="actions" />
</div>
<BulkActions :table="table" entity-name="variables">
<UiButton variant="outline" size="sm" @click="handleBatchAction('enable')">
{{ t('admin.cloudVariables.batchEnableBtn') }}
</UiButton>
<UiButton variant="outline" size="sm" @click="handleBatchAction('disable')">
{{ t('admin.cloudVariables.batchDisableBtn') }}
</UiButton>
<UiButton variant="destructive" size="sm" @click="handleBatchAction('delete')">
{{ t('admin.cloudVariables.batchDeleteBtn') }}
</UiButton>
</BulkActions>
<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,481 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import { BasicPage } from '@/components/global-layout'
import api from '@/services/api'
const router = useRouter()
const { t } = useI18n()
interface Application {
id: number
name: string
}
const saving = ref(false)
const applications = ref<Application[]>([])
const fileInput = ref<HTMLInputElement | null>(null)
const selectedFile = ref<File | null>(null)
const uploading = ref(false)
const form = ref({
app_id: '',
key: '',
default_value: '',
var_type: 'string',
max_records: 0,
scope: 'app',
write_permission: 'admin',
description: '',
status: 'active',
})
async function fetchApplications() {
try {
const data = await api.get<{ applications: Application[] }>('/dev/applications')
applications.value = Array.isArray(data?.applications) ? data.applications : []
}
catch (error) {
console.error('获取应用列表失败:', error)
}
}
const selectedApplication = computed(() => {
if (form.value.app_id) {
return applications.value.find(app => String(app.id) === form.value.app_id)
}
return null
})
function handleFileSelect(event: Event) {
const target = event.target as HTMLInputElement
if (target.files && target.files[0]) {
selectedFile.value = target.files[0]
if (!form.value.key) {
form.value.key = selectedFile.value.name.replace(/\.[^/.]+$/, '')
}
}
}
function clearFile() {
selectedFile.value = null
if (fileInput.value) {
fileInput.value.value = ''
}
}
async function handleSave() {
if (!form.value.key) {
toast.error(t('admin.cloudVariables.create.keyRequired'))
return
}
if (!form.value.app_id) {
toast.error(t('admin.cloudVariables.create.appRequired'))
return
}
if (form.value.var_type === 'binary') {
if (!selectedFile.value) {
toast.error('请选择要上传的文件')
return
}
await uploadFile()
return
}
saving.value = true
try {
await api.post('/dev/cloud-variables', {
app_id: Number(form.value.app_id),
key: form.value.key,
default_value: form.value.default_value,
var_type: form.value.var_type,
max_records: form.value.max_records,
scope: form.value.scope,
write_permission: form.value.write_permission,
description: form.value.description,
status: form.value.status,
})
toast.success(t('admin.cloudVariables.create.success'))
router.push('/admin/cloud-variables')
}
catch (error: any) {
console.error('创建云端变量失败:', error)
toast.error(error.message || t('admin.cloudVariables.create.failed'))
}
finally {
saving.value = false
}
}
async function uploadFile() {
if (!selectedFile.value || !form.value.app_id || !form.value.key)
return
uploading.value = true
try {
const formData = new FormData()
formData.append('file', selectedFile.value)
formData.append('app_id', form.value.app_id)
formData.append('key', form.value.key)
formData.append('description', form.value.description)
formData.append('status', form.value.status)
formData.append('scope', form.value.scope)
formData.append('write_permission', form.value.write_permission)
await api.postFormData('/dev/cloud-variables/upload', formData)
toast.success(t('admin.cloudVariables.create.success'))
router.push('/admin/cloud-variables')
}
catch (error: any) {
console.error('上传文件失败:', error)
toast.error(error.message || '上传文件失败')
}
finally {
uploading.value = false
}
}
function formatFileSize(bytes: number): string {
if (bytes === 0)
return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`
}
onMounted(() => {
fetchApplications()
})
</script>
<template>
<BasicPage
:title="t('admin.cloudVariables.addVariable')"
:description="t('admin.cloudVariables.createDescription')"
:breadcrumbs="[
{ title: t('admin.cloudVariables.title'), href: '/admin/cloud-variables' },
{ title: t('admin.cloudVariables.addVariable') },
]"
sticky
>
<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 class="flex items-center gap-2">
<Icon icon="lucide:settings-2" class="size-5" />
{{ t('admin.cloudVariables.create.config') }}
</UiCardTitle>
<UiCardDescription>{{ t('admin.cloudVariables.create.configDesc') }}</UiCardDescription>
</UiCardHeader>
<UiCardContent class="space-y-6">
<div class="space-y-2">
<UiLabel for="application">
{{ t('admin.cloudVariables.create.application') }}
</UiLabel>
<UiSelect v-model="form.app_id">
<UiSelectTrigger>
<UiSelectValue :placeholder="t('admin.cloudVariables.create.selectApp')" />
</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="varType">
{{ t('admin.cloudVariables.create.type') }}
</UiLabel>
<UiSelect id="varType" v-model="form.var_type">
<UiSelectTrigger>
<UiSelectValue :placeholder="t('admin.cloudVariables.create.selectType')" />
</UiSelectTrigger>
<UiSelectContent>
<UiSelectItem value="integer">
{{ t('admin.cloudVariables.types.integer') }}
</UiSelectItem>
<UiSelectItem value="decimal">
{{ t('admin.cloudVariables.types.decimal') }}
</UiSelectItem>
<UiSelectItem value="string">
{{ t('admin.cloudVariables.types.string') }}
</UiSelectItem>
<UiSelectItem value="binary">
二进制
</UiSelectItem>
<UiSelectItem value="stream">
记录
</UiSelectItem>
</UiSelectContent>
</UiSelect>
</div>
<div v-if="form.var_type === 'stream'" class="space-y-2">
<UiLabel for="maxRecords">
最大记录数
</UiLabel>
<UiInput
id="maxRecords"
v-model.number="form.max_records"
type="number"
min="0"
placeholder="0 = 不限制"
/>
<p class="text-xs text-muted-foreground">
超过限制时自动删除最旧的记录0表示不限制
</p>
</div>
<div class="space-y-2">
<UiLabel for="key">
{{ t('admin.cloudVariables.create.key') }}
</UiLabel>
<UiInput id="key" v-model="form.key" :placeholder="t('admin.cloudVariables.create.keyPlaceholder')" />
<p class="text-xs text-muted-foreground">
{{ t('admin.cloudVariables.create.keyHint') }}
</p>
</div>
<div v-if="form.var_type === 'binary'" class="space-y-2">
<UiLabel>选择文件</UiLabel>
<div v-if="!selectedFile" class="border-2 border-dashed rounded-lg p-6 text-center">
<input
ref="fileInput"
type="file"
class="hidden"
@change="handleFileSelect"
>
<Icon icon="lucide:upload" class="mx-auto h-12 w-12 text-muted-foreground mb-4" />
<p class="text-sm text-muted-foreground mb-2">
拖拽文件到此处或点击选择
</p>
<UiButton variant="outline" @click="fileInput?.click()">
选择文件
</UiButton>
</div>
<div v-else class="border rounded-lg p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<Icon icon="lucide:file" class="h-8 w-8 text-muted-foreground" />
<div>
<p class="font-medium">
{{ selectedFile.name }}
</p>
<p class="text-sm text-muted-foreground">
{{ formatFileSize(selectedFile.size) }}
</p>
</div>
</div>
<UiButton variant="ghost" size="icon" @click="clearFile">
<Icon icon="lucide:x" class="h-4 w-4" />
</UiButton>
</div>
</div>
</div>
<div v-else class="space-y-2">
<UiLabel for="defaultValue">
{{ t('admin.cloudVariables.create.defaultValue') }}
</UiLabel>
<UiTextarea id="defaultValue" v-model="form.default_value" :placeholder="t('admin.cloudVariables.create.defaultValuePlaceholder')" rows="3" />
</div>
<div class="space-y-2">
<UiLabel for="description">
{{ t('admin.cloudVariables.create.description') }}
</UiLabel>
<UiInput id="description" v-model="form.description" :placeholder="t('admin.cloudVariables.create.descriptionPlaceholder')" />
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader>
<UiCardTitle>{{ t('admin.cloudVariables.create.scopeTitle') }}</UiCardTitle>
<UiCardDescription>{{ t('admin.cloudVariables.create.scopeDesc') }}</UiCardDescription>
</UiCardHeader>
<UiCardContent>
<UiRadioGroup v-model="form.scope" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
:class="form.scope === 'app' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="form.scope = 'app'"
>
<UiRadioGroupItem value="app" class="mt-0.5" />
<div>
<div class="flex items-center gap-2">
<Icon icon="lucide:globe" class="size-4 text-primary" />
<p class="font-medium">
{{ t('admin.cloudVariables.create.appScope') }}
</p>
</div>
<p class="text-sm text-muted-foreground mt-1">
{{ t('admin.cloudVariables.create.appScopeDesc') }}
</p>
</div>
</div>
<div
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
:class="form.scope === 'user' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="form.scope = 'user'"
>
<UiRadioGroupItem value="user" class="mt-0.5" />
<div>
<div class="flex items-center gap-2">
<Icon icon="lucide:user" class="size-4 text-primary" />
<p class="font-medium">
{{ t('admin.cloudVariables.create.userScope') }}
</p>
</div>
<p class="text-sm text-muted-foreground mt-1">
{{ t('admin.cloudVariables.create.userScopeDesc') }}
</p>
</div>
</div>
</UiRadioGroup>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader>
<UiCardTitle>{{ t('admin.cloudVariables.create.permissionTitle') }}</UiCardTitle>
<UiCardDescription>{{ t('admin.cloudVariables.create.permissionDesc') }}</UiCardDescription>
</UiCardHeader>
<UiCardContent>
<UiRadioGroup v-model="form.write_permission" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
:class="form.write_permission === 'admin' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="form.write_permission = 'admin'"
>
<UiRadioGroupItem value="admin" class="mt-0.5" />
<div>
<div class="flex items-center gap-2">
<Icon icon="lucide:lock" class="size-4 text-primary" />
<p class="font-medium">
{{ t('admin.cloudVariables.create.developerOnly') }}
</p>
</div>
<p class="text-sm text-muted-foreground mt-1">
{{ form.scope === 'app' ? t('admin.cloudVariables.create.developerOnlyAppDesc') : t('admin.cloudVariables.create.developerOnlyDesc') }}
</p>
</div>
</div>
<div
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
:class="form.write_permission === 'user' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
@click="form.write_permission = 'user'"
>
<UiRadioGroupItem value="user" class="mt-0.5" />
<div>
<div class="flex items-center gap-2">
<Icon icon="lucide:unlock" class="size-4 text-primary" />
<p class="font-medium">
{{ t('admin.cloudVariables.create.userWritable') }}
</p>
</div>
<p class="text-sm text-muted-foreground mt-1">
{{ form.scope === 'app' ? t('admin.cloudVariables.create.userWritableAppDesc') : t('admin.cloudVariables.create.userWritableDesc') }}
</p>
</div>
</div>
</UiRadioGroup>
</UiCardContent>
</UiCard>
</div>
<div class="space-y-6">
<UiCard>
<UiCardHeader>
<UiCardTitle class="flex items-center gap-2">
<Icon icon="lucide:eye" class="size-5" />
{{ t('admin.cloudVariables.create.preview') }}
</UiCardTitle>
</UiCardHeader>
<UiCardContent class="space-y-4">
<div class="space-y-3">
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('admin.cloudVariables.create.application') }}</span>
<span>{{ selectedApplication?.name || '-' }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('admin.cloudVariables.create.type') }}</span>
<span>{{ t(`admin.cloudVariables.types.${form.var_type}`) }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('admin.cloudVariables.create.key') }}</span>
<span>{{ form.key || '-' }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('admin.cloudVariables.create.scope') }}</span>
<UiBadge variant="outline">
{{ form.scope === 'app' ? t('admin.cloudVariables.appScope') : t('admin.cloudVariables.userScope') }}
</UiBadge>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('admin.cloudVariables.create.writePermission') }}</span>
<UiBadge variant="secondary">
{{ form.write_permission === 'admin' ? t('admin.cloudVariables.create.developerOnly') : t('admin.cloudVariables.create.userWritable') }}
</UiBadge>
</div>
<div class="flex justify-between text-sm items-center">
<span class="text-muted-foreground">{{ t('admin.cloudVariables.create.status') }}</span>
<UiSwitch
:checked="form.status === 'active'"
@update:checked="form.status = $event ? 'active' : 'inactive'"
/>
</div>
<div class="border-t pt-3 mt-3">
<div class="text-sm">
<div class="text-muted-foreground mb-2">
{{ t('admin.cloudVariables.create.defaultValue') }}
</div>
<div class="bg-muted p-2 rounded max-h-[100px] overflow-y-auto text-xs">
{{ form.default_value || '-' }}
</div>
</div>
</div>
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardContent class="pt-6">
<div class="flex flex-col gap-3">
<UiButton
class="w-full"
size="lg"
:disabled="(saving || uploading) || !form.app_id || !form.key || (form.var_type === 'binary' && !selectedFile)"
@click="handleSave"
>
<Icon v-if="saving || uploading" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
<Icon v-else icon="lucide:check" class="mr-2 h-4 w-4" />
{{ uploading ? '上传中...' : t('admin.cloudVariables.create.submit') }}
</UiButton>
<UiButton
variant="outline"
class="w-full"
@click="router.back()"
>
{{ t('admin.cloudVariables.create.cancel') }}
</UiButton>
</div>
</UiCardContent>
</UiCard>
</div>
</div>
</div>
</BasicPage>
</template>
@@ -1,9 +0,0 @@
import type { CloudVariable } from './schema'
export const variableAppOptions: { label: string, value: string }[] = []
export function filterVariablesByApp(variables: CloudVariable[], appId: string): CloudVariable[] {
if (!appId || appId === 'all')
return variables
return variables.filter(v => String(v.app_id) === appId)
}
@@ -1,24 +0,0 @@
import { z } from 'zod'
export const cloudVariableSchema = z.object({
id: z.union([z.string(), z.number()]),
user_id: z.union([z.string(), z.number()]),
app_id: z.union([z.string(), z.number()]).nullable().optional(),
application_name: z.string().optional().nullable(),
key: z.string(),
default_value: z.string().optional().nullable(),
var_type: z.string().optional().nullable(),
max_records: z.union([z.string(), z.number()]).optional().nullable(),
file_path: z.string().optional().nullable(),
file_size: z.union([z.string(), z.number()]).optional().nullable(),
mime_type: z.string().optional().nullable(),
original_name: z.string().optional().nullable(),
scope: z.string().optional().nullable(),
write_permission: z.string().optional().nullable(),
description: z.string().optional().nullable(),
status: z.string().optional().nullable(),
created_at: z.string(),
updated_at: z.string().optional().nullable(),
})
export type CloudVariable = z.infer<typeof cloudVariableSchema>
@@ -1,361 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import ConfirmDialog from '@/components/confirm-dialog.vue'
import DateRangeFilter from '@/components/data-table/date-range-filter.vue'
import SingleFilter from '@/components/data-table/single-filter.vue'
import { BasicPage } from '@/components/global-layout'
import api from '@/services/api'
import type { CloudVariable } from './data/schema'
import DataTable from './components/data-table.vue'
const { t } = useI18n()
const router = useRouter()
interface Application {
id: number
name: string
app_key: string
}
const loading = ref(true)
const variables = ref<CloudVariable[]>([])
const applications = ref<Application[]>([])
const appFilter = ref<string>('')
const scopeFilter = ref<string>('')
const startDate = ref<string>('')
const endDate = ref<string>('')
const deleteDialogOpen = ref(false)
const deleteTarget = ref<CloudVariable | null>(null)
const batchDialogOpen = ref(false)
const batchAction = ref<'enable' | 'disable' | 'delete' | null>(null)
const selectedRows = ref<CloudVariable[]>([])
const filteredVariables = computed(() => {
let result = [...variables.value]
if (appFilter.value) {
result = result.filter(v => String(v.app_id) === appFilter.value)
}
if (scopeFilter.value) {
result = result.filter(v => v.scope === scopeFilter.value)
}
if (startDate.value) {
const fromDateTime = startDate.value.includes('T')
? startDate.value.replace('T', ' ')
: `${startDate.value} 00:00`
result = result.filter(v => v.created_at >= fromDateTime)
}
if (endDate.value) {
const toDateTime = endDate.value.includes('T')
? endDate.value.replace('T', ' ')
: `${endDate.value} 23:59`
result = result.filter(v => v.created_at <= toDateTime)
}
return result
})
const appScopeCount = computed(() => variables.value.filter(v => v.scope === 'app').length)
const userScopeCount = computed(() => variables.value.filter(v => v.scope === 'user').length)
const applicationOptions = computed(() => {
return applications.value.map(app => ({
label: app.name,
value: String(app.id),
}))
})
const scopeOptions = computed(() => [
{ label: t('admin.cloudVariables.appScope'), value: 'app' },
{ label: t('admin.cloudVariables.userScope'), value: 'user' },
])
async function fetchApplications() {
try {
const data = await api.get<{ applications: Application[] }>('/dev/applications')
applications.value = data?.applications || []
}
catch (error) {
console.error('获取应用列表失败:', error)
}
}
async function fetchVariables() {
loading.value = true
try {
const data = await api.get<{ variables: CloudVariable[] }>('/dev/cloud-variables')
const vars = data?.variables || []
variables.value = vars.map((v: any) => ({
...v,
application_name: applications.value.find(app => app.id === v.app_id)?.name || '',
}))
}
catch (error) {
console.error('获取云端变量失败:', error)
variables.value = []
}
finally {
loading.value = false
}
}
function openCreatePage() {
router.push('/admin/cloud-variables/create')
}
function openEditPage(variable: CloudVariable) {
router.push(`/admin/cloud-variables/${variable.id}`)
}
function confirmDelete(variable: CloudVariable) {
deleteTarget.value = variable
deleteDialogOpen.value = true
}
async function handleDelete() {
if (!deleteTarget.value)
return
try {
await api.delete(`/dev/cloud-variables/${deleteTarget.value.id}`)
toast.success(t('admin.cloudVariables.deleteSuccess'))
fetchVariables()
}
catch (error: any) {
console.error('删除云端变量失败:', error)
toast.error(error.message || t('admin.cloudVariables.deleteFailed'))
}
finally {
deleteTarget.value = null
}
}
async function handleToggleStatus(variable: CloudVariable) {
const newStatus = variable.status === 'active' ? 'inactive' : 'active'
try {
await api.put(`/dev/cloud-variables/${variable.id}`, {
key: variable.key,
default_value: variable.default_value,
var_type: variable.var_type,
scope: variable.scope,
write_permission: variable.write_permission,
description: variable.description,
status: newStatus,
})
toast.success(newStatus === 'active' ? t('admin.cloudVariables.enableSuccess') : t('admin.cloudVariables.disableSuccess'))
fetchVariables()
}
catch (error: any) {
console.error('更新状态失败:', error)
toast.error(error.message || t('admin.cloudVariables.updateFailed'))
}
}
function handleDownload(variable: CloudVariable) {
if (variable.var_type !== 'binary')
return
const token = localStorage.getItem('token')
const url = `/api/v1/dev/cloud-variables/${variable.id}/download?token=${token}`
window.open(url, '_blank')
}
function handleViewRecords(variable: CloudVariable) {
router.push(`/admin/cloud-variables/${variable.id}/records?app_id=${variable.app_id}`)
}
function openBatchDialog(action: 'enable' | 'disable' | 'delete', rows: CloudVariable[]) {
batchAction.value = action
selectedRows.value = rows
batchDialogOpen.value = true
}
async function handleBatchAction() {
if (!batchAction.value || selectedRows.value.length === 0)
return
const ids = selectedRows.value.map(v => v.id)
try {
if (batchAction.value === 'delete') {
await Promise.all(ids.map(id => api.delete(`/dev/cloud-variables/${id}`)))
toast.success(t('admin.cloudVariables.batchDeleteSuccess'))
}
else {
const status = batchAction.value === 'enable' ? 'active' : 'inactive'
await Promise.all(selectedRows.value.map(v => api.put(`/dev/cloud-variables/${v.id}`, {
key: v.key,
default_value: v.default_value,
var_type: v.var_type,
scope: v.scope,
write_permission: v.write_permission,
description: v.description,
status,
})))
toast.success(batchAction.value === 'enable' ? t('admin.cloudVariables.batchEnableSuccess') : t('admin.cloudVariables.batchDisableSuccess'))
}
fetchVariables()
}
catch (error: any) {
console.error('批量操作失败:', error)
toast.error(error.message || t('admin.cloudVariables.batchFailed'))
}
finally {
batchAction.value = null
selectedRows.value = []
}
}
onMounted(async () => {
await fetchApplications()
fetchVariables()
})
</script>
<template>
<BasicPage
:title="t('admin.cloudVariables.title')"
:description="t('admin.cloudVariables.description')"
sticky
>
<template #actions>
<UiButton size="sm" @click="openCreatePage">
<Icon icon="lucide:plus" class="mr-2 h-4 w-4" />
{{ t('admin.cloudVariables.addVariable') }}
</UiButton>
</template>
<div class="space-y-6">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.cloudVariables.totalVariables') }}
</UiCardTitle>
<Icon icon="lucide:variable" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ filteredVariables.length }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.cloudVariables.appScopeVariables') }}
</UiCardTitle>
<Icon icon="lucide:globe" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ appScopeCount }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.cloudVariables.userScopeVariables') }}
</UiCardTitle>
<Icon icon="lucide:user" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ userScopeCount }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.cloudVariables.appCount') }}
</UiCardTitle>
<Icon icon="lucide:layout-grid" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ applications.length }}
</div>
</UiCardContent>
</UiCard>
</div>
<UiCard>
<UiCardContent class="p-6">
<DataTable
:loading
:data="filteredVariables"
:on-edit="openEditPage"
:on-delete="confirmDelete"
:on-toggle-status="handleToggleStatus"
:on-download="handleDownload"
:on-view-records="handleViewRecords"
@refresh="fetchVariables"
@batch-action="openBatchDialog"
>
<template #filters>
<SingleFilter
v-model="appFilter"
:title="t('admin.cloudVariables.application')"
:options="applicationOptions"
/>
<SingleFilter
v-model="scopeFilter"
:title="t('admin.cloudVariables.scope')"
:options="scopeOptions"
/>
<DateRangeFilter
v-model:start-model-value="startDate"
v-model:end-model-value="endDate"
/>
</template>
</DataTable>
</UiCardContent>
</UiCard>
</div>
<ConfirmDialog
v-model:open="deleteDialogOpen"
destructive
:confirm-button-text="t('admin.cloudVariables.delete')"
:cancel-button-text="t('common.reset')"
@confirm="handleDelete"
>
<template #title>
{{ t('admin.cloudVariables.deleteVariable') }}
</template>
<template #description>
{{ t('admin.cloudVariables.deleteConfirm', { key: deleteTarget?.key }) }}
</template>
</ConfirmDialog>
<ConfirmDialog
v-model:open="batchDialogOpen"
:destructive="batchAction === 'delete'"
:confirm-button-text="batchAction === 'delete' ? t('admin.cloudVariables.delete') : t('common.confirm')"
:cancel-button-text="t('common.cancel')"
@confirm="handleBatchAction"
>
<template #title>
{{ batchAction === 'delete' ? t('admin.cloudVariables.batchDelete') : batchAction === 'enable' ? t('admin.cloudVariables.batchEnable') : t('admin.cloudVariables.batchDisable') }}
</template>
<template #description>
{{ batchAction === 'delete' ? t('admin.cloudVariables.batchDeleteConfirm', { count: selectedRows.length }) : batchAction === 'enable' ? t('admin.cloudVariables.batchEnableConfirm', { count: selectedRows.length }) : t('admin.cloudVariables.batchDisableConfirm', { count: selectedRows.length }) }}
</template>
</ConfirmDialog>
</BasicPage>
</template>
@@ -1,179 +0,0 @@
import type { ColumnDef } from '@tanstack/vue-table'
import { Ban, Eye, LogOut, MoreHorizontal, Trash2 } from 'lucide-vue-next'
import { h } from 'vue'
import { useRouter } from 'vue-router'
import type { Device } from '@/pages/admin/devices/data/schema'
import Badge from '@/components/ui/badge/Badge.vue'
import Button from '@/components/ui/button/Button.vue'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
export function getColumns(actions: {
onToggleStatus: (row: Device) => void
onDelete: (row: Device) => void
onForceOffline: (row: Device) => void
}): ColumnDef<Device>[] {
const router = useRouter()
return [
{
accessorKey: 'device_id',
header: '设备指纹',
cell: ({ row }) => {
const deviceId = row.getValue('device_id') as string
if (!deviceId)
return '-'
const displayText = deviceId.length > 16
? `${deviceId.substring(0, 8)}...${deviceId.substring(deviceId.length - 4)}`
: deviceId
return h('span', { class: 'font-mono text-xs' }, displayText)
},
},
{
accessorKey: 'device_name',
header: '设备名称',
cell: ({ row }) => {
const deviceName = row.getValue('device_name') as string
return deviceName || '-'
},
},
{
accessorKey: 'device_type',
header: '设备类型',
cell: ({ row }) => {
const deviceType = row.getValue('device_type') as string
return deviceType || '-'
},
},
{
accessorKey: 'user.username',
header: '所属用户',
cell: ({ row }) => {
const username = row.original.user?.username
return username || '-'
},
},
{
accessorKey: 'application.name',
header: '所属应用',
cell: ({ row }) => {
const appName = row.original.application?.name
return appName ? h(Badge, { variant: 'secondary' }, () => appName) : '-'
},
},
{
id: 'online_status',
header: '在线状态',
cell: ({ row }) => {
const onlineSessions = row.original.online_sessions || 0
const isOnline = onlineSessions > 0
return h(Badge, { variant: isOnline ? 'default' : 'secondary' }, () => isOnline ? '在线' : '离线')
},
},
{
accessorKey: 'online_sessions',
header: '在线实例',
cell: ({ row }) => {
const onlineSessions = row.getValue('online_sessions') as number
const count = onlineSessions || 0
if (count === 0) {
return h('span', { class: 'text-muted-foreground' }, '0')
}
return h(Badge, { variant: 'default', class: 'cursor-pointer' }, () => `${count}`)
},
},
{
accessorKey: 'status',
header: '状态',
cell: ({ row }) => {
const status = (row.getValue('status') as string) || 'active'
const isBanned = status === 'banned'
return h(Badge, { variant: isBanned ? 'destructive' : 'default' }, () => isBanned ? '已禁用' : '正常')
},
},
{
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 device = row.original
const isBanned = device.status === 'banned'
const hasOnlineSessions = (device.online_sessions || 0) > 0
return h(
DropdownMenu,
{},
{
default: () => [
h(DropdownMenuTrigger, { asChild: true }, () =>
h(Button, { variant: 'ghost', class: 'h-8 w-8 p-0' }, () => [
h(MoreHorizontal, { class: 'h-4 w-4' }),
h('span', { class: 'sr-only' }, '打开菜单'),
])),
h(
DropdownMenuContent,
{ align: 'end' },
() => [
hasOnlineSessions
? h(DropdownMenuItem, { onClick: () => router.push(`/admin/sessions?device_fingerprint=${device.device_id}`) }, () => [
h(Eye, { class: 'mr-2 h-4 w-4' }),
'查看实例',
])
: null,
hasOnlineSessions
? h(DropdownMenuItem, { class: 'text-destructive', onClick: () => actions.onForceOffline(device) }, () => [
h(LogOut, { class: 'mr-2 h-4 w-4' }),
'强制离线',
])
: null,
h(DropdownMenuItem, { onClick: () => actions.onToggleStatus(device) }, () => [
h(Ban, { class: 'mr-2 h-4 w-4' }),
isBanned ? '启用' : '禁用',
]),
h(DropdownMenuSeparator),
h(DropdownMenuItem, { class: 'text-destructive', onClick: () => actions.onDelete(device) }, () => [
h(Trash2, { class: 'mr-2 h-4 w-4' }),
'解绑',
]),
].filter(Boolean),
),
],
},
)
},
enableSorting: false,
enableHiding: false,
},
]
}
@@ -1,93 +0,0 @@
<script setup lang="ts">
import type { Table } from '@tanstack/vue-table'
import { Icon } from '@iconify/vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Device } from '@/pages/admin/devices/data/schema'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
const props = defineProps<{
table: Table<Device>
}>()
const emit = defineEmits<{
batchUnbind: []
batchBan: []
batchUnban: []
refresh: []
}>()
const { t } = useI18n()
const isFiltered = computed(() => props.table.getState().columnFilters.length > 0)
const selectedCount = computed(() => props.table.getSelectedRowModel().rows.length)
</script>
<template>
<div class="flex flex-1 items-center space-x-2">
<Input
:placeholder="t('admin.devices.searchPlaceholder')"
:model-value="(table.getColumn('device_id')?.getFilterValue() as string) ?? ''"
class="h-8 w-[150px] lg:w-[250px]"
@update:model-value="table.getColumn('device_id')?.setFilterValue($event)"
/>
<Button
v-if="isFiltered"
variant="ghost"
class="h-8 px-2 lg:px-3"
@click="table.resetColumnFilters()"
>
{{ t('admin.devices.reset') }}
<Icon icon="lucide:x" class="ml-2 h-4 w-4" />
</Button>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="sm"
class="h-8"
@click="emit('refresh')"
>
<Icon icon="lucide:refresh-cw" class="mr-2 h-4 w-4" />
刷新
</Button>
<template v-if="selectedCount > 0">
<span class="text-sm text-muted-foreground">
{{ t('admin.devices.selected', { count: selectedCount }) }}
</span>
<Button
variant="outline"
size="sm"
class="h-8"
@click="emit('batchUnban')"
>
<Icon icon="lucide:check-circle" class="mr-2 h-4 w-4" />
{{ t('admin.devices.batchUnban') }}
</Button>
<Button
variant="outline"
size="sm"
class="h-8"
@click="emit('batchBan')"
>
<Icon icon="lucide:ban" class="mr-2 h-4 w-4" />
{{ t('admin.devices.batchBan') }}
</Button>
<Button
variant="destructive"
size="sm"
class="h-8"
@click="emit('batchUnbind')"
>
<Icon icon="lucide:trash-2" class="mr-2 h-4 w-4" />
{{ t('admin.devices.batchUnbind') }}
</Button>
</template>
</div>
</template>
@@ -1,99 +0,0 @@
<script setup lang="ts">
import type { ColumnDef } from '@tanstack/vue-table'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { DataTableProps } from '@/components/data-table/types'
import type { Device } from '@/pages/admin/devices/data/schema'
import BulkActions from '@/components/data-table/bulk-actions.vue'
import DataTable from '@/components/data-table/data-table.vue'
import { SelectColumn } from '@/components/data-table/table-columns'
import { generateVueTable } from '@/components/data-table/use-generate-vue-table'
import DataTableViewOptions from '@/components/data-table/view-options.vue'
import { getColumns } from '@/pages/admin/devices/components/columns'
import DataTableToolbar from '@/pages/admin/devices/components/data-table-toolbar.vue'
const props = defineProps<Omit<DataTableProps<Device>, 'columns'> & {
onToggleStatus: (row: Device) => void
onDelete: (row: Device) => void
onForceOffline: (row: Device) => void
}>()
const emit = defineEmits<{
refresh: []
batchUnbind: []
batchBan: []
batchUnban: []
}>()
const { t } = useI18n()
const columns = computed(() => [
SelectColumn as ColumnDef<Device>,
...getColumns({
onToggleStatus: props.onToggleStatus,
onDelete: props.onDelete,
onForceOffline: props.onForceOffline,
}),
])
const table = generateVueTable<Device>({
get data() { return props.data },
get loading() { return props.loading },
columns: columns.value,
}, columns.value)
const columnLabels: Record<string, string> = {
'select': 'admin.devices.select',
'device_id': 'admin.devices.columns.deviceId',
'device_name': 'admin.devices.columns.deviceName',
'device_type': 'admin.devices.columns.deviceType',
'user.username': 'admin.devices.columns.user',
'application.name': 'admin.devices.columns.application',
'online_status': 'admin.devices.columns.status',
'online_sessions': 'admin.devices.columns.onlineSessions',
'status': 'admin.devices.columns.deviceStatus',
'created_at': 'admin.devices.columns.createdAt',
}
defineExpose({
table,
})
</script>
<template>
<DataTable :columns="columns" :data :loading :table @refresh="emit('refresh')">
<template #toolbar>
<div class="space-y-3">
<BulkActions :table="table" entity-name="devices">
<UiButton variant="outline" size="sm" @click="emit('batchUnban')">
{{ t('admin.devices.batchUnban') }}
</UiButton>
<UiButton variant="outline" size="sm" @click="emit('batchBan')">
{{ t('admin.devices.batchBan') }}
</UiButton>
<UiButton variant="destructive" size="sm" @click="emit('batchUnbind')">
{{ t('admin.devices.batchUnbind') }}
</UiButton>
</BulkActions>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-2">
<DataTableToolbar
:table
@batch-unbind="emit('batchUnbind')"
@batch-ban="emit('batchBan')"
@batch-unban="emit('batchUnban')"
@refresh="emit('refresh')"
/>
<slot name="filters" />
</div>
<div class="flex items-center gap-2">
<DataTableViewOptions :table="table" :column-labels="columnLabels" />
</div>
</div>
</div>
</template>
</DataTable>
</template>
@@ -1,19 +0,0 @@
import type { DeviceStatus } from './schema'
export function getDeviceStatusLabel(status: DeviceStatus): string {
const labels: Record<DeviceStatus, string> = {
active: '在线',
inactive: '离线',
banned: '已禁用',
}
return labels[status] || status
}
export function getDeviceStatusVariant(status: DeviceStatus): 'default' | 'secondary' | 'destructive' | 'outline' {
const variants: Record<DeviceStatus, 'default' | 'secondary' | 'destructive' | 'outline'> = {
active: 'default',
inactive: 'secondary',
banned: 'destructive',
}
return variants[status] || 'secondary'
}
@@ -1,27 +0,0 @@
import { z } from 'zod'
export const deviceStatusSchema = z.enum(['active', 'inactive', 'banned'])
export const deviceSchema = z.object({
id: z.union([z.string(), z.number()]),
user_id: z.union([z.string(), z.number()]),
application_id: z.union([z.string(), z.number()]),
device_id: z.string(),
device_name: z.string().optional().nullable(),
device_type: z.string().optional().nullable(),
status: deviceStatusSchema,
online_sessions: z.number().optional(),
created_at: z.string(),
updated_at: z.string().optional(),
user: z.object({
id: z.union([z.string(), z.number()]),
username: z.string(),
}).optional().nullable(),
application: z.object({
id: z.union([z.string(), z.number()]),
name: z.string(),
}).optional().nullable(),
})
export type Device = z.infer<typeof deviceSchema>
export type DeviceStatus = z.infer<typeof deviceStatusSchema>
@@ -1,386 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { toast } from 'vue-sonner'
import type { Device } from '@/pages/admin/devices/data/schema'
import ConfirmDialog from '@/components/confirm-dialog.vue'
import SingleFilter from '@/components/data-table/single-filter.vue'
import { BasicPage } from '@/components/global-layout'
import DataTable from '@/pages/admin/devices/components/data-table.vue'
import api from '@/services/api'
const { t } = useI18n()
const route = useRoute()
interface Application {
id: number
name: string
}
interface User {
id: number
username: string
}
const loading = ref(true)
const devices = ref<Device[]>([])
const tableRef = ref()
const applications = ref<Application[]>([])
const users = ref<User[]>([])
const appFilter = ref<string>('')
const statusFilter = ref<string>('')
const userIdFilter = ref<string>('')
const deleteDialogOpen = ref(false)
const deleteTarget = ref<Device | null>(null)
const batchDeleteDialogOpen = ref(false)
const batchDeleteIds = ref<(string | number)[]>([])
const forceOfflineDialogOpen = ref(false)
const forceOfflineTarget = ref<Device | null>(null)
const filteredDevices = computed(() => {
let result = devices.value
if (appFilter.value) {
result = result.filter(device => String(device.application_id) === appFilter.value)
}
if (statusFilter.value) {
result = result.filter(device => device.status === statusFilter.value)
}
if (userIdFilter.value) {
result = result.filter(device => String(device.user_id) === userIdFilter.value)
}
return result
})
const totalDevices = computed(() => filteredDevices.value.length)
const onlineSessionCount = computed(() => filteredDevices.value.reduce((sum, d) => sum + (d.online_sessions || 0), 0))
const bannedCount = computed(() => filteredDevices.value.filter(device => device.status === 'banned').length)
const applicationOptions = computed(() => {
return applications.value.map(app => ({
label: app.name,
value: String(app.id),
}))
})
const userOptions = computed(() => {
return users.value.map(user => ({
label: user.username,
value: String(user.id),
}))
})
const statusOptions = computed(() => [
{ label: t('admin.devices.normal'), value: 'active' },
{ label: t('admin.devices.banned'), value: 'banned' },
])
async function fetchApplications() {
try {
const data = await api.get<{ applications: Application[] }>('/dev/applications')
applications.value = data?.applications || []
}
catch (error) {
console.error('获取应用列表失败:', error)
}
}
async function fetchUsers() {
try {
const data = await api.get<{ users: User[] }>('/dev/app-users')
users.value = data?.users || []
}
catch (error) {
console.error('获取用户列表失败:', error)
}
}
async function fetchDevices() {
loading.value = true
try {
const userId = route.query.user_id as string
const deviceId = route.query.device_id as string
let url = '/dev/devices'
const params: string[] = []
if (userId) {
params.push(`user_id=${userId}`)
}
if (deviceId) {
params.push(`device_id=${deviceId}`)
}
if (params.length > 0) {
url += `?${params.join('&')}`
}
const data = await api.get<{ devices: Device[] }>(url)
devices.value = Array.isArray(data?.devices) ? data.devices : []
}
catch (error) {
console.error('获取设备列表失败:', error)
devices.value = []
}
finally {
loading.value = false
}
}
async function toggleDeviceStatus(device: Device) {
const newStatus = device.status === 'banned' ? 'active' : 'banned'
try {
await api.put(`/dev/devices/${device.id}/status`, { status: newStatus })
toast.success(t('admin.devices.statusUpdateSuccess'))
fetchDevices()
}
catch (error: any) {
console.error('切换设备状态失败:', error)
toast.error(error.message || t('admin.devices.statusUpdateFailed'))
}
}
function confirmDeleteDevice(device: Device) {
deleteTarget.value = device
deleteDialogOpen.value = true
}
async function handleDeleteDevice() {
if (!deleteTarget.value)
return
try {
await api.delete(`/dev/devices/${deleteTarget.value.id}`)
toast.success(t('admin.devices.unbindSuccess'))
fetchDevices()
}
catch (error: any) {
console.error('解绑设备失败:', error)
toast.error(error.message || t('admin.devices.unbindFailed'))
}
finally {
deleteTarget.value = null
}
}
function confirmBatchDelete(ids: (string | number)[]) {
batchDeleteIds.value = ids
batchDeleteDialogOpen.value = true
}
async function handleBatchDelete() {
try {
await api.delete('/dev/devices/batch', { device_ids: batchDeleteIds.value.map(id => Number(id)) } as any)
toast.success(t('admin.devices.batchUnbindSuccess'))
fetchDevices()
}
catch (error: any) {
console.error('批量解绑失败:', error)
toast.error(error.message || t('admin.devices.batchUnbindFailed'))
}
finally {
batchDeleteIds.value = []
}
}
async function batchToggleStatus(ids: (string | number)[], status: string) {
try {
await api.post('/dev/devices/batch/status', { device_ids: ids.map(id => Number(id)), status })
toast.success(t('admin.devices.batchUpdateSuccess'))
fetchDevices()
}
catch (error: any) {
console.error('批量更新状态失败:', error)
toast.error(error.message || t('admin.devices.batchUpdateFailed'))
}
}
function confirmForceOffline(device: Device) {
forceOfflineTarget.value = device
forceOfflineDialogOpen.value = true
}
async function handleForceOffline() {
if (!forceOfflineTarget.value)
return
try {
const data = await api.post<{ count: number }>(`/dev/devices/${forceOfflineTarget.value.id}/force-offline`)
toast.success(`已强制离线 ${data?.count || 0} 个实例`)
fetchDevices()
}
catch (error: any) {
console.error('强制离线失败:', error)
toast.error(error.message || '强制离线失败')
}
finally {
forceOfflineTarget.value = null
}
}
onMounted(() => {
const userId = route.query.user_id as string
if (userId) {
userIdFilter.value = userId
}
fetchApplications()
fetchUsers()
fetchDevices()
})
</script>
<template>
<BasicPage
:title="t('admin.devices.title')"
:description="t('admin.devices.description')"
sticky
>
<div class="space-y-6">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.devices.totalDevices') }}
</UiCardTitle>
<Icon icon="lucide:monitor" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ totalDevices }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.devices.onlineSessions') }}
</UiCardTitle>
<Icon icon="lucide:layers" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ onlineSessionCount }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.devices.normalDevices') }}
</UiCardTitle>
<Icon icon="lucide:check-circle" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ totalDevices - bannedCount }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.devices.banned') }}
</UiCardTitle>
<Icon icon="lucide:ban" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ bannedCount }}
</div>
</UiCardContent>
</UiCard>
</div>
<UiCard>
<UiCardContent class="p-6">
<DataTable
ref="tableRef"
:loading
:data="filteredDevices"
:on-toggle-status="toggleDeviceStatus"
:on-delete="confirmDeleteDevice"
:on-force-offline="confirmForceOffline"
@refresh="fetchDevices"
@batch-unbind="confirmBatchDelete(tableRef?.table?.getSelectedRowModel().rows.map((r: any) => r.original.id) || [])"
@batch-ban="batchToggleStatus(tableRef?.table?.getSelectedRowModel().rows.filter((r: any) => r.original.status !== 'banned').map((r: any) => r.original.id) || [], 'banned')"
@batch-unban="batchToggleStatus(tableRef?.table?.getSelectedRowModel().rows.filter((r: any) => r.original.status === 'banned').map((r: any) => r.original.id) || [], 'active')"
>
<template #filters>
<SingleFilter
v-model="appFilter"
:title="t('admin.devices.application')"
:options="applicationOptions"
/>
<SingleFilter
v-model="userIdFilter"
:title="t('admin.devices.user')"
:options="userOptions"
/>
<SingleFilter
v-model="statusFilter"
:title="t('admin.devices.status')"
:options="statusOptions"
/>
</template>
</DataTable>
</UiCardContent>
</UiCard>
</div>
<ConfirmDialog
v-model:open="deleteDialogOpen"
destructive
:confirm-button-text="t('admin.devices.unbind')"
:cancel-button-text="t('common.cancel')"
@confirm="handleDeleteDevice"
>
<template #title>
{{ t('admin.devices.unbindDevice') }}
</template>
<template #description>
{{ t('admin.devices.unbindConfirm', { deviceId: deleteTarget?.device_id }) }}
</template>
</ConfirmDialog>
<ConfirmDialog
v-model:open="batchDeleteDialogOpen"
destructive
:confirm-button-text="t('admin.devices.unbind')"
:cancel-button-text="t('common.cancel')"
@confirm="handleBatchDelete"
>
<template #title>
{{ t('admin.devices.batchUnbind') }}
</template>
<template #description>
{{ t('admin.devices.batchUnbindConfirm', { count: batchDeleteIds.length }) }}
</template>
</ConfirmDialog>
<ConfirmDialog
v-model:open="forceOfflineDialogOpen"
destructive
confirm-button-text="强制离线"
:cancel-button-text="t('common.cancel')"
@confirm="handleForceOffline"
>
<template #title>
强制离线
</template>
<template #description>
确定要将设备 "{{ forceOfflineTarget?.device_id }}" 的所有在线实例强制离线吗该设备将需要重新登录
</template>
</ConfirmDialog>
</BasicPage>
</template>
@@ -1,195 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { onMounted, ref } from 'vue'
const API_BASE = 'http://localhost:8080/api/v1'
interface DynamicCode {
id: string
name: string
code: string
description: string
applicationId: string
appName: string
enabled: boolean
createdAt: string
updatedAt: string
}
interface Application {
id: string
name: string
}
const loading = ref(true)
const codes = ref<DynamicCode[]>([])
const applications = ref<Application[]>([])
const selectedCode = ref<DynamicCode | null>(null)
const showCodeDialog = ref(false)
async function fetchCodes() {
loading.value = true
try {
const token = localStorage.getItem('token')
const response = await fetch(`${API_BASE}/dev/dynamic-codes`, {
headers: {
Authorization: `Bearer ${token}`,
},
})
const data = await response.json()
if (data.code === 200) {
codes.value = data.data || []
}
}
catch (error) {
console.error('获取云端函数失败:', error)
}
finally {
loading.value = false
}
}
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.data || []
}
}
catch (error) {
console.error('获取应用列表失败:', error)
}
}
function formatDate(date: string) {
return new Date(date).toLocaleString('zh-CN')
}
function openCodeDialog(code: DynamicCode) {
selectedCode.value = code
showCodeDialog.value = true
}
async function toggleCodeStatus(code: DynamicCode) {
try {
const token = localStorage.getItem('token')
const response = await fetch(`${API_BASE}/dev/dynamic-codes/${code.id}/status`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ enabled: !code.enabled }),
})
const data = await response.json()
if (data.code === 200) {
fetchCodes()
}
}
catch (error) {
console.error('切换云端函数状态失败:', error)
}
}
onMounted(() => {
fetchCodes()
fetchApplications()
})
</script>
<template>
<BasicPage
title="云端函数"
description="管理应用的云端函数配置"
>
<div class="space-y-6">
<UiCard>
<UiCardContent class="p-4 sm:p-6">
<div v-if="loading" class="text-center py-12">
<Icon icon="lucide:loader-2" class="h-8 w-8 animate-spin mx-auto text-muted-foreground" />
<p class="mt-2 text-muted-foreground">
加载中...
</p>
</div>
<div v-else-if="codes.length === 0" class="text-center py-8 text-muted-foreground">
<Icon icon="lucide:code" class="mx-auto h-12 w-12 text-muted-foreground mb-2" />
<p>暂无云端函数</p>
</div>
<div v-else class="space-y-4">
<div
v-for="code in codes"
:key="code.id"
class="border rounded-lg p-4 space-y-3"
:class="code.enabled ? 'bg-card' : 'bg-muted/30'"
>
<div class="flex items-start justify-between">
<div class="flex items-center space-x-3">
<div class="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<Icon icon="lucide:code" class="h-5 w-5 text-primary" />
</div>
<div>
<p class="font-medium">
{{ code.name }}
</p>
<p class="text-sm text-muted-foreground">
{{ code.appName }}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<UiBadge :variant="code.enabled ? 'default' : 'secondary'">
{{ code.enabled ? '已启用' : '已禁用' }}
</UiBadge>
<UiButton variant="outline" size="sm" @click="toggleCodeStatus(code)">
{{ code.enabled ? '禁用' : '启用' }}
</UiButton>
<UiButton variant="outline" size="sm" @click="openCodeDialog(code)">
<Icon icon="lucide:eye" class="h-4 w-4 mr-1" />
查看代码
</UiButton>
</div>
</div>
<div v-if="code.description" class="text-sm text-muted-foreground">
{{ code.description }}
</div>
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>创建时间: {{ formatDate(code.createdAt) }}</span>
<span>更新时间: {{ formatDate(code.updatedAt) }}</span>
</div>
</div>
</div>
</UiCardContent>
</UiCard>
</div>
<UiDialog v-model:open="showCodeDialog">
<UiDialogContent class="w-[95vw] sm:max-w-4xl max-h-[90vh] overflow-y-auto">
<UiDialogHeader>
<UiDialogTitle>{{ selectedCode?.name }}</UiDialogTitle>
<UiDialogDescription>云端函数详情</UiDialogDescription>
</UiDialogHeader>
<div class="py-4">
<div class="bg-muted rounded-lg p-4">
<pre class="text-sm font-mono whitespace-pre-wrap break-all">{{ selectedCode?.code }}</pre>
</div>
</div>
<UiDialogFooter>
<UiButton @click="showCodeDialog = false">
关闭
</UiButton>
</UiDialogFooter>
</UiDialogContent>
</UiDialog>
</BasicPage>
</template>
@@ -1,356 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import type { DynamicCode } from '@/pages/admin/dynamic-code/data/schema'
import ConfirmDialog from '@/components/confirm-dialog.vue'
import DateRangeFilter from '@/components/data-table/date-range-filter.vue'
import SingleFilter from '@/components/data-table/single-filter.vue'
import { BasicPage } from '@/components/global-layout'
import DataTable from '@/pages/admin/dynamic-code/components/data-table.vue'
import api from '@/services/api'
const { t } = useI18n()
interface Application {
id: number
name: string
}
const router = useRouter()
const loading = ref(true)
const codes = ref<DynamicCode[]>([])
const tableRef = ref()
const applications = ref<Application[]>([])
const appFilter = ref<string>('')
const statusFilter = ref<string>('')
const createdStartDate = ref<string>('')
const createdEndDate = ref<string>('')
const updatedStartDate = ref<string>('')
const updatedEndDate = ref<string>('')
const deleteDialogOpen = ref(false)
const deleteTarget = ref<DynamicCode | null>(null)
const batchDeleteDialogOpen = ref(false)
const batchDeleteIds = ref<(string | number)[]>([])
const filteredCodes = computed(() => {
let result = codes.value
if (appFilter.value) {
result = result.filter(code => String(code.application_id) === appFilter.value)
}
if (statusFilter.value) {
if (statusFilter.value === 'enabled') {
result = result.filter(code => code.enabled === true)
}
else if (statusFilter.value === 'disabled') {
result = result.filter(code => code.enabled === false)
}
}
if (createdStartDate.value) {
const fromDateTime = createdStartDate.value.includes('T')
? createdStartDate.value.replace('T', ' ')
: `${createdStartDate.value} 00:00`
result = result.filter(code => code.created_at >= fromDateTime)
}
if (createdEndDate.value) {
const toDateTime = createdEndDate.value.includes('T')
? createdEndDate.value.replace('T', ' ')
: `${createdEndDate.value} 23:59`
result = result.filter(code => code.created_at <= toDateTime)
}
if (updatedStartDate.value) {
const fromDateTime = updatedStartDate.value.includes('T')
? updatedStartDate.value.replace('T', ' ')
: `${updatedStartDate.value} 00:00`
result = result.filter(code => code.updated_at >= fromDateTime)
}
if (updatedEndDate.value) {
const toDateTime = updatedEndDate.value.includes('T')
? updatedEndDate.value.replace('T', ' ')
: `${updatedEndDate.value} 23:59`
result = result.filter(code => code.updated_at <= toDateTime)
}
return result
})
const enabledCount = computed(() => filteredCodes.value.filter(code => code.enabled === true).length)
const disabledCount = computed(() => filteredCodes.value.filter(code => code.enabled === false).length)
const linkedAppsCount = computed(() => new Set(filteredCodes.value.map(code => code.application_id)).size)
const applicationOptions = computed(() => {
return applications.value.map(app => ({
label: app.name,
value: String(app.id),
}))
})
const statusOptions = computed(() => [
{ label: t('admin.cloudFunction.enabled'), value: 'enabled' },
{ label: t('admin.cloudFunction.disabled'), value: 'disabled' },
])
async function fetchApplications() {
try {
const data = await api.get<{ applications: Application[] }>('/dev/applications')
applications.value = data?.applications || []
}
catch (error) {
console.error('获取应用列表失败:', error)
}
}
async function fetchCodes() {
loading.value = true
try {
const data = await api.get<DynamicCode[]>('/dev/dynamic-codes')
codes.value = Array.isArray(data) ? data : []
}
catch (error) {
console.error('获取云端函数列表失败:', error)
codes.value = []
}
finally {
loading.value = false
}
}
function goToCreate() {
router.push('/admin/cloud-function/create')
}
async function toggleStatus(code: DynamicCode) {
try {
const newStatus = !code.enabled
await api.put(`/dev/dynamic-codes/${code.id}/status`, { enabled: newStatus })
toast.success(t('admin.cloudFunction.statusUpdateSuccess'))
fetchCodes()
}
catch (error: any) {
console.error('切换云端函数状态失败:', error)
toast.error(error.message || t('admin.cloudFunction.statusUpdateFailed'))
}
}
function editCode(code: DynamicCode) {
router.push(`/admin/cloud-function/${code.id}`)
}
function confirmDeleteCode(code: DynamicCode) {
deleteTarget.value = code
deleteDialogOpen.value = true
}
async function handleDeleteCode() {
if (!deleteTarget.value)
return
try {
await api.delete(`/dev/dynamic-codes/${deleteTarget.value.id}`)
toast.success(t('admin.cloudFunction.deleteSuccess'))
fetchCodes()
}
catch (error: any) {
console.error('删除云端函数失败:', error)
toast.error(error.message || t('admin.cloudFunction.deleteFailed'))
}
finally {
deleteTarget.value = null
}
}
function confirmBatchDelete(ids: (string | number)[]) {
batchDeleteIds.value = ids
batchDeleteDialogOpen.value = true
}
async function handleBatchDelete() {
try {
await api.delete('/dev/dynamic-codes/batch', { ids: batchDeleteIds.value } as any)
toast.success(t('admin.cloudFunction.deleteSuccess'))
fetchCodes()
}
catch (error: any) {
console.error('批量删除失败:', error)
toast.error(error.message || t('admin.cloudFunction.deleteFailed'))
}
finally {
batchDeleteIds.value = []
}
}
async function batchToggleStatus(ids: (string | number)[], enabled: boolean) {
try {
await api.put('/dev/dynamic-codes/batch/status', { ids, enabled })
toast.success(t('admin.cloudFunction.batchUpdateSuccess'))
fetchCodes()
}
catch (error: any) {
console.error('批量更新状态失败:', error)
toast.error(error.message || t('admin.cloudFunction.batchUpdateFailed'))
}
}
onMounted(() => {
fetchApplications()
fetchCodes()
})
</script>
<template>
<BasicPage
:title="t('admin.cloudFunction.title')"
:description="t('admin.cloudFunction.description')"
sticky
>
<template #actions>
<UiButton size="sm" @click="goToCreate">
<Icon icon="lucide:plus" class="mr-2 h-4 w-4" />
{{ t('admin.cloudFunction.createBtn') }}
</UiButton>
</template>
<div class="space-y-6">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.cloudFunction.totalCodes') }}
</UiCardTitle>
<Icon icon="lucide:code" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ filteredCodes.length }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.cloudFunction.enabled') }}
</UiCardTitle>
<Icon icon="lucide:check-circle" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ enabledCount }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.cloudFunction.disabled') }}
</UiCardTitle>
<Icon icon="lucide:ban" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ disabledCount }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.cloudFunction.linkedApps') }}
</UiCardTitle>
<Icon icon="lucide:link" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ linkedAppsCount }}
</div>
</UiCardContent>
</UiCard>
</div>
<UiCard>
<UiCardContent class="p-6">
<DataTable
ref="tableRef"
:loading
:data="filteredCodes"
:on-toggle-status="toggleStatus"
:on-edit="editCode"
:on-delete="confirmDeleteCode"
@refresh="fetchCodes"
@batch-enable="batchToggleStatus(tableRef?.table?.getSelectedRowModel().rows.map((r: any) => r.original.id) || [], true)"
@batch-disable="batchToggleStatus(tableRef?.table?.getSelectedRowModel().rows.map((r: any) => r.original.id) || [], false)"
@batch-delete="confirmBatchDelete(tableRef?.table?.getSelectedRowModel().rows.map((r: any) => r.original.id) || [])"
>
<template #filters>
<SingleFilter
v-model="appFilter"
:title="t('admin.cloudFunction.application')"
:options="applicationOptions"
/>
<SingleFilter
v-model="statusFilter"
:title="t('admin.cloudFunction.status')"
:options="statusOptions"
/>
<DateRangeFilter
v-model:start-model-value="createdStartDate"
v-model:end-model-value="createdEndDate"
:title="t('admin.cloudFunction.columns.createdAt')"
/>
<DateRangeFilter
v-model:start-model-value="updatedStartDate"
v-model:end-model-value="updatedEndDate"
:title="t('admin.cloudFunction.columns.updatedAt')"
/>
</template>
</DataTable>
</UiCardContent>
</UiCard>
</div>
<ConfirmDialog
v-model:open="deleteDialogOpen"
destructive
:confirm-button-text="t('admin.cloudFunction.delete')"
:cancel-button-text="t('admin.cloudFunction.create.cancelBtn')"
@confirm="handleDeleteCode"
>
<template #title>
{{ t('admin.cloudFunction.deleteCode') }}
</template>
<template #description>
{{ t('admin.cloudFunction.deleteCodeConfirm', { codeName: deleteTarget?.name }) }}
</template>
</ConfirmDialog>
<ConfirmDialog
v-model:open="batchDeleteDialogOpen"
destructive
:confirm-button-text="t('admin.cloudFunction.delete')"
:cancel-button-text="t('admin.cloudFunction.create.cancelBtn')"
@confirm="handleBatchDelete"
>
<template #title>
{{ t('admin.cloudFunction.batchDelete') }}
</template>
<template #description>
{{ t('admin.cloudFunction.batchDeleteConfirm', { count: batchDeleteIds.length }) }}
</template>
</ConfirmDialog>
</BasicPage>
</template>
@@ -1,157 +0,0 @@
import type { ColumnDef } from '@tanstack/vue-table'
import type { Composer } from 'vue-i18n'
import { Ban, MoreHorizontal, Pencil, Trash2 } from 'lucide-vue-next'
import { h } from 'vue'
import type { DynamicCode } from '@/pages/admin/dynamic-code/data/schema'
import Badge from '@/components/ui/badge/Badge.vue'
import Button from '@/components/ui/button/Button.vue'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
export function getColumns(actions: {
onToggleStatus: (row: DynamicCode) => void
onEdit: (row: DynamicCode) => void
onDelete: (row: DynamicCode) => void
}, t: Composer['t']): ColumnDef<DynamicCode>[] {
return [
{
accessorKey: 'name',
header: () => t('admin.cloudFunction.columns.name'),
cell: ({ row }) => {
const name = row.getValue('name') as string
return name || '-'
},
},
{
accessorKey: 'application_name',
header: () => t('admin.cloudFunction.columns.application'),
cell: ({ row }) => {
const appName = row.getValue('application_name') as string
return appName ? h(Badge, { variant: 'secondary' }, () => appName) : '-'
},
},
{
accessorKey: 'description',
header: () => t('admin.cloudFunction.columns.description'),
cell: ({ row }) => {
const description = row.getValue('description') as string
return description || '-'
},
},
{
accessorKey: 'enabled',
header: () => t('admin.cloudFunction.columns.status'),
cell: ({ row }) => {
const enabled = row.getValue('enabled') as boolean
return h(Badge, { variant: enabled ? 'default' : 'destructive' }, () => enabled ? t('admin.cloudFunction.enabled') : t('admin.cloudFunction.disabled'))
},
},
{
accessorKey: 'creator.username',
header: () => t('admin.cloudFunction.columns.creator'),
cell: ({ row }) => {
const username = row.original.creator?.username
return username || '-'
},
},
{
accessorKey: 'created_at',
header: () => t('admin.cloudFunction.columns.createdAt'),
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 '-'
}
},
},
{
accessorKey: 'updated_at',
header: () => t('admin.cloudFunction.columns.updatedAt'),
cell: ({ row }) => {
const updatedAt = row.getValue('updated_at')
if (!updatedAt)
return '-'
try {
const date = new Date(updatedAt 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' }, t('common.actions')),
cell: ({ row }) => {
const code = row.original
const isDisabled = !code.enabled
return h(
DropdownMenu,
{},
{
default: () => [
h(DropdownMenuTrigger, { asChild: true }, () =>
h(Button, { variant: 'ghost', class: 'h-8 w-8 p-0' }, () => [
h(MoreHorizontal, { class: 'h-4 w-4' }),
h('span', { class: 'sr-only' }, t('common.openMenu')),
])),
h(
DropdownMenuContent,
{ align: 'end' },
() => [
h(DropdownMenuItem, { onClick: () => actions.onEdit(code) }, () => [
h(Pencil, { class: 'mr-2 h-4 w-4' }),
t('admin.cloudFunction.edit.title'),
]),
h(DropdownMenuItem, { onClick: () => actions.onToggleStatus(code) }, () => [
h(Ban, { class: 'mr-2 h-4 w-4' }),
isDisabled ? t('admin.cloudFunction.enable') : t('admin.cloudFunction.disable'),
]),
h(DropdownMenuSeparator),
h(DropdownMenuItem, { class: 'text-destructive', onClick: () => actions.onDelete(code) }, () => [
h(Trash2, { class: 'mr-2 h-4 w-4' }),
t('admin.cloudFunction.delete'),
]),
],
),
],
},
)
},
enableSorting: false,
enableHiding: false,
},
]
}
@@ -1,43 +0,0 @@
<script setup lang="ts">
import type { Table } from '@tanstack/vue-table'
import { X } from 'lucide-vue-next'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { DynamicCode } from '@/pages/admin/dynamic-code/data/schema'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
const props = defineProps<DataTableToolbarProps>()
const { t } = useI18n()
interface DataTableToolbarProps {
table: Table<DynamicCode>
}
const isFiltered = computed(() => props.table.getState().columnFilters.length > 0)
</script>
<template>
<div class="flex items-center flex-1 space-x-2">
<Input
:placeholder="t('admin.cloudFunction.searchCode')"
:model-value="(table.getColumn('name')?.getFilterValue() as string) ?? ''"
class="h-8 w-[150px] lg:w-[250px]"
@input="table.getColumn('name')?.setFilterValue($event.target.value)"
/>
<Button
v-if="isFiltered"
variant="ghost"
class="h-8 px-2 lg:px-3"
@click="table.resetColumnFilters()"
>
{{ t('admin.cloudFunction.reset') }}
<X class="size-4 ml-2" />
</Button>
</div>
</template>
@@ -1,91 +0,0 @@
<script setup lang="ts">
import type { ColumnDef } from '@tanstack/vue-table'
import { useI18n } from 'vue-i18n'
import type { DataTableProps } from '@/components/data-table/types'
import type { DynamicCode } from '@/pages/admin/dynamic-code/data/schema'
import BulkActions from '@/components/data-table/bulk-actions.vue'
import DataTable from '@/components/data-table/data-table.vue'
import { SelectColumn } from '@/components/data-table/table-columns'
import { generateVueTable } from '@/components/data-table/use-generate-vue-table'
import DataTableViewOptions from '@/components/data-table/view-options.vue'
import { getColumns } from '@/pages/admin/dynamic-code/components/columns'
import DataTableToolbar from '@/pages/admin/dynamic-code/components/data-table-toolbar.vue'
const props = defineProps<Omit<DataTableProps<DynamicCode>, 'columns'> & {
onToggleStatus: (row: DynamicCode) => void
onEdit: (row: DynamicCode) => void
onDelete: (row: DynamicCode) => void
}>()
const emit = defineEmits<{
refresh: []
batchEnable: []
batchDisable: []
batchDelete: []
}>()
const { t } = useI18n()
const columns = computed(() => [
SelectColumn as ColumnDef<DynamicCode>,
...getColumns({
onToggleStatus: props.onToggleStatus,
onEdit: props.onEdit,
onDelete: props.onDelete,
}, t),
])
const table = generateVueTable<DynamicCode>({
get data() { return props.data },
get loading() { return props.loading },
columns: columns.value,
}, columns.value)
const columnLabels: Record<string, string> = {
select: 'admin.cloudFunction.select',
name: 'admin.cloudFunction.columns.name',
application_name: 'admin.cloudFunction.columns.application',
description: 'admin.cloudFunction.columns.description',
enabled: 'admin.cloudFunction.columns.status',
creator_username: 'admin.cloudFunction.columns.creator',
created_at: 'admin.cloudFunction.columns.createdAt',
updated_at: 'admin.cloudFunction.columns.updatedAt',
}
defineExpose({
table,
})
</script>
<template>
<DataTable :columns="columns" :data :loading :table @refresh="emit('refresh')">
<template #toolbar>
<div class="space-y-3">
<div class="flex items-center justify-end">
<slot name="actions" />
</div>
<BulkActions :table="table" entity-name="functions">
<UiButton variant="outline" size="sm" @click="emit('batchEnable')">
{{ t('admin.cloudFunction.batchEnableBtn') }}
</UiButton>
<UiButton variant="outline" size="sm" @click="emit('batchDisable')">
{{ t('admin.cloudFunction.batchDisableBtn') }}
</UiButton>
<UiButton variant="destructive" size="sm" @click="emit('batchDelete')">
{{ t('admin.cloudFunction.batchDeleteBtn') }}
</UiButton>
</BulkActions>
<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,258 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import { BasicPage } from '@/components/global-layout'
import api from '@/services/api'
const router = useRouter()
const { t } = useI18n()
interface Application {
id: number
name: string
}
const saving = ref(false)
const applications = ref<Application[]>([])
const form = ref({
name: '',
application_id: '',
description: '',
code: '',
enabled: true,
})
const filteredApplications = computed(() => {
return applications.value.map(app => ({
label: app.name,
value: String(app.id),
}))
})
const selectedApplication = computed(() => {
if (form.value.application_id) {
return applications.value.find(app => String(app.id) === form.value.application_id)
}
return null
})
async function fetchApplications() {
try {
const data = await api.get<{ applications: Application[] }>('/dev/applications')
applications.value = data?.applications || []
}
catch (error) {
console.error('获取应用列表失败:', error)
}
}
async function handleSubmit() {
if (!form.value.name.trim()) {
toast.error(t('admin.cloudFunction.create.nameRequired'))
return
}
if (!form.value.application_id) {
toast.error(t('admin.cloudFunction.create.selectAppRequired'))
return
}
if (!form.value.code.trim()) {
toast.error(t('admin.cloudFunction.create.codeRequired'))
return
}
saving.value = true
try {
await api.post('/dev/dynamic-codes', {
name: form.value.name,
application_id: Number.parseInt(form.value.application_id),
description: form.value.description,
code: form.value.code,
enabled: form.value.enabled,
})
toast.success(t('admin.cloudFunction.create.submitSuccess'))
router.push('/admin/cloud-function')
}
catch (error: any) {
console.error('创建云端函数失败:', error)
toast.error(error.message || t('admin.cloudFunction.create.submitFailed'))
}
finally {
saving.value = false
}
}
function handleCancel() {
router.push('/admin/cloud-function')
}
onMounted(() => {
fetchApplications()
})
</script>
<template>
<BasicPage
:title="t('admin.cloudFunction.create.title')"
:description="t('admin.cloudFunction.create.description')"
:breadcrumbs="[
{ title: t('admin.cloudFunction.title'), href: '/admin/cloud-function' },
{ title: t('admin.cloudFunction.create.title') },
]"
sticky
>
<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 class="flex items-center gap-2">
<Icon icon="lucide:settings-2" class="size-5" />
{{ t('admin.cloudFunction.create.config') }}
</UiCardTitle>
<UiCardDescription>{{ t('admin.cloudFunction.create.configDesc') }}</UiCardDescription>
</UiCardHeader>
<UiCardContent class="space-y-6">
<div class="space-y-2">
<UiLabel for="application">
{{ t('admin.cloudFunction.create.application') }}
</UiLabel>
<UiSelect v-model="form.application_id">
<UiSelectTrigger>
<UiSelectValue :placeholder="t('admin.cloudFunction.create.selectAppPlaceholder')" />
</UiSelectTrigger>
<UiSelectContent>
<UiSelectItem
v-for="app in filteredApplications"
:key="app.value"
:value="app.value"
>
{{ app.label }}
</UiSelectItem>
</UiSelectContent>
</UiSelect>
</div>
<div class="space-y-2">
<UiLabel for="name">
{{ t('admin.cloudFunction.create.name') }}
</UiLabel>
<UiInput
id="name"
v-model="form.name"
:placeholder="t('admin.cloudFunction.create.namePlaceholder')"
/>
</div>
<div class="space-y-2">
<UiLabel for="description">
{{ t('admin.cloudFunction.create.description') }}
</UiLabel>
<UiTextarea
id="description"
v-model="form.description"
:placeholder="t('admin.cloudFunction.create.descriptionPlaceholder')"
rows="3"
/>
</div>
<div class="space-y-2">
<UiLabel for="code">
{{ t('admin.cloudFunction.create.code') }}
</UiLabel>
<UiTextarea
id="code"
v-model="form.code"
:placeholder="t('admin.cloudFunction.create.codePlaceholder')"
rows="12"
class="font-mono text-sm"
/>
<p class="text-xs text-muted-foreground">
{{ t('admin.cloudFunction.create.codeHelp') }}
</p>
</div>
<div class="flex items-center justify-between">
<div>
<p class="font-medium">
{{ t('admin.cloudFunction.create.enabled') }}
</p>
</div>
<UiSwitch v-model="form.enabled" />
</div>
</UiCardContent>
</UiCard>
</div>
<div class="space-y-6">
<UiCard>
<UiCardHeader>
<UiCardTitle class="flex items-center gap-2">
<Icon icon="lucide:eye" class="size-5" />
{{ t('admin.cloudFunction.create.preview') }}
</UiCardTitle>
</UiCardHeader>
<UiCardContent class="space-y-4">
<div class="space-y-3">
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('admin.cloudFunction.create.name') }}</span>
<span>{{ form.name || '-' }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('admin.cloudFunction.create.application') }}</span>
<span>{{ selectedApplication?.name || '-' }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('admin.cloudFunction.create.description') }}</span>
<span class="text-right max-w-[150px] truncate">{{ form.description || '-' }}</span>
</div>
<div class="flex justify-between text-sm items-center">
<span class="text-muted-foreground">{{ t('admin.cloudFunction.create.status') }}</span>
<UiSwitch
v-model="form.enabled"
/>
</div>
<div class="border-t pt-3 mt-3">
<div class="text-sm">
<div class="text-muted-foreground mb-2">
{{ t('admin.cloudFunction.create.code') }}
</div>
<div class="font-mono text-xs bg-muted p-2 rounded max-h-[150px] overflow-auto" style="white-space: pre !important;">{{ form.code || '-' }}</div>
</div>
</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.name || !form.application_id || !form.code"
@click="handleSubmit"
>
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
<Icon v-else icon="lucide:check" class="mr-2 h-4 w-4" />
{{ t('admin.cloudFunction.create.submitBtn') }}
</UiButton>
<UiButton
variant="outline"
class="w-full"
@click="handleCancel"
>
{{ t('admin.cloudFunction.create.cancelBtn') }}
</UiButton>
</div>
</UiCardContent>
</UiCard>
</div>
</div>
</div>
</BasicPage>
</template>
@@ -1,20 +0,0 @@
export interface DynamicCode {
id: number
name: string
code: string
description: string
application_id: number
application_name: string
enabled: boolean
created_at: string
updated_at: string
creator?: {
id: number
username: string
}
}
export interface Application {
id: number
name: string
}
@@ -1,356 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import type { DynamicCode } from '@/pages/admin/dynamic-code/data/schema'
import ConfirmDialog from '@/components/confirm-dialog.vue'
import DateRangeFilter from '@/components/data-table/date-range-filter.vue'
import SingleFilter from '@/components/data-table/single-filter.vue'
import { BasicPage } from '@/components/global-layout'
import DataTable from '@/pages/admin/dynamic-code/components/data-table.vue'
import api from '@/services/api'
const { t } = useI18n()
interface Application {
id: number
name: string
}
const router = useRouter()
const loading = ref(true)
const codes = ref<DynamicCode[]>([])
const tableRef = ref()
const applications = ref<Application[]>([])
const appFilter = ref<string>('')
const statusFilter = ref<string>('')
const createdStartDate = ref<string>('')
const createdEndDate = ref<string>('')
const updatedStartDate = ref<string>('')
const updatedEndDate = ref<string>('')
const deleteDialogOpen = ref(false)
const deleteTarget = ref<DynamicCode | null>(null)
const batchDeleteDialogOpen = ref(false)
const batchDeleteIds = ref<(string | number)[]>([])
const filteredCodes = computed(() => {
let result = codes.value
if (appFilter.value) {
result = result.filter(code => String(code.application_id) === appFilter.value)
}
if (statusFilter.value) {
if (statusFilter.value === 'enabled') {
result = result.filter(code => code.enabled === true)
}
else if (statusFilter.value === 'disabled') {
result = result.filter(code => code.enabled === false)
}
}
if (createdStartDate.value) {
const fromDateTime = createdStartDate.value.includes('T')
? createdStartDate.value.replace('T', ' ')
: `${createdStartDate.value} 00:00`
result = result.filter(code => code.created_at >= fromDateTime)
}
if (createdEndDate.value) {
const toDateTime = createdEndDate.value.includes('T')
? createdEndDate.value.replace('T', ' ')
: `${createdEndDate.value} 23:59`
result = result.filter(code => code.created_at <= toDateTime)
}
if (updatedStartDate.value) {
const fromDateTime = updatedStartDate.value.includes('T')
? updatedStartDate.value.replace('T', ' ')
: `${updatedStartDate.value} 00:00`
result = result.filter(code => code.updated_at >= fromDateTime)
}
if (updatedEndDate.value) {
const toDateTime = updatedEndDate.value.includes('T')
? updatedEndDate.value.replace('T', ' ')
: `${updatedEndDate.value} 23:59`
result = result.filter(code => code.updated_at <= toDateTime)
}
return result
})
const enabledCount = computed(() => filteredCodes.value.filter(code => code.enabled === true).length)
const disabledCount = computed(() => filteredCodes.value.filter(code => code.enabled === false).length)
const linkedAppsCount = computed(() => new Set(filteredCodes.value.map(code => code.application_id)).size)
const applicationOptions = computed(() => {
return applications.value.map(app => ({
label: app.name,
value: String(app.id),
}))
})
const statusOptions = computed(() => [
{ label: t('admin.cloudFunction.enabled'), value: 'enabled' },
{ label: t('admin.cloudFunction.disabled'), value: 'disabled' },
])
async function fetchApplications() {
try {
const data = await api.get<{ applications: Application[] }>('/dev/applications')
applications.value = data?.applications || []
}
catch (error) {
console.error('获取应用列表失败:', error)
}
}
async function fetchCodes() {
loading.value = true
try {
const data = await api.get<DynamicCode[]>('/dev/dynamic-codes')
codes.value = Array.isArray(data) ? data : []
}
catch (error) {
console.error('获取云端函数列表失败:', error)
codes.value = []
}
finally {
loading.value = false
}
}
function goToCreate() {
router.push('/admin/cloud-function/create')
}
async function toggleStatus(code: DynamicCode) {
try {
const newStatus = !code.enabled
await api.put(`/dev/dynamic-codes/${code.id}/status`, { enabled: newStatus })
toast.success(t('admin.cloudFunction.statusUpdateSuccess'))
fetchCodes()
}
catch (error: any) {
console.error('切换云端函数状态失败:', error)
toast.error(error.message || t('admin.cloudFunction.statusUpdateFailed'))
}
}
function editCode(code: DynamicCode) {
router.push(`/admin/cloud-function/${code.id}`)
}
function confirmDeleteCode(code: DynamicCode) {
deleteTarget.value = code
deleteDialogOpen.value = true
}
async function handleDeleteCode() {
if (!deleteTarget.value)
return
try {
await api.delete(`/dev/dynamic-codes/${deleteTarget.value.id}`)
toast.success(t('admin.cloudFunction.deleteSuccess'))
fetchCodes()
}
catch (error: any) {
console.error('删除云端函数失败:', error)
toast.error(error.message || t('admin.cloudFunction.deleteFailed'))
}
finally {
deleteTarget.value = null
}
}
function confirmBatchDelete(ids: (string | number)[]) {
batchDeleteIds.value = ids
batchDeleteDialogOpen.value = true
}
async function handleBatchDelete() {
try {
await api.delete('/dev/dynamic-codes/batch', { ids: batchDeleteIds.value } as any)
toast.success(t('admin.cloudFunction.deleteSuccess'))
fetchCodes()
}
catch (error: any) {
console.error('批量删除失败:', error)
toast.error(error.message || t('admin.cloudFunction.deleteFailed'))
}
finally {
batchDeleteIds.value = []
}
}
async function batchToggleStatus(ids: (string | number)[], enabled: boolean) {
try {
await api.put('/dev/dynamic-codes/batch/status', { ids, enabled })
toast.success(t('admin.cloudFunction.batchUpdateSuccess'))
fetchCodes()
}
catch (error: any) {
console.error('批量更新状态失败:', error)
toast.error(error.message || t('admin.cloudFunction.batchUpdateFailed'))
}
}
onMounted(() => {
fetchApplications()
fetchCodes()
})
</script>
<template>
<BasicPage
:title="t('admin.cloudFunction.title')"
:description="t('admin.cloudFunction.description')"
sticky
>
<template #actions>
<UiButton size="sm" @click="goToCreate">
<Icon icon="lucide:plus" class="mr-2 h-4 w-4" />
{{ t('admin.cloudFunction.createBtn') }}
</UiButton>
</template>
<div class="space-y-6">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.cloudFunction.totalCodes') }}
</UiCardTitle>
<Icon icon="lucide:code" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ filteredCodes.length }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.cloudFunction.enabled') }}
</UiCardTitle>
<Icon icon="lucide:check-circle" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ enabledCount }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.cloudFunction.disabled') }}
</UiCardTitle>
<Icon icon="lucide:ban" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ disabledCount }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.cloudFunction.linkedApps') }}
</UiCardTitle>
<Icon icon="lucide:link" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ linkedAppsCount }}
</div>
</UiCardContent>
</UiCard>
</div>
<UiCard>
<UiCardContent class="p-6">
<DataTable
ref="tableRef"
:loading
:data="filteredCodes"
:on-toggle-status="toggleStatus"
:on-edit="editCode"
:on-delete="confirmDeleteCode"
@refresh="fetchCodes"
@batch-enable="batchToggleStatus(tableRef?.table?.getSelectedRowModel().rows.map((r: any) => r.original.id) || [], true)"
@batch-disable="batchToggleStatus(tableRef?.table?.getSelectedRowModel().rows.map((r: any) => r.original.id) || [], false)"
@batch-delete="confirmBatchDelete(tableRef?.table?.getSelectedRowModel().rows.map((r: any) => r.original.id) || [])"
>
<template #filters>
<SingleFilter
v-model="appFilter"
:title="t('admin.cloudFunction.application')"
:options="applicationOptions"
/>
<SingleFilter
v-model="statusFilter"
:title="t('admin.cloudFunction.status')"
:options="statusOptions"
/>
<DateRangeFilter
v-model:start-model-value="createdStartDate"
v-model:end-model-value="createdEndDate"
:title="t('admin.cloudFunction.columns.createdAt')"
/>
<DateRangeFilter
v-model:start-model-value="updatedStartDate"
v-model:end-model-value="updatedEndDate"
:title="t('admin.cloudFunction.columns.updatedAt')"
/>
</template>
</DataTable>
</UiCardContent>
</UiCard>
</div>
<ConfirmDialog
v-model:open="deleteDialogOpen"
destructive
:confirm-button-text="t('admin.cloudFunction.delete')"
:cancel-button-text="t('admin.cloudFunction.create.cancelBtn')"
@confirm="handleDeleteCode"
>
<template #title>
{{ t('admin.cloudFunction.deleteCode') }}
</template>
<template #description>
{{ t('admin.cloudFunction.deleteCodeConfirm', { codeName: deleteTarget?.name }) }}
</template>
</ConfirmDialog>
<ConfirmDialog
v-model:open="batchDeleteDialogOpen"
destructive
:confirm-button-text="t('admin.cloudFunction.delete')"
:cancel-button-text="t('admin.cloudFunction.create.cancelBtn')"
@confirm="handleBatchDelete"
>
<template #title>
{{ t('admin.cloudFunction.batchDelete') }}
</template>
<template #description>
{{ t('admin.cloudFunction.batchDeleteConfirm', { count: batchDeleteIds.length }) }}
</template>
</ConfirmDialog>
</BasicPage>
</template>
@@ -1,318 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import { BasicPage } from '@/components/global-layout'
import api from '@/services/api'
const router = useRouter()
const { t } = useI18n()
interface Application {
id: number
name: string
}
const saving = ref(false)
const applications = ref<Application[]>([])
const form = ref({
name: '',
application_id: '',
permissions: [] as string[],
enabled: true,
})
const permissionGroups = computed(() => [
{
name: t('admin.extension.apiKeys.permissions.groups.user'),
permissions: [
{ value: 'user.read', label: t('admin.extension.apiKeys.permissions.userRead'), description: t('admin.extension.apiKeys.permissions.userReadDesc') },
{ value: 'user.list', label: t('admin.extension.apiKeys.permissions.userList'), description: t('admin.extension.apiKeys.permissions.userListDesc') },
{ value: 'user.recharge', label: t('admin.extension.apiKeys.permissions.userRecharge'), description: t('admin.extension.apiKeys.permissions.userRechargeDesc') },
{ value: 'user.deduct', label: t('admin.extension.apiKeys.permissions.userDeduct'), description: t('admin.extension.apiKeys.permissions.userDeductDesc') },
{ value: 'user.variables', label: t('admin.extension.apiKeys.permissions.userVariables'), description: t('admin.extension.apiKeys.permissions.userVariablesDesc') },
],
},
{
name: t('admin.extension.apiKeys.permissions.groups.card'),
permissions: [
{ value: 'card.read', label: t('admin.extension.apiKeys.permissions.cardRead'), description: t('admin.extension.apiKeys.permissions.cardReadDesc') },
{ value: 'card.generate', label: t('admin.extension.apiKeys.permissions.cardGenerate'), description: t('admin.extension.apiKeys.permissions.cardGenerateDesc') },
],
},
{
name: t('admin.extension.apiKeys.permissions.groups.notification'),
permissions: [
{ value: 'notification.send', label: t('admin.extension.apiKeys.permissions.notificationSend'), description: t('admin.extension.apiKeys.permissions.notificationSendDesc') },
],
},
{
name: t('admin.extension.apiKeys.permissions.groups.app'),
permissions: [
{ value: 'app.info', label: t('admin.extension.apiKeys.permissions.appInfo'), description: t('admin.extension.apiKeys.permissions.appInfoDesc') },
{ value: 'app.stats', label: t('admin.extension.apiKeys.permissions.appStats'), description: t('admin.extension.apiKeys.permissions.appStatsDesc') },
{ value: 'app.variables', label: t('admin.extension.apiKeys.permissions.appVariables'), description: t('admin.extension.apiKeys.permissions.appVariablesDesc') },
],
},
])
const filteredApplications = computed(() => {
return applications.value.map(app => ({
label: app.name,
value: String(app.id),
}))
})
const selectedApplication = computed(() => {
if (form.value.application_id) {
return applications.value.find(app => String(app.id) === form.value.application_id)
}
return null
})
async function fetchApplications() {
try {
const data = await api.get<{ applications: Application[] }>('/dev/applications')
applications.value = Array.isArray(data?.applications) ? data.applications : []
}
catch (error) {
console.error('获取应用列表失败:', error)
}
}
function togglePermission(permission: string) {
const index = form.value.permissions.indexOf(permission)
if (index > -1) {
form.value.permissions.splice(index, 1)
}
else {
form.value.permissions.push(permission)
}
}
function selectAllInGroup(permissions: { value: string }[]) {
for (const perm of permissions) {
if (!form.value.permissions.includes(perm.value)) {
form.value.permissions.push(perm.value)
}
}
}
function deselectAllInGroup(permissions: { value: string }[]) {
for (const perm of permissions) {
const index = form.value.permissions.indexOf(perm.value)
if (index > -1) {
form.value.permissions.splice(index, 1)
}
}
}
async function handleSubmit() {
if (!form.value.name) {
toast.error(t('admin.extension.apiKeys.create.nameRequired'))
return
}
if (!form.value.application_id) {
toast.error(t('admin.extension.apiKeys.create.selectAppRequired'))
return
}
if (form.value.permissions.length === 0) {
toast.error(t('admin.extension.apiKeys.create.permissionsRequired'))
return
}
saving.value = true
try {
await api.post('/dev/extension/api-keys', {
name: form.value.name,
application_id: Number.parseInt(form.value.application_id),
permissions: form.value.permissions,
enabled: form.value.enabled,
})
toast.success(t('admin.extension.apiKeys.create.submitSuccess'))
router.push('/admin/extension')
}
catch (error: any) {
console.error('创建API密钥失败:', error)
toast.error(error.message || t('admin.extension.apiKeys.create.submitFailed'))
}
finally {
saving.value = false
}
}
onMounted(() => {
fetchApplications()
})
</script>
<template>
<BasicPage
:title="t('admin.extension.apiKeys.create.title')"
:description="t('admin.extension.apiKeys.create.description')"
:breadcrumbs="[
{ title: t('admin.extension.title'), href: '/admin/extension' },
{ title: t('admin.extension.apiKeys.create.title') },
]"
sticky
>
<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 class="flex items-center gap-2">
<Icon icon="lucide:settings-2" class="size-5" />
{{ t('admin.extension.apiKeys.create.title') }}
</UiCardTitle>
<UiCardDescription>{{ t('admin.extension.apiKeys.create.description') }}</UiCardDescription>
</UiCardHeader>
<UiCardContent class="space-y-6">
<div class="space-y-2">
<UiLabel for="name">
{{ t('admin.extension.apiKeys.create.name') }} *
</UiLabel>
<UiInput
id="name"
v-model="form.name"
:placeholder="t('admin.extension.apiKeys.create.namePlaceholder')"
/>
</div>
<div class="space-y-2">
<UiLabel for="application">
{{ t('admin.extension.apiKeys.create.application') }} *
</UiLabel>
<UiSelect v-model="form.application_id">
<UiSelectTrigger>
<UiSelectValue :placeholder="t('admin.extension.apiKeys.create.selectAppPlaceholder')" />
</UiSelectTrigger>
<UiSelectContent>
<UiSelectItem v-for="app in filteredApplications" :key="app.value" :value="app.value">
{{ app.label }}
</UiSelectItem>
</UiSelectContent>
</UiSelect>
</div>
<div class="space-y-4">
<div class="flex items-center justify-between">
<UiLabel>{{ t('admin.extension.apiKeys.create.permissions') }} *</UiLabel>
<div class="flex gap-2">
<UiButton variant="outline" size="sm" @click="form.permissions = permissionGroups.flatMap(g => g.permissions.map(p => p.value))">
{{ t('admin.extension.apiKeys.create.selectAll') }}
</UiButton>
<UiButton variant="outline" size="sm" @click="form.permissions = []">
{{ t('admin.extension.apiKeys.create.deselectAll') }}
</UiButton>
</div>
</div>
<div v-for="group in permissionGroups" :key="group.name" class="space-y-2">
<div class="flex items-center justify-between">
<h4 class="text-sm font-medium">
{{ group.name }}
</h4>
<div class="flex gap-1">
<UiButton variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="selectAllInGroup(group.permissions)">
{{ t('admin.extension.apiKeys.create.selectAll') }}
</UiButton>
<UiButton variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="deselectAllInGroup(group.permissions)">
{{ t('admin.extension.apiKeys.create.deselectAll') }}
</UiButton>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<div
v-for="perm in group.permissions"
:key="perm.value"
class="flex items-start space-x-2 p-3 border rounded-lg cursor-pointer hover:bg-muted/50 transition-colors"
:class="{ 'bg-primary/10 border-primary': form.permissions.includes(perm.value) }"
@click="togglePermission(perm.value)"
>
<UiCheckbox
:checked="form.permissions.includes(perm.value)"
class="mt-0.5"
@update:checked="togglePermission(perm.value)"
/>
<div class="flex-1">
<div class="text-sm font-medium">
{{ perm.label }}
</div>
<div class="text-xs text-muted-foreground">
{{ perm.description }}
</div>
</div>
</div>
</div>
</div>
</div>
<div class="flex items-center space-x-2">
<UiSwitch id="enabled" v-model:checked="form.enabled" />
<UiLabel for="enabled">
{{ t('admin.extension.apiKeys.create.enabled') }}
</UiLabel>
</div>
</UiCardContent>
</UiCard>
</div>
<div class="space-y-6">
<UiCard>
<UiCardHeader>
<UiCardTitle class="flex items-center gap-2">
<Icon icon="lucide:info" class="size-5" />
{{ t('admin.extension.apiKeys.create.preview') || '预览' }}
</UiCardTitle>
</UiCardHeader>
<UiCardContent class="space-y-4">
<div class="space-y-3">
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('admin.extension.apiKeys.create.name') }}</span>
<span>{{ form.name || '-' }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('admin.extension.apiKeys.create.application') }}</span>
<span>{{ selectedApplication?.name || '-' }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('admin.extension.apiKeys.create.permissions') }}</span>
<span>{{ form.permissions.length }} {{ t('admin.extension.apiKeys.create.selected') || '已选择' }}</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.name || !form.application_id || form.permissions.length === 0"
@click="handleSubmit"
>
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
<Icon v-else icon="lucide:check" class="mr-2 h-4 w-4" />
{{ t('admin.extension.apiKeys.create.submitBtn') }}
</UiButton>
<UiButton
variant="outline"
class="w-full"
@click="router.back()"
>
{{ t('admin.extension.apiKeys.create.cancelBtn') }}
</UiButton>
</div>
</UiCardContent>
</UiCard>
</div>
</div>
</div>
</BasicPage>
</template>
@@ -1,156 +0,0 @@
import type { ColumnDef } from '@tanstack/vue-table'
import type { Composer } from 'vue-i18n'
import { Ban, MoreHorizontal, RefreshCw, Trash2 } from 'lucide-vue-next'
import { h } from 'vue'
import type { APIKey } from '@/pages/admin/extension/data/schema'
import { Copy } from '@/components/sva-ui/copy'
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 getAPIKeyColumns(actions: {
onToggleStatus: (row: APIKey) => void
onRegenerate: (row: APIKey) => void
onDelete: (row: APIKey) => void
}, t: Composer['t']): ColumnDef<APIKey>[] {
return [
{
accessorKey: 'name',
header: () => t('admin.extension.apiKeys.columns.name'),
cell: ({ row }) => {
const name = row.getValue('name') as string
return name || '-'
},
},
{
accessorKey: 'application_name',
header: () => t('admin.extension.apiKeys.columns.application'),
cell: ({ row }) => {
const appName = row.getValue('application_name') as string
return appName ? h(Badge, { variant: 'secondary' }, () => appName) : '-'
},
},
{
accessorKey: 'access_key',
header: () => t('admin.extension.apiKeys.columns.accessKey'),
cell: ({ row }) => {
const accessKey = row.getValue('access_key') as string
return accessKey
? h('div', { class: 'flex items-center space-x-2' }, [
h('code', { class: 'text-xs bg-muted px-2 py-1 rounded font-mono' }, accessKey),
h(Copy, { class: 'h-4 w-4', size: 'sm', content: accessKey }),
])
: '-'
},
},
{
accessorKey: 'secret_key',
header: () => t('admin.extension.apiKeys.columns.secretKey'),
cell: ({ row }) => {
const secretKey = row.getValue('secret_key') as string
return secretKey ? h('code', { class: 'text-xs bg-muted px-2 py-1 rounded font-mono' }, '••••••••••••••••') : '-'
},
},
{
accessorKey: 'permissions',
header: () => t('admin.extension.apiKeys.columns.permissions'),
cell: ({ row }) => {
const permissions = row.getValue('permissions') as string
try {
const permissionList = JSON.parse(permissions || '[]')
return h('div', { class: 'flex flex-wrap gap-1' }, permissionList.slice(0, 3).map((permission: string) =>
h(Badge, { variant: 'outline', class: 'text-xs' }, () => permission),
))
}
catch {
return '-'
}
},
},
{
accessorKey: 'status',
header: () => t('admin.extension.apiKeys.columns.status'),
cell: ({ row }) => {
const status = row.getValue('status') as string
return h(Badge, { variant: status === 'active' ? 'default' : 'secondary' }, () =>
status === 'active' ? t('admin.extension.enabled') : t('admin.extension.disabled'))
},
},
{
accessorKey: 'created_at',
header: () => t('admin.extension.apiKeys.columns.createdAt'),
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' }, t('common.actions')),
cell: ({ row }) => {
const apiKey = row.original
const isDisabled = apiKey.status === 'inactive'
return h(
DropdownMenu,
{},
{
default: () => [
h(DropdownMenuTrigger, { asChild: true }, () =>
h(Button, { variant: 'ghost', class: 'h-8 w-8 p-0' }, () => [
h(MoreHorizontal, { class: 'h-4 w-4' }),
h('span', { class: 'sr-only' }, t('common.openMenu')),
])),
h(
DropdownMenuContent,
{ align: 'end' },
() => [
h(DropdownMenuItem, { onClick: () => actions.onRegenerate(apiKey) }, () => [
h(RefreshCw, { class: 'mr-2 h-4 w-4' }),
t('admin.extension.apiKeys.regenerate'),
]),
h(DropdownMenuItem, { onClick: () => actions.onToggleStatus(apiKey) }, () => [
h(Ban, { class: 'mr-2 h-4 w-4' }),
isDisabled ? t('admin.extension.apiKeys.enable') : t('admin.extension.apiKeys.disable'),
]),
h(DropdownMenuSeparator),
h(DropdownMenuItem, { class: 'text-destructive', onClick: () => actions.onDelete(apiKey) }, () => [
h(Trash2, { class: 'mr-2 h-4 w-4' }),
t('admin.extension.apiKeys.delete'),
]),
],
),
],
},
)
},
enableSorting: false,
enableHiding: false,
},
]
}
@@ -1,52 +0,0 @@
<script setup lang="ts">
import type { Table } from '@tanstack/vue-table'
import { X } from 'lucide-vue-next'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { APIKey } from '@/pages/admin/extension/data/schema'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
const props = defineProps<DataTableToolbarProps>()
const emit = defineEmits<{
'update:searchFilter': [value: string]
}>()
const { t } = useI18n()
interface DataTableToolbarProps {
table: Table<APIKey>
searchFilter?: string
}
const isFiltered = computed(() => !!props.searchFilter)
function resetFilters() {
emit('update:searchFilter', '')
}
</script>
<template>
<div class="flex items-center flex-1 space-x-2">
<Input
:placeholder="t('admin.extension.apiKeys.searchAPIKey')"
:model-value="searchFilter"
class="h-8 w-[150px] lg:w-[250px]"
@update:model-value="emit('update:searchFilter', $event as string)"
/>
<Button
v-if="isFiltered"
variant="ghost"
class="h-8 px-2 lg:px-3"
@click="resetFilters"
>
{{ t('admin.extension.apiKeys.reset') }}
<X class="size-4 ml-2" />
</Button>
</div>
</template>
@@ -1,97 +0,0 @@
<script setup lang="ts">
import type { ColumnDef } from '@tanstack/vue-table'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { DataTableProps } from '@/components/data-table/types'
import type { APIKey } from '@/pages/admin/extension/data/schema'
import BulkActions from '@/components/data-table/bulk-actions.vue'
import DataTable from '@/components/data-table/data-table.vue'
import { SelectColumn } from '@/components/data-table/table-columns'
import { generateVueTable } from '@/components/data-table/use-generate-vue-table'
import DataTableViewOptions from '@/components/data-table/view-options.vue'
import { getAPIKeyColumns } from '@/pages/admin/extension/components/apikey-columns'
import APIKeyDataTableToolbar from '@/pages/admin/extension/components/apikey-data-table-toolbar.vue'
const props = defineProps<Omit<DataTableProps<APIKey>, 'columns'> & {
onToggleStatus: (row: APIKey) => void
onRegenerate: (row: APIKey) => void
onDelete: (row: APIKey) => void
searchFilter?: string
}>()
const emit = defineEmits<{
'refresh': []
'batchEnable': []
'batchDisable': []
'batchDelete': []
'update:searchFilter': [value: string]
}>()
const { t } = useI18n()
const columns = computed(() => [
SelectColumn as ColumnDef<APIKey>,
...getAPIKeyColumns({
onToggleStatus: props.onToggleStatus,
onRegenerate: props.onRegenerate,
onDelete: props.onDelete,
}, t),
])
const table = generateVueTable<APIKey>({
get data() { return props.data },
get loading() { return props.loading },
columns: columns.value,
}, columns.value)
const columnLabels: Record<string, string> = {
select: 'admin.extension.apiKeys.select',
name: 'admin.extension.apiKeys.columns.name',
application_name: 'admin.extension.apiKeys.columns.application',
access_key: 'admin.extension.apiKeys.columns.accessKey',
secret_key: 'admin.extension.apiKeys.columns.secretKey',
permissions: 'admin.extension.apiKeys.columns.permissions',
status: 'admin.extension.apiKeys.columns.status',
created_at: 'admin.extension.apiKeys.columns.createdAt',
}
defineExpose({
table,
})
</script>
<template>
<DataTable :columns="columns" :data :loading :table @refresh="emit('refresh')">
<template #toolbar>
<div class="space-y-3">
<BulkActions :table="table" entity-name="apiKeys">
<UiButton variant="outline" size="sm" @click="emit('batchEnable')">
{{ t('admin.extension.apiKeys.enable') }}
</UiButton>
<UiButton variant="outline" size="sm" @click="emit('batchDisable')">
{{ t('admin.extension.apiKeys.disable') }}
</UiButton>
<UiButton variant="destructive" size="sm" @click="emit('batchDelete')">
{{ t('admin.extension.apiKeys.batchDelete') }}
</UiButton>
</BulkActions>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-2">
<APIKeyDataTableToolbar
:table
:search-filter="searchFilter"
@update:search-filter="emit('update:searchFilter', $event)"
/>
<slot name="filters" />
</div>
<div class="flex items-center gap-2">
<DataTableViewOptions :table="table" :column-labels="columnLabels" />
</div>
</div>
</div>
</template>
</DataTable>
</template>
@@ -1,165 +0,0 @@
import type { ColumnDef } from '@tanstack/vue-table'
import type { Composer } from 'vue-i18n'
import { Ban, Eye, MoreHorizontal, Send, Trash2 } from 'lucide-vue-next'
import { h } from 'vue'
import type { Webhook } from '@/pages/admin/extension/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 getWebhookColumns(actions: {
onToggleStatus: (row: Webhook) => void
onTest: (row: Webhook) => void
onViewLogs?: (row: Webhook) => void
onDelete: (row: Webhook) => void
}, t: Composer['t']): ColumnDef<Webhook>[] {
return [
{
accessorKey: 'name',
header: () => t('admin.extension.webhooks.columns.name'),
cell: ({ row }) => {
const name = row.getValue('name') as string
return name || '-'
},
},
{
accessorKey: 'url',
header: () => t('admin.extension.webhooks.columns.url'),
cell: ({ row }) => {
const url = row.getValue('url') as string
return url ? h('code', { class: 'text-xs bg-muted px-2 py-1 rounded font-mono max-w-[200px] truncate inline-block' }, url) : '-'
},
},
{
accessorKey: 'application_name',
header: () => t('admin.extension.webhooks.columns.application'),
cell: ({ row }) => {
const appName = row.getValue('application_name') as string
return appName ? h(Badge, { variant: 'secondary' }, () => appName) : '-'
},
},
{
accessorKey: 'events',
header: () => t('admin.extension.webhooks.columns.events'),
cell: ({ row }) => {
const events = row.getValue('events') as string
try {
const eventList = JSON.parse(events || '[]')
return h('div', { class: 'flex flex-wrap gap-1' }, eventList.slice(0, 3).map((event: string) =>
h(Badge, { variant: 'outline', class: 'text-xs' }, () => event),
))
}
catch {
return '-'
}
},
},
{
accessorKey: 'status',
header: () => t('admin.extension.webhooks.columns.status'),
cell: ({ row }) => {
const status = row.getValue('status') as string
return h(Badge, { variant: status === 'active' ? 'default' : 'secondary' }, () =>
status === 'active' ? t('admin.extension.enabled') : t('admin.extension.disabled'))
},
},
{
accessorKey: 'retry_count',
header: () => t('admin.extension.webhooks.columns.retryCount'),
cell: ({ row }) => {
const retryCount = row.getValue('retry_count') as number
return retryCount ?? '-'
},
},
{
accessorKey: 'timeout',
header: () => t('admin.extension.webhooks.columns.timeout'),
cell: ({ row }) => {
const timeout = row.getValue('timeout') as number
return timeout ? `${timeout}s` : '-'
},
},
{
accessorKey: 'created_at',
header: () => t('admin.extension.webhooks.columns.createdAt'),
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' }, t('common.actions')),
cell: ({ row }) => {
const webhook = row.original
const isDisabled = webhook.status === 'inactive'
return h(
DropdownMenu,
{},
{
default: () => [
h(DropdownMenuTrigger, { asChild: true }, () =>
h(Button, { variant: 'ghost', class: 'h-8 w-8 p-0' }, () => [
h(MoreHorizontal, { class: 'h-4 w-4' }),
h('span', { class: 'sr-only' }, t('common.openMenu')),
])),
h(
DropdownMenuContent,
{ align: 'end' },
() => [
h(DropdownMenuItem, { onClick: () => actions.onTest(webhook) }, () => [
h(Send, { class: 'mr-2 h-4 w-4' }),
t('admin.extension.webhooks.test'),
]),
actions.onViewLogs
? h(DropdownMenuItem, { onClick: () => actions.onViewLogs?.(webhook) }, () => [
h(Eye, { class: 'mr-2 h-4 w-4' }),
t('admin.extension.webhooks.viewLogs'),
])
: null,
h(DropdownMenuItem, { onClick: () => actions.onToggleStatus(webhook) }, () => [
h(Ban, { class: 'mr-2 h-4 w-4' }),
isDisabled ? t('admin.extension.webhooks.enable') : t('admin.extension.webhooks.disable'),
]),
h(DropdownMenuSeparator),
h(DropdownMenuItem, { class: 'text-destructive', onClick: () => actions.onDelete(webhook) }, () => [
h(Trash2, { class: 'mr-2 h-4 w-4' }),
t('admin.extension.webhooks.delete'),
]),
],
),
],
},
)
},
enableSorting: false,
enableHiding: false,
},
]
}
@@ -1,52 +0,0 @@
<script setup lang="ts">
import type { Table } from '@tanstack/vue-table'
import { X } from 'lucide-vue-next'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Webhook } from '@/pages/admin/extension/data/schema'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
const props = defineProps<DataTableToolbarProps>()
const emit = defineEmits<{
'update:searchFilter': [value: string]
}>()
const { t } = useI18n()
interface DataTableToolbarProps {
table: Table<Webhook>
searchFilter?: string
}
const isFiltered = computed(() => !!props.searchFilter)
function resetFilters() {
emit('update:searchFilter', '')
}
</script>
<template>
<div class="flex items-center flex-1 space-x-2">
<Input
:placeholder="t('admin.extension.webhooks.searchWebhook')"
:model-value="searchFilter"
class="h-8 w-[150px] lg:w-[250px]"
@update:model-value="emit('update:searchFilter', $event as string)"
/>
<Button
v-if="isFiltered"
variant="ghost"
class="h-8 px-2 lg:px-3"
@click="resetFilters"
>
{{ t('admin.extension.webhooks.reset') }}
<X class="size-4 ml-2" />
</Button>
</div>
</template>
@@ -1,98 +0,0 @@
<script setup lang="ts">
import type { ColumnDef } from '@tanstack/vue-table'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { DataTableProps } from '@/components/data-table/types'
import type { Webhook } from '@/pages/admin/extension/data/schema'
import BulkActions from '@/components/data-table/bulk-actions.vue'
import DataTable from '@/components/data-table/data-table.vue'
import { SelectColumn } from '@/components/data-table/table-columns'
import { generateVueTable } from '@/components/data-table/use-generate-vue-table'
import DataTableViewOptions from '@/components/data-table/view-options.vue'
import { getWebhookColumns } from '@/pages/admin/extension/components/webhook-columns'
import WebhookDataTableToolbar from '@/pages/admin/extension/components/webhook-data-table-toolbar.vue'
const props = defineProps<Omit<DataTableProps<Webhook>, 'columns'> & {
onToggleStatus: (row: Webhook) => void
onTest: (row: Webhook) => void
onDelete: (row: Webhook) => void
searchFilter?: string
}>()
const emit = defineEmits<{
'refresh': []
'batchEnable': []
'batchDisable': []
'batchDelete': []
'update:searchFilter': [value: string]
}>()
const { t } = useI18n()
const columns = computed(() => [
SelectColumn as ColumnDef<Webhook>,
...getWebhookColumns({
onToggleStatus: props.onToggleStatus,
onTest: props.onTest,
onDelete: props.onDelete,
}, t),
])
const table = generateVueTable<Webhook>({
get data() { return props.data },
get loading() { return props.loading },
columns: columns.value,
}, columns.value)
const columnLabels: Record<string, string> = {
select: 'admin.extension.webhooks.select',
name: 'admin.extension.webhooks.columns.name',
url: 'admin.extension.webhooks.columns.url',
application_name: 'admin.extension.webhooks.columns.application',
events: 'admin.extension.webhooks.columns.events',
status: 'admin.extension.webhooks.columns.status',
retry_count: 'admin.extension.webhooks.columns.retryCount',
timeout: 'admin.extension.webhooks.columns.timeout',
created_at: 'admin.extension.webhooks.columns.createdAt',
}
defineExpose({
table,
})
</script>
<template>
<DataTable :columns="columns" :data :loading :table @refresh="emit('refresh')">
<template #toolbar>
<div class="space-y-3">
<BulkActions :table="table" entity-name="webhooks">
<UiButton variant="outline" size="sm" @click="emit('batchEnable')">
{{ t('admin.extension.webhooks.enable') }}
</UiButton>
<UiButton variant="outline" size="sm" @click="emit('batchDisable')">
{{ t('admin.extension.webhooks.disable') }}
</UiButton>
<UiButton variant="destructive" size="sm" @click="emit('batchDelete')">
{{ t('admin.extension.webhooks.batchDelete') }}
</UiButton>
</BulkActions>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-2">
<WebhookDataTableToolbar
:table
:search-filter="searchFilter"
@update:search-filter="emit('update:searchFilter', $event)"
/>
<slot name="filters" />
</div>
<div class="flex items-center gap-2">
<DataTableViewOptions :table="table" :column-labels="columnLabels" />
</div>
</div>
</div>
</template>
</DataTable>
</template>
@@ -1,40 +0,0 @@
export interface Webhook {
id: number
name: string
url: string
secret_key: string
events: string
retry_count: number
timeout: number
status: 'active' | 'inactive'
application_id: number
application_name: string
created_at: string
updated_at: string
creator?: {
id: number
username: string
}
}
export interface APIKey {
id: number
name: string
access_key: string
secret_key: string
permissions: string
status: 'active' | 'inactive'
application_id: number
application_name: string
created_at: string
updated_at: string
creator?: {
id: number
username: string
}
}
export interface Application {
id: number
name: string
}
@@ -1,560 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import type { APIKey, Webhook } from '@/pages/admin/extension/data/schema'
import ConfirmDialog from '@/components/confirm-dialog.vue'
import DateRangeFilter from '@/components/data-table/date-range-filter.vue'
import SingleFilter from '@/components/data-table/single-filter.vue'
import { BasicPage } from '@/components/global-layout'
import APIKeyDataTable from '@/pages/admin/extension/components/apikey-data-table.vue'
import WebhookDataTable from '@/pages/admin/extension/components/webhook-data-table.vue'
import api from '@/services/api'
const { t } = useI18n()
interface Application {
id: number
name: string
}
const router = useRouter()
const loading = ref(true)
const webhooks = ref<Webhook[]>([])
const apiKeys = ref<APIKey[]>([])
const webhookTableRef = ref()
const apiKeyTableRef = ref()
const applications = ref<Application[]>([])
const appFilter = ref<string>('')
const statusFilter = ref<string>('')
const startDate = ref<string>('')
const endDate = ref<string>('')
const webhookSearchFilter = ref<string>('')
const apiKeySearchFilter = ref<string>('')
const currentTab = ref<string>('webhooks')
const deleteDialogOpen = ref(false)
const deleteTarget = ref<Webhook | APIKey | null>(null)
const deleteType = ref<'webhook' | 'apikey'>('webhook')
const batchDeleteDialogOpen = ref(false)
const batchDeleteIds = ref<(string | number)[]>([])
const regenerateDialogOpen = ref(false)
const regenerateTarget = ref<APIKey | null>(null)
const filteredWebhooks = computed(() => {
let result = webhooks.value
if (appFilter.value) {
result = result.filter(webhook => String(webhook.application_id) === appFilter.value)
}
if (statusFilter.value) {
result = result.filter(webhook => webhook.status === statusFilter.value)
}
if (startDate.value) {
const fromDateTime = startDate.value.includes('T')
? startDate.value.replace('T', ' ')
: `${startDate.value} 00:00`
result = result.filter(webhook => webhook.created_at >= fromDateTime)
}
if (endDate.value) {
const toDateTime = endDate.value.includes('T')
? endDate.value.replace('T', ' ')
: `${endDate.value} 23:59`
result = result.filter(webhook => webhook.created_at <= toDateTime)
}
if (webhookSearchFilter.value) {
const search = webhookSearchFilter.value.toLowerCase()
result = result.filter(webhook =>
webhook.name?.toLowerCase().includes(search)
|| webhook.url?.toLowerCase().includes(search)
|| webhook.application_name?.toLowerCase().includes(search),
)
}
return result
})
const filteredAPIKeys = computed(() => {
let result = apiKeys.value
if (appFilter.value) {
result = result.filter(apiKey => String(apiKey.application_id) === appFilter.value)
}
if (statusFilter.value) {
result = result.filter(apiKey => apiKey.status === statusFilter.value)
}
if (startDate.value) {
const fromDateTime = startDate.value.includes('T')
? startDate.value.replace('T', ' ')
: `${startDate.value} 00:00`
result = result.filter(apiKey => apiKey.created_at >= fromDateTime)
}
if (endDate.value) {
const toDateTime = endDate.value.includes('T')
? endDate.value.replace('T', ' ')
: `${endDate.value} 23:59`
result = result.filter(apiKey => apiKey.created_at <= toDateTime)
}
if (apiKeySearchFilter.value) {
const search = apiKeySearchFilter.value.toLowerCase()
result = result.filter(apiKey =>
apiKey.name?.toLowerCase().includes(search)
|| apiKey.access_key?.toLowerCase().includes(search)
|| apiKey.application_name?.toLowerCase().includes(search),
)
}
return result
})
const activeWebhookCount = computed(() => filteredWebhooks.value.filter(webhook => webhook.status === 'active').length)
const inactiveWebhookCount = computed(() => filteredWebhooks.value.filter(webhook => webhook.status === 'inactive').length)
const activeAPIKeyCount = computed(() => filteredAPIKeys.value.filter(apiKey => apiKey.status === 'active').length)
const inactiveAPIKeyCount = computed(() => filteredAPIKeys.value.filter(apiKey => apiKey.status === 'inactive').length)
const applicationOptions = computed(() => {
return applications.value.map(app => ({
label: app.name,
value: String(app.id),
}))
})
const statusOptions = computed(() => [
{ label: t('admin.extension.enabled'), value: 'active' },
{ label: t('admin.extension.disabled'), value: 'inactive' },
])
async function fetchApplications() {
try {
const data = await api.get<{ applications: Application[] }>('/dev/applications')
applications.value = data?.applications || []
}
catch (error) {
console.error('获取应用列表失败:', error)
}
}
async function fetchWebhooks() {
loading.value = true
try {
const data = await api.get<Webhook[]>('/dev/extension/webhooks')
webhooks.value = Array.isArray(data) ? data : []
}
catch (error) {
console.error('获取Webhook列表失败:', error)
webhooks.value = []
}
finally {
loading.value = false
}
}
async function fetchAPIKeys() {
loading.value = true
try {
const data = await api.get<APIKey[]>('/dev/extension/api-keys')
apiKeys.value = Array.isArray(data) ? data : []
}
catch (error) {
console.error('获取API密钥列表失败:', error)
apiKeys.value = []
}
finally {
loading.value = false
}
}
function goToCreateWebhook() {
router.push('/admin/extension/webhooks/create')
}
function goToCreateAPIKey() {
router.push('/admin/extension/api-keys/create')
}
async function toggleWebhookStatus(webhook: Webhook) {
try {
const newStatus = webhook.status === 'active' ? 'inactive' : 'active'
await api.put(`/dev/extension/webhooks/${webhook.id}/status`, { status: newStatus })
toast.success(t('admin.extension.webhooks.statusUpdateSuccess'))
fetchWebhooks()
}
catch (error: any) {
console.error('切换Webhook状态失败:', error)
toast.error(error.message || t('admin.extension.webhooks.statusUpdateFailed'))
}
}
async function toggleAPIKeyStatus(apiKey: APIKey) {
try {
const newStatus = apiKey.status === 'active' ? 'inactive' : 'active'
await api.put(`/dev/extension/api-keys/${apiKey.id}/status`, { status: newStatus })
toast.success(t('admin.extension.apiKeys.statusUpdateSuccess'))
fetchAPIKeys()
}
catch (error: any) {
console.error('切换API密钥状态失败:', error)
toast.error(error.message || t('admin.extension.apiKeys.statusUpdateFailed'))
}
}
async function testWebhook(webhook: Webhook) {
try {
await api.post(`/dev/extension/webhooks/${webhook.id}/test`, {})
toast.success(t('admin.extension.webhooks.testSuccess'))
}
catch (error: any) {
console.error('测试Webhook失败:', error)
toast.error(error.message || t('admin.extension.webhooks.testFailed'))
}
}
function confirmRegenerateAPIKey(apiKey: APIKey) {
regenerateTarget.value = apiKey
regenerateDialogOpen.value = true
}
async function handleRegenerateAPIKey() {
if (!regenerateTarget.value)
return
try {
await api.post(`/dev/extension/api-keys/${regenerateTarget.value.id}/regenerate`, {})
toast.success(t('admin.extension.apiKeys.regenerateSuccess'))
fetchAPIKeys()
}
catch (error: any) {
console.error('重新生成API密钥失败:', error)
toast.error(error.message || t('admin.extension.apiKeys.regenerateFailed'))
}
finally {
regenerateTarget.value = null
}
}
function confirmDeleteWebhook(webhook: Webhook) {
deleteTarget.value = webhook
deleteType.value = 'webhook'
deleteDialogOpen.value = true
}
function confirmDeleteAPIKey(apiKey: APIKey) {
deleteTarget.value = apiKey
deleteType.value = 'apikey'
deleteDialogOpen.value = true
}
async function handleDelete() {
if (!deleteTarget.value)
return
try {
if (deleteType.value === 'webhook') {
await api.delete(`/dev/extension/webhooks/${deleteTarget.value.id}`)
toast.success(t('admin.extension.webhooks.deleteSuccess'))
fetchWebhooks()
}
else {
await api.delete(`/dev/extension/api-keys/${deleteTarget.value.id}`)
toast.success(t('admin.extension.apiKeys.deleteSuccess'))
fetchAPIKeys()
}
}
catch (error: any) {
console.error('删除失败:', error)
toast.error(error.message || t('admin.extension.deleteFailed'))
}
finally {
deleteTarget.value = null
}
}
function confirmBatchDelete(type: 'webhook' | 'apikey', ids: (string | number)[]) {
deleteType.value = type
batchDeleteIds.value = ids
batchDeleteDialogOpen.value = true
}
async function handleBatchDelete() {
try {
if (deleteType.value === 'webhook') {
await api.delete('/dev/extension/webhooks/batch', { ids: batchDeleteIds.value } as any)
toast.success(t('admin.extension.webhooks.deleteSuccess'))
fetchWebhooks()
}
else {
await api.delete('/dev/extension/api-keys/batch', { ids: batchDeleteIds.value } as any)
toast.success(t('admin.extension.apiKeys.deleteSuccess'))
fetchAPIKeys()
}
}
catch (error: any) {
console.error('批量删除失败:', error)
toast.error(error.message || t('admin.extension.deleteFailed'))
}
finally {
batchDeleteIds.value = []
}
}
async function batchToggleWebhookStatus(ids: (string | number)[], status: string) {
try {
await api.put('/dev/extension/webhooks/batch/status', { ids, status })
toast.success(t('admin.extension.webhooks.batchUpdateSuccess'))
fetchWebhooks()
}
catch (error: any) {
console.error('批量更新Webhook状态失败:', error)
toast.error(error.message || t('admin.extension.webhooks.batchUpdateFailed'))
}
}
async function batchToggleAPIKeyStatus(ids: (string | number)[], status: string) {
try {
await api.put('/dev/extension/api-keys/batch/status', { ids, status })
toast.success(t('admin.extension.apiKeys.batchUpdateSuccess'))
fetchAPIKeys()
}
catch (error: any) {
console.error('批量更新API密钥状态失败:', error)
toast.error(error.message || t('admin.extension.apiKeys.batchUpdateFailed'))
}
}
onMounted(() => {
fetchApplications()
fetchWebhooks()
fetchAPIKeys()
})
</script>
<template>
<BasicPage
:title="t('admin.extension.title')"
:description="t('admin.extension.description')"
sticky
>
<template #actions>
<UiButton v-if="currentTab === 'webhooks'" @click="goToCreateWebhook">
<Icon icon="lucide:plus" class="mr-2 h-4 w-4" />
{{ t('admin.extension.webhooks.createBtn') }}
</UiButton>
<UiButton v-else @click="goToCreateAPIKey">
<Icon icon="lucide:plus" class="mr-2 h-4 w-4" />
{{ t('admin.extension.apiKeys.createBtn') }}
</UiButton>
</template>
<div class="space-y-6">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.extension.totalWebhooks') }}
</UiCardTitle>
<Icon icon="lucide:webhook" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ filteredWebhooks.length }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.extension.totalAPIKeys') }}
</UiCardTitle>
<Icon icon="lucide:key" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ filteredAPIKeys.length }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.extension.enabled') }}
</UiCardTitle>
<Icon icon="lucide:check-circle" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ activeWebhookCount + activeAPIKeyCount }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.extension.disabled') }}
</UiCardTitle>
<Icon icon="lucide:ban" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ inactiveWebhookCount + inactiveAPIKeyCount }}
</div>
</UiCardContent>
</UiCard>
</div>
<UiTabs v-model="currentTab" default-value="webhooks" class="w-full">
<UiTabsList class="grid w-full grid-cols-2">
<UiTabsTrigger value="webhooks">
<Icon icon="lucide:webhook" class="h-4 w-4 mr-2" />
{{ t('admin.extension.webhooks.title') }}
</UiTabsTrigger>
<UiTabsTrigger value="apikeys">
<Icon icon="lucide:key" class="h-4 w-4 mr-2" />
{{ t('admin.extension.apiKeys.title') }}
</UiTabsTrigger>
</UiTabsList>
<UiTabsContent value="webhooks" class="space-y-4">
<UiCard>
<UiCardContent class="p-6">
<WebhookDataTable
ref="webhookTableRef"
:loading
:data="filteredWebhooks"
:on-toggle-status="toggleWebhookStatus"
:on-test="testWebhook"
:on-delete="confirmDeleteWebhook"
:search-filter="webhookSearchFilter"
@refresh="fetchWebhooks"
@batch-enable="batchToggleWebhookStatus(webhookTableRef?.table?.getSelectedRowModel().rows.filter((r: any) => r.original.status === 'inactive').map((r: any) => r.original.id) || [], 'active')"
@batch-disable="batchToggleWebhookStatus(webhookTableRef?.table?.getSelectedRowModel().rows.filter((r: any) => r.original.status === 'active').map((r: any) => r.original.id) || [], 'inactive')"
@batch-delete="confirmBatchDelete('webhook', webhookTableRef?.table?.getSelectedRowModel().rows.map((r: any) => r.original.id) || [])"
@update:search-filter="webhookSearchFilter = $event"
>
<template #filters>
<SingleFilter
v-model="appFilter"
:title="t('admin.extension.application')"
:options="applicationOptions"
/>
<SingleFilter
v-model="statusFilter"
:title="t('admin.extension.status')"
:options="statusOptions"
/>
<DateRangeFilter
v-model:start-model-value="startDate"
v-model:end-model-value="endDate"
/>
</template>
</WebhookDataTable>
</UiCardContent>
</UiCard>
</UiTabsContent>
<UiTabsContent value="apikeys" class="space-y-4">
<UiCard>
<UiCardContent class="p-6">
<APIKeyDataTable
ref="apiKeyTableRef"
:loading
:data="filteredAPIKeys"
:on-toggle-status="toggleAPIKeyStatus"
:on-regenerate="confirmRegenerateAPIKey"
:on-delete="confirmDeleteAPIKey"
:search-filter="apiKeySearchFilter"
@refresh="fetchAPIKeys"
@batch-enable="batchToggleAPIKeyStatus(apiKeyTableRef?.table?.getSelectedRowModel().rows.filter((r: any) => r.original.status === 'inactive').map((r: any) => r.original.id) || [], 'active')"
@batch-disable="batchToggleAPIKeyStatus(apiKeyTableRef?.table?.getSelectedRowModel().rows.filter((r: any) => r.original.status === 'active').map((r: any) => r.original.id) || [], 'inactive')"
@batch-delete="confirmBatchDelete('apikey', apiKeyTableRef?.table?.getSelectedRowModel().rows.map((r: any) => r.original.id) || [])"
@update:search-filter="apiKeySearchFilter = $event"
>
<template #filters>
<SingleFilter
v-model="appFilter"
:title="t('admin.extension.application')"
:options="applicationOptions"
/>
<SingleFilter
v-model="statusFilter"
:title="t('admin.extension.status')"
:options="statusOptions"
/>
<DateRangeFilter
v-model:start-model-value="startDate"
v-model:end-model-value="endDate"
/>
</template>
</APIKeyDataTable>
</UiCardContent>
</UiCard>
</UiTabsContent>
</UiTabs>
</div>
<ConfirmDialog
v-model:open="deleteDialogOpen"
destructive
:confirm-button-text="t('admin.extension.delete')"
:cancel-button-text="t('admin.extension.cancel')"
@confirm="handleDelete"
>
<template #title>
{{ deleteType === 'webhook' ? t('admin.extension.webhooks.deleteWebhook') : t('admin.extension.apiKeys.deleteAPIKey') }}
</template>
<template #description>
{{ deleteType === 'webhook'
? t('admin.extension.webhooks.deleteConfirm', { webhookName: (deleteTarget as Webhook)?.name })
: t('admin.extension.apiKeys.deleteConfirm', { apiKeyName: (deleteTarget as APIKey)?.name })
}}
</template>
</ConfirmDialog>
<ConfirmDialog
v-model:open="batchDeleteDialogOpen"
destructive
:confirm-button-text="t('admin.extension.delete')"
:cancel-button-text="t('admin.extension.cancel')"
@confirm="handleBatchDelete"
>
<template #title>
{{ deleteType === 'webhook' ? t('admin.extension.webhooks.batchDelete') : t('admin.extension.apiKeys.batchDelete') }}
</template>
<template #description>
{{ deleteType === 'webhook'
? t('admin.extension.webhooks.batchDeleteConfirm', { count: batchDeleteIds.length })
: t('admin.extension.apiKeys.batchDeleteConfirm', { count: batchDeleteIds.length })
}}
</template>
</ConfirmDialog>
<ConfirmDialog
v-model:open="regenerateDialogOpen"
:confirm-button-text="t('admin.extension.apiKeys.regenerate')"
:cancel-button-text="t('admin.extension.cancel')"
@confirm="handleRegenerateAPIKey"
>
<template #title>
{{ t('admin.extension.apiKeys.regenerate') }}
</template>
<template #description>
{{ t('admin.extension.apiKeys.regenerateConfirm') }}
</template>
</ConfirmDialog>
</BasicPage>
</template>
@@ -1,315 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import { BasicPage } from '@/components/global-layout'
import api from '@/services/api'
const router = useRouter()
const { t } = useI18n()
interface Application {
id: number
name: string
}
const saving = ref(false)
const applications = ref<Application[]>([])
const form = ref({
name: '',
application_id: '',
url: '',
secret_key: '',
events: [] as string[],
retry_count: 3,
timeout: 10,
enabled: true,
})
const availableEvents = computed(() => [
{ value: 'user.registered', label: t('admin.extension.webhooks.events.userRegistered') },
{ value: 'user.login', label: t('admin.extension.webhooks.events.userLogin') },
{ value: 'user.recharged', label: t('admin.extension.webhooks.events.userRecharged') },
{ value: 'user.expired', label: t('admin.extension.webhooks.events.userExpired') },
{ value: 'card.used', label: t('admin.extension.webhooks.events.cardUsed') },
{ value: 'card.expired', label: t('admin.extension.webhooks.events.cardExpired') },
{ value: 'abnormal.detected', label: t('admin.extension.webhooks.events.abnormalDetected') },
])
const filteredApplications = computed(() => {
return applications.value.map(app => ({
label: app.name,
value: String(app.id),
}))
})
const selectedApplication = computed(() => {
if (form.value.application_id) {
return applications.value.find(app => String(app.id) === form.value.application_id)
}
return null
})
async function fetchApplications() {
try {
const data = await api.get<{ applications: Application[] }>('/dev/applications')
applications.value = Array.isArray(data?.applications) ? data.applications : []
}
catch (error) {
console.error('获取应用列表失败:', error)
}
}
function toggleEvent(event: string) {
const index = form.value.events.indexOf(event)
if (index > -1) {
form.value.events.splice(index, 1)
}
else {
form.value.events.push(event)
}
}
async function handleSubmit() {
if (!form.value.name) {
toast.error(t('admin.extension.webhooks.create.nameRequired'))
return
}
if (!form.value.application_id) {
toast.error(t('admin.extension.webhooks.create.selectAppRequired'))
return
}
if (!form.value.url) {
toast.error(t('admin.extension.webhooks.create.urlRequired'))
return
}
if (form.value.events.length === 0) {
toast.error(t('admin.extension.webhooks.create.eventsRequired'))
return
}
saving.value = true
try {
await api.post('/dev/extension/webhooks', {
name: form.value.name,
application_id: Number.parseInt(form.value.application_id),
url: form.value.url,
secret_key: form.value.secret_key,
events: form.value.events,
retry_count: form.value.retry_count,
timeout: form.value.timeout,
enabled: form.value.enabled,
})
toast.success(t('admin.extension.webhooks.create.submitSuccess'))
router.push('/admin/extension')
}
catch (error: any) {
console.error('创建Webhook失败:', error)
toast.error(error.message || t('admin.extension.webhooks.create.submitFailed'))
}
finally {
saving.value = false
}
}
onMounted(() => {
fetchApplications()
})
</script>
<template>
<BasicPage
:title="t('admin.extension.webhooks.create.title')"
:description="t('admin.extension.webhooks.create.description')"
:breadcrumbs="[
{ title: t('admin.extension.title'), href: '/admin/extension' },
{ title: t('admin.extension.webhooks.create.title') },
]"
sticky
>
<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 class="flex items-center gap-2">
<Icon icon="lucide:settings-2" class="size-5" />
{{ t('admin.extension.webhooks.create.title') }}
</UiCardTitle>
<UiCardDescription>{{ t('admin.extension.webhooks.create.description') }}</UiCardDescription>
</UiCardHeader>
<UiCardContent class="space-y-6">
<div class="space-y-2">
<UiLabel for="name">
{{ t('admin.extension.webhooks.create.name') }} *
</UiLabel>
<UiInput
id="name"
v-model="form.name"
:placeholder="t('admin.extension.webhooks.create.namePlaceholder')"
/>
</div>
<div class="space-y-2">
<UiLabel for="application">
{{ t('admin.extension.webhooks.create.application') }} *
</UiLabel>
<UiSelect v-model="form.application_id">
<UiSelectTrigger>
<UiSelectValue :placeholder="t('admin.extension.webhooks.create.selectAppPlaceholder')" />
</UiSelectTrigger>
<UiSelectContent>
<UiSelectItem v-for="app in filteredApplications" :key="app.value" :value="app.value">
{{ app.label }}
</UiSelectItem>
</UiSelectContent>
</UiSelect>
</div>
<div class="space-y-2">
<UiLabel for="url">
{{ t('admin.extension.webhooks.create.url') }} *
</UiLabel>
<UiInput
id="url"
v-model="form.url"
:placeholder="t('admin.extension.webhooks.create.urlPlaceholder')"
/>
</div>
<div class="space-y-2">
<UiLabel for="secret_key">
{{ t('admin.extension.webhooks.create.secretKey') }}
</UiLabel>
<UiInput
id="secret_key"
v-model="form.secret_key"
:placeholder="t('admin.extension.webhooks.create.secretKeyPlaceholder')"
/>
</div>
<div class="space-y-2">
<UiLabel>{{ t('admin.extension.webhooks.create.events') }} *</UiLabel>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<div
v-for="event in availableEvents"
:key="event.value"
class="flex items-center space-x-2 p-3 border rounded-lg cursor-pointer hover:bg-muted/50 transition-colors"
:class="{ 'bg-primary/10 border-primary': form.events.includes(event.value) }"
@click="toggleEvent(event.value)"
>
<UiCheckbox
:checked="form.events.includes(event.value)"
@update:checked="toggleEvent(event.value)"
/>
<span class="text-sm">{{ event.label }}</span>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2">
<UiLabel for="retry_count">
{{ t('admin.extension.webhooks.create.retryCount') }}
</UiLabel>
<UiInput
id="retry_count"
v-model.number="form.retry_count"
type="number"
min="0"
max="10"
:placeholder="t('admin.extension.webhooks.create.retryCountPlaceholder')"
/>
</div>
<div class="space-y-2">
<UiLabel for="timeout">
{{ t('admin.extension.webhooks.create.timeout') }}
</UiLabel>
<UiInput
id="timeout"
v-model.number="form.timeout"
type="number"
min="1"
max="60"
:placeholder="t('admin.extension.webhooks.create.timeoutPlaceholder')"
/>
</div>
</div>
<div class="flex items-center space-x-2">
<UiSwitch id="enabled" v-model:checked="form.enabled" />
<UiLabel for="enabled">
{{ t('admin.extension.webhooks.create.enabled') }}
</UiLabel>
</div>
</UiCardContent>
</UiCard>
</div>
<div class="space-y-6">
<UiCard>
<UiCardHeader>
<UiCardTitle class="flex items-center gap-2">
<Icon icon="lucide:info" class="size-5" />
{{ t('admin.extension.webhooks.create.preview') || '预览' }}
</UiCardTitle>
</UiCardHeader>
<UiCardContent class="space-y-4">
<div class="space-y-3">
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('admin.extension.webhooks.create.name') }}</span>
<span>{{ form.name || '-' }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('admin.extension.webhooks.create.application') }}</span>
<span>{{ selectedApplication?.name || '-' }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('admin.extension.webhooks.create.events') }}</span>
<span>{{ form.events.length }} {{ t('admin.extension.webhooks.create.selected') || '已选择' }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('admin.extension.webhooks.create.retryCount') }}</span>
<span>{{ form.retry_count }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">{{ t('admin.extension.webhooks.create.timeout') }}</span>
<span>{{ form.timeout }}s</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.name || !form.application_id || !form.url || form.events.length === 0"
@click="handleSubmit"
>
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
<Icon v-else icon="lucide:check" class="mr-2 h-4 w-4" />
{{ t('admin.extension.webhooks.create.submitBtn') }}
</UiButton>
<UiButton
variant="outline"
class="w-full"
@click="router.back()"
>
{{ t('admin.extension.webhooks.create.cancelBtn') }}
</UiButton>
</div>
</UiCardContent>
</UiCard>
</div>
</div>
</div>
</BasicPage>
</template>
@@ -1,580 +0,0 @@
<script setup lang="ts">
import type {
ColumnDef,
ColumnFiltersState,
SortingState,
VisibilityState,
} from '@tanstack/vue-table'
import { Icon } from '@iconify/vue'
import {
FlexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useVueTable,
} from '@tanstack/vue-table'
import { computed, h, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import ConfirmDialog from '@/components/confirm-dialog.vue'
import DataTablePagination from '@/components/data-table/table-pagination.vue'
import { BasicPage } from '@/components/global-layout'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import DateTimePicker from '@/components/ui/date-picker/DateTimePicker.vue'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { valueUpdater } from '@/lib/utils'
import api from '@/services/api'
interface Application {
id: number
name: string
}
interface FinanceRecord {
id: number
order_no: string
type: 'recharge' | 'consumption'
user_id: number
app_id?: number
amount: number
detail: string
status: string
payment_type: string
remark: string
created_at: string
user?: {
id: number
username: string
email: string
}
}
const { t } = useI18n()
const loading = ref(true)
const records = ref<FinanceRecord[]>([])
const applications = ref<Application[]>([])
const appFilter = ref<string>('all')
const statusFilter = ref<string>('all')
const dateRange = ref<{ from: string, to: string }>({ from: '', to: '' })
function setQuickRange(range: 'today' | 'week' | 'month') {
const now = new Date()
const today = now.toISOString().split('T')[0]
const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().split('T')[0]
switch (range) {
case 'today':
dateRange.value = { from: `${today}T00:00`, to: `${today}T23:59` }
break
case 'week': {
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
dateRange.value = {
from: `${weekAgo.toISOString().split('T')[0]}T00:00`,
to: `${today}T23:59`,
}
break
}
case 'month':
dateRange.value = { from: `${firstDayOfMonth}T00:00`, to: `${today}T23:59` }
break
}
}
const filteredRecords = computed(() => {
let result = records.value
if (appFilter.value !== 'all') {
result = result.filter(r => r.app_id === Number(appFilter.value))
}
if (statusFilter.value !== 'all') {
result = result.filter(r => r.status === statusFilter.value)
}
if (dateRange.value.from) {
const fromDateTime = dateRange.value.from.includes('T')
? dateRange.value.from.replace('T', ' ')
: `${dateRange.value.from} 00:00`
result = result.filter(r => r.created_at >= fromDateTime)
}
if (dateRange.value.to) {
const toDateTime = dateRange.value.to.includes('T')
? dateRange.value.to.replace('T', ' ')
: `${dateRange.value.to} 23:59`
result = result.filter(r => r.created_at <= toDateTime)
}
return result
})
const stats = computed(() => {
const rechargeRecords = filteredRecords.value.filter(r => r.type === 'recharge' && r.status === 'success')
const consumptionRecords = filteredRecords.value.filter(r => r.type === 'consumption' && r.status === 'success')
const totalRecharge = rechargeRecords.reduce((sum, r) => sum + r.amount, 0)
const totalConsumption = consumptionRecords.reduce((sum, r) => sum + r.amount, 0)
return {
rechargeCount: rechargeRecords.length,
rechargeAmount: totalRecharge,
consumptionCount: consumptionRecords.length,
consumptionAmount: totalConsumption,
profit: totalRecharge - totalConsumption,
}
})
function formatTime(time: string) {
if (!time)
return '-'
try {
const date = new Date(time)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
catch {
return '-'
}
}
const sorting = ref<SortingState>([])
const columnFilters = ref<ColumnFiltersState>([])
const columnVisibility = ref<VisibilityState>({})
const rowSelection = ref({})
const deleteDialogOpen = ref(false)
const deleteTarget = ref<FinanceRecord | null>(null)
const batchDeleteDialogOpen = ref(false)
async function confirmDelete(record: FinanceRecord) {
deleteTarget.value = record
deleteDialogOpen.value = true
}
async function handleDelete() {
if (!deleteTarget.value)
return
try {
await api.delete(`/dev/finance/records/${deleteTarget.value.id}?type=${deleteTarget.value.type}`)
toast.success(t('admin.finance.deleteSuccess'))
fetchRecords()
}
catch (error: any) {
toast.error(error.message || t('admin.finance.deleteFailed'))
}
finally {
deleteTarget.value = null
}
}
const columns: ColumnDef<FinanceRecord>[] = [
{
id: 'select',
header: ({ table }) => h(Checkbox, {
'checked': table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && 'indeterminate'),
'onUpdate:checked': (value: boolean | 'indeterminate') => table.toggleAllPageRowsSelected(!!value),
'ariaLabel': 'Select all',
}),
cell: ({ row }) => h(Checkbox, {
'checked': row.getIsSelected(),
'onUpdate:checked': (value: boolean | 'indeterminate') => row.toggleSelected(!!value),
'ariaLabel': 'Select row',
}),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: 'type',
header: () => t('admin.finance.columns.type'),
cell: ({ row }) => {
const type = row.getValue('type') as string
return h(Badge, { variant: type === 'recharge' ? 'default' : 'secondary' }, () => type === 'recharge' ? t('admin.finance.types.recharge') : t('admin.finance.types.consumption'))
},
},
{
accessorKey: 'amount',
header: () => t('admin.finance.columns.amount'),
cell: ({ row }) => {
const type = row.getValue('type') as string
const amount = row.getValue('amount') as number
const prefix = type === 'recharge' ? '+' : '-'
return h('span', { class: 'font-medium' }, `${prefix}¥${amount.toFixed(2)}`)
},
},
{
accessorKey: 'user',
header: () => t('admin.finance.columns.user'),
cell: ({ row }) => {
const user = row.original.user
return user?.username || '-'
},
},
{
accessorKey: 'detail',
header: () => t('admin.finance.columns.detail'),
cell: ({ row }) => {
const detail = row.getValue('detail') as string
return h('span', { class: 'max-w-[200px] truncate text-muted-foreground block' }, detail || '-')
},
},
{
accessorKey: 'status',
header: () => t('admin.finance.columns.status'),
cell: ({ row }) => {
const status = row.getValue('status') as string
const map: Record<string, { label: string, variant: 'default' | 'secondary' | 'destructive' }> = {
success: { label: t('admin.finance.success'), variant: 'default' },
pending: { label: t('admin.finance.pending'), variant: 'secondary' },
failed: { label: t('admin.finance.failed'), variant: 'destructive' },
}
const { label, variant } = map[status] || { label: status, variant: 'secondary' }
return h(Badge, { variant }, () => label)
},
},
{
accessorKey: 'created_at',
header: () => t('admin.finance.columns.time'),
cell: ({ row }) => {
return h('span', { class: 'text-muted-foreground whitespace-nowrap' }, formatTime(row.getValue('created_at')))
},
},
{
id: 'actions',
header: () => t('admin.finance.columns.actions'),
cell: ({ row }) => {
return h(Button, {
variant: 'ghost',
size: 'sm',
class: 'h-8 w-8 p-0',
onClick: () => confirmDelete(row.original),
}, () => h(Icon, { icon: 'lucide:trash-2', class: 'size-4 text-muted-foreground hover:text-destructive' }))
},
},
]
const table = useVueTable({
get data() { return filteredRecords.value },
get columns() { return columns },
state: {
get sorting() { return sorting.value },
get columnFilters() { return columnFilters.value },
get columnVisibility() { return columnVisibility.value },
get rowSelection() { return rowSelection.value },
},
enableRowSelection: true,
onSortingChange: updaterOrValue => valueUpdater(updaterOrValue, sorting),
onColumnFiltersChange: updaterOrValue => valueUpdater(updaterOrValue, columnFilters),
onColumnVisibilityChange: updaterOrValue => valueUpdater(updaterOrValue, columnVisibility),
onRowSelectionChange: updaterOrValue => valueUpdater(updaterOrValue, rowSelection),
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
})
async function confirmBatchDelete() {
const selectedRows = table.getSelectedRowModel().rows
if (selectedRows.length === 0) {
toast.error(t('admin.finance.delete'))
return
}
batchDeleteDialogOpen.value = true
}
async function handleBatchDelete() {
const selectedRows = table.getSelectedRowModel().rows
const ids = selectedRows.map(row => row.original.id)
try {
await api.post('/dev/finance/records/batch-delete', { ids })
toast.success(t('admin.finance.deleteSuccess'))
fetchRecords()
}
catch (error: any) {
toast.error(error.message || t('admin.finance.deleteFailed'))
}
}
async function fetchApplications() {
try {
const data = await api.get<{ applications: Application[] }>('/dev/applications')
applications.value = data?.applications || []
}
catch (error) {
console.error('获取应用列表失败:', error)
}
}
async function fetchRecords() {
loading.value = true
try {
const data = await api.get<{ records: FinanceRecord[] }>('/dev/finance/records')
records.value = data?.records || []
}
catch (error) {
console.error('获取财务记录失败:', error)
}
finally {
loading.value = false
}
}
onMounted(() => {
fetchApplications()
fetchRecords()
})
</script>
<template>
<BasicPage
:title="t('admin.finance.title')"
:description="t('admin.finance.description')"
sticky
>
<div class="space-y-6">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.finance.rechargeCount') }}
</UiCardTitle>
<Icon icon="lucide:arrow-down-left" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ stats.rechargeCount }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.finance.rechargeAmount') }}
</UiCardTitle>
<Icon icon="lucide:dollar-sign" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
¥{{ stats.rechargeAmount.toFixed(2) }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.finance.consumptionCount') }}
</UiCardTitle>
<Icon icon="lucide:arrow-up-right" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ stats.consumptionCount }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.finance.consumptionAmount') }}
</UiCardTitle>
<Icon icon="lucide:credit-card" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
¥{{ stats.consumptionAmount.toFixed(2) }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.finance.profit') }}
</UiCardTitle>
<Icon icon="lucide:trending-up" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
¥{{ stats.profit.toFixed(2) }}
</div>
</UiCardContent>
</UiCard>
</div>
<UiCard>
<UiCardHeader>
<UiCardTitle>{{ t('admin.finance.transactionRecords') }}</UiCardTitle>
<UiCardDescription>
{{ t('admin.finance.transactionRecordsDesc') }}
</UiCardDescription>
</UiCardHeader>
<UiCardContent>
<div class="space-y-3 mb-4">
<div class="flex items-center justify-end">
<UiButton
v-if="table.getSelectedRowModel().rows.length > 0"
variant="destructive"
size="sm"
@click="confirmBatchDelete"
>
<Icon icon="lucide:trash-2" class="mr-2 size-4" />
{{ t('admin.finance.batchDeleteBtn') }}
</UiButton>
</div>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-2">
<UiSelect v-model="appFilter" class="w-[140px]">
<UiSelectTrigger>
<UiSelectValue :placeholder="t('admin.finance.allApplications')" />
</UiSelectTrigger>
<UiSelectContent>
<UiSelectItem value="all">
{{ t('admin.finance.allApplications') }}
</UiSelectItem>
<UiSelectItem v-for="app in applications" :key="app.id" :value="String(app.id)">
{{ app.name }}
</UiSelectItem>
</UiSelectContent>
</UiSelect>
<UiSelect v-model="statusFilter" class="w-[120px]">
<UiSelectTrigger>
<UiSelectValue :placeholder="t('admin.finance.allStatus')" />
</UiSelectTrigger>
<UiSelectContent>
<UiSelectItem value="all">
{{ t('admin.finance.allStatus') }}
</UiSelectItem>
<UiSelectItem value="success">
{{ t('admin.finance.success') }}
</UiSelectItem>
<UiSelectItem value="pending">
{{ t('admin.finance.pending') }}
</UiSelectItem>
<UiSelectItem value="failed">
{{ t('admin.finance.failed') }}
</UiSelectItem>
</UiSelectContent>
</UiSelect>
<DateTimePicker
v-model="dateRange.from"
:placeholder="t('admin.finance.startTime')"
class="w-[180px]"
/>
<span class="text-muted-foreground">{{ t('admin.finance.to') }}</span>
<DateTimePicker
v-model="dateRange.to"
:placeholder="t('admin.finance.endTime')"
class="w-[180px]"
/>
<UiButton variant="ghost" size="sm" @click="setQuickRange('today')">
{{ t('admin.finance.today') }}
</UiButton>
<UiButton variant="ghost" size="sm" @click="setQuickRange('week')">
{{ t('admin.finance.last7Days') }}
</UiButton>
<UiButton variant="ghost" size="sm" @click="setQuickRange('month')">
{{ t('admin.finance.thisMonth') }}
</UiButton>
</div>
</div>
</div>
<div v-if="loading" class="flex items-center justify-center py-12">
<Icon icon="lucide:loader-2" class="size-8 animate-spin text-muted-foreground" />
</div>
<div v-else class="border rounded-md">
<Table>
<TableHeader>
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead v-for="header in headerGroup.headers" :key="header.id">
<FlexRender v-if="!header.isPlaceholder" :render="header.column.columnDef.header" :props="header.getContext()" />
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<template v-if="table.getRowModel().rows?.length">
<TableRow
v-for="row in table.getRowModel().rows"
:key="row.id"
:data-state="row.getIsSelected() && 'selected'"
>
<TableCell v-for="cell in row.getVisibleCells()" :key="cell.id">
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</TableCell>
</TableRow>
</template>
<TableRow v-else>
<TableCell
:colspan="columns.length"
class="h-24 text-center"
>
<div class="flex flex-col items-center justify-center text-muted-foreground">
<Icon icon="lucide:inbox" class="size-12 mb-2 opacity-50" />
<span>{{ t('admin.finance.noRecords') }}</span>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<DataTablePagination v-if="!loading && table.getRowModel().rows?.length" :table="table" class="mt-4" />
</UiCardContent>
</UiCard>
</div>
</BasicPage>
<ConfirmDialog
v-model:open="deleteDialogOpen"
destructive
:confirm-button-text="t('admin.finance.delete')"
:cancel-button-text="t('admin.finance.cancel')"
@confirm="handleDelete"
>
<template #title>
{{ t('admin.finance.delete') }}
</template>
<template #description>
{{ t('admin.finance.deleteConfirm') }}
</template>
</ConfirmDialog>
<ConfirmDialog
v-model:open="batchDeleteDialogOpen"
destructive
:confirm-button-text="t('admin.finance.delete')"
:cancel-button-text="t('admin.finance.cancel')"
@confirm="handleBatchDelete"
>
<template #title>
{{ t('admin.finance.batchDeleteBtn') }}
</template>
<template #description>
{{ t('admin.finance.batchDeleteConfirm', { count: table.getSelectedRowModel().rows.length }) }}
</template>
</ConfirmDialog>
</template>
-695
View File
@@ -1,695 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import * as echarts from 'echarts'
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { BasicPage } from '@/components/global-layout'
import { BASE_URL } from '@/services/api'
const { t } = useI18n()
const router = useRouter()
interface DashboardStats {
totalApplications: number
totalCards: number
totalUsers: number
monthlyRevenue: number
}
interface Activity {
id: number
title: string
description: string
icon: string
createdAt: Date
}
interface Ticket {
id: number
title: string
status: string
priority: string
createdAt: Date
}
interface Province {
name: string
count: number
}
const loading = ref(true)
const currentUser = ref<any>(null)
const stats = ref<DashboardStats>({
totalApplications: 0,
totalCards: 0,
totalUsers: 0,
monthlyRevenue: 0,
})
const provinces = ref<Province[]>([])
const chinaProvinces = ref<Province[]>([])
const onlineTrendData = ref<number[]>([])
const recentActivities = ref<Activity[]>([])
const recentTickets = ref<Ticket[]>([])
const mapChart = ref<HTMLElement | null>(null)
const activityChart = ref<HTMLCanvasElement | null>(null)
let chartInstance: any = null
let activityChartInstance: any = null
const currentMapType = ref<'china'>('china')
const domesticUsers = computed(() => {
if (!chinaProvinces.value || chinaProvinces.value.length === 0)
return 0
return chinaProvinces.value.reduce((sum, province) => sum + (province.count || 0), 0)
})
const overseasUsers = computed(() => {
if (!provinces.value || provinces.value.length === 0)
return 0
return provinces.value.reduce((sum, country) => {
if (country.name !== 'China')
return sum + (country.count || 0)
return sum
}, 0)
})
const totalUsers = computed(() => {
if (stats.value?.totalUsers && stats.value.totalUsers > 0) {
return stats.value.totalUsers
}
return (domesticUsers.value || 0) + (overseasUsers.value || 0)
})
function formatDate(date: Date) {
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(date)
}
function formatNumber(num: number) {
return (num || 0).toLocaleString()
}
function getTicketStatusColor(status: string) {
const colors: Record<string, string> = {
open: 'bg-yellow-500/10 text-yellow-500',
processing: 'bg-blue-500/10 text-blue-500',
resolved: 'bg-green-500/10 text-green-500',
closed: 'bg-gray-500/10 text-gray-500',
}
return colors[status] || 'bg-gray-500/10 text-gray-500'
}
function getTicketStatusText(status: string) {
const texts: Record<string, string> = {
open: t('admin.ticketStatusOpen'),
processing: t('admin.ticketStatusProcessing'),
resolved: t('admin.ticketStatusResolved'),
closed: t('admin.ticketStatusClosed'),
}
return texts[status] || status
}
function getTicketPriorityColor(priority: string) {
const colors: Record<string, string> = {
low: 'text-gray-500',
normal: 'text-blue-500',
high: 'text-orange-500',
urgent: 'text-red-500',
}
return colors[priority] || 'text-gray-500'
}
function getTicketPriorityText(priority: string) {
const texts: Record<string, string> = {
low: t('admin.priorityLow'),
normal: t('admin.priorityNormal'),
high: t('admin.priorityHigh'),
urgent: t('admin.priorityUrgent'),
}
return texts[priority] || priority
}
function goToTicket(ticketId: number) {
router.push(`/admin/tickets/${ticketId}`)
}
function goToTickets() {
router.push('/admin/tickets')
}
async function fetchDashboard() {
loading.value = true
try {
const token = localStorage.getItem('token')
if (!token) {
console.error('No token found')
return
}
const response = await fetch(`${BASE_URL}/dev/dashboard`, {
headers: {
Authorization: `Bearer ${token}`,
},
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
if (data.code === 200 && data.data) {
if (data.data.stats) {
stats.value = {
totalApplications: data.data.stats.totalApplications || 0,
totalCards: data.data.stats.totalCards || 0,
totalUsers: data.data.stats.totalUsers || 0,
monthlyRevenue: data.data.stats.monthlyRevenue || 0,
}
}
if (data.data.userDistribution) {
if (data.data.userDistribution.provinces && Array.isArray(data.data.userDistribution.provinces)) {
chinaProvinces.value = data.data.userDistribution.provinces.map((p: any) => ({
name: p.name,
count: p.count || 0,
}))
}
if (data.data.userDistribution.overseas && Array.isArray(data.data.userDistribution.overseas)) {
provinces.value = data.data.userDistribution.overseas.map((p: any) => ({
name: p.name,
count: p.count || 0,
}))
}
}
if (data.data.onlineTrend && Array.isArray(data.data.onlineTrend)) {
onlineTrendData.value = data.data.onlineTrend.map((t: any) => t.value || t.count || 0)
}
if (data.data.recentActivities && Array.isArray(data.data.recentActivities)) {
recentActivities.value = data.data.recentActivities.map((log: any) => {
let icon = 'lucide:activity'
let title = log.action || t('admin.operation')
let description = log.details || log.resource || t('admin.noDescription')
if (log.log_type === 'operation') {
icon = 'lucide:plus-circle'
title = t('admin.operationRecord')
}
else if (log.log_type === 'verification') {
icon = 'lucide:check-circle'
title = t('admin.cardVerification')
description = `${t('admin.verification')}${log.status === 'success' ? t('admin.success') : t('admin.failed')}`
}
else if (log.log_type === 'exception') {
icon = 'lucide:alert-circle'
title = t('admin.exceptionRecord')
}
return {
id: log.id,
title,
description,
icon,
createdAt: new Date(log.created_at),
}
})
}
if (data.data.recentTickets && Array.isArray(data.data.recentTickets)) {
recentTickets.value = data.data.recentTickets.map((t: any) => ({
id: t.id,
title: t.title,
status: t.status,
priority: t.priority,
createdAt: new Date(t.created_at),
}))
}
}
else {
console.error('API returned error:', data)
}
}
catch (error) {
console.error(t('admin.fetchDashboardError'), error)
}
finally {
loading.value = false
}
}
function fetchCurrentUser() {
const userStr = localStorage.getItem('user')
if (userStr) {
currentUser.value = JSON.parse(userStr)
}
}
async function initMapChart() {
if (!mapChart.value)
return
try {
const chinaResponse = await fetch('https://geo.datav.aliyun.com/areas_v3/bound/100000_full.json')
if (!chinaResponse.ok) {
throw new Error('Failed to load China map data')
}
const chinaData = await chinaResponse.json()
echarts.registerMap('china', chinaData)
chartInstance = echarts.init(mapChart.value)
updateMapOption()
window.addEventListener('resize', () => {
chartInstance?.resize()
})
}
catch (error) {
console.error(t('admin.initMapError'), error)
}
}
function updateMapOption() {
if (!chartInstance)
return
const isChinaMap = currentMapType.value === 'china'
const data = isChinaMap ? chinaProvinces.value : provinces.value
const option = {
tooltip: {
trigger: 'item',
backgroundColor: 'rgba(15, 23, 42, 0.95)',
borderColor: '#334155',
borderWidth: 1,
padding: 12,
formatter: (params: any) => {
if (params.data && params.data.count > 0) {
return `<div style="padding: 8px;">
<div style="font-weight: bold; margin-bottom: 4px; color: #f8fafc;">${params.name}</div>
<div style="color: #cbd5e1;">${t('admin.userCount')}: ${params.data.count}</div>
</div>`
}
return `<div style="padding: 8px;">
<div style="font-weight: bold; margin-bottom: 4px; color: #f8fafc;">${params.name}</div>
<div style="color: #cbd5e1;">${t('admin.noUserData')}</div>
</div>`
},
},
visualMap: {
show: false,
min: 0,
max: Math.max(...data.map((d: any) => d.count || 0), 1),
inRange: {
color: ['#e0f2fe', '#3b82f6'],
},
},
geo: {
map: isChinaMap ? 'china' : 'world',
roam: false,
zoom: isChinaMap ? 1.2 : 1.1,
center: isChinaMap ? [104.195, 35.861] : undefined,
label: {
show: false,
},
emphasis: {
label: {
show: true,
color: '#f8fafc',
},
itemStyle: {
areaColor: '#3b82f6',
borderColor: '#1d4ed8',
borderWidth: 2,
},
},
itemStyle: {
areaColor: 'rgba(226, 232, 240, 0.3)',
borderColor: '#e2e8f0',
borderWidth: 1,
},
},
series: [
{
name: t('admin.userDistribution'),
type: 'map',
geoIndex: 0,
data,
},
],
}
chartInstance.setOption(option)
}
function initActivityChart() {
if (!activityChart.value)
return
try {
import('chart.js/auto').then(({ default: Chart }) => {
const ctx = activityChart.value?.getContext('2d')
if (!ctx)
return
const labels = Array.from({ length: 30 }, (_, i) => {
const date = new Date()
date.setDate(date.getDate() - (29 - i))
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })
})
const data = onlineTrendData.value.length > 0
? onlineTrendData.value
: Array.from({ length: 30 }).fill(0)
if (activityChartInstance) {
activityChartInstance.destroy()
}
activityChartInstance = new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [
{
label: t('admin.verificationCount'),
data,
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
fill: true,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 6,
pointHoverBackgroundColor: '#3b82f6',
pointHoverBorderColor: '#fff',
pointHoverBorderWidth: 2,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
tooltip: {
backgroundColor: 'rgba(15, 23, 42, 0.95)',
titleColor: '#f8fafc',
bodyColor: '#cbd5e1',
borderColor: '#334155',
borderWidth: 1,
padding: 12,
displayColors: false,
callbacks: {
label: (context: any) => `${context.parsed.y} ${t('admin.online')}`,
},
},
},
scales: {
x: {
grid: {
display: false,
},
ticks: {
color: '#94a3b8',
font: {
size: 11,
},
},
},
y: {
grid: {
color: '#334155',
},
ticks: {
color: '#94a3b8',
font: {
size: 11,
},
},
beginAtZero: true,
},
},
interaction: {
intersect: false,
mode: 'index',
},
},
})
})
}
catch (error) {
console.error(t('admin.initActivityChartError'), error)
}
}
onMounted(async () => {
fetchCurrentUser()
await fetchDashboard()
await nextTick()
initMapChart()
initActivityChart()
})
onUnmounted(() => {
if (chartInstance) {
chartInstance.dispose()
chartInstance = null
}
if (activityChartInstance) {
activityChartInstance.destroy()
activityChartInstance = null
}
})
</script>
<template>
<BasicPage
:title="t('admin.dashboard.title')"
:description="`${t('admin.welcomeBack')}, ${currentUser?.username || t('admin.developer')}`"
>
<div v-if="loading" class="flex items-center justify-center py-12">
<div class="text-center">
<Icon icon="lucide:loader-2" class="size-8 animate-spin mx-auto text-muted-foreground" />
<p class="mt-4 text-muted-foreground">
{{ t('admin.loading') }}...
</p>
</div>
</div>
<div v-else class="space-y-4">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.totalApplications') }}
</UiCardTitle>
<Icon icon="lucide:layout-grid" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ formatNumber(stats.totalApplications) }}
</div>
<p class="text-xs text-muted-foreground">
+12.5% {{ t('admin.lastMonth') }}
</p>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.totalCards') }}
</UiCardTitle>
<Icon icon="lucide:key" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ formatNumber(stats.totalCards) }}
</div>
<p class="text-xs text-muted-foreground">
+8.2% {{ t('admin.lastMonth') }}
</p>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.totalUsers') }}
</UiCardTitle>
<Icon icon="lucide:users" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ formatNumber(totalUsers) }}
</div>
<p class="text-xs text-muted-foreground">
+15.3% {{ t('admin.lastMonth') }}
</p>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.monthlyRevenue') }}
</UiCardTitle>
<Icon icon="lucide:dollar-sign" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
¥{{ formatNumber(stats.monthlyRevenue) }}
</div>
<p class="text-xs text-muted-foreground">
+23.1% {{ t('admin.lastMonth') }}
</p>
</UiCardContent>
</UiCard>
</div>
<div class="grid gap-4 lg:grid-cols-2">
<UiCard>
<UiCardHeader>
<div class="flex items-center justify-between">
<div>
<UiCardTitle>{{ t('admin.userDistribution') }}</UiCardTitle>
<UiCardDescription>{{ t('admin.userDistributionDesc') }}</UiCardDescription>
</div>
<div class="flex items-center gap-4 text-sm">
<div class="flex items-center gap-2">
<div class="size-3 rounded-full bg-blue-500" />
<span class="text-muted-foreground">{{ t('admin.domesticUsers') }}: {{ formatNumber(domesticUsers) }}</span>
</div>
<div class="flex items-center gap-2">
<div class="size-3 rounded-full bg-green-500" />
<span class="text-muted-foreground">{{ t('admin.overseasUsers') }}: {{ formatNumber(overseasUsers) }}</span>
</div>
</div>
</div>
</UiCardHeader>
<UiCardContent>
<div class="relative h-[350px] rounded-lg overflow-hidden bg-card">
<div ref="mapChart" class="w-full h-full" />
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader>
<UiCardTitle>{{ t('admin.onlineTrend') }}</UiCardTitle>
<UiCardDescription>{{ t('admin.onlineTrendDesc') }}</UiCardDescription>
</UiCardHeader>
<UiCardContent class="pl-2">
<div class="relative h-[350px]">
<canvas ref="activityChart" />
</div>
</UiCardContent>
</UiCard>
</div>
<UiCard>
<UiCardHeader>
<div class="flex items-center justify-between">
<div>
<UiCardTitle>{{ t('admin.recentTickets') }}</UiCardTitle>
<UiCardDescription>{{ t('admin.recentTicketsDesc') }}</UiCardDescription>
</div>
<UiButton variant="ghost" size="sm" @click="goToTickets">
{{ t('admin.viewAll') }}
<Icon icon="lucide:arrow-right" class="ml-1 size-4" />
</UiButton>
</div>
</UiCardHeader>
<UiCardContent>
<div v-if="recentTickets.length > 0" class="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
<div
v-for="ticket in recentTickets.slice(0, 5)"
:key="ticket.id"
class="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted/50 cursor-pointer transition-colors"
@click="goToTicket(ticket.id)"
>
<div class="flex-shrink-0">
<div class="size-8 rounded-full bg-primary/10 flex items-center justify-center">
<Icon icon="lucide:ticket" class="size-4 text-primary" />
</div>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">
{{ ticket.title }}
</p>
<div class="flex items-center gap-2 mt-1">
<UiBadge :class="getTicketStatusColor(ticket.status)" class="text-[10px] flex-shrink-0">
{{ getTicketStatusText(ticket.status) }}
</UiBadge>
<span :class="`text-xs ${getTicketPriorityColor(ticket.priority)}`">
{{ getTicketPriorityText(ticket.priority) }}
</span>
</div>
<span class="text-xs text-muted-foreground">{{ formatDate(ticket.createdAt) }}</span>
</div>
</div>
</div>
<div v-else class="text-center py-8 text-muted-foreground">
<Icon icon="lucide:ticket" class="mx-auto size-10 text-muted-foreground mb-2" />
<p class="text-sm">
{{ t('admin.noTickets') }}
</p>
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader>
<UiCardTitle>{{ t('admin.recentActivities') }}</UiCardTitle>
<UiCardDescription>{{ t('admin.recentActivitiesDesc') }}</UiCardDescription>
</UiCardHeader>
<UiCardContent>
<div v-if="recentActivities.length > 0" class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div
v-for="activity in recentActivities.slice(0, 8)"
:key="activity.id"
class="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted/50 transition-colors"
>
<div class="flex-shrink-0">
<div class="size-7 rounded-full bg-primary/10 flex items-center justify-center">
<Icon :icon="activity.icon" class="size-3.5 text-primary" />
</div>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium">
{{ activity.title }}
</p>
<p class="text-xs text-muted-foreground truncate">
{{ activity.description }}
</p>
<p class="text-xs text-muted-foreground mt-0.5">
{{ formatDate(activity.createdAt) }}
</p>
</div>
</div>
</div>
<div v-else class="text-center py-8 text-muted-foreground">
<Icon icon="lucide:activity" class="mx-auto size-10 text-muted-foreground mb-2" />
<p class="text-sm">
{{ t('admin.noActivities') }}
</p>
</div>
</UiCardContent>
</UiCard>
</div>
</BasicPage>
</template>
-214
View File
@@ -1,214 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Log } from '@/pages/admin/logs/data/schema'
import DateRangeFilter from '@/components/data-table/date-range-filter.vue'
import SingleFilter from '@/components/data-table/single-filter.vue'
import { BasicPage } from '@/components/global-layout'
import DataTable from '@/pages/admin/logs/components/data-table.vue'
import api from '@/services/api'
const { t } = useI18n()
interface Application {
id: number
name: string
}
const loading = ref(true)
const logs = ref<Log[]>([])
const applications = ref<Application[]>([])
const appFilter = ref<string>('')
const typeFilter = ref<string>('')
const statusFilter = ref<string>('')
const startDate = ref<string>('')
const endDate = ref<string>('')
const filteredLogs = computed(() => {
let result = logs.value
if (appFilter.value) {
result = result.filter(log => String(log.application_id) === appFilter.value)
}
if (typeFilter.value) {
result = result.filter(log => log.log_type === typeFilter.value)
}
if (statusFilter.value) {
result = result.filter(log => log.status === statusFilter.value)
}
if (startDate.value) {
const fromDateTime = startDate.value.includes('T')
? startDate.value.replace('T', ' ')
: `${startDate.value} 00:00`
result = result.filter(log => log.created_at >= fromDateTime)
}
if (endDate.value) {
const toDateTime = endDate.value.includes('T')
? endDate.value.replace('T', ' ')
: `${endDate.value} 23:59`
result = result.filter(log => log.created_at <= toDateTime)
}
return result
})
const successCount = computed(() => filteredLogs.value.filter(log => log.status === 'success').length)
const failedCount = computed(() => filteredLogs.value.filter(log => log.status === 'failed').length)
const operationCount = computed(() => filteredLogs.value.filter(log => log.log_type === 'operation').length)
const applicationOptions = computed(() => {
return applications.value.map(app => ({
label: app.name,
value: String(app.id),
}))
})
const typeOptions = computed(() => [
{ label: t('admin.logs.types.operation'), value: 'operation' },
{ label: t('admin.logs.types.verification'), value: 'verification' },
{ label: t('admin.logs.types.exception'), value: 'exception' },
])
const statusOptions = computed(() => [
{ label: t('admin.success'), value: 'success' },
{ label: t('admin.failed'), value: 'failed' },
])
async function fetchApplications() {
try {
const data = await api.get<{ applications: Application[] }>('/dev/applications')
applications.value = data?.applications || []
}
catch (error) {
console.error('获取应用列表失败:', error)
}
}
async function fetchLogs() {
loading.value = true
try {
const data = await api.get<{ logs: Log[] }>('/dev/logs')
logs.value = data?.logs || []
}
catch (error) {
console.error('获取日志记录失败:', error)
logs.value = []
}
finally {
loading.value = false
}
}
onMounted(() => {
fetchApplications()
fetchLogs()
})
</script>
<template>
<BasicPage
:title="t('admin.logs.title')"
:description="t('admin.logs.description')"
sticky
>
<div class="space-y-6">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.logs.totalLogs') }}
</UiCardTitle>
<Icon icon="lucide:file-text" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ filteredLogs.length }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.logs.successCount') }}
</UiCardTitle>
<Icon icon="lucide:check-circle" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ successCount }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.logs.failedCount') }}
</UiCardTitle>
<Icon icon="lucide:x-circle" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ failedCount }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('admin.logs.operationCount') }}
</UiCardTitle>
<Icon icon="lucide:activity" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ operationCount }}
</div>
</UiCardContent>
</UiCard>
</div>
<UiCard>
<UiCardContent class="p-6">
<DataTable
:loading
:data="filteredLogs"
@refresh="fetchLogs"
>
<template #filters>
<SingleFilter
v-model="appFilter"
:title="t('admin.logs.columns.application')"
:options="applicationOptions"
/>
<SingleFilter
v-model="typeFilter"
:title="t('admin.logs.columns.type')"
:options="typeOptions"
/>
<SingleFilter
v-model="statusFilter"
:title="t('admin.logs.columns.status')"
:options="statusOptions"
/>
<DateRangeFilter
v-model:start-model-value="startDate"
v-model:end-model-value="endDate"
/>
</template>
</DataTable>
</UiCardContent>
</UiCard>
</div>
</BasicPage>
</template>
@@ -1,93 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ColumnDef } from '@tanstack/vue-table'
import DataTable from '@/components/data-table/data-table.vue'
import { generateVueTable } from '@/components/data-table/use-generate-vue-table'
import { SelectColumn } from '@/components/data-table/table-columns'
import DataTableViewOptions from '@/components/data-table/view-options.vue'
import type { Log } from '@/pages/admin/logs/data/schema'
import { getColumns } from '@/pages/admin/logs/components/columns'
import DataTableToolbar from '@/pages/admin/logs/components/data-table-toolbar.vue'
const { t } = useI18n()
interface Props {
loading?: boolean
data: Log[]
serverPage?: number
serverPageSize?: number
serverTotal?: number
serverTotalPages?: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
refresh: []
'page-change': [page: number]
'page-size-change': [pageSize: number]
}>()
const columns = computed(() => [
SelectColumn as ColumnDef<Log>,
...getColumns(t),
])
const table = generateVueTable<Log>({
get data() { return props.data },
get loading() { return props.loading },
columns: columns.value,
}, columns.value)
const columnLabels: Record<string, string> = {
'select': 'admin.logs.select',
'type': 'admin.logs.columns.type',
'action': 'admin.logs.columns.action',
'details': 'admin.logs.columns.detail',
'status': 'admin.logs.columns.status',
'ip': 'admin.logs.columns.ip',
'application_name': 'admin.logs.columns.application',
'user_username': 'admin.logs.columns.user',
'created_at': 'admin.logs.columns.time',
}
const serverPagination = computed(() => {
if (props.serverPage && props.serverPageSize && props.serverTotal) {
return {
page: props.serverPage,
pageSize: props.serverPageSize,
total: props.serverTotal,
onPageChange: (page: number) => emit('page-change', page),
onPageSizeChange: (pageSize: number) => emit('page-size-change', pageSize),
}
}
return undefined
})
defineExpose({
table,
})
</script>
<template>
<DataTable :columns="columns" :data :loading :table :server-pagination @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,37 +0,0 @@
import { z } from 'zod'
export const logTypeSchema = z.enum(['operation', 'verification', 'exception'])
export const logStatusSchema = z.enum(['success', 'failed'])
export const logSchema = z.object({
id: z.union([z.string(), z.number()]),
log_type: logTypeSchema,
action: z.string(),
resource: z.string().optional().nullable(),
resource_id: z.union([z.string(), z.number()]).optional().nullable(),
details: z.string(),
status: logStatusSchema,
ip_address: z.string(),
user_agent: z.string().optional().nullable(),
device_id: z.string().optional().nullable(),
level: z.string().optional().nullable(),
error_message: z.string().optional().nullable(),
application_id: z.union([z.string(), z.number()]).optional().nullable(),
user_id: z.union([z.string(), z.number()]).optional().nullable(),
app_user_id: z.union([z.string(), z.number()]).optional().nullable(),
created_at: z.string(),
application: z.object({
id: z.union([z.string(), z.number()]),
name: z.string(),
}).optional().nullable(),
user: z.object({
id: z.union([z.string(), z.number()]),
username: z.string(),
}).optional().nullable(),
app_user: z.object({
id: z.union([z.string(), z.number()]),
username: z.string(),
}).optional().nullable(),
})
export type Log = z.infer<typeof logSchema>
@@ -1,289 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import type { RiskRule } from '@/pages/admin/risk-control/data/schema'
import ConfirmDialog from '@/components/confirm-dialog.vue'
import SingleFilter from '@/components/data-table/single-filter.vue'
import { BasicPage } from '@/components/global-layout'
import DataTable from '@/pages/admin/risk-control/components/data-table.vue'
import api from '@/services/api'
const router = useRouter()
const loading = ref(true)
const rules = ref<RiskRule[]>([])
const tableRef = ref()
const typeFilter = ref<string>('')
const statusFilter = ref<string>('')
const deleteDialogOpen = ref(false)
const deleteTarget = ref<RiskRule | null>(null)
const batchDeleteDialogOpen = ref(false)
const batchDeleteIds = ref<(string | number)[]>([])
const typeOptions = computed(() => [
{ label: 'IP封禁', value: 'ip' },
{ label: '设备封禁', value: 'device' },
{ label: '用户封禁', value: 'user' },
{ label: '地区封禁', value: 'region' },
])
const statusOptions = computed(() => [
{ label: '生效中', value: 'active' },
{ label: '已失效', value: 'inactive' },
])
const filteredRules = computed(() => {
let result = rules.value
if (typeFilter.value) {
result = result.filter(rule => rule.type === typeFilter.value)
}
if (statusFilter.value) {
result = result.filter(rule => rule.status === statusFilter.value)
}
return result
})
const activeCount = computed(() => filteredRules.value.filter(rule => rule.status === 'active').length)
const inactiveCount = computed(() => filteredRules.value.filter(rule => rule.status === 'inactive').length)
const ipCount = computed(() => filteredRules.value.filter(rule => rule.type === 'ip').length)
const deviceCount = computed(() => filteredRules.value.filter(rule => rule.type === 'device').length)
async function fetchRules() {
loading.value = true
try {
const data = await api.get<RiskRule[]>('/dev/risk-control/rules')
rules.value = Array.isArray(data) ? data : []
}
catch (error) {
console.error('获取风控规则失败:', error)
rules.value = []
}
finally {
loading.value = false
}
}
function goToCreate() {
router.push('/admin/risk-control/create')
}
function openEditPage(rule: RiskRule) {
router.push(`/admin/risk-control/${rule.id}`)
}
async function toggleStatus(rule: RiskRule) {
try {
const newStatus = rule.status === 'active' ? 'inactive' : 'active'
await api.put(`/dev/risk-control/rules/${rule.id}/status`, { status: newStatus })
toast.success('状态更新成功')
fetchRules()
}
catch (error: any) {
console.error('切换状态失败:', error)
toast.error(error.message || '状态更新失败')
}
}
function confirmDelete(rule: RiskRule) {
deleteTarget.value = rule
deleteDialogOpen.value = true
}
async function handleDelete() {
if (!deleteTarget.value)
return
try {
await api.delete(`/dev/risk-control/rules/${deleteTarget.value.id}`)
toast.success('删除成功')
fetchRules()
}
catch (error: any) {
console.error('删除风控规则失败:', error)
toast.error(error.message || '删除失败')
}
finally {
deleteTarget.value = null
}
}
function confirmBatchDelete(ids: (string | number)[]) {
batchDeleteIds.value = ids
batchDeleteDialogOpen.value = true
}
async function handleBatchDelete() {
try {
await api.delete('/dev/risk-control/rules/batch', { ids: batchDeleteIds.value } as any)
toast.success('批量删除成功')
fetchRules()
}
catch (error: any) {
console.error('批量删除失败:', error)
toast.error(error.message || '批量删除失败')
}
finally {
batchDeleteIds.value = []
}
}
async function batchToggleStatus(ids: (string | number)[], status: string) {
try {
await api.put('/dev/risk-control/rules/batch/status', { ids, status })
toast.success('批量更新状态成功')
fetchRules()
}
catch (error: any) {
console.error('批量更新状态失败:', error)
toast.error(error.message || '批量更新状态失败')
}
}
onMounted(() => {
fetchRules()
})
</script>
<template>
<BasicPage
title="风控管理"
description="管理封禁的IP、设备、用户等,保护应用安全"
sticky
>
<template #actions>
<UiButton @click="goToCreate">
<Icon icon="lucide:plus" class="mr-2 h-4 w-4" />
添加规则
</UiButton>
</template>
<div class="space-y-6">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
生效中
</UiCardTitle>
<Icon icon="lucide:shield-check" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ activeCount }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
已失效
</UiCardTitle>
<Icon icon="lucide:shield-off" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ inactiveCount }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
IP封禁
</UiCardTitle>
<Icon icon="lucide:globe" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ ipCount }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
设备封禁
</UiCardTitle>
<Icon icon="lucide:smartphone" class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ deviceCount }}
</div>
</UiCardContent>
</UiCard>
</div>
<UiCard>
<UiCardContent class="p-6">
<DataTable
ref="tableRef"
:loading
:data="filteredRules"
:on-toggle-status="toggleStatus"
:on-edit="openEditPage"
:on-delete="confirmDelete"
@refresh="fetchRules"
@batch-disable="batchToggleStatus(tableRef?.table?.getSelectedRowModel().rows.filter((r: any) => r.original.status === 'active').map((r: any) => r.original.id) || [], 'inactive')"
@batch-enable="batchToggleStatus(tableRef?.table?.getSelectedRowModel().rows.filter((r: any) => r.original.status === 'inactive').map((r: any) => r.original.id) || [], 'active')"
@batch-delete="confirmBatchDelete(tableRef?.table?.getSelectedRowModel().rows.map((r: any) => r.original.id) || [])"
>
<template #filters>
<SingleFilter
v-model="typeFilter"
title="规则类型"
:options="typeOptions"
/>
<SingleFilter
v-model="statusFilter"
title="状态"
:options="statusOptions"
/>
</template>
</DataTable>
</UiCardContent>
</UiCard>
</div>
<ConfirmDialog
v-model:open="deleteDialogOpen"
destructive
confirm-button-text="删除"
cancel-button-text="取消"
@confirm="handleDelete"
>
<template #title>
删除风控规则
</template>
<template #description>
确定要删除该风控规则吗?此操作不可撤销。
</template>
</ConfirmDialog>
<ConfirmDialog
v-model:open="batchDeleteDialogOpen"
destructive
confirm-button-text="删除"
cancel-button-text="取消"
@confirm="handleBatchDelete"
>
<template #title>
批量删除风控规则
</template>
<template #description>
确定要删除选中的 {{ batchDeleteIds.length }} 条规则吗此操作不可撤销
</template>
</ConfirmDialog>
</BasicPage>
</template>

Some files were not shown because too many files have changed in this diff Show More