Files
hfb_sys/frontend/src/features/admin/views/AdminPaymentsView.vue
T
yml2213 0d6a7786a4 feat(admin): 用户管理新增用户明细抽屉,聚合资金与交易全链路数据
- 用户管理操作列新增「明细」入口,抽屉按权限提供 5 个 Tab:钱包流水、订单记录、提现记录、邀请返利、支付记录
- 后端:后台订单列表新增 user_id 精确过滤(owner_id / renter_id),邀请关系与返利流水新增 inviter_id / invitee_id 过滤
- 修复:返利流水的「被邀请人手机号」筛选此前后端未实现,一直无效
- 资金流水页、支付记录页支持 ?user_id= 预填,可从明细直达
- 顺带将 invitation/dto.go、repository.go 中两处既有缩进错位按 gofmt 规整
2026-10-13 14:49:36 +08:00

787 lines
19 KiB
Vue

<script setup lang="ts">
import {
CreditCard,
DocumentChecked,
Money,
Refresh,
Search,
TopRight,
View,
Wallet,
} from '@element-plus/icons-vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { fetchAdminPayments, type AdminPayment } from '@/features/admin/api/adminPayments'
import { adminPath } from '@/shared/utils/adminPath'
import { formatCentWithSymbol } from '@/shared/utils/money'
import { formatDateTime } from '@/shared/utils/time'
import AdminTablePagination from '../components/AdminTablePagination.vue'
const loading = ref(false)
const payments = ref<AdminPayment[]>([])
const currentPage = ref(1)
const currentPageSize = ref(20)
const total = ref(0)
const detailVisible = ref(false)
const activePayment = ref<AdminPayment | null>(null)
const filters = reactive({
user_id: '',
order_id: '',
order_no: '',
biz_type: '',
status: '',
provider: '',
})
const payAmount = computed(() =>
payments.value
.filter(item => item.biz_type === 'order_pay' || item.biz_type === 'mohong_pay')
.reduce((sum, item) => sum + Number(item.amount_cent || 0), 0)
)
const refundAmount = computed(() =>
payments.value
.filter(item => item.biz_type.includes('refund'))
.reduce((sum, item) => sum + Number(item.amount_cent || 0), 0)
)
onMounted(() => {
// 支持从用户管理「明细」跳转预填用户,直接查看该用户的支付记录。
const routeUserID = useRoute().query.user_id
if (typeof routeUserID === 'string' && routeUserID.trim()) {
filters.user_id = routeUserID.trim()
}
void loadPayments()
})
async function loadPayments() {
loading.value = true
try {
const result = await fetchAdminPayments({
...filters,
page: currentPage.value,
page_size: currentPageSize.value,
})
payments.value = result.items
total.value = result.total
} catch {
payments.value = []
total.value = 0
} finally {
loading.value = false
}
}
function resetFilters() {
filters.user_id = ''
filters.order_id = ''
filters.order_no = ''
filters.biz_type = ''
filters.status = ''
filters.provider = ''
currentPage.value = 1
void loadPayments()
}
async function handlePageChange() {
await loadPayments()
}
function moneyCent(value: number | undefined | null) {
return formatCentWithSymbol(value || 0)
}
function paymentStatusType(status: string): 'success' | 'danger' | 'warning' | 'info' {
if (['paid', 'refunded'].includes(status)) return 'success'
if (status === 'failed') return 'danger'
if (['paying', 'refunding'].includes(status)) return 'warning'
return 'info'
}
function paymentStatusLabel(status: string) {
const map: Record<string, string> = {
created: '已创建',
paying: '支付中',
paid: '已支付',
refunding: '退款中',
refunded: '已退款',
failed: '失败',
closed: '已关闭',
}
return map[status] || status
}
function bizTypeLabel(type: string) {
const map: Record<string, string> = {
order_pay: '订单支付',
cancel_refund: '取消退款',
admin_close_refund: '客服关闭退款',
admin_refund: '人工退款',
checkout_refund: '结账退款',
deposit_refund: '押金退款',
rent_refund: '租金退款',
arbitration_refund: '仲裁退款',
mohong_pay: '撞车支付',
mohong_refund: '撞车退款',
}
return map[type] || type
}
function openDetail(row: AdminPayment) {
activePayment.value = row
detailVisible.value = true
}
function jsonText(value: unknown) {
if (!value) return '{}'
try {
return JSON.stringify(value, null, 2)
} catch {
return String(value)
}
}
</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>PAYMENT LEDGER</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="loadPayments">
查询
</el-button>
</div>
</div>
</header>
<!-- 汇总指标卡片 -->
<section class="summary-metric-grid">
<div class="metric-card metric-card--pay">
<div class="metric-icon-wrap emerald">
<el-icon><Money /></el-icon>
</div>
<div class="metric-info">
<span class="metric-label">本页支付成功额</span>
<strong class="metric-val emerald-val">{{ moneyCent(payAmount) }}</strong>
<span class="metric-sub-pill success">支付流水</span>
</div>
</div>
<div class="metric-card metric-card--refund">
<div class="metric-icon-wrap rose">
<el-icon><TopRight /></el-icon>
</div>
<div class="metric-info">
<span class="metric-label">本页退款发生额</span>
<strong class="metric-val rose-val">{{ moneyCent(refundAmount) }}</strong>
<span class="metric-sub-pill danger">退款流水</span>
</div>
</div>
<div class="metric-card metric-card--total">
<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">{{ 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="loadPayments">
<el-form-item label="用户 ID">
<el-input v-model="filters.user_id" clearable placeholder="按用户 ID 检索" />
</el-form-item>
<el-form-item label="订单 ID">
<el-input v-model="filters.order_id" clearable placeholder="按订单 ID 检索" />
</el-form-item>
<el-form-item label="订单号">
<el-input v-model="filters.order_no" clearable placeholder="按订单号检索" />
</el-form-item>
<el-form-item label="业务类型">
<el-select
v-model="filters.biz_type"
clearable
placeholder="全部业务类型"
class="full-control"
>
<el-option label="订单支付" value="order_pay" />
<el-option label="撞车支付" value="mohong_pay" />
<el-option label="结账退款" value="checkout_refund" />
<el-option label="仲裁退款" value="arbitration_refund" />
<el-option label="人工退款" value="admin_refund" />
<el-option label="取消退款" value="cancel_refund" />
<el-option label="撞车退款" value="mohong_refund" />
</el-select>
</el-form-item>
<el-form-item label="流水状态">
<el-select v-model="filters.status" clearable placeholder="全部状态" class="full-control">
<el-option label="已支付" value="paid" />
<el-option label="支付中" value="paying" />
<el-option label="退款中" value="refunding" />
<el-option label="已退款" value="refunded" />
<el-option label="失败" value="failed" />
</el-select>
</el-form-item>
<el-form-item label="支付渠道">
<el-select
v-model="filters.provider"
clearable
placeholder="全部渠道"
class="full-control"
>
<el-option label="拉卡拉" value="lakala" />
<el-option label="乐刷" value="leshua" />
<el-option label="顺成" value="shuncheng" />
<el-option label="Mock" value="mock" />
</el-select>
</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="payments"
stripe
empty-text="暂无支付流水记录"
>
<el-table-column label="支付流水号" min-width="210" fixed="left">
<template #default="{ row }">
<span class="mono-code">{{ row.payment_no }}</span>
</template>
</el-table-column>
<el-table-column label="渠道 / 方式" width="150">
<template #default="{ row }">
<div class="provider-cell">
<el-tag size="small" effect="plain" class="provider-tag">{{ row.provider }}</el-tag>
<small class="pay-way">{{ row.pay_way || '-' }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="业务类型" width="130">
<template #default="{ row }">
<span class="biz-type-text">{{ bizTypeLabel(row.biz_type) }}</span>
</template>
</el-table-column>
<el-table-column label="交易金额" min-width="130" align="right">
<template #default="{ row }">
<strong
class="amount-val"
:class="row.biz_type.includes('refund') ? 'refund-val' : 'pay-val'"
>
{{ moneyCent(row.amount_cent) }}
</strong>
</template>
</el-table-column>
<el-table-column label="状态" width="110" align="center">
<template #default="{ row }">
<el-tag :type="paymentStatusType(row.status)" class="status-pill">
{{ paymentStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="关联订单 / 用户" min-width="170">
<template #default="{ row }">
<div class="order-user-cell">
<RouterLink
v-if="row.order_id"
class="order-link"
:to="adminPath(`orders/${row.order_id}`)"
>
{{ row.order_no || `订单 #${row.order_id}` }}
</RouterLink>
<small class="user-id">用户 ID: {{ row.user_id }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="渠道交易单号" min-width="190">
<template #default="{ row }">
<span class="channel-no">{{ row.channel_trade_no || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="异常与失败信息" min-width="170" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.error_message" class="error-msg">{{ row.error_message }}</span>
<span v-else class="muted-text">-</span>
</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="90" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" :icon="View" @click="openDetail(row)">
报文
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<AdminTablePagination
v-if="total > 0"
v-model:current-page="currentPage"
v-model:page-size="currentPageSize"
:total="total"
@page-change="handlePageChange"
/>
</section>
<!-- 报文详情弹窗 -->
<el-dialog v-model="detailVisible" title="支付流水报文详情" width="680px" destroy-on-close>
<div v-if="activePayment" class="payload-details">
<div class="payload-block">
<div class="payload-title">请求报文 Payload</div>
<pre class="json-box"><code>{{ jsonText(activePayment.raw_request) }}</code></pre>
</div>
<div class="payload-block">
<div class="payload-title">响应报文 Payload</div>
<pre class="json-box"><code>{{ jsonText(activePayment.raw_response) }}</code></pre>
</div>
</div>
</el-dialog>
</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: #059669;
background: #ecfdf5;
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(240px, 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--pay::before {
background: linear-gradient(90deg, #10b981, #34d399);
}
.metric-card--refund::before {
background: linear-gradient(90deg, #f43f5e, #fb7185);
}
.metric-card--total::before {
background: linear-gradient(90deg, #6366f1, #818cf8);
}
.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.emerald {
background: #ecfdf5;
color: #10b981;
}
.metric-icon-wrap.rose {
background: #fff1f2;
color: #f43f5e;
}
.metric-icon-wrap.indigo {
background: #eef2ff;
color: #6366f1;
}
.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;
}
.emerald-val {
color: #059669;
}
.rose-val {
color: #e11d48;
}
.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.success {
background: #f0fdf4;
color: #16a34a;
}
.metric-sub-pill.danger {
background: #fff1f2;
color: #e11d48;
}
.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(180px, 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: #059669;
font-size: 18px;
}
.count-badge {
font-size: 12px;
font-weight: 600;
color: #059669;
background: #ecfdf5;
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;
}
.mono-code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12.5px;
font-weight: 600;
color: #1e293b;
}
.provider-cell {
display: flex;
flex-direction: column;
gap: 2px;
}
.provider-tag {
width: fit-content;
font-weight: 600;
}
.pay-way {
font-size: 11px;
color: #64748b;
}
.biz-type-text {
font-size: 12.5px;
color: #334155;
}
.amount-val {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13.5px;
font-weight: 700;
}
.pay-val {
color: #059669;
}
.refund-val {
color: #e11d48;
}
.status-pill {
font-weight: 600;
}
.order-user-cell {
display: flex;
flex-direction: column;
gap: 2px;
}
.order-link {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
font-weight: 600;
color: #3b82f6;
text-decoration: none;
}
.order-link:hover {
text-decoration: underline;
}
.user-id {
font-size: 11px;
color: #94a3b8;
}
.channel-no {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11.5px;
color: #64748b;
}
.error-msg {
font-size: 12px;
color: #ef4444;
}
.time-cell {
font-size: 12px;
color: #475569;
}
.muted-text {
color: #cbd5e1;
}
/* ================= 报文弹窗 ================= */
.payload-details {
display: flex;
flex-direction: column;
gap: 16px;
}
.payload-title {
font-size: 13px;
font-weight: 600;
color: #475569;
margin-bottom: 6px;
}
.json-box {
background: #0f172a;
color: #e2e8f0;
border-radius: 8px;
padding: 12px 14px;
font-size: 12px;
max-height: 240px;
overflow: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
</style>