Initial commit: 网络验证平台
This commit is contained in:
@@ -0,0 +1,580 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
SortingState,
|
||||
VisibilityState,
|
||||
} from '@tanstack/vue-table'
|
||||
|
||||
import { Icon } from '@iconify/vue'
|
||||
import {
|
||||
FlexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useVueTable,
|
||||
} from '@tanstack/vue-table'
|
||||
import { computed, h, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import ConfirmDialog from '@/components/confirm-dialog.vue'
|
||||
import DataTablePagination from '@/components/data-table/table-pagination.vue'
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import DateTimePicker from '@/components/ui/date-picker/DateTimePicker.vue'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { valueUpdater } from '@/lib/utils'
|
||||
import api from '@/services/api'
|
||||
|
||||
interface Application {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
interface FinanceRecord {
|
||||
id: number
|
||||
order_no: string
|
||||
type: 'recharge' | 'consumption'
|
||||
user_id: number
|
||||
app_id?: number
|
||||
amount: number
|
||||
detail: string
|
||||
status: string
|
||||
payment_type: string
|
||||
remark: string
|
||||
created_at: string
|
||||
user?: {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
}
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const loading = ref(true)
|
||||
const records = ref<FinanceRecord[]>([])
|
||||
const applications = ref<Application[]>([])
|
||||
|
||||
const appFilter = ref<string>('all')
|
||||
const statusFilter = ref<string>('all')
|
||||
const dateRange = ref<{ from: string, to: string }>({ from: '', to: '' })
|
||||
|
||||
function setQuickRange(range: 'today' | 'week' | 'month') {
|
||||
const now = new Date()
|
||||
const today = now.toISOString().split('T')[0]
|
||||
const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().split('T')[0]
|
||||
|
||||
switch (range) {
|
||||
case 'today':
|
||||
dateRange.value = { from: `${today}T00:00`, to: `${today}T23:59` }
|
||||
break
|
||||
case 'week': {
|
||||
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||
dateRange.value = {
|
||||
from: `${weekAgo.toISOString().split('T')[0]}T00:00`,
|
||||
to: `${today}T23:59`,
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'month':
|
||||
dateRange.value = { from: `${firstDayOfMonth}T00:00`, to: `${today}T23:59` }
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const filteredRecords = computed(() => {
|
||||
let result = records.value
|
||||
|
||||
if (appFilter.value !== 'all') {
|
||||
result = result.filter(r => r.app_id === Number(appFilter.value))
|
||||
}
|
||||
|
||||
if (statusFilter.value !== 'all') {
|
||||
result = result.filter(r => r.status === statusFilter.value)
|
||||
}
|
||||
|
||||
if (dateRange.value.from) {
|
||||
const fromDateTime = dateRange.value.from.includes('T')
|
||||
? dateRange.value.from.replace('T', ' ')
|
||||
: `${dateRange.value.from} 00:00`
|
||||
result = result.filter(r => r.created_at >= fromDateTime)
|
||||
}
|
||||
if (dateRange.value.to) {
|
||||
const toDateTime = dateRange.value.to.includes('T')
|
||||
? dateRange.value.to.replace('T', ' ')
|
||||
: `${dateRange.value.to} 23:59`
|
||||
result = result.filter(r => r.created_at <= toDateTime)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const stats = computed(() => {
|
||||
const rechargeRecords = filteredRecords.value.filter(r => r.type === 'recharge' && r.status === 'success')
|
||||
const consumptionRecords = filteredRecords.value.filter(r => r.type === 'consumption' && r.status === 'success')
|
||||
|
||||
const totalRecharge = rechargeRecords.reduce((sum, r) => sum + r.amount, 0)
|
||||
const totalConsumption = consumptionRecords.reduce((sum, r) => sum + r.amount, 0)
|
||||
|
||||
return {
|
||||
rechargeCount: rechargeRecords.length,
|
||||
rechargeAmount: totalRecharge,
|
||||
consumptionCount: consumptionRecords.length,
|
||||
consumptionAmount: totalConsumption,
|
||||
profit: totalRecharge - totalConsumption,
|
||||
}
|
||||
})
|
||||
|
||||
function formatTime(time: string) {
|
||||
if (!time)
|
||||
return '-'
|
||||
try {
|
||||
const date = new Date(time)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
catch {
|
||||
return '-'
|
||||
}
|
||||
}
|
||||
|
||||
const sorting = ref<SortingState>([])
|
||||
const columnFilters = ref<ColumnFiltersState>([])
|
||||
const columnVisibility = ref<VisibilityState>({})
|
||||
const rowSelection = ref({})
|
||||
|
||||
const deleteDialogOpen = ref(false)
|
||||
const deleteTarget = ref<FinanceRecord | null>(null)
|
||||
const batchDeleteDialogOpen = ref(false)
|
||||
|
||||
async function confirmDelete(record: FinanceRecord) {
|
||||
deleteTarget.value = record
|
||||
deleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteTarget.value)
|
||||
return
|
||||
|
||||
try {
|
||||
await api.delete(`/dev/finance/records/${deleteTarget.value.id}?type=${deleteTarget.value.type}`)
|
||||
toast.success(t('developer.finance.deleteSuccess'))
|
||||
fetchRecords()
|
||||
}
|
||||
catch (error: any) {
|
||||
toast.error(error.message || t('developer.finance.deleteFailed'))
|
||||
}
|
||||
finally {
|
||||
deleteTarget.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnDef<FinanceRecord>[] = [
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => h(Checkbox, {
|
||||
'checked': table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && 'indeterminate'),
|
||||
'onUpdate:checked': (value: boolean | 'indeterminate') => table.toggleAllPageRowsSelected(!!value),
|
||||
'ariaLabel': 'Select all',
|
||||
}),
|
||||
cell: ({ row }) => h(Checkbox, {
|
||||
'checked': row.getIsSelected(),
|
||||
'onUpdate:checked': (value: boolean | 'indeterminate') => row.toggleSelected(!!value),
|
||||
'ariaLabel': 'Select row',
|
||||
}),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'type',
|
||||
header: () => t('developer.finance.columns.type'),
|
||||
cell: ({ row }) => {
|
||||
const type = row.getValue('type') as string
|
||||
return h(Badge, { variant: type === 'recharge' ? 'default' : 'secondary' }, () => type === 'recharge' ? t('developer.finance.types.recharge') : t('developer.finance.types.consumption'))
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'amount',
|
||||
header: () => t('developer.finance.columns.amount'),
|
||||
cell: ({ row }) => {
|
||||
const type = row.getValue('type') as string
|
||||
const amount = row.getValue('amount') as number
|
||||
const prefix = type === 'recharge' ? '+' : '-'
|
||||
return h('span', { class: 'font-medium' }, `${prefix}¥${amount.toFixed(2)}`)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'user',
|
||||
header: () => t('developer.finance.columns.user'),
|
||||
cell: ({ row }) => {
|
||||
const user = row.original.user
|
||||
return user?.username || '-'
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'detail',
|
||||
header: () => t('developer.finance.columns.detail'),
|
||||
cell: ({ row }) => {
|
||||
const detail = row.getValue('detail') as string
|
||||
return h('span', { class: 'max-w-[200px] truncate text-muted-foreground block' }, detail || '-')
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: () => t('developer.finance.columns.status'),
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue('status') as string
|
||||
const map: Record<string, { label: string, variant: 'default' | 'secondary' | 'destructive' }> = {
|
||||
success: { label: t('developer.finance.success'), variant: 'default' },
|
||||
pending: { label: t('developer.finance.pending'), variant: 'secondary' },
|
||||
failed: { label: t('developer.finance.failed'), variant: 'destructive' },
|
||||
}
|
||||
const { label, variant } = map[status] || { label: status, variant: 'secondary' }
|
||||
return h(Badge, { variant }, () => label)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: () => t('developer.finance.columns.time'),
|
||||
cell: ({ row }) => {
|
||||
return h('span', { class: 'text-muted-foreground whitespace-nowrap' }, formatTime(row.getValue('created_at')))
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => t('developer.finance.columns.actions'),
|
||||
cell: ({ row }) => {
|
||||
return h(Button, {
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
class: 'h-8 w-8 p-0',
|
||||
onClick: () => confirmDelete(row.original),
|
||||
}, () => h(Icon, { icon: 'lucide:trash-2', class: 'size-4 text-muted-foreground hover:text-destructive' }))
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const table = useVueTable({
|
||||
get data() { return filteredRecords.value },
|
||||
get columns() { return columns },
|
||||
state: {
|
||||
get sorting() { return sorting.value },
|
||||
get columnFilters() { return columnFilters.value },
|
||||
get columnVisibility() { return columnVisibility.value },
|
||||
get rowSelection() { return rowSelection.value },
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onSortingChange: updaterOrValue => valueUpdater(updaterOrValue, sorting),
|
||||
onColumnFiltersChange: updaterOrValue => valueUpdater(updaterOrValue, columnFilters),
|
||||
onColumnVisibilityChange: updaterOrValue => valueUpdater(updaterOrValue, columnVisibility),
|
||||
onRowSelectionChange: updaterOrValue => valueUpdater(updaterOrValue, rowSelection),
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
})
|
||||
|
||||
async function confirmBatchDelete() {
|
||||
const selectedRows = table.getSelectedRowModel().rows
|
||||
if (selectedRows.length === 0) {
|
||||
toast.error(t('developer.finance.delete'))
|
||||
return
|
||||
}
|
||||
batchDeleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
const selectedRows = table.getSelectedRowModel().rows
|
||||
const ids = selectedRows.map(row => row.original.id)
|
||||
try {
|
||||
await api.post('/dev/finance/records/batch-delete', { ids })
|
||||
toast.success(t('developer.finance.deleteSuccess'))
|
||||
fetchRecords()
|
||||
}
|
||||
catch (error: any) {
|
||||
toast.error(error.message || t('developer.finance.deleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchApplications() {
|
||||
try {
|
||||
const data = await api.get<{ applications: Application[] }>('/dev/applications')
|
||||
applications.value = data?.applications || []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取应用列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRecords() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<{ records: FinanceRecord[] }>('/dev/finance/records')
|
||||
records.value = data?.records || []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取财务记录失败:', error)
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchApplications()
|
||||
fetchRecords()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('developer.finance.title')"
|
||||
:description="t('developer.finance.description')"
|
||||
sticky
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.finance.rechargeCount') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:arrow-down-left" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ stats.rechargeCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.finance.rechargeAmount') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:dollar-sign" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
¥{{ stats.rechargeAmount.toFixed(2) }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.finance.consumptionCount') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:arrow-up-right" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ stats.consumptionCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.finance.consumptionAmount') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:credit-card" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
¥{{ stats.consumptionAmount.toFixed(2) }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.finance.profit') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:trending-up" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
¥{{ stats.profit.toFixed(2) }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>{{ t('developer.finance.transactionRecords') }}</UiCardTitle>
|
||||
<UiCardDescription>
|
||||
{{ t('developer.finance.transactionRecordsDesc') }}
|
||||
</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="space-y-3 mb-4">
|
||||
<div class="flex items-center justify-end">
|
||||
<UiButton
|
||||
v-if="table.getSelectedRowModel().rows.length > 0"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
@click="confirmBatchDelete"
|
||||
>
|
||||
<Icon icon="lucide:trash-2" class="mr-2 size-4" />
|
||||
{{ t('developer.finance.batchDeleteBtn') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<UiSelect v-model="appFilter" class="w-[140px]">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue :placeholder="t('developer.finance.allApplications')" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem value="all">
|
||||
{{ t('developer.finance.allApplications') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem v-for="app in applications" :key="app.id" :value="String(app.id)">
|
||||
{{ app.name }}
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
|
||||
<UiSelect v-model="statusFilter" class="w-[120px]">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue :placeholder="t('developer.finance.allStatus')" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem value="all">
|
||||
{{ t('developer.finance.allStatus') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="success">
|
||||
{{ t('developer.finance.success') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="pending">
|
||||
{{ t('developer.finance.pending') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="failed">
|
||||
{{ t('developer.finance.failed') }}
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
|
||||
<DateTimePicker
|
||||
v-model="dateRange.from"
|
||||
:placeholder="t('developer.finance.startTime')"
|
||||
class="w-[180px]"
|
||||
/>
|
||||
<span class="text-muted-foreground">{{ t('developer.finance.to') }}</span>
|
||||
<DateTimePicker
|
||||
v-model="dateRange.to"
|
||||
:placeholder="t('developer.finance.endTime')"
|
||||
class="w-[180px]"
|
||||
/>
|
||||
<UiButton variant="ghost" size="sm" @click="setQuickRange('today')">
|
||||
{{ t('developer.finance.today') }}
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" size="sm" @click="setQuickRange('week')">
|
||||
{{ t('developer.finance.last7Days') }}
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" size="sm" @click="setQuickRange('month')">
|
||||
{{ t('developer.finance.thisMonth') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<Icon icon="lucide:loader-2" class="size-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div v-else class="border rounded-md">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<TableHead v-for="header in headerGroup.headers" :key="header.id">
|
||||
<FlexRender v-if="!header.isPlaceholder" :render="header.column.columnDef.header" :props="header.getContext()" />
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<template v-if="table.getRowModel().rows?.length">
|
||||
<TableRow
|
||||
v-for="row in table.getRowModel().rows"
|
||||
:key="row.id"
|
||||
:data-state="row.getIsSelected() && 'selected'"
|
||||
>
|
||||
<TableCell v-for="cell in row.getVisibleCells()" :key="cell.id">
|
||||
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
|
||||
<TableRow v-else>
|
||||
<TableCell
|
||||
:colspan="columns.length"
|
||||
class="h-24 text-center"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center text-muted-foreground">
|
||||
<Icon icon="lucide:inbox" class="size-12 mb-2 opacity-50" />
|
||||
<span>{{ t('developer.finance.noRecords') }}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<DataTablePagination v-if="!loading && table.getRowModel().rows?.length" :table="table" class="mt-4" />
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
</BasicPage>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="deleteDialogOpen"
|
||||
destructive
|
||||
:confirm-button-text="t('developer.finance.delete')"
|
||||
:cancel-button-text="t('developer.finance.cancel')"
|
||||
@confirm="handleDelete"
|
||||
>
|
||||
<template #title>
|
||||
{{ t('developer.finance.delete') }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{ t('developer.finance.deleteConfirm') }}
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="batchDeleteDialogOpen"
|
||||
destructive
|
||||
:confirm-button-text="t('developer.finance.delete')"
|
||||
:cancel-button-text="t('developer.finance.cancel')"
|
||||
@confirm="handleBatchDelete"
|
||||
>
|
||||
<template #title>
|
||||
{{ t('developer.finance.batchDeleteBtn') }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{ t('developer.finance.batchDeleteConfirm', { count: table.getSelectedRowModel().rows.length }) }}
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
</template>
|
||||
Reference in New Issue
Block a user