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

862 lines
29 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,
CircleClose,
Clock,
Coin,
CreditCard,
Document,
Money,
Plus,
Refresh,
Search,
UploadFilled,
View,
Wallet,
WarningFilled,
} from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, onMounted, reactive, ref } from 'vue'
import {
createManualDisbursement,
fetchFinanceDisbursements,
voidManualDisbursement,
type FinanceDisbursementItem,
type FinanceDisbursementListSummary,
} from '@/features/admin/api/adminFinance'
import { fetchAdminWithdrawal, type WithdrawalDetail } from '@/features/admin/api/adminWithdrawal'
import AuthImage from '@/shared/components/business/AuthImage.vue'
import { uploadAdminFile } from '@/shared/api/files'
import { useAdminSessionStore } from '@/stores/adminSession'
import { adminPath } from '@/shared/utils/adminPath'
import { readError } from '@/shared/utils/error'
import { formatCentWithSymbol, yuanToCent } from '@/shared/utils/money'
import { formatDateTime, formatInputDate } from '@/shared/utils/time'
import AdminTablePagination from '../components/AdminTablePagination.vue'
import WithdrawalDetailDialog from '../components/WithdrawalDetailDialog.vue'
const adminSession = useAdminSessionStore()
const loading = ref(false)
const detailLoading = ref(false)
const items = ref<FinanceDisbursementItem[]>([])
const total = ref(0)
const currentPage = ref(1)
const currentPageSize = ref(20)
const showWithdrawalDetail = ref(false)
const selectedWithdrawal = ref<WithdrawalDetail | null>(null)
const createVisible = ref(false)
const createSaving = ref(false)
const voucherUploading = ref(false)
const manualActionID = ref(0)
const selectedManual = ref<FinanceDisbursementItem | null>(null)
const summary = ref<FinanceDisbursementListSummary>(emptySummary())
const canManageManual = computed(() => adminSession.hasPermission('finance:manual_disbursement'))
const manualCategories = [
{ value: 'user_compensation', label: '用户补偿' },
{ value: 'seller_supplement', label: '卖家补款' },
{ value: 'operating_expense', label: '运营支出' },
{ value: 'channel_fee', label: '渠道费用' },
{ value: 'duoduo_deposit_refund', label: '多多退押金' },
{ value: 'other', label: '其他' },
{ value: 'custom', label: '自定义分类' },
]
const createForm = reactive({
category: '',
custom_category_name: '',
payee_name: '',
amount_yuan: 0,
paid_at: new Date(),
remark: '',
voucher_url: '',
})
const filters = reactive({
date_type: 'created',
start_date: defaultStartDate(),
end_date: formatInputDate(new Date()),
source_type: '',
status: '',
business_no: '',
keyword: '',
user_id: '',
})
onMounted(loadItems)
async function loadItems() {
loading.value = true
try {
const result = await fetchFinanceDisbursements({
...filters,
page: currentPage.value,
page_size: currentPageSize.value,
})
items.value = result.items
total.value = result.total
summary.value = result.summary
} catch (error) {
items.value = []
total.value = 0
summary.value = emptySummary()
ElMessage.error(readError(error, '资金出款明细加载失败'))
} finally {
loading.value = false
}
}
function search() {
currentPage.value = 1
void loadItems()
}
function resetFilters() {
filters.date_type = 'created'
filters.start_date = defaultStartDate()
filters.end_date = formatInputDate(new Date())
filters.source_type = ''
filters.status = ''
filters.business_no = ''
filters.keyword = ''
filters.user_id = ''
currentPage.value = 1
void loadItems()
}
async function openWithdrawalDetail(row: FinanceDisbursementItem) {
if (!row.withdrawal_id || detailLoading.value) return
detailLoading.value = true
try {
selectedWithdrawal.value = await fetchAdminWithdrawal(row.withdrawal_id)
showWithdrawalDetail.value = true
} catch (error) {
ElMessage.error(readError(error, '提现详情加载失败'))
} finally {
detailLoading.value = false
}
}
function onWithdrawalSaved() {
showWithdrawalDetail.value = false
selectedWithdrawal.value = null
void loadItems()
}
function openCreate() {
resetCreateForm()
createVisible.value = true
}
function resetCreateForm() {
createForm.category = ''
createForm.custom_category_name = ''
createForm.payee_name = ''
createForm.amount_yuan = 0
createForm.paid_at = new Date()
createForm.remark = ''
createForm.voucher_url = ''
}
async function uploadVoucher(options: { file: File }) {
if (!options.file.type.startsWith('image/')) {
ElMessage.warning('付款凭证仅支持图片')
return
}
voucherUploading.value = true
try {
const uploaded = await uploadAdminFile(options.file, 'manual-disbursement')
createForm.voucher_url = uploaded.url
ElMessage.success('凭证已上传')
} catch (error) {
ElMessage.error(readError(error, '凭证上传失败'))
} finally {
voucherUploading.value = false
}
}
async function submitManualDisbursement() {
const amountCent = yuanToCent(createForm.amount_yuan)
if (
!createForm.category ||
!createForm.payee_name.trim() ||
amountCent <= 0 ||
!createForm.paid_at
) {
ElMessage.warning('请填写完整的出款信息')
return
}
if (!createForm.remark.trim()) {
ElMessage.warning('请填写出款用途')
return
}
if (createForm.category === 'custom' && !createForm.custom_category_name.trim()) {
ElMessage.warning('请填写自定义分类名称')
return
}
createSaving.value = true
try {
await createManualDisbursement({
category: createForm.category,
custom_category_name:
createForm.category === 'custom' ? createForm.custom_category_name.trim() : undefined,
payee_name: createForm.payee_name.trim(),
amount_cent: amountCent,
paid_at: createForm.paid_at.toISOString(),
remark: createForm.remark.trim(),
voucher_url: createForm.voucher_url || undefined,
})
createVisible.value = false
ElMessage.success('线下出款已记账')
await loadItems()
} catch (error) {
ElMessage.error(readError(error, '线下出款记账失败'))
} finally {
createSaving.value = false
}
}
function openManualDetail(row: FinanceDisbursementItem) {
selectedManual.value = row
}
async function handleVoidManual(row: FinanceDisbursementItem) {
if (manualActionID.value) return
try {
const { value } = await ElMessageBox.prompt(
`确认作废出款单 ${row.business_no}?作废后将从财务统计中排除。`,
'作废线下出款',
{
confirmButtonText: '确认作废',
cancelButtonText: '取消',
inputPlaceholder: '请输入作废原因',
inputValidator: value => !!value.trim() || '请输入作废原因',
type: 'warning',
}
)
manualActionID.value = row.source_id
await voidManualDisbursement(row.source_id, value.trim())
selectedManual.value = null
ElMessage.success('线下出款已作废')
await loadItems()
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(readError(error, '作废线下出款失败'))
} finally {
manualActionID.value = 0
}
}
function emptySummary(): FinanceDisbursementListSummary {
return {
record_count: 0,
paid_amount_cent: 0,
paid_count: 0,
payment_pending_amount_cent: 0,
payment_pending_count: 0,
review_pending_amount_cent: 0,
review_pending_count: 0,
withdrawal_fee_amount_cent: 0,
}
}
function defaultStartDate() {
const date = new Date()
date.setDate(date.getDate() - 29)
return formatInputDate(date)
}
function money(cents: number | undefined | null) {
return formatCentWithSymbol(cents || 0)
}
function sourceLabel(sourceType: string) {
const labels: Record<string, string> = {
platform_managed: '平台代管',
pickup: '线下提号',
pickup_adjustment: '提号调整',
withdrawal: '用户提现',
manual_offline: '线下出款',
}
return labels[sourceType] || sourceType
}
function sourceType(sourceType: string) {
const types: Record<string, 'primary' | 'success' | 'warning' | 'info' | 'danger'> = {
platform_managed: 'primary',
pickup: 'success',
pickup_adjustment: 'warning',
withdrawal: 'success',
manual_offline: 'warning',
}
return types[sourceType] || 'info'
}
function statusType(status: string) {
const types: Record<string, 'primary' | 'success' | 'warning' | 'info' | 'danger'> = {
review_pending: 'info',
payment_pending: 'warning',
paid: 'success',
rejected: 'danger',
cancelled: 'info',
voided: 'danger',
}
return types[status] || 'info'
}
function itemStatusLabel(row: FinanceDisbursementItem) {
if (row.source_type === 'withdrawal') {
const map: Record<string, string> = {
pending: '待审核',
processing: '待打款',
completed: '已打款',
rejected: '已拒绝',
cancelled: '已取消',
}
return map[row.raw_status] || row.status
}
if (row.source_type === 'platform_managed' || row.source_type === 'pickup') {
return row.raw_status === 'settled' ? '已打款' : '待打款'
}
if (row.source_type === 'pickup_adjustment') {
return row.raw_status === 'settled' ? '已打款' : '待打款'
}
return row.status === 'paid' ? '已记账' : '已作废'
}
function categoryLabel(category?: string, customCategoryName?: string) {
if (category === 'custom') {
return customCategoryName?.trim() || '自定义分类'
}
const item = manualCategories.find(c => c.value === category)
return item?.label || category || '其他'
}
function accountTypeLabel(type: string) {
const labels: Record<string, string> = {
alipay: '支付宝',
wechat: '微信',
bank: '银行卡',
}
return labels[type] || type || '线下约定'
}
</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><CreditCard /></el-icon>
<span>DISBURSEMENTS</span>
</div>
<h1 class="page-title">资金出款</h1>
<p class="page-subtitle">
统一查询平台代管出款、用户钱包提现及手工记账的线下出款全生命周期记录。
</p>
</div>
<div class="toolbar-actions">
<el-button
v-if="canManageManual"
type="primary"
:icon="Plus"
class="action-btn-primary"
@click="openCreate"
>
新增线下出款
</el-button>
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
<el-button type="primary" :icon="Search" :loading="loading" @click="search">
查询
</el-button>
</div>
</div>
</header>
<!-- 汇总核心指标卡片 -->
<section class="summary-metric-grid">
<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(summary.paid_amount_cent) }}</strong>
<span class="metric-sub-pill success">{{ summary.paid_count }} 笔已完成</span>
</div>
</div>
<div class="metric-card metric-card--pending">
<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">{{
money(summary.payment_pending_amount_cent)
}}</strong>
<span class="metric-sub-pill warning">{{ summary.payment_pending_count }} 笔待打款</span>
</div>
</div>
<div class="metric-card metric-card--review">
<div class="metric-icon-wrap indigo">
<el-icon><Document /></el-icon>
</div>
<div class="metric-info">
<span class="metric-label">提现待初审</span>
<strong class="metric-val indigo-val">{{
money(summary.review_pending_amount_cent)
}}</strong>
<span class="metric-sub-pill info">{{ summary.review_pending_count }} 笔待审核</span>
</div>
</div>
<div class="metric-card metric-card--fee">
<div class="metric-icon-wrap teal">
<el-icon><Coin /></el-icon>
</div>
<div class="metric-info">
<span class="metric-label">已核销提现手续费</span>
<strong class="metric-val teal-val">{{
money(summary.withdrawal_fee_amount_cent)
}}</strong>
<span class="metric-sub-text">随筛选区间统计</span>
</div>
</div>
<div class="metric-card metric-card--total">
<div class="metric-icon-wrap slate">
<el-icon><Wallet /></el-icon>
</div>
<div class="metric-info">
<span class="metric-label">当前筛选总计</span>
<strong class="metric-val">{{ summary.record_count }} 笔</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="search">
<el-form-item label="日期口径">
<el-select v-model="filters.date_type" class="full-control">
<el-option label="业务形成日期" value="created" />
<el-option label="实际打款日期" value="paid" />
</el-select>
</el-form-item>
<el-form-item label="开始日期">
<el-date-picker
v-model="filters.start_date"
class="full-control"
type="date"
value-format="YYYY-MM-DD"
:clearable="false"
/>
</el-form-item>
<el-form-item label="结束日期">
<el-date-picker
v-model="filters.end_date"
class="full-control"
type="date"
value-format="YYYY-MM-DD"
:clearable="false"
/>
</el-form-item>
<el-form-item label="出款类型">
<el-select
v-model="filters.source_type"
clearable
placeholder="全部类型"
class="full-control"
>
<el-option label="平台代管" value="platform_managed" />
<el-option label="线下提号" value="pickup" />
<el-option label="提号调整" value="pickup_adjustment" />
<el-option label="用户提现" value="withdrawal" />
<el-option label="其他线下出款" value="manual_offline" />
</el-select>
</el-form-item>
<el-form-item label="出款状态">
<el-select v-model="filters.status" clearable placeholder="全部状态" class="full-control">
<el-option label="待审核" value="review_pending" />
<el-option label="待打款" value="payment_pending" />
<el-option label="已打款" value="paid" />
<el-option label="已拒绝" value="rejected" />
<el-option label="已取消" value="cancelled" />
<el-option label="已作废" value="voided" />
</el-select>
</el-form-item>
<el-form-item label="业务单号">
<el-input
v-model="filters.business_no"
clearable
placeholder="订单号/提现单号/出款单号"
/>
</el-form-item>
<el-form-item label="收款人 / 关键词">
<el-input v-model="filters.keyword" clearable placeholder="姓名、手机号或账号" />
</el-form-item>
<el-form-item label="用户 ID">
<el-input v-model="filters.user_id" clearable placeholder="按用户 ID 检索" />
</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"><Money /></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="items"
stripe
empty-text="暂无出款记录"
>
<el-table-column label="业务类型 / 单号" min-width="210" fixed="left">
<template #default="{ row }">
<div class="business-cell">
<el-tag size="small" :type="sourceType(row.source_type)" class="source-tag">
{{ sourceLabel(row.source_type) }}
</el-tag>
<RouterLink
v-if="row.order_id"
class="business-no link-no"
:to="adminPath(`orders/${row.order_id}`)"
>
{{ row.business_no }}
</RouterLink>
<strong v-else class="business-no">{{ row.business_no }}</strong>
</div>
</template>
</el-table-column>
<el-table-column label="收款人 / 渠道" min-width="180">
<template #default="{ row }">
<div class="stack-cell">
<span class="payee-name">{{ row.payee_name || '-' }}</span>
<small v-if="row.payee_phone" class="phone-text">{{ row.payee_phone }}</small>
<small v-else-if="row.source_type === 'withdrawal'" class="muted-note">
用户 ID: {{ row.user_id }}
</small>
<small v-else-if="row.source_type === 'manual_offline'" class="muted-note">
{{ categoryLabel(row.category, row.custom_category_name) }}
</small>
<small v-if="row.uploader_name" class="channel-note"
>上传:{{ row.uploader_name }}</small
>
<small v-if="row.source_channel" class="channel-note"
>渠道:{{ row.source_channel }}</small
>
</div>
</template>
</el-table-column>
<el-table-column label="出款账户信息" min-width="210">
<template #default="{ row }">
<div v-if="row.source_type === 'withdrawal'" class="stack-cell">
<span class="account-line">
<span class="account-type-badge">{{ accountTypeLabel(row.account_type) }}</span>
{{ row.account_name || '-' }}
</span>
<small class="account-num">{{ row.account_no || '-' }}</small>
<small v-if="row.bank_name" class="bank-name">{{ row.bank_name }}</small>
</div>
<div v-else-if="row.source_type === 'manual_offline'" class="stack-cell">
<el-tag size="small" effect="plain" class="category-tag">
{{ categoryLabel(row.category, row.custom_category_name) }}
</el-tag>
<small :class="row.voucher_url ? 'voucher-uploaded' : 'voucher-missing'">
{{ row.voucher_url ? '✓ 已上传付款凭证' : '未附凭证' }}
</small>
</div>
<span v-else class="muted-text">平台代管线下结算</span>
</template>
</el-table-column>
<el-table-column label="实际出款金额" min-width="160" align="right">
<template #default="{ row }">
<div class="amount-stack">
<strong class="actual-amount">{{ money(row.actual_amount_cent) }}</strong>
<small v-if="row.source_type === 'withdrawal'" class="fee-detail">
提现 {{ money(row.amount_cent) }} (扣费 {{ money(row.fee_cent) }})
</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">
{{ itemStatusLabel(row) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="业务形成时间" min-width="165">
<template #default="{ row }">
<span class="time-cell">{{ formatDateTime(row.business_created_at) }}</span>
</template>
</el-table-column>
<el-table-column label="实际打款时间" min-width="165">
<template #default="{ row }">
<span class="time-cell" :class="{ 'empty-time': !row.paid_at }">
{{ formatDateTime(row.paid_at) }}
</span>
</template>
</el-table-column>
<el-table-column label="操作人" min-width="110">
<template #default="{ row }">
<span class="operator-name">{{ row.operator_name || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="备注" min-width="160" show-overflow-tooltip>
<template #default="{ row }">
<span class="remark-text">{{ row.remark || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right" align="center">
<template #default="{ row }">
<div class="action-cell">
<el-button
v-if="row.withdrawal_id && adminSession.hasPermission('withdrawal:detail')"
link
type="primary"
:icon="View"
@click="openWithdrawalDetail(row)"
>
详情
</el-button>
<el-button
v-if="row.source_type === 'manual_offline'"
link
type="primary"
:icon="View"
@click="openManualDetail(row)"
>
查看
</el-button>
<el-button
v-if="
row.source_type === 'manual_offline' && row.status === 'paid' && canManageManual
"
link
type="danger"
:icon="CircleClose"
:loading="manualActionID === row.source_id"
@click="handleVoidManual(row)"
>
作废
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
<AdminTablePagination
v-model:current-page="currentPage"
v-model:page-size="currentPageSize"
:total="total"
@page-change="loadItems"
/>
</section>
<!-- 新增线下出款抽屉 / 弹窗 -->
<el-dialog
v-model="createVisible"
title="新增线下出款记账"
width="560px"
destroy-on-close
class="custom-modal"
>
<el-form label-position="top" class="modal-form">
<el-form-item label="出款分类" required>
<el-select
v-model="createForm.category"
placeholder="请选择出款类别"
class="full-control"
>
<el-option
v-for="item in manualCategories"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item v-if="createForm.category === 'custom'" label="自定义分类名称" required>
<el-input
v-model="createForm.custom_category_name"
placeholder="请输入分类名称(不超过 50 字)"
maxlength="50"
show-word-limit
/>
</el-form-item>
<el-form-item label="收款人姓名 / 单位" required>
<el-input v-model="createForm.payee_name" placeholder="请输入实际收款方姓名或单位" />
</el-form-item>
<el-form-item label="出款金额(元)" required>
<el-input-number
v-model="createForm.amount_yuan"
:min="0.01"
:precision="2"
:step="10"
class="full-control"
/>
</el-form-item>
<el-form-item label="打款时间" required>
<el-date-picker
v-model="createForm.paid_at"
type="datetime"
placeholder="选择打款时间"
class="full-control"
/>
</el-form-item>
<el-form-item label="出款用途 / 详细备注" required>
<el-input
v-model="createForm.remark"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="详细记录出款原因与对账依据"
/>
</el-form-item>
<el-form-item label="打款凭证截图(可选)">
<div class="voucher-upload-box">
<el-upload
action="#"
:http-request="uploadVoucher"
:show-file-list="false"
accept="image/*"
>
<el-button :loading="voucherUploading" :icon="UploadFilled">
{{ createForm.voucher_url ? '重新上传凭证' : '上传支付凭证' }}
</el-button>
</el-upload>
<div v-if="createForm.voucher_url" class="voucher-preview-wrap">
<AuthImage
:source="createForm.voucher_url"
alt="凭证预览"
class="voucher-img-preview"
/>
</div>
</div>
</el-form-item>
</el-form>
<template #footer>
<div class="modal-footer">
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="createSaving" @click="submitManualDisbursement">
确认记账
</el-button>
</div>
</template>
</el-dialog>
<!-- 线下出款详情弹窗 -->
<el-dialog
:model-value="!!selectedManual"
title="线下出款详情"
width="540px"
@update:model-value="(val: boolean) => (!val ? (selectedManual = null) : null)"
>
<div v-if="selectedManual" class="detail-content-wrap">
<div class="detail-row">
<span class="detail-label">出款单号</span>
<span class="detail-val font-mono">{{ selectedManual.business_no }}</span>
</div>
<div class="detail-row">
<span class="detail-label">分类</span>
<span class="detail-val">{{
categoryLabel(selectedManual.category, selectedManual.custom_category_name)
}}</span>
</div>
<div class="detail-row">
<span class="detail-label">收款人</span>
<span class="detail-val">{{ selectedManual.payee_name }}</span>
</div>
<div class="detail-row">
<span class="detail-label">出款金额</span>
<span class="detail-val text-primary font-bold text-lg">
{{ money(selectedManual.amount_cent) }}
</span>
</div>
<div class="detail-row">
<span class="detail-label">状态</span>
<el-tag :type="statusType(selectedManual.status)">
{{ itemStatusLabel(selectedManual) }}
</el-tag>
</div>
<div class="detail-row">
<span class="detail-label">打款时间</span>
<span class="detail-val">{{ formatDateTime(selectedManual.paid_at) }}</span>
</div>
<div class="detail-row">
<span class="detail-label">录入人</span>
<span class="detail-val">{{ selectedManual.operator_name || '-' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">用途备注</span>
<span class="detail-val">{{ selectedManual.remark || '-' }}</span>
</div>
<div v-if="selectedManual.voucher_url" class="detail-row column-row">
<span class="detail-label">打款凭证</span>
<div class="detail-img-container">
<AuthImage :source="selectedManual.voucher_url" alt="凭证" class="detail-voucher-img" />
</div>
</div>
<div v-if="selectedManual.status === 'voided'" class="void-info-box">
<div class="void-title">
<el-icon><WarningFilled /></el-icon>
<span>作废信息</span>
</div>
<p>作废时间:{{ formatDateTime(selectedManual.voided_at) }}</p>
<p>作废人:{{ selectedManual.voided_by_name || '-' }}</p>
<p>作废原因:{{ selectedManual.void_reason || '-' }}</p>
</div>
</div>
</el-dialog>
<WithdrawalDetailDialog
v-model="showWithdrawalDetail"
:withdrawal="selectedWithdrawal"
@saved="onWithdrawalSaved"
/>
</div>
</template>
<style scoped src="./AdminFinanceDisbursementsView.css"></style>