Files
verify/frontend/src/pages/developer/finance/index.vue
T

581 lines
19 KiB
Vue

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