581 lines
19 KiB
Vue
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>
|