fix: 修复所有admin编辑页面[id].vue从列表页改为正确的编辑表单
This commit is contained in:
@@ -2,355 +2,312 @@
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, 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 route = useRoute()
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Agent {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
status: string
|
||||
}
|
||||
|
||||
const agentId = computed(() => route.params.id as string)
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
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 batchDeleteDialogOpen = ref(false)
|
||||
const batchDeleteIds = ref<number[]>([])
|
||||
|
||||
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 form = ref({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
parent_agent_id: '',
|
||||
can_create_agent: false,
|
||||
balance: 0,
|
||||
})
|
||||
|
||||
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() {
|
||||
async function fetchAgent() {
|
||||
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 || []
|
||||
const data = await api.get<any>(`/dev/agents/${agentId.value}`)
|
||||
const agent = data?.agent || data
|
||||
if (agent) {
|
||||
form.value.username = agent.username || ''
|
||||
form.value.email = agent.email || ''
|
||||
form.value.parent_agent_id = agent.parent_agent_id ? String(agent.parent_agent_id) : 'none'
|
||||
form.value.can_create_agent = agent.can_create_agent || false
|
||||
form.value.balance = agent.balance || 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取代理列表失败:', error)
|
||||
agents.value = []
|
||||
} finally {
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取代理信息失败:', error)
|
||||
toast.error(t('admin.agents.editFailed'))
|
||||
}
|
||||
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) {
|
||||
async function fetchAgents() {
|
||||
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'))
|
||||
const data = await api.get<{ agents?: Agent[], total: number }>('/dev/agents')
|
||||
agents.value = data?.agents || []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取代理列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(agent: Agent) {
|
||||
deleteTarget.value = agent
|
||||
deleteDialogOpen.value = true
|
||||
const selectedParentAgent = computed(() => {
|
||||
if (form.value.parent_agent_id && form.value.parent_agent_id !== 'none') {
|
||||
return agents.value.find(a => String(a.id) === form.value.parent_agent_id)
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.value.username) {
|
||||
toast.error(t('admin.agents.create.usernameRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteTarget.value) return
|
||||
|
||||
saving.value = true
|
||||
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
|
||||
}
|
||||
const payload: any = {
|
||||
username: form.value.username,
|
||||
can_create_agent: form.value.can_create_agent,
|
||||
balance: form.value.balance,
|
||||
}
|
||||
|
||||
function confirmBatchDelete(ids: number[]) {
|
||||
batchDeleteIds.value = ids
|
||||
batchDeleteDialogOpen.value = true
|
||||
if (form.value.email) {
|
||||
payload.email = form.value.email
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
try {
|
||||
await api.delete('/dev/agents/batch', { agent_ids: batchDeleteIds.value } as any)
|
||||
toast.success(t('admin.agents.batchDeleteSuccess'))
|
||||
fetchAgents()
|
||||
} catch (error: any) {
|
||||
console.error('批量删除失败:', error)
|
||||
toast.error(error.message || t('admin.agents.batchDeleteFailed'))
|
||||
} finally {
|
||||
batchDeleteIds.value = []
|
||||
}
|
||||
if (form.value.password) {
|
||||
payload.password = form.value.password
|
||||
}
|
||||
|
||||
async function batchToggleStatus(ids: number[], status: string) {
|
||||
try {
|
||||
await api.post('/dev/agents/batch/status', { agent_ids: ids, status })
|
||||
toast.success(t('admin.agents.batchUpdateSuccess'))
|
||||
fetchAgents()
|
||||
} catch (error: any) {
|
||||
console.error('批量更新状态失败:', error)
|
||||
toast.error(error.message || t('admin.agents.batchUpdateFailed'))
|
||||
if (form.value.parent_agent_id && form.value.parent_agent_id !== 'none') {
|
||||
payload.parent_agent_id = Number.parseInt(form.value.parent_agent_id)
|
||||
}
|
||||
|
||||
await api.put(`/dev/agents/${agentId.value}`, payload)
|
||||
toast.success(t('admin.agents.editSuccess'))
|
||||
router.push('/admin/agents')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('更新代理失败:', error)
|
||||
toast.error(error.message || t('admin.agents.editFailed'))
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchAgents()
|
||||
fetchAgent()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('admin.agents.title')"
|
||||
:description="t('admin.agents.description')"
|
||||
:title="t('admin.agents.edit')"
|
||||
:description="t('admin.agents.create.description')"
|
||||
:breadcrumbs="[
|
||||
{ title: t('admin.agents.title'), href: '/admin/agents' },
|
||||
{ title: t('admin.agents.edit') },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton @click="goToCreate">
|
||||
<Icon icon="lucide:plus" class="mr-2 h-4 w-4" />
|
||||
{{ t('admin.agents.addAgent') }}
|
||||
</UiButton>
|
||||
</template>
|
||||
<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">
|
||||
<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">
|
||||
留空则不修改密码
|
||||
</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>
|
||||
</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>
|
||||
</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="form.can_create_agent" />
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<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') }}
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:receipt" class="size-5" />
|
||||
{{ t('admin.agents.create.preview') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:users" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ totalAgents }}
|
||||
<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>
|
||||
<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"
|
||||
@batch-unban="batchToggleStatus(tableRef?.table?.getSelectedRowModel().rows.filter((r: any) => r.original.status === 'banned').map((r: any) => r.original.id) || [], 'active')"
|
||||
@batch-ban="batchToggleStatus(tableRef?.table?.getSelectedRowModel().rows.filter((r: any) => r.original.status !== 'banned').map((r: any) => r.original.id) || [], 'banned')"
|
||||
@batch-delete="confirmBatchDelete(tableRef?.table?.getSelectedRowModel().rows.map((r: any) => r.original.id) || [])"
|
||||
<UiCardContent class="pt-6">
|
||||
<div class="flex flex-col gap-3">
|
||||
<UiButton
|
||||
class="w-full"
|
||||
size="lg"
|
||||
:disabled="saving || !form.username"
|
||||
@click="handleSave"
|
||||
>
|
||||
<template #filters>
|
||||
<SingleFilter
|
||||
v-model="statusFilter"
|
||||
:title="t('admin.agents.columns.status')"
|
||||
:options="statusOptions"
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
<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.agents.create.saveBtn') || '保存修改' }}
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
@click="router.back()"
|
||||
>
|
||||
{{ t('admin.agents.create.cancel') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="batchDeleteDialogOpen"
|
||||
destructive
|
||||
:confirm-button-text="t('common.delete')"
|
||||
:cancel-button-text="t('common.cancel')"
|
||||
@confirm="handleBatchDelete"
|
||||
>
|
||||
<template #title>
|
||||
{{ t('admin.agents.batchDeleteAgents') }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{ t('admin.agents.batchDeleteConfirm', { count: batchDeleteIds.length }) }}
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
<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()
|
||||
import api from '@/services/api'
|
||||
|
||||
interface Application {
|
||||
id: number
|
||||
@@ -23,70 +15,57 @@ interface Application {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const announcementId = computed(() => route.params.id as string)
|
||||
const loading = ref(true)
|
||||
const announcements = ref<Announcement[]>([])
|
||||
const saving = ref(false)
|
||||
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 formData = ref({
|
||||
application_id: '',
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'info' as 'info' | 'warning' | 'error' | 'success',
|
||||
status: 'active' as 'active' | 'inactive',
|
||||
is_top: false,
|
||||
})
|
||||
|
||||
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 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 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 selectedApplication = computed(() => {
|
||||
return applications.value.find(app => String(app.id) === formData.value.application_id)
|
||||
})
|
||||
|
||||
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)
|
||||
const selectedType = computed(() => {
|
||||
return typeOptions.find(t => t.value === formData.value.type)
|
||||
})
|
||||
|
||||
async function fetchAnnouncement() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<any>(`/dev/announcements/${announcementId.value}`)
|
||||
const ann = data?.announcement || data
|
||||
if (ann) {
|
||||
formData.value.application_id = String(ann.application_id || '')
|
||||
formData.value.title = ann.title || ''
|
||||
formData.value.content = ann.content || ''
|
||||
formData.value.type = ann.type || 'info'
|
||||
formData.value.status = ann.status || 'active'
|
||||
formData.value.is_top = ann.is_top || false
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取公告信息失败:', error)
|
||||
toast.error('获取公告信息失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchApplications() {
|
||||
try {
|
||||
@@ -98,257 +77,237 @@ async function fetchApplications() {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
async function handleSubmit() {
|
||||
if (!formData.value.application_id) {
|
||||
toast.error('请选择应用')
|
||||
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)
|
||||
if (!formData.value.title) {
|
||||
toast.error('请输入公告标题')
|
||||
return
|
||||
}
|
||||
if (!formData.value.content) {
|
||||
toast.error('请输入公告内容')
|
||||
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() {
|
||||
saving.value = true
|
||||
try {
|
||||
await api.delete('/dev/announcements/batch', { ids: batchDeleteIds.value } as any)
|
||||
toast.success(t('admin.announcements.batchDeleteSuccess'))
|
||||
fetchAnnouncements()
|
||||
await api.put(`/dev/announcements/${announcementId.value}`, {
|
||||
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 || t('common.error'))
|
||||
console.error('更新公告失败:', error)
|
||||
toast.error(error.message || '更新失败')
|
||||
}
|
||||
finally {
|
||||
batchDeleteIds.value = []
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchApplications()
|
||||
fetchAnnouncements()
|
||||
const appParam = route.query.app as string
|
||||
if (appParam) {
|
||||
appFilter.value = appParam
|
||||
}
|
||||
fetchAnnouncement()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('admin.announcements.title')"
|
||||
:description="t('admin.announcements.description')"
|
||||
title="编辑公告"
|
||||
description="修改公告内容和配置"
|
||||
:breadcrumbs="[
|
||||
{ title: '公告管理', href: '/admin/announcements' },
|
||||
{ title: '编辑公告' },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton @click="goToCreate">
|
||||
<Icon icon="lucide:plus" class="mr-2 h-4 w-4" />
|
||||
{{ t('admin.announcements.create') }}
|
||||
</UiButton>
|
||||
</template>
|
||||
<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">
|
||||
<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>选择应用</UiLabel>
|
||||
<UiSelect v-model="formData.application_id" :disabled="saving">
|
||||
<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">
|
||||
公告标题
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="title"
|
||||
v-model="formData.title"
|
||||
placeholder="输入公告标题"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="content">
|
||||
公告内容
|
||||
</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">
|
||||
<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') }}
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:eye" class="size-5" />
|
||||
预览
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:megaphone" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ filteredAnnouncements.length }}
|
||||
<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>
|
||||
<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) || [])"
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
<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" />
|
||||
保存修改
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
@click="router.back()"
|
||||
>
|
||||
取消
|
||||
</UiButton>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
|
||||
@@ -1,90 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, 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()
|
||||
import api from '@/services/api'
|
||||
|
||||
const router = useRouter()
|
||||
const API_BASE = 'http://localhost:8080/api/v1'
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Application {
|
||||
id: string
|
||||
name: string
|
||||
billing_type: string
|
||||
}
|
||||
|
||||
const cardTypeId = computed(() => route.params.id as string)
|
||||
const loading = ref(true)
|
||||
const cardTypes = ref<CardType[]>([])
|
||||
const saving = ref(false)
|
||||
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 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,
|
||||
})
|
||||
|
||||
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() {
|
||||
async function fetchCardType() {
|
||||
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 = []
|
||||
const data = await api.get<any>(`/dev/card-types/${cardTypeId.value}`)
|
||||
const ct = data?.card_type || data
|
||||
if (ct) {
|
||||
form.value.name = ct.name || ''
|
||||
form.value.description = ct.description || ''
|
||||
form.value.application_id = String(ct.application_id || '')
|
||||
form.value.price = ct.price || 0
|
||||
form.value.recharge_type = ct.recharge_type || 'balance'
|
||||
form.value.is_permanent = ct.value === -1
|
||||
form.value.value = ct.value === -1 ? 1 : ct.value
|
||||
form.value.value_unit = ct.value_unit || 'day'
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取卡密类型失败:', error)
|
||||
cardTypes.value = []
|
||||
toast.error(t('admin.cardTypes.editFailed'))
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
@@ -93,317 +61,345 @@ async function fetchCardTypes() {
|
||||
|
||||
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 = []
|
||||
}
|
||||
const data = await api.get<{ applications: Application[] }>('/dev/applications')
|
||||
applications.value = Array.isArray(data?.applications) ? data.applications : []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取应用列表失败:', error)
|
||||
applications.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function goToCreate() {
|
||||
router.push('/admin/card-types/create')
|
||||
const selectedApplication = computed(() => {
|
||||
if (form.value.application_id) {
|
||||
return applications.value.find(app => String(app.id) === form.value.application_id)
|
||||
}
|
||||
|
||||
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 }),
|
||||
return null
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
toast.success(t('admin.cardTypes.statusUpdateSuccess'))
|
||||
fetchCardTypes()
|
||||
watch(selectedApplication, (app) => {
|
||||
if (app) {
|
||||
if (app.billing_type === 'subscription') {
|
||||
form.value.recharge_type = 'subscription'
|
||||
}
|
||||
else {
|
||||
toast.error(data.message || t('admin.cardTypes.statusUpdateFailed'))
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('切换卡密类型状态失败:', error)
|
||||
toast.error(t('admin.cardTypes.statusUpdateFailed'))
|
||||
else if (app.billing_type === 'time' || app.billing_type === 'count') {
|
||||
form.value.recharge_type = 'balance'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function confirmDeleteType(type: CardType) {
|
||||
deleteTarget.value = type
|
||||
deleteDialogOpen.value = true
|
||||
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 handleDeleteType() {
|
||||
if (!deleteTarget.value)
|
||||
async function handleSave() {
|
||||
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 {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${API_BASE}/dev/card-types/${deleteTarget.value.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
await api.put(`/dev/card-types/${cardTypeId.value}`, {
|
||||
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,
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
toast.success(t('admin.cardTypes.deleteSuccess'))
|
||||
fetchCardTypes()
|
||||
toast.success(t('admin.cardTypes.editSuccess'))
|
||||
router.push('/admin/card-types')
|
||||
}
|
||||
else {
|
||||
toast.error(data.message || t('admin.cardTypes.deleteFailed'))
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('删除卡密类型失败:', error)
|
||||
toast.error(t('admin.cardTypes.deleteFailed'))
|
||||
catch (error: any) {
|
||||
console.error('更新卡密类型失败:', error)
|
||||
toast.error(error.message || t('admin.cardTypes.editFailed'))
|
||||
}
|
||||
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'))
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchCardTypes()
|
||||
fetchApplications()
|
||||
fetchCardType()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('admin.cardTypes.title')"
|
||||
:description="t('admin.cardTypes.description')"
|
||||
:title="t('admin.cardTypes.edit')"
|
||||
:description="t('admin.cardTypes.create.description')"
|
||||
:breadcrumbs="[
|
||||
{ title: t('admin.cardTypes.title'), href: '/admin/card-types' },
|
||||
{ title: t('admin.cardTypes.edit') },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton @click="goToCreate">
|
||||
<Icon icon="lucide:plus" class="mr-2 h-4 w-4" />
|
||||
{{ t('admin.cardTypes.createCardType') }}
|
||||
</UiButton>
|
||||
</template>
|
||||
<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">
|
||||
<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" />
|
||||
{{ t('admin.cardTypes.create.rechargeConfig') || '充值配置' }}
|
||||
</UiCardTitle>
|
||||
<UiCardDescription>{{ t('admin.cardTypes.create.rechargeConfigDesc') || '设置卡密的充值类型和金额' }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('admin.cardTypes.create.rechargeType') || '充值类型' }}</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">
|
||||
{{ t('admin.cardTypes.create.balanceRecharge') || '余额充值' }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('admin.cardTypes.create.balanceRechargeDesc') || '充值账户余额,适用于计时/计次模式' }}
|
||||
</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">
|
||||
{{ t('admin.cardTypes.create.subscriptionRecharge') || '订阅充值' }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('admin.cardTypes.create.subscriptionRechargeDesc') || '充值会员时长,适用于订阅模式' }}
|
||||
</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">
|
||||
{{ t('admin.cardTypes.create.permanent') || '永久会员' }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('admin.cardTypes.create.permanentDesc') || '开启后,使用此卡密的用户将成为永久会员' }}
|
||||
</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>{{ t('admin.cardTypes.create.rechargeAmount') || '充值金额' }}</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">
|
||||
{{ t('admin.cardTypes.create.rechargeAmountDesc') || '用户充值后获得的余额数量' }}
|
||||
</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>{{ t('admin.cardTypes.create.rechargeDuration') || '充值时长' }}</UiLabel>
|
||||
<UiNumberField v-model="form.value" :min="1" :step="1">
|
||||
<UiNumberFieldContent>
|
||||
<UiNumberFieldDecrement />
|
||||
<UiNumberFieldInput />
|
||||
<UiNumberFieldIncrement />
|
||||
</UiNumberFieldContent>
|
||||
</UiNumberField>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('admin.cardTypes.create.durationUnit') || '时长单位' }}</UiLabel>
|
||||
<UiSelect v-model="form.value_unit">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem value="minute">
|
||||
{{ t('admin.cardTypes.units.minute') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="hour">
|
||||
{{ t('admin.cardTypes.units.hour') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="day">
|
||||
{{ t('admin.cardTypes.units.day') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="month">
|
||||
{{ t('admin.cardTypes.units.month') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="year">
|
||||
{{ t('admin.cardTypes.units.year') }}
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<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') }}
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:eye" class="size-5" />
|
||||
{{ t('admin.cardTypes.create.preview') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:layers" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ filteredCardTypes.length }}
|
||||
<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">{{ t('admin.cardTypes.create.rechargeType') || '充值类型' }}</span>
|
||||
<span>{{ form.recharge_type === 'balance' ? (t('admin.cardTypes.create.balanceRecharge') || '余额充值') : (t('admin.cardTypes.create.subscriptionRecharge') || '订阅充值') }}</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>
|
||||
<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) || [])"
|
||||
<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="handleSave"
|
||||
>
|
||||
<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>
|
||||
<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.cardTypes.create.saveBtn') || '保存修改' }}
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
@click="router.back()"
|
||||
>
|
||||
{{ t('admin.cardTypes.create.cancel') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
|
||||
@@ -2,336 +2,378 @@
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, 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()
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Application {
|
||||
id: number
|
||||
name: string
|
||||
app_key: string
|
||||
}
|
||||
|
||||
const constantId = computed(() => route.params.id as string)
|
||||
const loading = ref(true)
|
||||
const constants = ref<CloudConstant[]>([])
|
||||
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 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 form = ref({
|
||||
app_id: '',
|
||||
key: '',
|
||||
value: '',
|
||||
var_type: 'string',
|
||||
description: '',
|
||||
status: 'active',
|
||||
})
|
||||
|
||||
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() {
|
||||
async function fetchConstant() {
|
||||
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 || '',
|
||||
}))
|
||||
const data = await api.get<any>(`/dev/cloud-constants/${constantId.value}`)
|
||||
const ct = data?.cloud_constant || data
|
||||
if (ct) {
|
||||
form.value.app_id = String(ct.app_id || '')
|
||||
form.value.key = ct.key || ''
|
||||
form.value.value = ct.value || ''
|
||||
form.value.var_type = ct.var_type || 'string'
|
||||
form.value.description = ct.description || ''
|
||||
form.value.status = ct.status || 'active'
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取云端常量失败:', error)
|
||||
constants.value = []
|
||||
toast.error(t('admin.cloudConstants.editFailed'))
|
||||
}
|
||||
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
|
||||
|
||||
async function fetchApplications() {
|
||||
try {
|
||||
await api.delete(`/dev/cloud-constants/${deleteTarget.value.id}`)
|
||||
toast.success(t('admin.cloudConstants.deleteSuccess'))
|
||||
fetchConstants()
|
||||
const data = await api.get<{ applications: Application[] }>('/dev/applications')
|
||||
applications.value = Array.isArray(data?.applications) ? data.applications : []
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('删除云端常量失败:', error)
|
||||
toast.error(error.message || t('admin.cloudConstants.deleteFailed'))
|
||||
}
|
||||
finally {
|
||||
deleteTarget.value = null
|
||||
catch (error) {
|
||||
console.error('获取应用列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
const selectedApplication = computed(() => {
|
||||
if (form.value.app_id) {
|
||||
return applications.value.find(app => String(app.id) === form.value.app_id)
|
||||
}
|
||||
return null
|
||||
})
|
||||
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 handleFileSelect(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (target.files && target.files[0]) {
|
||||
selectedFile.value = target.files[0]
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownload(constant: CloudConstant) {
|
||||
if (constant.var_type !== 'binary')
|
||||
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
|
||||
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)
|
||||
if (!form.value.app_id) {
|
||||
toast.error(t('admin.cloudConstants.create.appRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
const ids = selectedRows.value.map(c => c.id)
|
||||
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 {
|
||||
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()
|
||||
await api.put(`/dev/cloud-constants/${constantId.value}`, {
|
||||
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.editSuccess'))
|
||||
router.push('/admin/cloud-constants')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('批量操作失败:', error)
|
||||
toast.error(error.message || t('admin.cloudConstants.batchFailed'))
|
||||
console.error('更新云端常量失败:', error)
|
||||
toast.error(error.message || t('admin.cloudConstants.editFailed'))
|
||||
}
|
||||
finally {
|
||||
batchAction.value = null
|
||||
selectedRows.value = []
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchApplications()
|
||||
fetchConstants()
|
||||
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/${constantId.value}/upload`, formData)
|
||||
toast.success(t('admin.cloudConstants.editSuccess'))
|
||||
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()
|
||||
fetchConstant()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('admin.cloudConstants.title')"
|
||||
:description="t('admin.cloudConstants.description')"
|
||||
:title="t('admin.cloudConstants.edit')"
|
||||
:description="t('admin.cloudConstants.createDescription')"
|
||||
:breadcrumbs="[
|
||||
{ title: t('admin.cloudConstants.title'), href: '/admin/cloud-constants' },
|
||||
{ title: t('admin.cloudConstants.edit') },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton size="sm" @click="openCreatePage">
|
||||
<Icon icon="lucide:plus" class="mr-2 h-4 w-4" />
|
||||
{{ t('admin.cloudConstants.addConstant') }}
|
||||
<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">
|
||||
<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')" />
|
||||
</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>
|
||||
</template>
|
||||
</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">
|
||||
<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') }}
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:eye" class="size-5" />
|
||||
{{ t('admin.cloudConstants.create.preview') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:database" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ filteredConstants.length }}
|
||||
<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>
|
||||
</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 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>
|
||||
</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 class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('admin.cloudConstants.create.key') }}</span>
|
||||
<span>{{ form.key || '-' }}</span>
|
||||
</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"
|
||||
<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'"
|
||||
/>
|
||||
<DateRangeFilter
|
||||
v-model:start-model-value="startDate"
|
||||
v-model:end-model-value="endDate"
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
|
||||
@@ -2,360 +2,520 @@
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, 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()
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Application {
|
||||
id: number
|
||||
name: string
|
||||
app_key: string
|
||||
}
|
||||
|
||||
const variableId = computed(() => route.params.id as string)
|
||||
const loading = ref(true)
|
||||
const variables = ref<CloudVariable[]>([])
|
||||
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 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 form = ref({
|
||||
app_id: '',
|
||||
key: '',
|
||||
default_value: '',
|
||||
var_type: 'string',
|
||||
max_records: 0,
|
||||
scope: 'app',
|
||||
write_permission: 'admin',
|
||||
description: '',
|
||||
status: 'active',
|
||||
})
|
||||
|
||||
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() {
|
||||
async function fetchVariable() {
|
||||
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 || '',
|
||||
}))
|
||||
const data = await api.get<any>(`/dev/cloud-variables/${variableId.value}`)
|
||||
const v = data?.cloud_variable || data
|
||||
if (v) {
|
||||
form.value.app_id = String(v.app_id || '')
|
||||
form.value.key = v.key || ''
|
||||
form.value.default_value = v.default_value || ''
|
||||
form.value.var_type = v.var_type || 'string'
|
||||
form.value.max_records = v.max_records || 0
|
||||
form.value.scope = v.scope || 'app'
|
||||
form.value.write_permission = v.write_permission || 'admin'
|
||||
form.value.description = v.description || ''
|
||||
form.value.status = v.status || 'active'
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取云端变量失败:', error)
|
||||
variables.value = []
|
||||
toast.error(t('admin.cloudVariables.editFailed'))
|
||||
}
|
||||
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
|
||||
|
||||
async function fetchApplications() {
|
||||
try {
|
||||
await api.delete(`/dev/cloud-variables/${deleteTarget.value.id}`)
|
||||
toast.success(t('admin.cloudVariables.deleteSuccess'))
|
||||
fetchVariables()
|
||||
const data = await api.get<{ applications: Application[] }>('/dev/applications')
|
||||
applications.value = Array.isArray(data?.applications) ? data.applications : []
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('删除云端变量失败:', error)
|
||||
toast.error(error.message || t('admin.cloudVariables.deleteFailed'))
|
||||
}
|
||||
finally {
|
||||
deleteTarget.value = null
|
||||
catch (error) {
|
||||
console.error('获取应用列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
const selectedApplication = computed(() => {
|
||||
if (form.value.app_id) {
|
||||
return applications.value.find(app => String(app.id) === form.value.app_id)
|
||||
}
|
||||
return null
|
||||
})
|
||||
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 handleFileSelect(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (target.files && target.files[0]) {
|
||||
selectedFile.value = target.files[0]
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownload(variable: CloudVariable) {
|
||||
if (variable.var_type !== 'binary')
|
||||
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
|
||||
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)
|
||||
if (!form.value.app_id) {
|
||||
toast.error(t('admin.cloudVariables.create.appRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
const ids = selectedRows.value.map(v => v.id)
|
||||
if (form.value.var_type === 'binary') {
|
||||
if (!selectedFile.value) {
|
||||
toast.error('请选择要上传的文件')
|
||||
return
|
||||
}
|
||||
await uploadFile()
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
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()
|
||||
await api.put(`/dev/cloud-variables/${variableId.value}`, {
|
||||
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.editSuccess'))
|
||||
router.push('/admin/cloud-variables')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('批量操作失败:', error)
|
||||
toast.error(error.message || t('admin.cloudVariables.batchFailed'))
|
||||
console.error('更新云端变量失败:', error)
|
||||
toast.error(error.message || t('admin.cloudVariables.editFailed'))
|
||||
}
|
||||
finally {
|
||||
batchAction.value = null
|
||||
selectedRows.value = []
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchApplications()
|
||||
fetchVariables()
|
||||
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/${variableId.value}/upload`, formData)
|
||||
toast.success(t('admin.cloudVariables.editSuccess'))
|
||||
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()
|
||||
fetchVariable()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('admin.cloudVariables.title')"
|
||||
:description="t('admin.cloudVariables.description')"
|
||||
:title="t('admin.cloudVariables.edit')"
|
||||
:description="t('admin.cloudVariables.createDescription')"
|
||||
:breadcrumbs="[
|
||||
{ title: t('admin.cloudVariables.title'), href: '/admin/cloud-variables' },
|
||||
{ title: t('admin.cloudVariables.edit') },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton size="sm" @click="openCreatePage">
|
||||
<Icon icon="lucide:plus" class="mr-2 h-4 w-4" />
|
||||
{{ t('admin.cloudVariables.addVariable') }}
|
||||
<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">
|
||||
<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 = 不限制"
|
||||
/>
|
||||
</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')" />
|
||||
</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>
|
||||
</template>
|
||||
</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 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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="form.var_type === 'stream'"
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.write_permission === 'app_user' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.write_permission = 'app_user'"
|
||||
>
|
||||
<UiRadioGroupItem value="app_user" class="mt-0.5" />
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="lucide:users" class="size-4 text-primary" />
|
||||
<p class="font-medium">
|
||||
应用用户可写
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UiRadioGroup>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<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') }}
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:eye" class="size-5" />
|
||||
{{ t('admin.cloudVariables.create.preview') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:variable" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ filteredVariables.length }}
|
||||
<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>
|
||||
</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 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>
|
||||
</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 v-if="form.var_type === 'stream'" class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">最大记录数</span>
|
||||
<span>{{ form.max_records || '不限制' }}</span>
|
||||
</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 class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('admin.cloudVariables.create.key') }}</span>
|
||||
<span>{{ form.key || '-' }}</span>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
<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>
|
||||
|
||||
<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"
|
||||
<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') : form.write_permission === 'app_user' ? '应用用户可写' : 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'"
|
||||
/>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
|
||||
@@ -2,18 +2,14 @@
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, 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 router = useRouter()
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Application {
|
||||
@@ -21,87 +17,54 @@ interface Application {
|
||||
name: string
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const codeId = computed(() => route.params.id as string)
|
||||
const loading = ref(true)
|
||||
const codes = ref<DynamicCode[]>([])
|
||||
const tableRef = ref()
|
||||
const saving = ref(false)
|
||||
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 form = ref({
|
||||
name: '',
|
||||
application_id: '',
|
||||
description: '',
|
||||
code: '',
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
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(() => {
|
||||
const filteredApplications = 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' },
|
||||
])
|
||||
const selectedApplication = computed(() => {
|
||||
if (form.value.application_id) {
|
||||
return applications.value.find(app => String(app.id) === form.value.application_id)
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
async function fetchCode() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<any>(`/dev/dynamic-codes/${codeId.value}`)
|
||||
const code = data?.dynamic_code || data
|
||||
if (code) {
|
||||
form.value.name = code.name || ''
|
||||
form.value.application_id = String(code.application_id || '')
|
||||
form.value.description = code.description || ''
|
||||
form.value.code = code.code || ''
|
||||
form.value.enabled = code.enabled !== false
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取云端函数失败:', error)
|
||||
toast.error(t('admin.cloudFunction.editFailed'))
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchApplications() {
|
||||
try {
|
||||
@@ -113,244 +76,207 @@ async function fetchApplications() {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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.delete(`/dev/dynamic-codes/${deleteTarget.value.id}`)
|
||||
toast.success(t('admin.cloudFunction.deleteSuccess'))
|
||||
fetchCodes()
|
||||
await api.put(`/dev/dynamic-codes/${codeId.value}`, {
|
||||
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.editSuccess'))
|
||||
router.push('/admin/cloud-function')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('删除云端函数失败:', error)
|
||||
toast.error(error.message || t('admin.cloudFunction.deleteFailed'))
|
||||
console.error('更新云端函数失败:', error)
|
||||
toast.error(error.message || t('admin.cloudFunction.editFailed'))
|
||||
}
|
||||
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'))
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchApplications()
|
||||
fetchCodes()
|
||||
fetchCode()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('admin.cloudFunction.title')"
|
||||
:description="t('admin.cloudFunction.description')"
|
||||
:title="t('admin.cloudFunction.edit')"
|
||||
:description="t('admin.cloudFunction.create.description')"
|
||||
:breadcrumbs="[
|
||||
{ title: t('admin.cloudFunction.title'), href: '/admin/cloud-function' },
|
||||
{ title: t('admin.cloudFunction.edit') },
|
||||
]"
|
||||
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 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">
|
||||
<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">
|
||||
<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') }}
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:eye" class="size-5" />
|
||||
{{ t('admin.cloudFunction.create.preview') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:code" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ filteredCodes.length }}
|
||||
<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-wrap;">{{ form.code || '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</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) || [])"
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
<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.saveBtn') || '保存修改' }}
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
@click="router.push('/admin/cloud-function')"
|
||||
>
|
||||
{{ t('admin.cloudFunction.create.cancelBtn') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
|
||||
@@ -1,289 +1,445 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, 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 CountrySelect from '@/components/country-select.vue'
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import DataTable from '@/pages/admin/risk-control/components/data-table.vue'
|
||||
import api from '@/services/api'
|
||||
|
||||
interface Application {
|
||||
id: number
|
||||
name: string
|
||||
app_key: string
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const ruleId = computed(() => route.params.id as string)
|
||||
const loading = ref(true)
|
||||
const rules = ref<RiskRule[]>([])
|
||||
const tableRef = ref()
|
||||
const saving = ref(false)
|
||||
const applications = ref<Application[]>([])
|
||||
const loadingApps = ref(true)
|
||||
|
||||
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 form = ref({
|
||||
type: 'device' as 'ip' | 'device' | 'user' | 'region',
|
||||
value: '',
|
||||
reason: '',
|
||||
expires_at: undefined as string | undefined,
|
||||
application_id: null as number | null,
|
||||
is_global: true,
|
||||
})
|
||||
|
||||
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)
|
||||
const typeOptions: { value: 'ip' | 'device' | 'user' | 'region', label: string, icon: string, description: string, placeholder: string, scopeType: 'global' | 'app' | 'both' }[] = [
|
||||
{ value: 'ip', label: 'IP封禁', icon: 'lucide:globe', description: '封禁特定IP地址或IP段', placeholder: '输入IP地址,如 192.168.1.1 或 192.168.1.0/24', scopeType: 'both' },
|
||||
{ value: 'device', label: '设备封禁', icon: 'lucide:smartphone', description: '封禁特定设备指纹', placeholder: '输入设备指纹', scopeType: 'both' },
|
||||
{ value: 'user', label: '用户封禁', icon: 'lucide:user-x', description: '封禁特定用户账号(需指定应用)', placeholder: '输入用户ID或用户名', scopeType: 'app' },
|
||||
{ value: 'region', label: '地区封禁', icon: 'lucide:map-pin', description: '封禁特定地区访问', placeholder: '', scopeType: 'both' },
|
||||
]
|
||||
|
||||
async function fetchRules() {
|
||||
const currentTypeInfo = computed(() => {
|
||||
return typeOptions.find(t => t.value === form.value.type) || typeOptions[0]
|
||||
})
|
||||
|
||||
const needApplication = computed(() => {
|
||||
return currentTypeInfo.value.scopeType === 'app' || (!form.value.is_global && currentTypeInfo.value.scopeType === 'both')
|
||||
})
|
||||
|
||||
const canSelectScope = computed(() => {
|
||||
return currentTypeInfo.value.scopeType === 'both'
|
||||
})
|
||||
|
||||
watch(() => form.value.type, (newType) => {
|
||||
const typeInfo = typeOptions.find(t => t.value === newType)
|
||||
if (typeInfo) {
|
||||
if (typeInfo.scopeType === 'global') {
|
||||
form.value.is_global = true
|
||||
}
|
||||
else if (typeInfo.scopeType === 'app') {
|
||||
form.value.is_global = false
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
async function fetchRule() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<RiskRule[]>('/dev/risk-control/rules')
|
||||
rules.value = Array.isArray(data) ? data : []
|
||||
const data = await api.get<any>(`/dev/risk-control/rules/${ruleId.value}`)
|
||||
const rule = data?.rule || data
|
||||
if (rule) {
|
||||
form.value.type = rule.type || 'device'
|
||||
form.value.value = rule.value || ''
|
||||
form.value.reason = rule.reason || ''
|
||||
form.value.expires_at = rule.expires_at || undefined
|
||||
form.value.application_id = rule.application_id || null
|
||||
form.value.is_global = rule.is_global !== false
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取风控规则失败:', error)
|
||||
rules.value = []
|
||||
toast.error('获取风控规则失败')
|
||||
}
|
||||
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) {
|
||||
async function fetchApplications() {
|
||||
loadingApps.value = true
|
||||
try {
|
||||
const newStatus = rule.status === 'active' ? 'inactive' : 'active'
|
||||
await api.put(`/dev/risk-control/rules/${rule.id}/status`, { status: newStatus })
|
||||
toast.success('状态更新成功')
|
||||
fetchRules()
|
||||
const data = await api.get<{ applications: Application[] }>('/dev/applications')
|
||||
applications.value = data?.applications || []
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('切换状态失败:', error)
|
||||
toast.error(error.message || '状态更新失败')
|
||||
catch (error) {
|
||||
console.error('获取应用列表失败:', error)
|
||||
}
|
||||
finally {
|
||||
loadingApps.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(rule: RiskRule) {
|
||||
deleteTarget.value = rule
|
||||
deleteDialogOpen.value = true
|
||||
const countries: Record<string, { name: string, flag: string }> = {
|
||||
CN: { name: '中国', flag: '🇨🇳' },
|
||||
US: { name: '美国', flag: '🇺🇸' },
|
||||
JP: { name: '日本', flag: '🇯🇵' },
|
||||
KR: { name: '韩国', flag: '🇰🇷' },
|
||||
GB: { name: '英国', flag: '🇬🇧' },
|
||||
DE: { name: '德国', flag: '🇩🇪' },
|
||||
FR: { name: '法国', flag: '🇫🇷' },
|
||||
IT: { name: '意大利', flag: '🇮🇹' },
|
||||
ES: { name: '西班牙', flag: '🇪🇸' },
|
||||
RU: { name: '俄罗斯', flag: '🇷🇺' },
|
||||
CA: { name: '加拿大', flag: '🇨🇦' },
|
||||
AU: { name: '澳大利亚', flag: '🇦🇺' },
|
||||
BR: { name: '巴西', flag: '🇧🇷' },
|
||||
IN: { name: '印度', flag: '🇮🇳' },
|
||||
MX: { name: '墨西哥', flag: '🇲🇽' },
|
||||
NL: { name: '荷兰', flag: '🇳🇱' },
|
||||
SG: { name: '新加坡', flag: '🇸🇬' },
|
||||
HK: { name: '中国香港', flag: '🇭🇰' },
|
||||
TW: { name: '中国台湾', flag: '🇹🇼' },
|
||||
TH: { name: '泰国', flag: '🇹🇭' },
|
||||
VN: { name: '越南', flag: '🇻🇳' },
|
||||
MY: { name: '马来西亚', flag: '🇲🇾' },
|
||||
ID: { name: '印度尼西亚', flag: '🇮🇩' },
|
||||
PH: { name: '菲律宾', flag: '🇵🇭' },
|
||||
AE: { name: '阿联酋', flag: '🇦🇪' },
|
||||
SA: { name: '沙特阿拉伯', flag: '🇸🇦' },
|
||||
TR: { name: '土耳其', flag: '🇹🇷' },
|
||||
PL: { name: '波兰', flag: '🇵🇱' },
|
||||
SE: { name: '瑞典', flag: '🇸🇪' },
|
||||
CH: { name: '瑞士', flag: '🇨🇭' },
|
||||
AT: { name: '奥地利', flag: '🇦🇹' },
|
||||
BE: { name: '比利时', flag: '🇧🇪' },
|
||||
DK: { name: '丹麦', flag: '🇩🇰' },
|
||||
FI: { name: '芬兰', flag: '🇫🇮' },
|
||||
NO: { name: '挪威', flag: '🇳🇴' },
|
||||
IE: { name: '爱尔兰', flag: '🇮🇪' },
|
||||
PT: { name: '葡萄牙', flag: '🇵🇹' },
|
||||
CZ: { name: '捷克', flag: '🇨🇿' },
|
||||
RO: { name: '罗马尼亚', flag: '🇷🇴' },
|
||||
HU: { name: '匈牙利', flag: '🇭🇺' },
|
||||
IL: { name: '以色列', flag: '🇮🇱' },
|
||||
ZA: { name: '南非', flag: '🇿🇦' },
|
||||
EG: { name: '埃及', flag: '🇪🇬' },
|
||||
NG: { name: '尼日利亚', flag: '🇳🇬' },
|
||||
AR: { name: '阿根廷', flag: '🇦🇷' },
|
||||
CL: { name: '智利', flag: '🇨🇱' },
|
||||
CO: { name: '哥伦比亚', flag: '🇨🇴' },
|
||||
PE: { name: '秘鲁', flag: '🇵🇪' },
|
||||
NZ: { name: '新西兰', flag: '🇳🇿' },
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteTarget.value)
|
||||
function getCountryDisplay(code: string) {
|
||||
const country = countries[code]
|
||||
return country ? `${country.flag} ${country.name} (${code})` : code
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.value.value.trim()) {
|
||||
toast.error('请输入封禁值')
|
||||
return
|
||||
}
|
||||
|
||||
if (needApplication.value && !form.value.application_id) {
|
||||
toast.error('请选择应用')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await api.delete(`/dev/risk-control/rules/${deleteTarget.value.id}`)
|
||||
toast.success('删除成功')
|
||||
fetchRules()
|
||||
const payload: any = {
|
||||
type: form.value.type,
|
||||
value: form.value.value,
|
||||
reason: form.value.reason,
|
||||
application_id: needApplication.value ? form.value.application_id : null,
|
||||
is_global: form.value.is_global,
|
||||
}
|
||||
|
||||
if (form.value.expires_at) {
|
||||
payload.expires_at = form.value.expires_at
|
||||
}
|
||||
|
||||
await api.put(`/dev/risk-control/rules/${ruleId.value}`, payload)
|
||||
toast.success('更新成功')
|
||||
router.push('/admin/risk-control')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('删除风控规则失败:', error)
|
||||
toast.error(error.message || '删除失败')
|
||||
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 || '批量更新状态失败')
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchRules()
|
||||
fetchApplications()
|
||||
fetchRule()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
title="风控管理"
|
||||
description="管理封禁的IP、设备、用户等,保护应用安全"
|
||||
title="编辑规则"
|
||||
description="修改风控规则配置"
|
||||
:breadcrumbs="[
|
||||
{ title: '风控管理', href: '/admin/risk-control' },
|
||||
{ title: '编辑规则' },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton @click="goToCreate">
|
||||
<Icon icon="lucide:plus" 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="size-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:shield-plus" class="size-5" />
|
||||
规则配置
|
||||
</UiCardTitle>
|
||||
<UiCardDescription>配置风控规则的类型和封禁目标</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="space-y-3">
|
||||
<UiLabel>规则类型</UiLabel>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<button
|
||||
v-for="option in typeOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="flex items-start gap-3 p-4 rounded-lg border text-left transition-all duration-200"
|
||||
:class="form.type === option.value ? 'border-primary bg-primary/5 ring-1 ring-primary' : 'border-border hover:border-primary/50 hover:bg-muted/50'"
|
||||
@click="form.type = option.value"
|
||||
>
|
||||
<Icon
|
||||
:icon="option.icon"
|
||||
class="size-5 mt-0.5 transition-colors duration-200"
|
||||
:class="form.type === option.value ? 'text-primary' : 'text-muted-foreground'"
|
||||
/>
|
||||
<div>
|
||||
<div
|
||||
class="font-medium transition-colors duration-200"
|
||||
:class="form.type === option.value ? 'text-primary' : ''"
|
||||
>
|
||||
{{ option.label }}
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{{ option.description }}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="canSelectScope" class="space-y-3">
|
||||
<UiLabel>生效范围</UiLabel>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-start gap-3 p-4 rounded-lg border text-left transition-all duration-200"
|
||||
:class="form.is_global ? 'border-primary bg-primary/5 ring-1 ring-primary' : 'border-border hover:border-primary/50 hover:bg-muted/50'"
|
||||
@click="form.is_global = true"
|
||||
>
|
||||
<Icon
|
||||
icon="lucide:globe-2"
|
||||
class="size-5 mt-0.5 transition-colors duration-200"
|
||||
:class="form.is_global ? 'text-primary' : 'text-muted-foreground'"
|
||||
/>
|
||||
<div>
|
||||
<div
|
||||
class="font-medium transition-colors duration-200"
|
||||
:class="form.is_global ? 'text-primary' : ''"
|
||||
>
|
||||
全局封禁
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
对所有应用生效
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-start gap-3 p-4 rounded-lg border text-left transition-all duration-200"
|
||||
:class="!form.is_global ? 'border-primary bg-primary/5 ring-1 ring-primary' : 'border-border hover:border-primary/50 hover:bg-muted/50'"
|
||||
@click="form.is_global = false"
|
||||
>
|
||||
<Icon
|
||||
icon="lucide:app-window"
|
||||
class="size-5 mt-0.5 transition-colors duration-200"
|
||||
:class="!form.is_global ? 'text-primary' : 'text-muted-foreground'"
|
||||
/>
|
||||
<div>
|
||||
<div
|
||||
class="font-medium transition-colors duration-200"
|
||||
:class="!form.is_global ? 'text-primary' : ''"
|
||||
>
|
||||
应用级封禁
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
仅对指定应用生效
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="needApplication" class="space-y-2">
|
||||
<UiLabel for="application">
|
||||
目标应用
|
||||
</UiLabel>
|
||||
<UiSelect v-model="form.application_id" :disabled="loadingApps">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue placeholder="选择要封禁的应用" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem v-for="app in applications" :key="app.id" :value="app.id">
|
||||
{{ app.name }}
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ currentTypeInfo.scopeType === 'app' ? '用户封禁必须指定应用' : '选择要应用此封禁规则的应用' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="value">
|
||||
封禁值
|
||||
</UiLabel>
|
||||
<CountrySelect
|
||||
v-if="form.type === 'region'"
|
||||
v-model="form.value"
|
||||
/>
|
||||
<UiInput
|
||||
v-else
|
||||
id="value"
|
||||
:key="form.type"
|
||||
v-model="form.value"
|
||||
:placeholder="currentTypeInfo.placeholder"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ form.type === 'region' ? '选择要封禁的国家或地区' : '根据规则类型输入对应的封禁目标' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="reason">
|
||||
封禁原因
|
||||
</UiLabel>
|
||||
<UiInput id="reason" v-model="form.reason" placeholder="输入封禁原因(可选)" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="expires">
|
||||
过期时间
|
||||
</UiLabel>
|
||||
<UiDatePickerDateTimePicker v-model="form.expires_at" placeholder="选择过期时间" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
留空表示永久封禁
|
||||
</p>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
生效中
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:info" class="size-5" />
|
||||
规则预览
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:shield-check" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ activeCount }}
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">生效范围</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<Icon :icon="form.is_global ? 'lucide:globe-2' : 'lucide:app-window'" class="size-4" />
|
||||
{{ form.is_global ? '全局封禁' : '应用级封禁' }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="needApplication" class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">目标应用</span>
|
||||
<span>{{ applications.find(a => a.id === form.application_id)?.name || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">规则类型</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<Icon :icon="currentTypeInfo.icon" class="size-4" />
|
||||
{{ currentTypeInfo.label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">封禁值</span>
|
||||
<span class="font-mono">
|
||||
{{ form.type === 'region' && form.value ? getCountryDisplay(form.value) : (form.value || '-') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">封禁原因</span>
|
||||
<span>{{ form.reason || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">过期时间</span>
|
||||
<span>{{ form.expires_at || '永久' }}</span>
|
||||
</div>
|
||||
</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) || [])"
|
||||
<UiCardContent class="pt-6">
|
||||
<div class="flex flex-col gap-3">
|
||||
<UiButton
|
||||
class="w-full"
|
||||
size="lg"
|
||||
:disabled="saving || !form.value.trim()"
|
||||
@click="handleSave"
|
||||
>
|
||||
<template #filters>
|
||||
<SingleFilter
|
||||
v-model="typeFilter"
|
||||
title="规则类型"
|
||||
:options="typeOptions"
|
||||
/>
|
||||
<SingleFilter
|
||||
v-model="statusFilter"
|
||||
title="状态"
|
||||
:options="statusOptions"
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
<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" />
|
||||
保存修改
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
@click="router.back()"
|
||||
>
|
||||
取消
|
||||
</UiButton>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
|
||||
@@ -1,385 +1,236 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onActivated, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import type { User } from '@/pages/admin/users/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/users/components/data-table.vue'
|
||||
import api from '@/services/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Application {
|
||||
id: number
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const userId = computed(() => route.params.id as string)
|
||||
const loading = ref(true)
|
||||
const users = ref<User[]>([])
|
||||
const tableRef = ref()
|
||||
const saving = ref(false)
|
||||
const applications = ref<Application[]>([])
|
||||
|
||||
const appFilter = ref<string>('')
|
||||
const statusFilter = ref<string>('')
|
||||
const accountStatusFilter = ref<string>('')
|
||||
const lastLoginStartDate = ref<string>('')
|
||||
const lastLoginEndDate = ref<string>('')
|
||||
const createdStartDate = ref<string>('')
|
||||
const createdEndDate = ref<string>('')
|
||||
|
||||
const deleteDialogOpen = ref(false)
|
||||
const deleteTarget = ref<User | null>(null)
|
||||
const batchDeleteDialogOpen = ref(false)
|
||||
const batchDeleteIds = ref<(string | number)[]>([])
|
||||
|
||||
const filteredUsers = computed(() => {
|
||||
let result = users.value
|
||||
|
||||
if (appFilter.value) {
|
||||
result = result.filter(user => String(user.application_id) === appFilter.value)
|
||||
}
|
||||
|
||||
if (statusFilter.value) {
|
||||
result = result.filter(user => user.online_status === statusFilter.value)
|
||||
}
|
||||
|
||||
if (accountStatusFilter.value) {
|
||||
result = result.filter(user => user.status === accountStatusFilter.value)
|
||||
}
|
||||
|
||||
if (lastLoginStartDate.value) {
|
||||
const fromDateTime = lastLoginStartDate.value.includes('T')
|
||||
? lastLoginStartDate.value.replace('T', ' ')
|
||||
: `${lastLoginStartDate.value} 00:00`
|
||||
result = result.filter((user) => {
|
||||
if (!user.last_login_at)
|
||||
return false
|
||||
return user.last_login_at >= fromDateTime
|
||||
})
|
||||
}
|
||||
|
||||
if (lastLoginEndDate.value) {
|
||||
const toDateTime = lastLoginEndDate.value.includes('T')
|
||||
? lastLoginEndDate.value.replace('T', ' ')
|
||||
: `${lastLoginEndDate.value} 23:59`
|
||||
result = result.filter((user) => {
|
||||
if (!user.last_login_at)
|
||||
return false
|
||||
return user.last_login_at <= toDateTime
|
||||
})
|
||||
}
|
||||
|
||||
if (createdStartDate.value) {
|
||||
const fromDateTime = createdStartDate.value.includes('T')
|
||||
? createdStartDate.value.replace('T', ' ')
|
||||
: `${createdStartDate.value} 00:00`
|
||||
result = result.filter(user => user.created_at >= fromDateTime)
|
||||
}
|
||||
|
||||
if (createdEndDate.value) {
|
||||
const toDateTime = createdEndDate.value.includes('T')
|
||||
? createdEndDate.value.replace('T', ' ')
|
||||
: `${createdEndDate.value} 23:59`
|
||||
result = result.filter(user => user.created_at <= toDateTime)
|
||||
}
|
||||
|
||||
return result
|
||||
const form = ref({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
application_id: '',
|
||||
})
|
||||
|
||||
const onlineCount = computed(() => filteredUsers.value.filter(user => user.online_status === 'online').length)
|
||||
const offlineCount = computed(() => filteredUsers.value.filter(user => user.online_status === 'offline').length)
|
||||
const bannedCount = computed(() => filteredUsers.value.filter(user => user.status === 'banned').length)
|
||||
|
||||
const applicationOptions = computed(() => {
|
||||
return applications.value.map(app => ({
|
||||
label: app.name,
|
||||
value: String(app.id),
|
||||
}))
|
||||
})
|
||||
|
||||
const statusOptions = computed(() => [
|
||||
{ label: t('admin.users.online'), value: 'online' },
|
||||
{ label: t('admin.users.offline'), value: 'offline' },
|
||||
])
|
||||
|
||||
const accountStatusOptions = computed(() => [
|
||||
{ label: t('admin.users.accountStatus.active'), value: 'active' },
|
||||
{ label: t('admin.users.accountStatus.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() {
|
||||
async function fetchUser() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<{ users: User[] }>('/dev/app-users')
|
||||
users.value = Array.isArray(data?.users) ? data.users : []
|
||||
const data = await api.get<any>(`/dev/app-users/${userId.value}`)
|
||||
const user = data?.user || data
|
||||
if (user) {
|
||||
form.value.username = user.username || ''
|
||||
form.value.email = user.email || ''
|
||||
form.value.application_id = String(user.application_id || '')
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取用户列表失败:', error)
|
||||
users.value = []
|
||||
console.error('获取用户信息失败:', error)
|
||||
toast.error(t('admin.users.editFailed'))
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToCreate() {
|
||||
router.push('/admin/users/create')
|
||||
}
|
||||
|
||||
function goToEdit(user: User) {
|
||||
router.push(`/admin/users/${user.id}`)
|
||||
}
|
||||
|
||||
function goToUserDevices(user: User) {
|
||||
router.push(`/admin/devices?user_id=${user.id}`)
|
||||
}
|
||||
|
||||
async function toggleUserStatus(user: User) {
|
||||
const newStatus = user.status === 'banned' ? 'active' : 'banned'
|
||||
async function fetchApplications() {
|
||||
try {
|
||||
await api.post('/dev/app-users/batch/status', {
|
||||
user_ids: [Number(user.id)],
|
||||
status: newStatus,
|
||||
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
|
||||
})
|
||||
toast.success(t('admin.users.statusUpdateSuccess'))
|
||||
fetchUsers()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('切换用户状态失败:', error)
|
||||
toast.error(error.message || t('admin.users.statusUpdateFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteUser(user: User) {
|
||||
deleteTarget.value = user
|
||||
deleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDeleteUser() {
|
||||
if (!deleteTarget.value)
|
||||
async function handleSave() {
|
||||
if (!form.value.username) {
|
||||
toast.error(t('admin.users.create.usernameRequired'))
|
||||
return
|
||||
}
|
||||
if (!form.value.email) {
|
||||
toast.error(t('admin.users.create.emailRequired'))
|
||||
return
|
||||
}
|
||||
if (!form.value.application_id) {
|
||||
toast.error(t('admin.users.create.applicationRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await api.delete(`/dev/app-users/${deleteTarget.value.id}`)
|
||||
toast.success(t('admin.users.deleteSuccess'))
|
||||
fetchUsers()
|
||||
const payload: any = {
|
||||
username: form.value.username,
|
||||
email: form.value.email,
|
||||
application_id: Number(form.value.application_id),
|
||||
}
|
||||
|
||||
if (form.value.password) {
|
||||
payload.password = form.value.password
|
||||
}
|
||||
|
||||
await api.put(`/dev/app-users/${userId.value}`, payload)
|
||||
toast.success(t('admin.users.editSuccess'))
|
||||
router.push('/admin/users')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('删除用户失败:', error)
|
||||
toast.error(error.message || t('admin.users.deleteFailed'))
|
||||
console.error('更新用户失败:', error)
|
||||
toast.error(error.message || t('admin.users.editFailed'))
|
||||
}
|
||||
finally {
|
||||
deleteTarget.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function confirmBatchDelete(ids: (string | number)[]) {
|
||||
batchDeleteIds.value = ids
|
||||
batchDeleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
try {
|
||||
await api.delete('/dev/app-users/batch', { user_ids: batchDeleteIds.value.map(id => Number(id)) } as any)
|
||||
toast.success(t('admin.users.deleteSuccess'))
|
||||
fetchUsers()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('批量删除失败:', error)
|
||||
toast.error(error.message || t('admin.users.deleteFailed'))
|
||||
}
|
||||
finally {
|
||||
batchDeleteIds.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function batchToggleStatus(ids: (string | number)[], status: string) {
|
||||
try {
|
||||
await api.post('/dev/app-users/batch/status', { user_ids: ids.map(id => Number(id)), status })
|
||||
toast.success(t('admin.users.batchUpdateSuccess'))
|
||||
fetchUsers()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('批量更新状态失败:', error)
|
||||
toast.error(error.message || t('admin.users.batchUpdateFailed'))
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchApplications()
|
||||
fetchUsers()
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
fetchUsers()
|
||||
fetchUser()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('admin.users.title')"
|
||||
:description="t('admin.users.description')"
|
||||
:title="t('admin.users.edit')"
|
||||
:description="t('admin.users.create.basicInfoDesc')"
|
||||
:breadcrumbs="[
|
||||
{ title: t('admin.users.title'), href: '/admin/users' },
|
||||
{ title: t('admin.users.edit') },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton @click="goToCreate">
|
||||
<Icon icon="lucide:plus" class="mr-2 h-4 w-4" />
|
||||
{{ t('admin.users.addUser') }}
|
||||
</UiButton>
|
||||
</template>
|
||||
<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">
|
||||
<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.users.create.basicInfo') }}
|
||||
</UiCardTitle>
|
||||
<UiCardDescription>{{ t('admin.users.create.basicInfoDesc') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="application">
|
||||
{{ t('admin.users.create.application') }}
|
||||
</UiLabel>
|
||||
<UiSelect v-model="form.application_id">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue :placeholder="t('admin.users.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="username">
|
||||
{{ t('admin.users.create.username') }}
|
||||
</UiLabel>
|
||||
<UiInput id="username" v-model="form.username" :placeholder="t('admin.users.create.usernamePlaceholder')" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="email">
|
||||
{{ t('admin.users.create.email') }}
|
||||
</UiLabel>
|
||||
<UiInput id="email" v-model="form.email" type="email" :placeholder="t('admin.users.create.emailPlaceholder')" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="password">
|
||||
{{ t('admin.users.create.password') }}
|
||||
</UiLabel>
|
||||
<UiInput id="password" v-model="form.password" type="password" :placeholder="t('admin.users.create.passwordPlaceholder')" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
留空则不修改密码
|
||||
</p>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<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.users.totalUsers') }}
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:eye" class="size-5" />
|
||||
{{ t('admin.users.create.preview') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:users" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ filteredUsers.length }}
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('admin.users.create.app') }}</span>
|
||||
<span>{{ selectedApplication?.name || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('admin.users.create.usernameLabel') }}</span>
|
||||
<span>{{ form.username || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('admin.users.create.emailLabel') }}</span>
|
||||
<span>{{ form.email || '-' }}</span>
|
||||
</div>
|
||||
</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.users.online') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:check-circle" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ onlineCount }}
|
||||
</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.users.offline') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:clock" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ offlineCount }}
|
||||
</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.users.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="filteredUsers"
|
||||
:on-edit="goToEdit"
|
||||
:on-toggle-status="toggleUserStatus"
|
||||
:on-delete="confirmDeleteUser"
|
||||
:on-manage-devices="goToUserDevices"
|
||||
@refresh="fetchUsers"
|
||||
@batch-unban="batchToggleStatus(tableRef?.table?.getSelectedRowModel().rows.filter((r: any) => r.original.status === 'banned').map((r: any) => r.original.id) || [], 'active')"
|
||||
@batch-ban="batchToggleStatus(tableRef?.table?.getSelectedRowModel().rows.filter((r: any) => r.original.status !== 'banned').map((r: any) => r.original.id) || [], 'banned')"
|
||||
@batch-delete="confirmBatchDelete(tableRef?.table?.getSelectedRowModel().rows.map((r: any) => r.original.id) || [])"
|
||||
<UiCardContent class="pt-6">
|
||||
<div class="flex flex-col gap-3">
|
||||
<UiButton
|
||||
class="w-full"
|
||||
size="lg"
|
||||
:disabled="saving || !form.username || !form.email || !form.application_id"
|
||||
@click="handleSave"
|
||||
>
|
||||
<template #filters>
|
||||
<SingleFilter
|
||||
v-model="appFilter"
|
||||
:title="t('admin.users.application')"
|
||||
:options="applicationOptions"
|
||||
/>
|
||||
<SingleFilter
|
||||
v-model="statusFilter"
|
||||
:title="t('admin.users.onlineStatus')"
|
||||
:options="statusOptions"
|
||||
/>
|
||||
<SingleFilter
|
||||
v-model="accountStatusFilter"
|
||||
:title="t('admin.users.accountStatus.title')"
|
||||
:options="accountStatusOptions"
|
||||
/>
|
||||
<DateRangeFilter
|
||||
v-model:start-model-value="lastLoginStartDate"
|
||||
v-model:end-model-value="lastLoginEndDate"
|
||||
:title="t('admin.users.lastLoginTime')"
|
||||
/>
|
||||
<DateRangeFilter
|
||||
v-model:start-model-value="createdStartDate"
|
||||
v-model:end-model-value="createdEndDate"
|
||||
:title="t('admin.users.registerTime')"
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
<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.users.create.saveBtn') || '保存修改' }}
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
@click="router.back()"
|
||||
>
|
||||
{{ t('admin.users.create.cancel') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="deleteDialogOpen"
|
||||
destructive
|
||||
:confirm-button-text="t('admin.users.delete')"
|
||||
:cancel-button-text="t('common.cancel')"
|
||||
@confirm="handleDeleteUser"
|
||||
>
|
||||
<template #title>
|
||||
{{ t('admin.users.deleteUser') }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{ t('admin.users.deleteConfirm', { username: deleteTarget?.username }) }}
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="batchDeleteDialogOpen"
|
||||
destructive
|
||||
:confirm-button-text="t('admin.users.delete')"
|
||||
:cancel-button-text="t('common.cancel')"
|
||||
@confirm="handleBatchDelete"
|
||||
>
|
||||
<template #title>
|
||||
{{ t('admin.users.batchDeleteUsers') }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{ t('admin.users.batchDeleteConfirm', { count: batchDeleteIds.length }) }}
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
|
||||
@@ -1,100 +1,104 @@
|
||||
<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 { Version } from '@/pages/admin/versions/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/versions/components/data-table.vue'
|
||||
import api, { BASE_URL } from '@/services/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
import api from '@/services/api'
|
||||
|
||||
interface Application {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
interface VersionFileInfo {
|
||||
file_path: string
|
||||
file_name: string
|
||||
file_size: number
|
||||
file_hash: string
|
||||
file_type: string
|
||||
}
|
||||
|
||||
interface UploadResponse {
|
||||
file_path: string
|
||||
file_size: number
|
||||
file_hash: string
|
||||
files: VersionFileInfo[]
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const versionId = computed(() => route.params.id as string)
|
||||
const loading = ref(true)
|
||||
const versions = ref<Version[]>([])
|
||||
const saving = ref(false)
|
||||
const uploading = ref(false)
|
||||
const applications = ref<Application[]>([])
|
||||
const tableRef = ref()
|
||||
|
||||
const appFilter = ref<string>('')
|
||||
const strategyFilter = ref<string>('')
|
||||
const methodFilter = ref<string>('')
|
||||
const searchFilter = ref<string>('')
|
||||
|
||||
const deleteDialogOpen = ref(false)
|
||||
const deleteTarget = ref<Version | 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 formData = ref({
|
||||
application_id: '',
|
||||
version: '',
|
||||
description: '',
|
||||
update_strategy: 'optional' as 'optional' | 'forced',
|
||||
update_method: 'manual' as 'manual' | 'auto',
|
||||
min_version: '',
|
||||
changelog: '',
|
||||
entry_file: '',
|
||||
file: null as File | null,
|
||||
})
|
||||
|
||||
const strategyOptions = computed(() => [
|
||||
{ label: t('admin.versions.strategies.optional'), value: 'optional' },
|
||||
{ label: t('admin.versions.strategies.forced'), value: 'forced' },
|
||||
])
|
||||
const uploadedData = ref<UploadResponse | null>(null)
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const methodOptions = computed(() => [
|
||||
{ label: t('admin.versions.methods.manual'), value: 'manual' },
|
||||
{ label: t('admin.versions.methods.auto'), value: 'auto' },
|
||||
])
|
||||
|
||||
const filteredVersions = computed(() => {
|
||||
let result = versions.value
|
||||
|
||||
if (appFilter.value) {
|
||||
result = result.filter(v => String(v.application_id) === appFilter.value)
|
||||
}
|
||||
|
||||
if (strategyFilter.value) {
|
||||
result = result.filter(v => v.update_strategy === strategyFilter.value)
|
||||
}
|
||||
|
||||
if (methodFilter.value) {
|
||||
result = result.filter(v => v.update_method === methodFilter.value)
|
||||
}
|
||||
|
||||
if (searchFilter.value) {
|
||||
const search = searchFilter.value.toLowerCase()
|
||||
result = result.filter(v =>
|
||||
v.version?.toLowerCase().includes(search)
|
||||
|| v.description?.toLowerCase().includes(search)
|
||||
|| v.application_name?.toLowerCase().includes(search),
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
const selectedApplication = computed(() => {
|
||||
return applications.value.find(app => String(app.id) === formData.value.application_id)
|
||||
})
|
||||
|
||||
const totalSize = computed(() => {
|
||||
return filteredVersions.value.reduce((sum, v) => sum + (v.file_size || 0), 0)
|
||||
const executableFiles = computed(() => {
|
||||
if (!uploadedData.value)
|
||||
return []
|
||||
return uploadedData.value.files.filter(f => f.file_type === 'executable')
|
||||
})
|
||||
|
||||
const forcedCount = computed(() => filteredVersions.value.filter(v => v.update_strategy === 'forced').length)
|
||||
const fileSize = computed(() => {
|
||||
if (formData.value.file) {
|
||||
return formatFileSize(formData.value.file.size)
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
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]}`
|
||||
function formatFileSize(size: number): string {
|
||||
if (size < 1024)
|
||||
return `${size} B`
|
||||
if (size < 1024 * 1024)
|
||||
return `${(size / 1024).toFixed(2)} KB`
|
||||
return `${(size / (1024 * 1024)).toFixed(2)} MB`
|
||||
}
|
||||
|
||||
async function fetchVersion() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<any>(`/dev/versions/${versionId.value}`)
|
||||
const ver = data?.version || data
|
||||
if (ver) {
|
||||
formData.value.application_id = String(ver.application_id || '')
|
||||
formData.value.version = ver.version || ''
|
||||
formData.value.description = ver.description || ''
|
||||
formData.value.update_strategy = ver.update_strategy || 'optional'
|
||||
formData.value.update_method = ver.update_method || 'manual'
|
||||
formData.value.min_version = ver.min_version || ''
|
||||
formData.value.changelog = ver.changelog || ''
|
||||
formData.value.entry_file = ver.entry_file || ''
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取版本信息失败:', error)
|
||||
toast.error('获取版本信息失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchApplications() {
|
||||
@@ -107,253 +111,428 @@ async function fetchApplications() {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchVersions() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<{ versions: Version[], total: number }>('/dev/versions')
|
||||
versions.value = data?.versions || []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('加载版本列表失败:', error)
|
||||
versions.value = []
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToCreate() {
|
||||
router.push('/admin/versions/create')
|
||||
}
|
||||
|
||||
function goToEdit(version: Version) {
|
||||
router.push(`/admin/versions/${version.id}`)
|
||||
}
|
||||
|
||||
function downloadVersion(version: Version) {
|
||||
if (!version.file_path)
|
||||
function handleFileSelect(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (target.files && target.files.length > 0) {
|
||||
const file = target.files[0]
|
||||
if (!file.name.toLowerCase().endsWith('.zip')) {
|
||||
toast.error('只支持ZIP格式文件')
|
||||
return
|
||||
const token = localStorage.getItem('token')
|
||||
const downloadUrl = `${version.file_path}?token=${token}`
|
||||
window.open(downloadUrl, '_blank')
|
||||
}
|
||||
formData.value.file = file
|
||||
uploadedData.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(version: Version) {
|
||||
deleteTarget.value = version
|
||||
deleteDialogOpen.value = true
|
||||
function removeFile() {
|
||||
formData.value.file = null
|
||||
uploadedData.value = null
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteTarget.value)
|
||||
async function uploadZipFile() {
|
||||
if (!formData.value.file) {
|
||||
toast.error('请先选择ZIP文件')
|
||||
return
|
||||
}
|
||||
|
||||
uploading.value = true
|
||||
try {
|
||||
await api.delete(`/dev/applications/${deleteTarget.value.application_id}/versions/${deleteTarget.value.id}`)
|
||||
toast.success(t('admin.versions.deleteSuccess'))
|
||||
fetchVersions()
|
||||
const formDataObj = new FormData()
|
||||
formDataObj.append('file', formData.value.file)
|
||||
|
||||
const response = await api.postFormData<UploadResponse>('/dev/versions/upload-zip', formDataObj)
|
||||
uploadedData.value = response
|
||||
toast.success('文件上传成功')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('删除版本失败:', error)
|
||||
toast.error(error.message || t('common.error'))
|
||||
console.error('上传文件失败:', error)
|
||||
toast.error(error.message || '上传失败')
|
||||
}
|
||||
finally {
|
||||
deleteTarget.value = null
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
const token = localStorage.getItem('token')
|
||||
const params = new URLSearchParams()
|
||||
if (appFilter.value)
|
||||
params.append('application_id', appFilter.value)
|
||||
if (strategyFilter.value)
|
||||
params.append('update_strategy', strategyFilter.value)
|
||||
if (methodFilter.value)
|
||||
params.append('update_method', methodFilter.value)
|
||||
|
||||
const queryString = params.toString()
|
||||
const url = `${BASE_URL}/dev/versions/export?${queryString}&token=${token}`
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
function handleBatchExport(ids: number[]) {
|
||||
if (!ids.length)
|
||||
async function handleSubmit() {
|
||||
if (!formData.value.application_id) {
|
||||
toast.error('请选择应用')
|
||||
return
|
||||
}
|
||||
if (!formData.value.version) {
|
||||
toast.error('请输入版本号')
|
||||
return
|
||||
const token = localStorage.getItem('token')
|
||||
const params = new URLSearchParams()
|
||||
params.append('ids', ids.join(','))
|
||||
const url = `${BASE_URL}/dev/versions/export?${params.toString()}&token=${token}`
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
function confirmBatchDelete(ids: number[]) {
|
||||
batchDeleteIds.value = ids
|
||||
batchDeleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
saving.value = true
|
||||
try {
|
||||
await api.delete('/dev/versions/batch', { ids: batchDeleteIds.value } as any)
|
||||
toast.success(t('admin.versions.batchDeleteSuccess'))
|
||||
fetchVersions()
|
||||
const payload: any = {
|
||||
application_id: Number(formData.value.application_id),
|
||||
version: formData.value.version,
|
||||
description: formData.value.description,
|
||||
update_strategy: formData.value.update_strategy,
|
||||
update_method: formData.value.update_method,
|
||||
min_version: formData.value.min_version,
|
||||
changelog: formData.value.changelog,
|
||||
entry_file: formData.value.entry_file,
|
||||
}
|
||||
|
||||
if (uploadedData.value) {
|
||||
payload.file_path = uploadedData.value.file_path
|
||||
payload.file_size = uploadedData.value.file_size
|
||||
payload.file_hash = uploadedData.value.file_hash
|
||||
}
|
||||
|
||||
await api.put(`/dev/versions/${versionId.value}`, payload)
|
||||
toast.success('版本更新成功')
|
||||
router.push('/admin/versions')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('批量删除失败:', error)
|
||||
toast.error(error.message || t('common.error'))
|
||||
console.error('更新版本失败:', error)
|
||||
toast.error(error.message || '更新失败')
|
||||
}
|
||||
finally {
|
||||
batchDeleteIds.value = []
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchApplications()
|
||||
fetchVersions()
|
||||
const appParam = route.query.app as string
|
||||
if (appParam) {
|
||||
appFilter.value = appParam
|
||||
}
|
||||
fetchVersion()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('admin.versions.title')"
|
||||
:description="t('admin.versions.description')"
|
||||
title="编辑版本"
|
||||
description="修改版本信息和更新策略"
|
||||
:breadcrumbs="[
|
||||
{ title: '版本管理', href: '/admin/versions' },
|
||||
{ title: '编辑版本' },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton @click="goToCreate">
|
||||
<Icon icon="lucide:plus" class="mr-2 h-4 w-4" />
|
||||
{{ t('admin.versions.create') }}
|
||||
<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">
|
||||
<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:git-branch" class="size-5" />
|
||||
版本配置
|
||||
</UiCardTitle>
|
||||
<UiCardDescription>修改版本的基本信息和上传更新文件</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>选择应用</UiLabel>
|
||||
<UiSelect v-model="formData.application_id" :disabled="saving">
|
||||
<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="version">
|
||||
版本号
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="version"
|
||||
v-model="formData.version"
|
||||
placeholder="例如: 1.0.0"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="min_version">
|
||||
最低版本
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="min_version"
|
||||
v-model="formData.min_version"
|
||||
placeholder="例如: 0.9.0"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="description">
|
||||
版本描述
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="description"
|
||||
v-model="formData.description"
|
||||
placeholder="简短描述此版本的更新内容"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel>更新文件 (ZIP格式,可选)</UiLabel>
|
||||
<div class="border-2 border-dashed rounded-lg p-4">
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
accept=".zip"
|
||||
class="hidden"
|
||||
@change="handleFileSelect"
|
||||
>
|
||||
<div v-if="!formData.file" class="text-center">
|
||||
<Icon icon="lucide:upload-cloud" class="size-10 mx-auto text-muted-foreground mb-2" />
|
||||
<p class="text-sm text-muted-foreground mb-2">
|
||||
拖拽ZIP文件到此处或点击上传
|
||||
</p>
|
||||
<UiButton variant="outline" size="sm" :disabled="saving || uploading" @click="fileInputRef?.click()">
|
||||
<Icon icon="lucide:file-plus" class="h-4 w-4 mr-2" />
|
||||
选择文件
|
||||
</UiButton>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<Icon icon="lucide:file-archive" class="size-8 text-primary" />
|
||||
<div>
|
||||
<p class="text-sm font-medium">
|
||||
{{ formData.file.name }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ fileSize }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<UiButton
|
||||
v-if="!uploadedData"
|
||||
size="sm"
|
||||
:disabled="uploading"
|
||||
@click="uploadZipFile"
|
||||
>
|
||||
<Icon v-if="uploading" icon="lucide:loader-2" class="h-4 w-4 mr-2 animate-spin" />
|
||||
<Icon v-else icon="lucide:upload" class="h-4 w-4 mr-2" />
|
||||
上传解析
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" size="icon" :disabled="saving" @click="removeFile">
|
||||
<Icon icon="lucide:x" class="size-4" />
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="uploadedData" class="rounded-lg bg-muted/50 p-3">
|
||||
<div class="flex items-center gap-2 text-sm text-green-600 mb-2">
|
||||
<Icon icon="lucide:check-circle" class="size-4" />
|
||||
<span>文件已上传并解析</span>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground space-y-1">
|
||||
<p>文件哈希: {{ uploadedData.file_hash.substring(0, 16) }}...</p>
|
||||
<p>包含 {{ uploadedData.files.length }} 个文件</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="uploadedData && uploadedData.files.length > 0" class="space-y-2">
|
||||
<UiLabel>文件列表</UiLabel>
|
||||
<div class="border rounded-lg max-h-[300px] overflow-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-muted/50 sticky top-0">
|
||||
<tr>
|
||||
<th class="text-left p-2 font-medium">
|
||||
文件名
|
||||
</th>
|
||||
<th class="text-left p-2 font-medium">
|
||||
路径
|
||||
</th>
|
||||
<th class="text-right p-2 font-medium">
|
||||
大小
|
||||
</th>
|
||||
<th class="text-left p-2 font-medium">
|
||||
类型
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(file, index) in uploadedData.files"
|
||||
:key="index"
|
||||
class="border-t"
|
||||
>
|
||||
<td class="p-2 truncate max-w-[150px]">
|
||||
{{ file.file_name }}
|
||||
</td>
|
||||
<td class="p-2 truncate max-w-[200px] text-muted-foreground">
|
||||
{{ file.file_path }}
|
||||
</td>
|
||||
<td class="p-2 text-right text-muted-foreground">
|
||||
{{ formatFileSize(file.file_size) }}
|
||||
</td>
|
||||
<td class="p-2">
|
||||
<span class="px-2 py-0.5 rounded text-xs bg-muted">
|
||||
{{ file.file_type }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="executableFiles.length > 0" class="space-y-2">
|
||||
<UiLabel>入口文件</UiLabel>
|
||||
<UiSelect v-model="formData.entry_file" :disabled="saving">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue placeholder="选择入口文件(可选)" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem
|
||||
v-for="file in executableFiles"
|
||||
:key="file.file_path"
|
||||
:value="file.file_path"
|
||||
>
|
||||
{{ file.file_name }} ({{ formatFileSize(file.file_size) }})
|
||||
</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.update_strategy === 'forced'"
|
||||
:disabled="saving"
|
||||
@update:checked="formData.update_strategy = $event ? 'forced' : 'optional'"
|
||||
/>
|
||||
</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.update_method === 'auto'"
|
||||
:disabled="saving"
|
||||
@update:checked="formData.update_method = $event ? 'auto' : 'manual'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="changelog">
|
||||
更新日志
|
||||
</UiLabel>
|
||||
<textarea
|
||||
id="changelog"
|
||||
v-model="formData.changelog"
|
||||
class="flex min-h-[120px] 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>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<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.versions.totalVersions') }}
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:eye" class="size-5" />
|
||||
预览
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:git-branch" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ filteredVersions.length }}
|
||||
<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>{{ formData.version || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">最低版本</span>
|
||||
<span>{{ formData.min_version || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">版本描述</span>
|
||||
<span class="truncate max-w-[120px]">{{ formData.description || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">强制更新</span>
|
||||
<span>{{ formData.update_strategy === 'forced' ? '是' : '否' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">自动更新</span>
|
||||
<span>{{ formData.update_method === 'auto' ? '是' : '否' }}</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.changelog || '暂无更新日志' }}
|
||||
</p>
|
||||
</div>
|
||||
</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.versions.forcedCount') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:alert-triangle" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ forcedCount }}
|
||||
</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.versions.totalSize') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:hard-drive" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ formatFileSize(totalSize) }}
|
||||
</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.versions.appCount') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:package" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ new Set(filteredVersions.map(v => v.application_id)).size }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="p-6">
|
||||
<DataTable
|
||||
ref="tableRef"
|
||||
:loading
|
||||
:data="filteredVersions"
|
||||
:on-download="downloadVersion"
|
||||
:on-edit="goToEdit"
|
||||
:on-delete="confirmDelete"
|
||||
:search-filter="searchFilter"
|
||||
@refresh="fetchVersions"
|
||||
@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) || [])"
|
||||
<UiCardContent class="pt-6">
|
||||
<div class="flex flex-col gap-3">
|
||||
<UiButton
|
||||
class="w-full"
|
||||
size="lg"
|
||||
:disabled="saving || !formData.application_id || !formData.version"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<template #filters>
|
||||
<SingleFilter
|
||||
v-model="appFilter"
|
||||
:title="t('admin.versions.application')"
|
||||
:options="applicationOptions"
|
||||
/>
|
||||
<SingleFilter
|
||||
v-model="strategyFilter"
|
||||
:title="t('admin.versions.strategy')"
|
||||
:options="strategyOptions"
|
||||
/>
|
||||
<SingleFilter
|
||||
v-model="methodFilter"
|
||||
:title="t('admin.versions.method')"
|
||||
:options="methodOptions"
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
<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" />
|
||||
保存修改
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
@click="router.back()"
|
||||
>
|
||||
取消
|
||||
</UiButton>
|
||||
</div>
|
||||
</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.versions.deleteVersion') }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{ t('admin.versions.deleteConfirm', { version: deleteTarget?.version }) }}
|
||||
</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.versions.batchDelete') }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{ t('admin.versions.batchDeleteConfirm', { count: batchDeleteIds.length }) }}
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user