633 lines
15 KiB
Vue
633 lines
15 KiB
Vue
<script setup lang="ts">
|
|
import {
|
|
BottomLeft,
|
|
Coin,
|
|
DocumentChecked,
|
|
Refresh,
|
|
Search,
|
|
TopRight,
|
|
Wallet,
|
|
} from '@element-plus/icons-vue'
|
|
import { computed, onMounted, reactive, ref } from 'vue'
|
|
|
|
import { fetchAdminWalletLedger, type AdminWalletLedger } from '@/features/admin/api/adminWallet'
|
|
import { adminPath } from '@/shared/utils/adminPath'
|
|
import { formatCentWithSymbol } from '@/shared/utils/money'
|
|
import { balanceTypeLabel, ledgerDirectionLabel } from '@/shared/utils/statusLabels'
|
|
import { formatDateTime } from '@/shared/utils/time'
|
|
import AdminTablePagination from '../components/AdminTablePagination.vue'
|
|
|
|
const loading = ref(false)
|
|
const ledger = ref<AdminWalletLedger[]>([])
|
|
const currentPage = ref(1)
|
|
const currentPageSize = ref(20)
|
|
const total = ref(0)
|
|
const filters = reactive({
|
|
user_id: '',
|
|
order_id: '',
|
|
biz_type: '',
|
|
})
|
|
|
|
const inAmount = computed(() =>
|
|
ledger.value
|
|
.filter(item => item.direction === 'in')
|
|
.reduce((sum, item) => sum + Number(item.amount_cent || 0), 0)
|
|
)
|
|
const outAmount = computed(() =>
|
|
ledger.value
|
|
.filter(item => item.direction === 'out')
|
|
.reduce((sum, item) => sum + Number(item.amount_cent || 0), 0)
|
|
)
|
|
|
|
onMounted(loadLedger)
|
|
|
|
async function loadLedger() {
|
|
loading.value = true
|
|
try {
|
|
const query = {
|
|
...filters,
|
|
page: currentPage.value,
|
|
page_size: currentPageSize.value,
|
|
}
|
|
const result = await fetchAdminWalletLedger(query)
|
|
const rows = Array.isArray(result.items) ? result.items : []
|
|
ledger.value = rows
|
|
total.value = Number(result.total ?? rows.length)
|
|
} catch {
|
|
ledger.value = []
|
|
total.value = 0
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function resetFilters() {
|
|
filters.user_id = ''
|
|
filters.order_id = ''
|
|
filters.biz_type = ''
|
|
currentPage.value = 1
|
|
void loadLedger()
|
|
}
|
|
|
|
async function handlePageChange() {
|
|
await loadLedger()
|
|
}
|
|
|
|
function money(value: number | undefined | null) {
|
|
return formatCentWithSymbol(value || 0)
|
|
}
|
|
|
|
function directionType(direction: string): 'success' | 'danger' | 'warning' | 'info' {
|
|
if (direction === 'in') return 'success'
|
|
if (direction === 'out') return 'danger'
|
|
return 'warning'
|
|
}
|
|
|
|
function directionLabel(direction: string) {
|
|
return ledgerDirectionLabel(direction)
|
|
}
|
|
</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><Wallet /></el-icon>
|
|
<span>WALLET 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="loadLedger">
|
|
查询
|
|
</el-button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- 汇总指标卡片 -->
|
|
<section class="summary-metric-grid">
|
|
<div class="metric-card metric-card--in">
|
|
<div class="metric-icon-wrap emerald">
|
|
<el-icon><BottomLeft /></el-icon>
|
|
</div>
|
|
<div class="metric-info">
|
|
<span class="metric-label">本页入账合计</span>
|
|
<strong class="metric-val emerald-val">{{ money(inAmount) }}</strong>
|
|
<span class="metric-sub-pill success">资金流入</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="metric-card metric-card--out">
|
|
<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">{{ money(outAmount) }}</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><Coin /></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="loadLedger">
|
|
<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-select
|
|
v-model="filters.biz_type"
|
|
clearable
|
|
placeholder="全部业务类型"
|
|
class="full-control"
|
|
>
|
|
<el-option label="订单冻结" value="order_freeze" />
|
|
<el-option label="订单取消释放" value="order_cancel_release" />
|
|
<el-option label="订单结算" value="order_settlement" />
|
|
<el-option label="押金退回" value="deposit_refund" />
|
|
</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="ledger"
|
|
stripe
|
|
empty-text="暂无资金流水记录"
|
|
>
|
|
<el-table-column label="流水号" min-width="210" fixed="left">
|
|
<template #default="{ row }">
|
|
<span class="mono-code">{{ row.ledger_no }}</span>
|
|
</template>
|
|
</el-table-column>
|
|
|
|
<el-table-column label="关联用户" min-width="150">
|
|
<template #default="{ row }">
|
|
<div class="user-cell">
|
|
<span class="user-title">{{ row.user_phone || `用户 ID: ${row.user_id}` }}</span>
|
|
<small v-if="row.user_phone" class="user-sub">UID: {{ row.user_id }}</small>
|
|
</div>
|
|
</template>
|
|
</el-table-column>
|
|
|
|
<el-table-column label="关联订单" min-width="160">
|
|
<template #default="{ row }">
|
|
<RouterLink
|
|
v-if="row.order_id"
|
|
class="order-link"
|
|
:to="adminPath(`orders/${row.order_id}`)"
|
|
>
|
|
{{ row.order_no || `订单 #${row.order_id}` }}
|
|
</RouterLink>
|
|
<span v-else class="muted-text">-</span>
|
|
</template>
|
|
</el-table-column>
|
|
|
|
<el-table-column label="业务类型" width="140">
|
|
<template #default="{ row }">
|
|
<el-tag size="small" effect="plain" class="biz-tag">{{ row.biz_type }}</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
|
|
<el-table-column label="资金流向" width="110" align="center">
|
|
<template #default="{ row }">
|
|
<el-tag :type="directionType(row.direction)" class="direction-tag">
|
|
{{ directionLabel(row.direction) }}
|
|
</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
|
|
<el-table-column label="变动金额" min-width="130" align="right">
|
|
<template #default="{ row }">
|
|
<span class="amount-val" :class="row.direction === 'in' ? 'in-val' : 'out-val'">
|
|
{{ row.direction === 'in' ? '+' : '-' }}{{ money(Number(row.amount_cent)) }}
|
|
</span>
|
|
</template>
|
|
</el-table-column>
|
|
|
|
<el-table-column label="余额账户" width="120">
|
|
<template #default="{ row }">
|
|
<span class="balance-type">{{ balanceTypeLabel(row.balance_type) }}</span>
|
|
</template>
|
|
</el-table-column>
|
|
|
|
<el-table-column label="变动后余额" min-width="130" align="right">
|
|
<template #default="{ row }">
|
|
<span class="amount-val">{{ money(Number(row.balance_after_cent)) }}</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>
|
|
</div>
|
|
|
|
<AdminTablePagination
|
|
v-if="total > 0"
|
|
v-model:current-page="currentPage"
|
|
v-model:page-size="currentPageSize"
|
|
:total="total"
|
|
@page-change="handlePageChange"
|
|
/>
|
|
</section>
|
|
</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: #0284c7;
|
|
background: #f0f9ff;
|
|
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--in::before {
|
|
background: linear-gradient(90deg, #10b981, #34d399);
|
|
}
|
|
|
|
.metric-card--out::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(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: #0284c7;
|
|
font-size: 18px;
|
|
}
|
|
|
|
.count-badge {
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
color: #0284c7;
|
|
background: #f0f9ff;
|
|
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;
|
|
}
|
|
|
|
.user-cell {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
}
|
|
|
|
.user-title {
|
|
font-weight: 600;
|
|
color: #0f172a;
|
|
}
|
|
|
|
.user-sub {
|
|
font-size: 11px;
|
|
color: #94a3b8;
|
|
}
|
|
|
|
.order-link {
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
font-size: 12.5px;
|
|
font-weight: 600;
|
|
color: #3b82f6;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.order-link:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.biz-tag {
|
|
font-weight: 500;
|
|
}
|
|
|
|
.direction-tag {
|
|
font-weight: 600;
|
|
}
|
|
|
|
.amount-val {
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
font-size: 13.5px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.in-val {
|
|
color: #059669;
|
|
}
|
|
|
|
.out-val {
|
|
color: #e11d48;
|
|
}
|
|
|
|
.balance-type {
|
|
font-size: 12.5px;
|
|
color: #475569;
|
|
}
|
|
|
|
.time-cell {
|
|
font-size: 12px;
|
|
color: #475569;
|
|
}
|
|
|
|
.muted-text {
|
|
color: #cbd5e1;
|
|
}
|
|
</style>
|