Files
hfb_sys/frontend/src/features/admin/views/AdminWithdrawalsView.vue
T

901 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import {
Check,
Clock,
DocumentChecked,
Money,
Refresh,
Search,
Tickets,
User,
View,
Wallet,
} from '@element-plus/icons-vue'
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
fetchAdminWithdrawals,
reviewWithdrawal,
confirmPayment,
type WithdrawalDetail,
} from '@/features/admin/api/adminWithdrawal'
import { formatCentWithSymbol } from '@/shared/utils/money'
import { formatDateTime } from '@/shared/utils/time'
import { adminPath } from '@/shared/utils/adminPath'
import AdminTablePagination from '../components/AdminTablePagination.vue'
import WithdrawalDetailDialog from '../components/WithdrawalDetailDialog.vue'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const withdrawals = ref<WithdrawalDetail[]>([])
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(20)
const filters = ref({
status: '',
user_id: undefined as number | undefined,
})
const showDetailDialog = ref(false)
const selectedWithdrawal = ref<WithdrawalDetail | null>(null)
const statusOptions = [
{ label: '全部状态', value: '' },
{ label: '待审核', value: 'pending' },
{ label: '处理中 (待打款)', value: 'processing' },
{ label: '已完成', value: 'completed' },
{ label: '已拒绝', value: 'rejected' },
{ label: '已取消', value: 'cancelled' },
]
const withdrawalReturnQuery = computed<Record<string, string>>(() => {
const query: Record<string, string> = {
from: 'withdrawals',
withdrawal_page: String(currentPage.value),
withdrawal_page_size: String(pageSize.value),
}
if (filters.value.status) query.withdrawal_status = filters.value.status
if (filters.value.user_id) query.withdrawal_user_id = String(filters.value.user_id)
return query
})
const pendingCount = computed(() => withdrawals.value.filter(w => w.status === 'pending').length)
const processingCount = computed(
() => withdrawals.value.filter(w => w.status === 'processing').length
)
const completedAmount = computed(() =>
withdrawals.value
.filter(w => w.status === 'completed')
.reduce((sum, w) => sum + Number(w.actual_amount_cent || w.amount_cent || 0), 0)
)
onMounted(() => {
restoreListState()
void loadWithdrawals()
})
function routeQueryString(value: unknown) {
return typeof value === 'string' ? value : ''
}
function restoreListState() {
const status = routeQueryString(route.query.withdrawal_status)
if (statusOptions.some(item => item.value === status)) {
filters.value.status = status
}
const userID = Number(routeQueryString(route.query.withdrawal_user_id))
if (Number.isSafeInteger(userID) && userID > 0) {
filters.value.user_id = userID
}
const restoredPage = Number(routeQueryString(route.query.withdrawal_page))
if (Number.isSafeInteger(restoredPage) && restoredPage > 0) {
currentPage.value = restoredPage
}
const restoredPageSize = Number(routeQueryString(route.query.withdrawal_page_size))
if ([10, 20, 50, 100].includes(restoredPageSize)) {
pageSize.value = restoredPageSize
}
}
async function loadWithdrawals() {
loading.value = true
try {
const result = await fetchAdminWithdrawals({
status: filters.value.status || undefined,
user_id: filters.value.user_id,
page: currentPage.value,
page_size: pageSize.value,
})
withdrawals.value = result.items
total.value = result.total
} catch (error: any) {
ElMessage.error(error.response?.data?.message || '提现列表加载失败')
} finally {
loading.value = false
}
}
function handleFilter() {
currentPage.value = 1
loadWithdrawals()
}
function resetFilters() {
filters.value.status = ''
filters.value.user_id = undefined
currentPage.value = 1
void loadWithdrawals()
}
function openDetail(withdrawal: WithdrawalDetail) {
selectedWithdrawal.value = withdrawal
showDetailDialog.value = true
}
function goToOwnerOrders(withdrawal: WithdrawalDetail) {
const keyword = withdrawal.user_phone?.trim() || String(withdrawal.user_id || '')
void router.push({
path: adminPath('orders'),
query: {
...withdrawalReturnQuery.value,
...(keyword ? { keyword } : {}),
},
})
}
async function handleReview(withdrawal: WithdrawalDetail, approved: boolean) {
const action = approved ? '通过' : '拒绝'
try {
const { value: remark } = await ElMessageBox.prompt(
`请输入${action}原因(可选)`,
`${action}审核`,
{
confirmButtonText: `确认${action}`,
cancelButtonText: '取消',
inputType: 'textarea',
inputPlaceholder: '输入备注信息...',
}
)
await reviewWithdrawal(withdrawal.id, {
approved,
remark: remark || '',
})
ElMessage.success(`审核${action}`)
await loadWithdrawals()
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error.response?.data?.message || '操作失败')
}
}
}
async function handleConfirmPayment(withdrawal: WithdrawalDetail) {
try {
const { value: remark } = await ElMessageBox.prompt(
'请输入打款备注(如:已通过支付宝/微信转账)',
'确认打款',
{
confirmButtonText: '确认完成',
cancelButtonText: '取消',
inputType: 'textarea',
inputPlaceholder: '输入打款备注...',
inputValidator: value => {
return !!(value && value.trim().length > 0)
},
inputErrorMessage: '请输入打款备注',
}
)
await confirmPayment(withdrawal.id, {
remark: remark || '',
})
ElMessage.success('打款完成')
await loadWithdrawals()
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error.response?.data?.message || '操作失败')
}
}
}
function statusLabel(status: string) {
const labels: Record<string, string> = {
pending: '待审核',
processing: '待打款',
completed: '已打款',
rejected: '已拒绝',
cancelled: '已取消',
}
return labels[status] || status
}
function statusType(status: string): 'success' | 'warning' | 'danger' | 'info' | 'primary' {
const types: Record<string, 'success' | 'warning' | 'danger' | 'info' | 'primary'> = {
pending: 'warning',
processing: 'primary',
completed: 'success',
rejected: 'danger',
cancelled: 'info',
}
return types[status] || 'info'
}
function accountTypeLabel(type: string) {
const labels: Record<string, string> = {
alipay: '支付宝',
wechat: '微信',
bank: '银行卡',
}
return labels[type] || type
}
function onDetailDialogSaved() {
showDetailDialog.value = false
loadWithdrawals()
}
function money(cent: number | undefined | null) {
return formatCentWithSymbol(cent || 0)
}
</script>
<template>
<div class="finance-page-container">
<!-- 头部卡片 -->
<header class="page-header-card">
<div class="header-main">
<div class="title-group">
<div class="eyebrow-tag">
<el-icon><Money /></el-icon>
<span>WITHDRAWALS</span>
</div>
<h1 class="page-title">提现审核</h1>
<p class="page-subtitle">审核号主用户钱包提现申请,核验转账账户并执行打款放款处理。</p>
</div>
<div class="toolbar-actions">
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
<el-button type="primary" :icon="Search" :loading="loading" @click="handleFilter">
查询
</el-button>
</div>
</div>
</header>
<!-- 汇总指标卡片 -->
<section class="summary-metric-grid">
<div class="metric-card metric-card--review">
<div class="metric-icon-wrap amber">
<el-icon><Clock /></el-icon>
</div>
<div class="metric-info">
<span class="metric-label">本页待初审</span>
<strong class="metric-val amber-val">{{ pendingCount }} 笔</strong>
<span class="metric-sub-pill warning">待审核</span>
</div>
</div>
<div class="metric-card metric-card--pay">
<div class="metric-icon-wrap indigo">
<el-icon><Wallet /></el-icon>
</div>
<div class="metric-info">
<span class="metric-label">本页待打款</span>
<strong class="metric-val indigo-val">{{ processingCount }} 笔</strong>
<span class="metric-sub-pill info">已过审待放款</span>
</div>
</div>
<div class="metric-card metric-card--paid">
<div class="metric-icon-wrap emerald">
<el-icon><Check /></el-icon>
</div>
<div class="metric-info">
<span class="metric-label">本页已打款总额</span>
<strong class="metric-val emerald-val">{{ money(completedAmount) }}</strong>
<span class="metric-sub-pill success">已完成出款</span>
</div>
</div>
<div class="metric-card metric-card--total">
<div class="metric-icon-wrap slate">
<el-icon><Tickets /></el-icon>
</div>
<div class="metric-info">
<span class="metric-label">总申请记录</span>
<strong class="metric-val">{{ total }} 笔</strong>
<span class="metric-sub-text">所有提现记录</span>
</div>
</div>
</section>
<!-- 筛选卡片 -->
<section class="section-card filter-card">
<el-form class="filter-grid" label-position="top" @keyup.enter="handleFilter">
<el-form-item label="提现状态">
<el-select
v-model="filters.status"
placeholder="全部状态"
class="full-control"
@change="handleFilter"
>
<el-option
v-for="opt in statusOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item label="用户 ID">
<el-input
v-model.number="filters.user_id"
placeholder="输入用户 ID 检索"
clearable
@clear="handleFilter"
/>
</el-form-item>
</el-form>
</section>
<!-- 提现列表表格 -->
<section class="section-card table-section-card">
<div class="table-card-header">
<div class="table-header-title">
<el-icon class="header-icon"><DocumentChecked /></el-icon>
<span>提现申请明细</span>
</div>
<span class="count-badge">共 {{ total }} 条申请</span>
</div>
<div class="table-container">
<el-table
v-loading="loading"
class="custom-enterprise-table"
:data="withdrawals"
stripe
empty-text="暂无提现记录"
>
<el-table-column label="提现单号" min-width="190" fixed="left">
<template #default="{ row }">
<div class="withdraw-no-cell">
<span class="mono-code">{{ row.withdraw_no }}</span>
<small class="id-tag">#ID: {{ row.id }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="申请用户" min-width="160">
<template #default="{ row }">
<div class="user-cell">
<div class="user-name-line">
<el-icon class="user-icon"><User /></el-icon>
<span class="user-name">{{ row.user_nickname || '-' }}</span>
</div>
<small class="user-sub">{{ row.user_phone || `UID: ${row.user_id}` }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="提现金额" min-width="140" align="right">
<template #default="{ row }">
<div class="amount-stack">
<strong class="withdraw-amount">{{ money(row.amount_cent) }}</strong>
<small v-if="row.fee_cent" class="fee-text">扣费 {{ money(row.fee_cent) }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="收款账户信息" min-width="210">
<template #default="{ row }">
<div class="account-cell">
<div class="account-header">
<el-tag size="small" effect="plain" class="acc-tag">
{{ accountTypeLabel(row.account_type) }}
</el-tag>
<span class="acc-name">{{ row.account_name || '-' }}</span>
</div>
<small class="acc-no">{{ row.account_no }}</small>
<small v-if="row.bank_name" class="bank-name">{{ row.bank_name }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="110" align="center">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" class="status-pill">
{{ statusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="申请时间" min-width="165">
<template #default="{ row }">
<span class="time-cell">{{ formatDateTime(row.created_at) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="240" fixed="right" align="center">
<template #default="{ row }">
<div class="action-cell">
<el-button link type="primary" :icon="View" @click="openDetail(row)">
详情
</el-button>
<el-button link type="primary" @click="goToOwnerOrders(row)"> 号主订单 </el-button>
<el-button
v-if="row.status === 'pending'"
link
type="success"
@click="handleReview(row, true)"
>
通过
</el-button>
<el-button
v-if="row.status === 'pending'"
link
type="danger"
@click="handleReview(row, false)"
>
拒绝
</el-button>
<el-button
v-if="row.status === 'processing'"
link
type="primary"
@click="handleConfirmPayment(row)"
>
确认打款
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
<AdminTablePagination
v-if="total > 0"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
@page-change="loadWithdrawals"
/>
</section>
<!-- 详情对话框 -->
<WithdrawalDetailDialog
v-model="showDetailDialog"
:withdrawal="selectedWithdrawal"
:return-query="withdrawalReturnQuery"
@saved="onDetailDialogSaved"
/>
</div>
</template>
<style scoped>
.finance-page-container {
display: flex;
flex-direction: column;
gap: 20px;
padding: 0 4px 32px;
}
/* ================= 头部卡片 ================= */
.page-header-card {
background: #ffffff;
border-radius: 16px;
padding: 24px 28px;
box-shadow:
0 1px 3px rgba(15, 23, 42, 0.04),
0 4px 12px rgba(15, 23, 42, 0.02);
border: 1px solid rgba(226, 232, 240, 0.8);
}
.header-main {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.title-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.eyebrow-tag {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.8px;
color: #7c3aed;
background: #f5f3ff;
padding: 3px 10px;
border-radius: 20px;
width: fit-content;
}
.page-title {
margin: 0;
font-size: 26px;
font-weight: 800;
color: #0f172a;
letter-spacing: -0.5px;
}
.page-subtitle {
margin: 0;
font-size: 13.5px;
color: #64748b;
line-height: 1.5;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 10px;
}
/* ================= 核心指标卡片 ================= */
.summary-metric-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
}
.metric-card {
background: #ffffff;
border-radius: 16px;
padding: 18px 20px;
border: 1px solid rgba(226, 232, 240, 0.8);
box-shadow:
0 1px 3px rgba(15, 23, 42, 0.04),
0 4px 12px rgba(15, 23, 42, 0.02);
display: flex;
align-items: center;
gap: 16px;
position: relative;
overflow: hidden;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
.metric-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
}
.metric-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
}
.metric-card--review::before {
background: linear-gradient(90deg, #f59e0b, #fbbf24);
}
.metric-card--pay::before {
background: linear-gradient(90deg, #6366f1, #818cf8);
}
.metric-card--paid::before {
background: linear-gradient(90deg, #10b981, #34d399);
}
.metric-card--total::before {
background: linear-gradient(90deg, #64748b, #94a3b8);
}
.metric-icon-wrap {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
flex-shrink: 0;
}
.metric-icon-wrap.amber {
background: #fffbeb;
color: #f59e0b;
}
.metric-icon-wrap.indigo {
background: #eef2ff;
color: #6366f1;
}
.metric-icon-wrap.emerald {
background: #ecfdf5;
color: #10b981;
}
.metric-icon-wrap.slate {
background: #f1f5f9;
color: #64748b;
}
.metric-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.metric-label {
font-size: 12.5px;
font-weight: 500;
color: #64748b;
}
.metric-val {
font-size: 22px;
font-weight: 800;
color: #0f172a;
letter-spacing: -0.3px;
line-height: 1.2;
}
.amber-val {
color: #d97706;
}
.indigo-val {
color: #4f46e5;
}
.emerald-val {
color: #059669;
}
.metric-sub-pill {
display: inline-flex;
align-items: center;
font-size: 11px;
font-weight: 600;
padding: 1px 6px;
border-radius: 4px;
width: fit-content;
}
.metric-sub-pill.warning {
background: #fffbeb;
color: #d97706;
}
.metric-sub-pill.info {
background: #eef2ff;
color: #4f46e5;
}
.metric-sub-pill.success {
background: #f0fdf4;
color: #16a34a;
}
.metric-sub-text {
font-size: 11.5px;
color: #94a3b8;
}
/* ================= 筛选面板 ================= */
.section-card {
background: #ffffff;
border-radius: 16px;
padding: 22px 24px;
border: 1px solid rgba(226, 232, 240, 0.8);
box-shadow:
0 1px 3px rgba(15, 23, 42, 0.04),
0 4px 12px rgba(15, 23, 42, 0.02);
}
.filter-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 14px 16px;
}
.filter-grid :deep(.el-form-item) {
margin-bottom: 0;
}
.filter-grid :deep(.el-form-item__label) {
padding-bottom: 4px;
font-size: 12px;
font-weight: 600;
color: #64748b;
}
.full-control {
width: 100%;
}
/* ================= 表格卡片 ================= */
.table-section-card {
padding-bottom: 16px;
}
.table-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.table-header-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 700;
color: #1e293b;
}
.header-icon {
color: #7c3aed;
font-size: 18px;
}
.count-badge {
font-size: 12px;
font-weight: 600;
color: #7c3aed;
background: #f5f3ff;
padding: 3px 10px;
border-radius: 20px;
}
.table-container {
border-radius: 12px;
overflow: hidden;
border: 1px solid #e2e8f0;
}
.custom-enterprise-table :deep(.el-table__header-wrapper th) {
background-color: #f8fafc !important;
color: #475569;
font-size: 13px;
font-weight: 600;
padding: 12px 0;
border-bottom: 1px solid #e2e8f0;
}
.custom-enterprise-table :deep(.el-table__body-wrapper td) {
padding: 12px 0;
font-size: 13px;
color: #334155;
}
.withdraw-no-cell {
display: flex;
flex-direction: column;
gap: 2px;
}
.mono-code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12.5px;
font-weight: 600;
color: #1e293b;
}
.id-tag {
font-size: 11px;
color: #94a3b8;
}
.user-cell {
display: flex;
flex-direction: column;
gap: 2px;
}
.user-name-line {
display: flex;
align-items: center;
gap: 4px;
font-weight: 600;
color: #0f172a;
}
.user-icon {
color: #94a3b8;
font-size: 13px;
}
.user-sub {
font-size: 11.5px;
color: #64748b;
}
.amount-stack {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.withdraw-amount {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
.fee-text {
font-size: 11px;
color: #94a3b8;
}
.account-cell {
display: flex;
flex-direction: column;
gap: 2px;
}
.account-header {
display: flex;
align-items: center;
gap: 6px;
}
.acc-tag {
font-weight: 600;
}
.acc-name {
font-size: 12.5px;
font-weight: 500;
color: #1e293b;
}
.acc-no {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11.5px;
color: #64748b;
}
.bank-name {
font-size: 11px;
color: #94a3b8;
}
.status-pill {
font-weight: 600;
}
.time-cell {
font-size: 12px;
color: #475569;
}
.action-cell {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
flex-wrap: wrap;
}
</style>