fix: 修复所有admin编辑页面[id].vue从列表页改为正确的编辑表单

This commit is contained in:
2026-05-01 10:33:19 +08:00
parent 739ad3ae55
commit 9563c8fad6
9 changed files with 2873 additions and 2647 deletions
+249 -292
View File
@@ -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>
+240 -281
View File
@@ -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>
+322 -326
View File
@@ -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>
+304 -262
View File
@@ -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>
+444 -284
View File
@@ -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>
+209 -283
View File
@@ -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>
+379 -223
View File
@@ -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>
+169 -318
View File
@@ -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>
+447 -268
View File
@@ -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>