优化财务管理整个ui

This commit is contained in:
yml2213
2026-08-20 16:32:57 +08:00
parent 6e4689edc4
commit 47ba202517
7 changed files with 4387 additions and 1569 deletions
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { Search } from '@element-plus/icons-vue' import { Document, DocumentChecked, Refresh, Search, View } from '@element-plus/icons-vue'
import { onMounted, reactive, ref } from 'vue' import { onMounted, reactive, ref } from 'vue'
import { import {
@@ -18,6 +18,7 @@ const details = ref<FinanceDetail[]>([])
const currentPage = ref(1) const currentPage = ref(1)
const currentPageSize = ref(20) const currentPageSize = ref(20)
const total = ref(0) const total = ref(0)
const filters = reactive({ const filters = reactive({
start_date: defaultStartDate(), start_date: defaultStartDate(),
end_date: defaultEndDate(), end_date: defaultEndDate(),
@@ -65,8 +66,8 @@ async function handlePageChange() {
await loadDetails() await loadDetails()
} }
function moneyCent(value: number) { function moneyCent(value: number | undefined | null) {
return formatCentWithSymbol(value) return formatCentWithSymbol(value || 0)
} }
function financeStatusLabel(status: string) { function financeStatusLabel(status: string) {
@@ -80,7 +81,7 @@ function financeStatusLabel(status: string) {
return map[status] || status return map[status] || status
} }
function financeStatusType(status: string) { function financeStatusType(status: string): 'success' | 'danger' | 'warning' | 'info' {
if (status === 'normal') return 'success' if (status === 'normal') return 'success'
if (status === 'settlement_diff' || status === 'refund_failed') return 'danger' if (status === 'settlement_diff' || status === 'refund_failed') return 'danger'
if (status === 'refund_pending' || status === 'offline_settlement_pending') return 'warning' if (status === 'refund_pending' || status === 'offline_settlement_pending') return 'warning'
@@ -113,12 +114,6 @@ function offlineSettlementStatusLabel(status: string) {
return map[status] || status || '-' return map[status] || status || '-'
} }
function shortDateTime(value?: string) {
if (!value) return '-'
const text = formatDateTime(value)
return text.replace(/^\d{4}-/, '')
}
function defaultStartDate() { function defaultStartDate() {
const date = new Date() const date = new Date()
date.setDate(date.getDate() - 29) date.setDate(date.getDate() - 29)
@@ -131,22 +126,33 @@ function defaultEndDate() {
</script> </script>
<template> <template>
<section class="page finance-details-page"> <div class="finance-page-container">
<div class="page-header-row"> <!-- 页面头部卡片 -->
<div class="page-header"> <header class="page-header-card">
<p class="eyebrow">Finance Details</p> <div class="header-main">
<h1>财务明细</h1> <div class="title-group">
<p>按订单核对收款退款平台收入号主应得与钱包入账/线下结算</p> <div class="eyebrow-tag">
<el-icon><Document /></el-icon>
<span>FINANCE DETAILS</span>
</div> </div>
<h1 class="page-title">财务明细</h1>
<p class="page-subtitle">
按订单维度精细核对收款退款平台利润扣费号主应得与实际入账差异
</p>
</div>
<div class="toolbar-actions"> <div class="toolbar-actions">
<el-button @click="resetFilters">重置</el-button> <el-button :icon="Refresh" @click="resetFilters">重置</el-button>
<el-button type="primary" :icon="Search" :loading="loading" @click="loadDetails"> <el-button type="primary" :icon="Search" :loading="loading" @click="loadDetails">
查询 查询
</el-button> </el-button>
</div> </div>
</div> </div>
</header>
<el-form class="filter-panel finance-filter-panel" label-position="top"> <!-- 筛选面板卡片 -->
<section class="section-card filter-card">
<el-form class="filter-grid" label-position="top" @keyup.enter="loadDetails">
<el-form-item label="日期类型"> <el-form-item label="日期类型">
<el-select v-model="filters.date_type" class="full-control"> <el-select v-model="filters.date_type" class="full-control">
<el-option label="结算日期" value="settled" /> <el-option label="结算日期" value="settled" />
@@ -154,28 +160,35 @@ function defaultEndDate() {
<el-option label="下单日期" value="created" /> <el-option label="下单日期" value="created" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="开始日期"> <el-form-item label="开始日期">
<el-date-picker <el-date-picker
v-model="filters.start_date" v-model="filters.start_date"
class="full-control" class="full-control"
type="date" type="date"
value-format="YYYY-MM-DD" value-format="YYYY-MM-DD"
:clearable="false"
/> />
</el-form-item> </el-form-item>
<el-form-item label="结束日期"> <el-form-item label="结束日期">
<el-date-picker <el-date-picker
v-model="filters.end_date" v-model="filters.end_date"
class="full-control" class="full-control"
type="date" type="date"
value-format="YYYY-MM-DD" value-format="YYYY-MM-DD"
:clearable="false"
/> />
</el-form-item> </el-form-item>
<el-form-item label="订单号"> <el-form-item label="订单号">
<el-input v-model="filters.order_no" clearable placeholder="按订单号筛选" /> <el-input v-model="filters.order_no" clearable placeholder="按订单号检索" />
</el-form-item> </el-form-item>
<el-form-item label="用户 ID"> <el-form-item label="用户 ID">
<el-input v-model="filters.user_id" clearable placeholder="租客或号主 ID" /> <el-input v-model="filters.user_id" clearable placeholder="租客或号主 ID" />
</el-form-item> </el-form-item>
<el-form-item label="订单状态"> <el-form-item label="订单状态">
<el-select <el-select
v-model="filters.order_status" v-model="filters.order_status"
@@ -190,6 +203,7 @@ function defaultEndDate() {
<el-option label="已关闭" value="closed" /> <el-option label="已关闭" value="closed" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="结算状态"> <el-form-item label="结算状态">
<el-select <el-select
v-model="filters.settlement_status" v-model="filters.settlement_status"
@@ -201,6 +215,7 @@ function defaultEndDate() {
<el-option label="已结算" value="settled" /> <el-option label="已结算" value="settled" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="结算模式"> <el-form-item label="结算模式">
<el-select <el-select
v-model="filters.settlement_mode" v-model="filters.settlement_mode"
@@ -212,6 +227,7 @@ function defaultEndDate() {
<el-option label="平台代管" value="platform_managed" /> <el-option label="平台代管" value="platform_managed" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="线下状态"> <el-form-item label="线下状态">
<el-select <el-select
v-model="filters.offline_settlement_status" v-model="filters.offline_settlement_status"
@@ -225,223 +241,485 @@ function defaultEndDate() {
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-form> </el-form>
</section>
<div v-loading="loading" class="table-panel finance-details-table"> <!-- 财务明细数据表格 -->
<div class="finance-grid-header"> <section class="section-card table-section-card">
<div>订单</div> <div class="table-card-header">
<div>状态</div> <div class="table-header-title">
<div>租客/号主</div> <el-icon class="header-icon"><DocumentChecked /></el-icon>
<div class="amount-cell">收款</div> <span>订单财务流水台账</span>
<div class="amount-cell">已退</div> </div>
<div class="amount-cell">退中</div> <span class="count-badge"> {{ total }} 条订单明细</span>
<div class="amount-cell">净入</div>
<div class="amount-cell">平台</div>
<div class="amount-cell">应得</div>
<div class="amount-cell">入账/线下</div>
<div class="amount-cell">差异</div>
</div> </div>
<div v-if="details.length > 0" class="finance-grid-body"> <div class="table-container">
<div v-for="row in details" :key="row.order_id" class="finance-grid-row"> <el-table
v-loading="loading"
class="custom-enterprise-table"
:data="details"
stripe
empty-text="暂无财务明细记录"
>
<!-- 订单标识 -->
<el-table-column label="订单信息" min-width="190" fixed="left">
<template #default="{ row }">
<div class="order-cell"> <div class="order-cell">
<RouterLink class="order-no" :to="adminPath(`orders/${row.order_id}`)"> <RouterLink class="order-no-link" :to="adminPath(`orders/${row.order_id}`)">
{{ row.order_no }} {{ row.order_no }}
</RouterLink> </RouterLink>
<span class="cell-muted">结算 {{ shortDateTime(row.settled_at) }}</span> <small class="time-sub">结算 {{ formatDateTime(row.settled_at) || '-' }}</small>
</div> </div>
</template>
</el-table-column>
<!-- 状态复合列 -->
<el-table-column label="订单与结算状态" min-width="170">
<template #default="{ row }">
<div class="status-stack"> <div class="status-stack">
<span>{{ orderStatusLabel(row.order_status) }}</span> <div class="status-row">
<small <span class="order-status-badge">{{ orderStatusLabel(row.order_status) }}</span>
>{{ settlementStatusLabel(row.settlement_status) }} · <el-tag size="small" :type="financeStatusType(row.finance_status)" effect="plain">
{{ settlementModeLabel(row) }}</small
>
<small v-if="isPlatformManaged(row)">
{{ offlineSettlementStatusLabel(row.offline_settlement_status) }}
</small>
<el-tag size="small" :type="financeStatusType(row.finance_status)">
{{ financeStatusLabel(row.finance_status) }} {{ financeStatusLabel(row.finance_status) }}
</el-tag> </el-tag>
</div> </div>
<small class="settle-desc">
<div class="party-cell"> {{ settlementStatusLabel(row.settlement_status) }} ·
<span> {{ row.renter_phone || row.renter_nickname || row.renter_id }}</span> {{ settlementModeLabel(row) }}
<span> {{ row.owner_phone || row.owner_nickname || row.owner_id }}</span> </small>
<small v-if="isPlatformManaged(row)" class="offline-desc">
{{ offlineSettlementStatusLabel(row.offline_settlement_status) }}
</small>
</div> </div>
</template>
</el-table-column>
<span class="amount-text amount-cell">{{ moneyCent(row.paid_amount_cent) }}</span> <!-- 交易双方 -->
<span class="amount-text amount-cell muted-amount"> <el-table-column label="交易双方" min-width="160">
{{ moneyCent(row.refunded_amount_cent) }} <template #default="{ row }">
<div class="parties-cell">
<div class="party-line">
<span class="role-badge renter"></span>
<span class="party-info">{{
row.renter_phone || row.renter_nickname || `UID ${row.renter_id}`
}}</span>
</div>
<div class="party-line">
<span class="role-badge owner"></span>
<span class="party-info">{{
row.owner_phone || row.owner_nickname || `UID ${row.owner_id}`
}}</span>
</div>
</div>
</template>
</el-table-column>
<!-- 收款 -->
<el-table-column label="收款金额" min-width="120" align="right">
<template #default="{ row }">
<span class="amount-val flow-amount">{{ moneyCent(row.paid_amount_cent) }}</span>
</template>
</el-table-column>
<!-- 退款情况 -->
<el-table-column label="已退 / 退中" min-width="130" align="right">
<template #default="{ row }">
<div class="refund-stack">
<span v-if="row.refunded_amount_cent > 0" class="refund-text">
退 {{ moneyCent(row.refunded_amount_cent) }}
</span> </span>
<span class="amount-text amount-cell muted-amount"> <span v-else class="zero-text">0.0</span>
{{ moneyCent(row.refunding_amount_cent) }}
<span v-if="row.refunding_amount_cent > 0" class="refunding-text">
{{ moneyCent(row.refunding_amount_cent) }}
</span> </span>
<span class="amount-text amount-cell">{{ moneyCent(row.channel_net_amount_cent) }}</span> </div>
<span class="amount-text amount-cell">{{ </template>
moneyCent(row.checkout_platform_fee_cent) </el-table-column>
<!-- 渠道净流入 -->
<el-table-column label="渠道净流入" min-width="125" align="right">
<template #default="{ row }">
<span class="amount-val net-amount">{{
moneyCent(row.channel_net_amount_cent)
}}</span> }}</span>
<span class="amount-text amount-cell">{{ </template>
moneyCent(row.checkout_owner_income_cent) </el-table-column>
}}</span>
<span class="amount-text amount-cell">{{ <!-- 平台利润 -->
moneyCent(row.owner_effective_settlement_amount_cent) <el-table-column label="平台收益" min-width="125" align="right">
}}</span> <template #default="{ row }">
<span <strong class="amount-val profit-amount">
class="amount-text amount-cell" {{ moneyCent(row.checkout_platform_fee_cent) }}
:class="{ </strong>
'amount-danger': </template>
Math.abs(row.settlement_diff_amount_cent) >= SETTLEMENT_DIFF_THRESHOLD_CENT, </el-table-column>
}"
<!-- 号主应得 -->
<el-table-column label="号主应得" min-width="125" align="right">
<template #default="{ row }">
<span class="amount-val">{{ moneyCent(row.checkout_owner_income_cent) }}</span>
</template>
</el-table-column>
<!-- 实际入账 / 线下 -->
<el-table-column label="实际结算" min-width="130" align="right">
<template #default="{ row }">
<span class="amount-val success-val">
{{ moneyCent(row.owner_effective_settlement_amount_cent) }}
</span>
</template>
</el-table-column>
<!-- 结算差异 -->
<el-table-column label="结算差异" min-width="120" align="right">
<template #default="{ row }">
<el-tag
v-if="Math.abs(row.settlement_diff_amount_cent) >= SETTLEMENT_DIFF_THRESHOLD_CENT"
type="danger"
size="small"
class="diff-tag"
> >
{{ moneyCent(row.settlement_diff_amount_cent) }} {{ moneyCent(row.settlement_diff_amount_cent) }}
</el-tag>
<span v-else class="amount-val zero-diff">
{{ moneyCent(row.settlement_diff_amount_cent) }}
</span> </span>
</div> </template>
</div> </el-table-column>
<el-empty v-else :image-size="80" description="暂无财务明细" /> <!-- 操作 -->
<el-table-column label="操作" width="90" fixed="right" align="center">
<template #default="{ row }">
<RouterLink :to="adminPath(`orders/${row.order_id}`)" class="action-link">
<el-icon><View /></el-icon>
<span>查看</span>
</RouterLink>
</template>
</el-table-column>
</el-table>
</div> </div>
<AdminTablePagination <AdminTablePagination
v-if="total > 0"
v-model:current-page="currentPage" v-model:current-page="currentPage"
v-model:page-size="currentPageSize" v-model:page-size="currentPageSize"
:total="total" :total="total"
:loading="loading" @change="handlePageChange"
@page-change="handlePageChange"
/> />
</section> </section>
</div>
</template> </template>
<style scoped> <style scoped>
.finance-details-page { .finance-page-container {
--finance-muted: #7f8aa3; display: flex;
flex-direction: column;
gap: 20px;
padding: 0 4px 32px;
} }
.finance-details-page :deep(.page-header-row) { /* ================= 头部卡片 ================= */
margin-bottom: 18px; .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);
} }
.finance-details-page :deep(.page-header h1) { .header-main {
font-size: 28px; display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
} }
.finance-filter-panel { .title-group {
grid-template-columns: repeat(9, minmax(100px, 1fr)); display: flex;
gap: 10px; flex-direction: column;
margin-bottom: 16px; gap: 6px;
padding: 14px 16px;
} }
.finance-filter-panel :deep(.el-form-item__label) { .eyebrow-tag {
margin-bottom: 4px; display: inline-flex;
line-height: 18px;
}
.finance-filter-panel :deep(.el-input__wrapper),
.finance-filter-panel :deep(.el-select__wrapper) {
min-height: 32px;
}
.finance-details-table {
padding: 0;
}
.finance-grid-header,
.finance-grid-row {
display: grid;
grid-template-columns: 15% 9% 16% repeat(8, 7.5%);
align-items: center; align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.8px;
color: #4f46e5;
background: #eef2ff;
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;
}
/* ================= 筛选面板 ================= */
.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%; width: 100%;
} }
.finance-grid-header { /* ================= 表格卡片 ================= */
border-bottom: 1px solid #edf1f7; .table-section-card {
color: #7f8aa3; 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: #4f46e5;
font-size: 18px;
}
.count-badge {
font-size: 12px; font-size: 12px;
font-weight: 650; font-weight: 600;
letter-spacing: 0; color: #4f46e5;
line-height: 1.35; background: #eef2ff;
text-transform: none; padding: 3px 10px;
border-radius: 20px;
} }
.finance-grid-header > div, .table-container {
.finance-grid-row > div, border-radius: 12px;
.finance-grid-row > span { overflow: hidden;
min-width: 0; border: 1px solid #e2e8f0;
padding: 8px 7px;
} }
.finance-grid-row { .custom-enterprise-table :deep(.el-table__header-wrapper th) {
border-bottom: 1px solid #edf1f7; background-color: #f8fafc !important;
color: #475569;
font-size: 13px;
font-weight: 600;
padding: 12px 0;
border-bottom: 1px solid #e2e8f0;
} }
.finance-grid-row:last-child { .custom-enterprise-table :deep(.el-table__body-wrapper td) {
border-bottom: 0; padding: 12px 0;
font-size: 13px;
color: #334155;
} }
.order-cell, .order-cell {
.status-stack,
.party-cell {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 3px; gap: 3px;
min-width: 0;
} }
.order-no { .order-no-link {
display: block; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
max-width: 100%; font-size: 12.5px;
overflow: hidden; font-weight: 700;
color: #364152; color: #3b82f6;
font-weight: 650; text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
} }
.cell-muted, .order-no-link:hover {
.status-stack small, text-decoration: underline;
.party-cell span { }
color: var(--finance-muted);
.time-sub {
font-size: 11px;
color: #94a3b8;
}
.status-stack {
display: flex;
flex-direction: column;
gap: 3px;
}
.status-row {
display: flex;
align-items: center;
gap: 6px;
}
.order-status-badge {
font-size: 12px; font-size: 12px;
}
.status-stack > span {
color: #364152;
font-weight: 600; font-weight: 600;
color: #1e293b;
} }
.party-cell span { .settle-desc {
overflow: hidden; font-size: 11.5px;
text-overflow: ellipsis; color: #64748b;
white-space: nowrap;
} }
.amount-text { .offline-desc {
display: block; font-size: 11px;
overflow: hidden; color: #d97706;
color: #344054; }
.parties-cell {
display: flex;
flex-direction: column;
gap: 4px;
}
.party-line {
display: flex;
align-items: center;
gap: 6px;
}
.role-badge {
font-size: 10px;
font-weight: 700;
padding: 1px 4px;
border-radius: 3px;
}
.role-badge.renter {
background: #eff6ff;
color: #3b82f6;
}
.role-badge.owner {
background: #f5f3ff;
color: #7c3aed;
}
.party-info {
font-size: 12px; font-size: 12px;
font-weight: 650; color: #334155;
text-overflow: ellipsis;
white-space: nowrap;
} }
.amount-cell { .amount-val {
text-align: right; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
} }
.muted-amount { .flow-amount {
color: #667085; color: #1e293b;
} }
.amount-danger { .net-amount {
color: #dc2626; color: #0891b2;
}
.profit-amount {
color: #059669;
font-size: 13.5px;
}
.success-val {
color: #10b981;
}
.refund-stack {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.refund-text {
font-size: 12px;
font-weight: 600;
color: #ef4444;
}
.refunding-text {
font-size: 11px;
color: #f59e0b;
}
.zero-text {
font-size: 12px;
color: #cbd5e1;
}
.diff-tag {
font-weight: 700; font-weight: 700;
} }
@media (max-width: 1440px) { .zero-diff {
.finance-filter-panel { color: #94a3b8;
grid-template-columns: repeat(4, 1fr); }
}
.action-link {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12.5px;
color: #3b82f6;
text-decoration: none;
font-weight: 500;
padding: 4px 8px;
border-radius: 6px;
transition: all 0.2s ease;
}
.action-link:hover {
background: #eff6ff;
} }
</style> </style>
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { CircleClose, Plus, Search, UploadFilled, View } from '@element-plus/icons-vue' import {
CircleClose,
Money,
Plus,
Refresh,
Search,
Tickets,
UploadFilled,
View,
Wallet,
WarningFilled,
} from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
@@ -31,6 +42,7 @@ const currentPage = ref(1)
const currentPageSize = ref(20) const currentPageSize = ref(20)
const summary = ref<OperatingExpenseListSummary>(emptySummary()) const summary = ref<OperatingExpenseListSummary>(emptySummary())
const canManage = computed(() => adminSession.hasPermission('finance:operating_expense')) const canManage = computed(() => adminSession.hasPermission('finance:operating_expense'))
const filters = reactive({ const filters = reactive({
date_type: 'created' as 'created' | 'occurred', date_type: 'created' as 'created' | 'occurred',
start_date: defaultStartDate(), start_date: defaultStartDate(),
@@ -39,6 +51,7 @@ const filters = reactive({
category: '', category: '',
keyword: '', keyword: '',
}) })
const createForm = reactive({ const createForm = reactive({
category: '', category: '',
payee_name: '', payee_name: '',
@@ -183,7 +196,7 @@ function defaultStartDate() {
return formatInputDate(date) return formatInputDate(date)
} }
function money(value: number) { function money(value: number | undefined | null) {
return formatCentWithSymbol(Number(value || 0)) return formatCentWithSymbol(Number(value || 0))
} }
@@ -191,117 +204,187 @@ function statusLabel(status: string) {
return status === 'paid' ? '已记账' : '已作废' return status === 'paid' ? '已记账' : '已作废'
} }
function statusType(status: string) { function statusType(status: string): 'success' | 'danger' {
return status === 'paid' ? 'success' : 'danger' return status === 'paid' ? 'success' : 'danger'
} }
</script> </script>
<template> <template>
<section class="page operating-expenses-page"> <div class="finance-page-container">
<div class="page-header-row"> <!-- 头部卡片 -->
<div class="page-header"> <header class="page-header-card">
<p class="eyebrow">Operating Expenses</p> <div class="header-main">
<h1>运营开支</h1> <div class="title-group">
<p>独立记录日常运营费用并在财务仪表盘按发生日期汇总</p> <div class="eyebrow-tag">
<el-icon><Tickets /></el-icon>
<span>OPERATING EXPENSES</span>
</div> </div>
<h1 class="page-title">运营开支</h1>
<p class="page-subtitle">独立记录日常运营支出费用并在财务总览中按发生日期汇总统计</p>
</div>
<div class="toolbar-actions"> <div class="toolbar-actions">
<el-button v-if="canManage" type="success" :icon="Plus" @click="openCreate"> <el-button
v-if="canManage"
type="primary"
:icon="Plus"
class="action-btn-primary"
@click="openCreate"
>
新增运营开支 新增运营开支
</el-button> </el-button>
<el-button @click="resetFilters">重置</el-button> <el-button :icon="Refresh" @click="resetFilters">重置</el-button>
<el-button type="primary" :icon="Search" :loading="loading" @click="search">查询</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 amber">
<el-icon><Money /></el-icon>
</div>
<div class="metric-info">
<span class="metric-label">有效运营开支</span>
<strong class="metric-val amber-val">{{ money(summary.paid_amount_cent) }}</strong>
<span class="metric-sub-pill warning">{{ summary.paid_count }} 笔有效记账</span>
</div> </div>
</div> </div>
<div class="metric-grid operating-metrics"> <div class="metric-card metric-card--total">
<div class="metric-card"> <div class="metric-icon-wrap slate">
<span>筛选记录</span> <el-icon><Wallet /></el-icon>
<strong>{{ summary.record_count }} </strong>
<small>当前筛选条件</small>
</div> </div>
<div class="metric-card operating-metric"> <div class="metric-info">
<span>有效运营开支</span> <span class="metric-label">当前筛选总计</span>
<strong>{{ money(summary.paid_amount_cent) }}</strong> <strong class="metric-val">{{ summary.record_count }} </strong>
<small>{{ summary.paid_count }} 笔已记账开支</small> <span class="metric-sub-text">包含作废与有效记录</span>
</div> </div>
</div> </div>
</section>
<el-form class="table-panel operating-filters" label-position="top"> <!-- 筛选卡片 -->
<section class="section-card filter-card">
<el-form class="filter-grid" label-position="top" @keyup.enter="search">
<el-form-item label="日期类型"> <el-form-item label="日期类型">
<el-select v-model="filters.date_type" class="full-control"> <el-select v-model="filters.date_type" class="full-control">
<el-option label="录入日期" value="created" /> <el-option label="录入日期" value="created" />
<el-option label="开支日期" value="occurred" /> <el-option label="实际开支日期" value="occurred" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="开始日期"> <el-form-item label="开始日期">
<el-date-picker <el-date-picker
v-model="filters.start_date" v-model="filters.start_date"
class="full-control" class="full-control"
type="date" type="date"
value-format="YYYY-MM-DD" value-format="YYYY-MM-DD"
:clearable="false"
/> />
</el-form-item> </el-form-item>
<el-form-item label="结束日期"> <el-form-item label="结束日期">
<el-date-picker <el-date-picker
v-model="filters.end_date" v-model="filters.end_date"
class="full-control" class="full-control"
type="date" type="date"
value-format="YYYY-MM-DD" value-format="YYYY-MM-DD"
:clearable="false"
/> />
</el-form-item> </el-form-item>
<el-form-item label="状态"> <el-form-item label="状态">
<el-select v-model="filters.status" clearable placeholder="全部状态" class="full-control"> <el-select v-model="filters.status" clearable placeholder="全部状态" class="full-control">
<el-option label="已记账" value="paid" /> <el-option label="已记账" value="paid" />
<el-option label="已作废" value="voided" /> <el-option label="已作废" value="voided" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="开支类别"> <el-form-item label="开支类别">
<el-input v-model="filters.category" clearable placeholder="按类别精确筛选" /> <el-input v-model="filters.category" clearable placeholder="按类别精确筛选" />
</el-form-item> </el-form-item>
<el-form-item label="关键词">
<el-input <el-form-item label="关键词检索">
v-model="filters.keyword" <el-input v-model="filters.keyword" clearable placeholder="单号、收款方或用途" />
clearable
placeholder="单号、收款方或用途"
@keyup.enter="search"
/>
</el-form-item> </el-form-item>
</el-form> </el-form>
</section>
<el-table v-loading="loading" class="table-panel" :data="items"> <!-- 开支数据表格 -->
<el-table-column label="开支单号" min-width="190"> <section class="section-card table-section-card">
<template #default="{ row }">{{ row.expense_no }}</template> <div class="table-card-header">
</el-table-column> <div class="table-header-title">
<el-table-column prop="category" label="开支类别" min-width="130" /> <el-icon class="header-icon"><Tickets /></el-icon>
<el-table-column label="收款方" min-width="150"> <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="190" fixed="left">
<template #default="{ row }"> <template #default="{ row }">
<strong>{{ row.payee_name }}</strong> <span class="mono-code">{{ row.expense_no }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="开支金额" min-width="135" align="right">
<template #default="{ row }" <el-table-column label="开支类别" min-width="130">
><strong>{{ money(row.amount_cent) }}</strong></template
>
</el-table-column>
<el-table-column label="开支时间" min-width="175">
<template #default="{ row }">{{ formatDateTime(row.occurred_at) }}</template>
</el-table-column>
<el-table-column label="状态" width="105" align="center">
<template #default="{ row }"
><el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag></template
>
</el-table-column>
<el-table-column label="录入人" min-width="120">
<template #default="{ row }">{{ row.created_by_name || '-' }}</template>
</el-table-column>
<el-table-column label="备注" min-width="180" show-overflow-tooltip>
<template #default="{ row }">{{ row.remark }}</template>
</el-table-column>
<el-table-column label="操作" width="145" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="primary" :icon="View" @click="selectedExpense = row" <el-tag size="small" effect="plain" class="category-tag">{{ row.category }}</el-tag>
>详情</el-button </template>
> </el-table-column>
<el-table-column label="收款方" min-width="150">
<template #default="{ row }">
<strong class="payee-name">{{ row.payee_name }}</strong>
</template>
</el-table-column>
<el-table-column label="开支金额" min-width="140" align="right">
<template #default="{ row }">
<span class="amount-val orange-val">{{ money(row.amount_cent) }}</span>
</template>
</el-table-column>
<el-table-column label="实际开支时间" min-width="165">
<template #default="{ row }">
<span class="time-cell">{{ formatDateTime(row.occurred_at) }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="105" align="center">
<template #default="{ row }">
<el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="录入管理员" min-width="120">
<template #default="{ row }">
<span class="operator-name">{{ row.created_by_name || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="开支备注" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<span class="remark-text">{{ row.remark }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right" align="center">
<template #default="{ row }">
<div class="action-cell">
<el-button link type="primary" :icon="View" @click="selectedExpense = row">
详情
</el-button>
<el-button <el-button
v-if="row.status === 'paid' && canManage" v-if="row.status === 'paid' && canManage"
link link
@@ -312,209 +395,568 @@ function statusType(status: string) {
> >
作废 作废
</el-button> </el-button>
</div>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</div>
<AdminTablePagination <AdminTablePagination
v-if="total > 0"
v-model:current-page="currentPage" v-model:current-page="currentPage"
v-model:page-size="currentPageSize" v-model:page-size="currentPageSize"
:total="total" :total="total"
:loading="loading" @change="loadItems"
@page-change="loadItems"
/> />
</section>
<!-- 新增开支弹窗 -->
<el-dialog <el-dialog
v-model="createVisible" v-model="createVisible"
title="新增运营开支" title="新增运营开支记账"
width="560px" width="540px"
:close-on-click-modal="false" destroy-on-close
class="custom-modal"
> >
<el-form class="expense-form" label-position="top" @submit.prevent="submitExpense"> <el-form label-position="top" class="modal-form">
<el-form-item label="开支类别" required> <el-form-item label="开支类别" required>
<el-input <el-input
v-model="createForm.category" v-model="createForm.category"
maxlength="50" placeholder="例如:服务器宽带、日常采购、软件授权"
placeholder="例如:推广投放、办公采购"
/> />
</el-form-item> </el-form-item>
<el-form-item label="收款方" required>
<el-input v-model="createForm.payee_name" maxlength="100" placeholder="姓名或单位名称" /> <el-form-item label="收款方姓名 / 单位" required>
<el-input v-model="createForm.payee_name" placeholder="请输入实际收款方或单位名称" />
</el-form-item> </el-form-item>
<el-form-item label="开支金额" required>
<el-form-item label="开支金额(元)" required>
<el-input-number <el-input-number
v-model="createForm.amount_yuan" v-model="createForm.amount_yuan"
class="full-control"
:min="0.01" :min="0.01"
:precision="2" :precision="2"
:step="100" :step="10"
controls-position="right" class="full-control"
/> />
</el-form-item> </el-form-item>
<el-form-item label="开支时间" required>
<el-form-item label="开支发生时间" required>
<el-date-picker <el-date-picker
v-model="createForm.occurred_at" v-model="createForm.occurred_at"
class="full-control"
type="datetime" type="datetime"
placeholder="选择开支时间" placeholder="选择实际支付发生时间"
class="full-control"
/> />
</el-form-item> </el-form-item>
<el-form-item class="expense-form-wide" label="开支用途" required>
<el-form-item label="开支用途 / 详细备注" required>
<el-input <el-input
v-model="createForm.remark" v-model="createForm.remark"
type="textarea" type="textarea"
:rows="3" :rows="3"
maxlength="500" maxlength="500"
show-word-limit show-word-limit
placeholder="填写开支用途" placeholder="详细记录运营开支用途"
/> />
</el-form-item> </el-form-item>
<el-form-item class="expense-form-wide" label="开支凭证(选填)">
<div v-if="createForm.voucher_url" class="voucher-preview"> <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 <AuthImage
:source="createForm.voucher_url" :source="createForm.voucher_url"
:admin="true" alt="凭证预览"
fit="cover" class="voucher-img-preview"
:preview-src-list="[createForm.voucher_url]"
:image-style="{ width: '104px', height: '104px', borderRadius: '6px' }"
/>
<el-button
class="voucher-remove"
circle
plain
type="danger"
:icon="CircleClose"
title="移除凭证"
@click="createForm.voucher_url = ''"
/> />
</div> </div>
<el-upload </div>
v-else
:show-file-list="false"
:http-request="uploadVoucher"
accept="image/jpeg,image/png,image/webp"
>
<el-button :icon="UploadFilled" :loading="voucherUploading">上传凭证</el-button>
</el-upload>
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button :disabled="createSaving" @click="createVisible = false">取消</el-button> <div class="modal-footer">
<el-button <el-button @click="createVisible = false">取消</el-button>
type="primary" <el-button type="primary" :loading="createSaving" @click="submitExpense">
:loading="createSaving" 确认记账
:disabled="voucherUploading" </el-button>
@click="submitExpense" </div>
>确认记账</el-button
>
</template> </template>
</el-dialog> </el-dialog>
<!-- 运营开支详情弹窗 -->
<el-dialog <el-dialog
:model-value="!!selectedExpense" :model-value="!!selectedExpense"
title="运营开支详情" title="运营开支详情"
width="620px" width="520px"
@update:model-value="selectedExpense = null" @update:model-value="(val: boolean) => (!val ? (selectedExpense = null) : null)"
> >
<el-descriptions v-if="selectedExpense" :column="2" border> <div v-if="selectedExpense" class="detail-content-wrap">
<el-descriptions-item label="开支单号">{{ <div class="detail-row">
selectedExpense.expense_no <span class="detail-label">开支单号</span>
}}</el-descriptions-item> <span class="detail-val font-mono">{{ selectedExpense.expense_no }}</span>
<el-descriptions-item label="状态" </div>
><el-tag :type="statusType(selectedExpense.status)">{{ <div class="detail-row">
statusLabel(selectedExpense.status) <span class="detail-label">开支类别</span>
}}</el-tag></el-descriptions-item <span class="detail-val">{{ selectedExpense.category }}</span>
> </div>
<el-descriptions-item label="开支类别">{{ selectedExpense.category }}</el-descriptions-item> <div class="detail-row">
<el-descriptions-item label="收款方">{{ selectedExpense.payee_name }}</el-descriptions-item> <span class="detail-label">收款方</span>
<el-descriptions-item label="开支金额" <span class="detail-val">{{ selectedExpense.payee_name }}</span>
><strong>{{ money(selectedExpense.amount_cent) }}</strong></el-descriptions-item </div>
> <div class="detail-row">
<el-descriptions-item label="开支时间">{{ <span class="detail-label">开支金额</span>
formatDateTime(selectedExpense.occurred_at) <span class="detail-val text-amber font-bold text-lg">
}}</el-descriptions-item> {{ money(selectedExpense.amount_cent) }}
<el-descriptions-item label="录入人">{{ </span>
selectedExpense.created_by_name || '-' </div>
}}</el-descriptions-item> <div class="detail-row">
<el-descriptions-item label="录入时间">{{ <span class="detail-label">状态</span>
formatDateTime(selectedExpense.created_at) <el-tag :type="statusType(selectedExpense.status)">
}}</el-descriptions-item> {{ statusLabel(selectedExpense.status) }}
<el-descriptions-item label="开支用途" :span="2">{{ </el-tag>
selectedExpense.remark </div>
}}</el-descriptions-item> <div class="detail-row">
<el-descriptions-item v-if="selectedExpense.voucher_url" label="开支凭证" :span="2"> <span class="detail-label">发生时间</span>
<span class="detail-val">{{ formatDateTime(selectedExpense.occurred_at) }}</span>
</div>
<div class="detail-row">
<span class="detail-label">录入人</span>
<span class="detail-val">{{ selectedExpense.created_by_name || '-' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">用途备注</span>
<span class="detail-val">{{ selectedExpense.remark }}</span>
</div>
<div v-if="selectedExpense.voucher_url" class="detail-row column-row">
<span class="detail-label">开支凭证</span>
<div class="detail-img-container">
<AuthImage <AuthImage
:source="selectedExpense.voucher_url" :source="selectedExpense.voucher_url"
:admin="true" alt="凭证"
fit="cover" class="detail-voucher-img"
:preview-src-list="[selectedExpense.voucher_url]"
:image-style="{ width: '140px', height: '140px', borderRadius: '6px' }"
/> />
</el-descriptions-item> </div>
<el-descriptions-item v-if="selectedExpense.status === 'voided'" label="作废信息" :span="2"> </div>
{{ selectedExpense.voided_by_name || '-' }} · <div v-if="selectedExpense.status === 'voided'" class="void-info-box">
{{ formatDateTime(selectedExpense.voided_at) }} · {{ selectedExpense.void_reason || '-' }} <div class="void-title">
</el-descriptions-item> <el-icon><WarningFilled /></el-icon>
</el-descriptions> <span>作废记录</span>
<template #footer> </div>
<el-button @click="selectedExpense = null">关闭</el-button> <p>作废时间{{ formatDateTime(selectedExpense.voided_at) }}</p>
<el-button <p>作废人{{ selectedExpense.voided_by_name || '-' }}</p>
v-if="selectedExpense?.status === 'paid' && canManage" <p>作废原因{{ selectedExpense.void_reason || '-' }}</p>
type="danger" </div>
:icon="CircleClose" </div>
:loading="actionID === selectedExpense.id"
@click="handleVoid(selectedExpense)"
>作废记录</el-button
>
</template>
</el-dialog> </el-dialog>
</section> </div>
</template> </template>
<style scoped> <style scoped>
.operating-metrics { .finance-page-container {
grid-template-columns: repeat(2, minmax(170px, 1fr)); display: flex;
margin-bottom: 16px; flex-direction: column;
gap: 20px;
padding: 0 4px 32px;
} }
.operating-metric { /* ================= 头部卡片 ================= */
border-left: 3px solid #b45309; .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);
} }
.operating-filters { .header-main {
display: grid; display: flex;
grid-template-columns: repeat(6, minmax(130px, 1fr)); 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: #ea580c;
background: #fff7ed;
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; gap: 10px;
margin-bottom: 16px;
padding: 14px 16px;
} }
.expense-form { /* ================= 核心指标卡片 ================= */
.summary-metric-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 0 16px; gap: 16px;
} }
.expense-form-wide { .metric-card {
grid-column: 1 / -1; background: #ffffff;
} border-radius: 16px;
padding: 18px 20px;
.voucher-preview { 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; position: relative;
width: 104px; overflow: hidden;
height: 104px; transition:
transform 0.2s ease,
box-shadow 0.2s ease;
} }
.voucher-remove { .metric-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
}
.metric-card::before {
content: '';
position: absolute; position: absolute;
top: -10px; top: 0;
right: -10px; left: 0;
right: 0;
height: 4px;
} }
@media (max-width: 1100px) { .metric-card--paid::before {
.operating-filters { background: linear-gradient(90deg, #f59e0b, #fbbf24);
grid-template-columns: repeat(3, minmax(140px, 1fr)); }
}
.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.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;
}
.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-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: #ea580c;
font-size: 18px;
}
.count-badge {
font-size: 12px;
font-weight: 600;
color: #ea580c;
background: #fff7ed;
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;
}
.category-tag {
font-weight: 600;
}
.payee-name {
color: #0f172a;
}
.amount-val {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 14px;
font-weight: 700;
}
.orange-val {
color: #ea580c;
}
.time-cell {
font-size: 12px;
color: #475569;
}
.operator-name {
font-size: 12.5px;
color: #475569;
}
.remark-text {
font-size: 12px;
color: #64748b;
}
.action-cell {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
/* ================= 弹窗样式 ================= */
.voucher-upload-box {
display: flex;
align-items: center;
gap: 16px;
}
.voucher-preview-wrap {
width: 60px;
height: 60px;
border-radius: 8px;
overflow: hidden;
border: 1px solid #e2e8f0;
}
.voucher-img-preview {
width: 100%;
height: 100%;
object-fit: cover;
}
.detail-content-wrap {
display: flex;
flex-direction: column;
gap: 12px;
padding: 4px 0;
}
.detail-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13.5px;
border-bottom: 1px dashed #f1f5f9;
padding-bottom: 8px;
}
.detail-row.column-row {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.detail-label {
color: #64748b;
}
.detail-val {
color: #0f172a;
}
.detail-img-container {
max-width: 100%;
max-height: 240px;
border-radius: 8px;
overflow: hidden;
border: 1px solid #e2e8f0;
}
.detail-voucher-img {
max-width: 100%;
max-height: 240px;
object-fit: contain;
}
.void-info-box {
background: #fef2f2;
border-radius: 8px;
padding: 12px 14px;
border: 1px solid #fee2e2;
color: #b91c1c;
font-size: 12.5px;
display: flex;
flex-direction: column;
gap: 4px;
}
.void-title {
display: flex;
align-items: center;
gap: 6px;
font-weight: 700;
margin-bottom: 2px;
} }
</style> </style>
@@ -308,18 +308,40 @@ function deleteDisabledReason(row: PaymentConfig) {
</script> </script>
<template> <template>
<section class="page"> <div class="finance-page-container">
<div class="page-header"> <header class="page-header-card">
<p class="eyebrow">Payment Configs</p> <div class="header-main">
<h1>支付配置管理</h1> <div class="title-group">
<p>管理多个支付商户配置支付宝和微信可分别启用默认渠道</p> <div class="eyebrow-tag">
<el-icon><CreditCard /></el-icon>
<span>PAYMENT CONFIGS</span>
</div>
<h1 class="page-title">支付配置管理</h1>
<p class="page-subtitle">
管理平台多渠道支付商户配置支持支付宝和微信独立指定默认与备用支付通道
</p>
</div> </div>
<div class="filter-bar"> <div class="toolbar-actions">
<el-button :icon="Upload" :loading="importing" @click="handleImportBackup">
导入备份
</el-button>
<el-button :icon="Download" :loading="exporting" @click="handleExportBackup">
导出备份
</el-button>
<el-button type="primary" :icon="Plus" class="action-btn-primary" @click="handleCreate">
新增配置
</el-button>
</div>
</div>
</header>
<section class="section-card filter-card">
<div class="filter-bar-flex">
<el-select <el-select
v-model="filterProvider" v-model="filterProvider"
placeholder="支付服务商" placeholder="支付服务商"
style="width: 150px" class="filter-select"
@change="loadConfigs" @change="loadConfigs"
> >
<el-option <el-option
@@ -332,7 +354,7 @@ function deleteDisabledReason(row: PaymentConfig) {
<el-select <el-select
v-model="filterPayWay" v-model="filterPayWay"
placeholder="支付方式" placeholder="支付方式"
style="width: 120px" class="filter-select"
@change="loadConfigs" @change="loadConfigs"
> >
<el-option <el-option
@@ -345,7 +367,7 @@ function deleteDisabledReason(row: PaymentConfig) {
<el-select <el-select
v-model="filterStatus" v-model="filterStatus"
placeholder="状态" placeholder="状态"
style="width: 120px" class="filter-select"
@change="loadConfigs" @change="loadConfigs"
> >
<el-option <el-option
@@ -358,7 +380,7 @@ function deleteDisabledReason(row: PaymentConfig) {
<el-select <el-select
v-model="filterEnvironment" v-model="filterEnvironment"
placeholder="环境" placeholder="环境"
style="width: 120px" class="filter-select"
@change="loadConfigs" @change="loadConfigs"
> >
<el-option <el-option
@@ -369,14 +391,8 @@ function deleteDisabledReason(row: PaymentConfig) {
/> />
</el-select> </el-select>
<el-button :icon="Refresh" @click="loadConfigs">刷新</el-button> <el-button :icon="Refresh" @click="loadConfigs">刷新</el-button>
<el-button :icon="Upload" :loading="importing" @click="handleImportBackup"
>导入备份</el-button
>
<el-button :icon="Download" :loading="exporting" @click="handleExportBackup"
>导出备份</el-button
>
<el-button type="primary" :icon="Plus" @click="handleCreate">新增配置</el-button>
</div> </div>
</section>
<input <input
ref="backupFileInput" ref="backupFileInput"
@@ -386,10 +402,20 @@ function deleteDisabledReason(row: PaymentConfig) {
@change="handleImportFile" @change="handleImportFile"
/> />
<section class="section-card table-section-card">
<div class="table-card-header">
<div class="table-header-title">
<el-icon class="header-icon"><CreditCard /></el-icon>
<span>支付通道配置列表</span>
</div>
<span class="count-badge"> {{ total }} 个配置</span>
</div>
<div class="table-container">
<el-table <el-table
:data="filteredConfigs" :data="filteredConfigs"
v-loading="loading" v-loading="loading"
class="payment-config-table" class="payment-config-table custom-enterprise-table"
:row-class-name="tableRowClassName" :row-class-name="tableRowClassName"
stripe stripe
> >
@@ -487,6 +513,7 @@ function deleteDisabledReason(row: PaymentConfig) {
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</div>
<div class="pagination"> <div class="pagination">
<el-pagination <el-pagination
@@ -499,6 +526,7 @@ function deleteDisabledReason(row: PaymentConfig) {
@size-change="loadConfigs" @size-change="loadConfigs"
/> />
</div> </div>
</section>
<PaymentConfigDialog <PaymentConfigDialog
v-model="dialogVisible" v-model="dialogVisible"
@@ -507,46 +535,137 @@ function deleteDisabledReason(row: PaymentConfig) {
@close="handleDialogClose" @close="handleDialogClose"
@saved="handleSaved" @saved="handleSaved"
/> />
</section> </div>
</template> </template>
<style scoped> <style scoped>
.page { .finance-page-container {
padding: 24px; display: flex;
flex-direction: column;
gap: 20px;
padding: 0 4px 32px;
} }
.page-header { .page-header-card {
margin-bottom: 24px; 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);
} }
.page-header .eyebrow { .header-main {
font-size: 12px; display: flex;
font-weight: 600; align-items: flex-start;
text-transform: uppercase; justify-content: space-between;
letter-spacing: 0.05em; gap: 16px;
color: #6b7280; flex-wrap: wrap;
margin-bottom: 8px;
} }
.page-header h1 { .title-group {
font-size: 24px; display: flex;
font-weight: 600; flex-direction: column;
margin: 0 0 8px 0; gap: 6px;
} }
.page-header p { .eyebrow-tag {
color: #6b7280; display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.8px;
color: #3b82f6;
background: #eff6ff;
padding: 3px 10px;
border-radius: 20px;
width: fit-content;
}
.page-title {
margin: 0; margin: 0;
font-size: 26px;
font-weight: 800;
color: #0f172a;
letter-spacing: -0.5px;
} }
.filter-bar { .page-subtitle {
margin: 0;
font-size: 13.5px;
color: #64748b;
line-height: 1.5;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 10px;
}
.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-bar-flex {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: 12px; gap: 12px;
}
.filter-select {
width: 140px;
}
.table-section-card {
padding-bottom: 16px;
}
.table-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px; margin-bottom: 16px;
} }
.table-header-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 700;
color: #1e293b;
}
.header-icon {
color: #3b82f6;
font-size: 18px;
}
.count-badge {
font-size: 12px;
font-weight: 600;
color: #3b82f6;
background: #eff6ff;
padding: 3px 10px;
border-radius: 20px;
}
.table-container {
border-radius: 12px;
overflow: hidden;
border: 1px solid #e2e8f0;
}
.backup-file-input { .backup-file-input {
display: none; display: none;
} }
@@ -1,5 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { Document, Search } from '@element-plus/icons-vue' import {
CreditCard,
DocumentChecked,
Money,
Refresh,
Search,
TopRight,
View,
Wallet,
} from '@element-plus/icons-vue'
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { fetchAdminPayments, type AdminPayment } from '@/features/admin/api/adminPayments' import { fetchAdminPayments, type AdminPayment } from '@/features/admin/api/adminPayments'
@@ -15,6 +24,7 @@ const currentPageSize = ref(20)
const total = ref(0) const total = ref(0)
const detailVisible = ref(false) const detailVisible = ref(false)
const activePayment = ref<AdminPayment | null>(null) const activePayment = ref<AdminPayment | null>(null)
const filters = reactive({ const filters = reactive({
user_id: '', user_id: '',
order_id: '', order_id: '',
@@ -70,11 +80,11 @@ async function handlePageChange() {
await loadPayments() await loadPayments()
} }
function moneyCent(value: number) { function moneyCent(value: number | undefined | null) {
return formatCentWithSymbol(value) return formatCentWithSymbol(value || 0)
} }
function paymentStatusType(status: string) { function paymentStatusType(status: string): 'success' | 'danger' | 'warning' | 'info' {
if (['paid', 'refunded'].includes(status)) return 'success' if (['paid', 'refunded'].includes(status)) return 'success'
if (status === 'failed') return 'danger' if (status === 'failed') return 'danger'
if (['paying', 'refunding'].includes(status)) return 'warning' if (['paying', 'refunding'].includes(status)) return 'warning'
@@ -126,48 +136,85 @@ function jsonText(value: unknown) {
</script> </script>
<template> <template>
<section class="page"> <div class="finance-page-container">
<div class="page-header-row"> <!-- 头部卡片 -->
<div class="page-header"> <header class="page-header-card">
<p class="eyebrow">Payment Ledger</p> <div class="header-main">
<h1>支付流水</h1> <div class="title-group">
<p>查看第三方支付退款请求渠道单号状态和失败原因</p> <div class="eyebrow-tag">
<el-icon><CreditCard /></el-icon>
<span>PAYMENT LEDGER</span>
</div> </div>
<h1 class="page-title">支付流水</h1>
<p class="page-subtitle">
聚合监控第三方通道支付与退款交互渠道交易号状态及失败异常报文
</p>
</div>
<div class="toolbar-actions"> <div class="toolbar-actions">
<el-button @click="resetFilters">重置</el-button> <el-button :icon="Refresh" @click="resetFilters">重置</el-button>
<el-button type="primary" :icon="Search" :loading="loading" @click="loadPayments" <el-button type="primary" :icon="Search" :loading="loading" @click="loadPayments">
>查询</el-button 查询
> </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> </div>
<div class="metric-grid"> <div class="metric-card metric-card--refund">
<div class="metric-card"> <div class="metric-icon-wrap rose">
<span>总条数</span> <el-icon><TopRight /></el-icon>
<strong>{{ total }} </strong>
</div> </div>
<div class="metric-card"> <div class="metric-info">
<span>本页支付</span> <span class="metric-label">本页退款发生</span>
<strong>{{ moneyCent(payAmount) }}</strong> <strong class="metric-val rose-val">{{ moneyCent(refundAmount) }}</strong>
</div> <span class="metric-sub-pill danger">退款流水</span>
<div class="metric-card">
<span>本页退款额</span>
<strong>{{ moneyCent(refundAmount) }}</strong>
</div> </div>
</div> </div>
<el-form class="filter-panel" label-position="top"> <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-form-item label="用户 ID">
<el-input v-model="filters.user_id" clearable placeholder="按用户筛选" /> <el-input v-model="filters.user_id" clearable placeholder="按用户 ID 检索" />
</el-form-item> </el-form-item>
<el-form-item label="订单 ID"> <el-form-item label="订单 ID">
<el-input v-model="filters.order_id" clearable placeholder="按订单筛选" /> <el-input v-model="filters.order_id" clearable placeholder="按订单 ID 检索" />
</el-form-item> </el-form-item>
<el-form-item label="订单号"> <el-form-item label="订单号">
<el-input v-model="filters.order_no" clearable placeholder="按订单号筛选" /> <el-input v-model="filters.order_no" clearable placeholder="按订单号检索" />
</el-form-item> </el-form-item>
<el-form-item label="业务类型"> <el-form-item label="业务类型">
<el-select v-model="filters.biz_type" clearable placeholder="全部业务" class="full-control"> <el-select
v-model="filters.biz_type"
clearable
placeholder="全部业务类型"
class="full-control"
>
<el-option label="订单支付" value="order_pay" /> <el-option label="订单支付" value="order_pay" />
<el-option label="撞车支付" value="mohong_pay" /> <el-option label="撞车支付" value="mohong_pay" />
<el-option label="结账退款" value="checkout_refund" /> <el-option label="结账退款" value="checkout_refund" />
@@ -177,7 +224,7 @@ function jsonText(value: unknown) {
<el-option label="撞车退款" value="mohong_refund" /> <el-option label="撞车退款" value="mohong_refund" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="状态"> <el-form-item label="流水状态">
<el-select v-model="filters.status" clearable placeholder="全部状态" class="full-control"> <el-select v-model="filters.status" clearable placeholder="全部状态" class="full-control">
<el-option label="已支付" value="paid" /> <el-option label="已支付" value="paid" />
<el-option label="支付中" value="paying" /> <el-option label="支付中" value="paying" />
@@ -186,8 +233,13 @@ function jsonText(value: unknown) {
<el-option label="失败" value="failed" /> <el-option label="失败" value="failed" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="渠道"> <el-form-item label="支付渠道">
<el-select v-model="filters.provider" clearable placeholder="全部渠道" class="full-control"> <el-select
v-model="filters.provider"
clearable
placeholder="全部渠道"
class="full-control"
>
<el-option label="拉卡拉" value="lakala" /> <el-option label="拉卡拉" value="lakala" />
<el-option label="乐刷" value="leshua" /> <el-option label="乐刷" value="leshua" />
<el-option label="顺成" value="shuncheng" /> <el-option label="顺成" value="shuncheng" />
@@ -195,130 +247,532 @@ function jsonText(value: unknown) {
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-form> </el-form>
</section>
<el-table v-loading="loading" class="table-panel" :data="payments"> <!-- 支付流水表格 -->
<el-table-column prop="payment_no" label="支付单号" min-width="220" /> <section class="section-card table-section-card">
<el-table-column label="订单" min-width="180"> <div class="table-card-header">
<template #default="{ row }"> <div class="table-header-title">
<RouterLink v-if="row.order_id" :to="adminPath(`orders/${row.order_id}`)">{{ <el-icon class="header-icon"><DocumentChecked /></el-icon>
row.order_no || row.order_id <span>支付与退款渠道明细</span>
}}</RouterLink> </div>
<span v-else>-</span> <span class="count-badge"> {{ total }} 条记录</span>
</template> </div>
</el-table-column>
<el-table-column label="用户" min-width="130"> <div class="table-container">
<template #default="{ row }">{{ row.user_phone || `用户 ${row.user_id}` }}</template> <el-table
</el-table-column> v-loading="loading"
<el-table-column label="业务" width="120"> class="custom-enterprise-table"
<template #default="{ row }">{{ bizTypeLabel(row.biz_type) }}</template> :data="payments"
</el-table-column> stripe
<el-table-column label="渠道" width="90"> empty-text="暂无支付流水记录"
<template #default="{ row }">{{ row.provider || '-' }}</template>
</el-table-column>
<el-table-column label="金额" width="110">
<template #default="{ row }">{{ moneyCent(row.amount_cent) }}</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag :type="paymentStatusType(row.status)">{{
paymentStatusLabel(row.status)
}}</el-tag>
</template>
</el-table-column>
<el-table-column label="失败原因" min-width="180">
<template #default="{ row }">
<span v-if="row.status === 'failed' && row.error_message"
>{{ row.error_code }} {{ row.error_message }}</span
> >
<span v-else>-</span> <el-table-column label="支付流水号" min-width="210" fixed="left">
<template #default="{ row }">
<span class="mono-code">{{ row.payment_no }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="third_order_id" label="平台单号" min-width="230" />
<el-table-column prop="provider_order_id" label="渠道单号" min-width="220" /> <el-table-column label="渠道 / 方式" width="150">
<el-table-column label="创建时间" min-width="180">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button size="small" :icon="Document" @click="openDetail(row)">详情</el-button> <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> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</div>
<AdminTablePagination <AdminTablePagination
v-if="total > 0" v-if="total > 0"
v-model:current-page="currentPage" v-model:current-page="currentPage"
v-model:page-size="currentPageSize" v-model:page-size="currentPageSize"
:total="total" :total="total"
:loading="loading" @change="handlePageChange"
@page-change="handlePageChange"
/> />
</section>
<el-dialog v-model="detailVisible" title="支付流水详情" width="820px"> <!-- 报文详情弹窗 -->
<div v-if="activePayment" class="payment-detail"> <el-dialog v-model="detailVisible" title="支付流水报文详情" width="680px" destroy-on-close>
<div class="detail-grid"> <div v-if="activePayment" class="payload-details">
<p> <div class="payload-block">
<span>业务类型</span><strong>{{ bizTypeLabel(activePayment.biz_type) }}</strong> <div class="payload-title">请求报文 Payload</div>
</p> <pre class="json-box"><code>{{ jsonText(activePayment.raw_request) }}</code></pre>
<p> </div>
<span>状态</span><strong>{{ paymentStatusLabel(activePayment.status) }}</strong> <div class="payload-block">
</p> <div class="payload-title">响应报文 Payload</div>
<p> <pre class="json-box"><code>{{ jsonText(activePayment.raw_response) }}</code></pre>
<span>金额</span><strong>{{ moneyCent(activePayment.amount_cent) }}</strong>
</p>
<p>
<span>渠道</span><strong>{{ activePayment.provider || '-' }}</strong>
</p>
</div> </div>
<h3>请求参数</h3>
<pre>{{ jsonText(activePayment.raw_request) }}</pre>
<h3>响应结果</h3>
<pre>{{ jsonText(activePayment.raw_response) }}</pre>
</div> </div>
</el-dialog> </el-dialog>
</section> </div>
</template> </template>
<style scoped> <style scoped>
.payment-detail h3 { .finance-page-container {
margin: 18px 0 8px; display: flex;
font-size: 15px; flex-direction: column;
gap: 20px;
padding: 0 4px 32px;
} }
.detail-grid { /* ================= 头部卡片 ================= */
display: grid; .page-header-card {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); 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; gap: 10px;
} }
.detail-grid p { /* ================= 核心指标卡片 ================= */
margin: 0; .summary-metric-grid {
padding: 10px 12px; display: grid;
background: #f8fafc; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
border: 1px solid #e5e7eb; gap: 16px;
border-radius: 8px;
} }
.detail-grid span { .metric-card {
display: block; 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; 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-size: 12px;
font-weight: 600;
color: #64748b;
} }
.detail-grid strong { .full-control {
display: block; width: 100%;
margin-top: 4px;
} }
pre { /* ================= 表格卡片 ================= */
max-height: 260px; .table-section-card {
overflow: auto; padding-bottom: 16px;
margin: 0; }
padding: 12px;
.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; background: #0f172a;
color: #e2e8f0; color: #e2e8f0;
border-radius: 8px; border-radius: 8px;
white-space: pre-wrap; padding: 12px 14px;
word-break: break-word; font-size: 12px;
max-height: 240px;
overflow: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
} }
</style> </style>
@@ -1,5 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { Search } from '@element-plus/icons-vue' import {
BottomLeft,
Coin,
DocumentChecked,
Refresh,
Search,
TopRight,
Wallet,
} from '@element-plus/icons-vue'
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { fetchAdminWalletLedger, type AdminWalletLedger } from '@/features/admin/api/adminWallet' import { fetchAdminWalletLedger, type AdminWalletLedger } from '@/features/admin/api/adminWallet'
@@ -65,11 +73,11 @@ async function handlePageChange() {
await loadLedger() await loadLedger()
} }
function money(value: number) { function money(value: number | undefined | null) {
return formatCentWithSymbol(value) return formatCentWithSymbol(value || 0)
} }
function directionType(direction: string) { function directionType(direction: string): 'success' | 'danger' | 'warning' | 'info' {
if (direction === 'in') return 'success' if (direction === 'in') return 'success'
if (direction === 'out') return 'danger' if (direction === 'out') return 'danger'
return 'warning' return 'warning'
@@ -81,45 +89,82 @@ function directionLabel(direction: string) {
</script> </script>
<template> <template>
<section class="page"> <div class="finance-page-container">
<div class="page-header-row"> <!-- 头部卡片 -->
<div class="page-header"> <header class="page-header-card">
<p class="eyebrow">Wallet Ledger</p> <div class="header-main">
<h1>资金流水</h1> <div class="title-group">
<p>查看订单金额押金冻结解冻退款和结算流水</p> <div class="eyebrow-tag">
<el-icon><Wallet /></el-icon>
<span>WALLET LEDGER</span>
</div> </div>
<h1 class="page-title">资金流水</h1>
<p class="page-subtitle">
全站用户钱包账户的订单金额押金冻结结算解冻及扣划流水台账
</p>
</div>
<div class="toolbar-actions"> <div class="toolbar-actions">
<el-button @click="resetFilters">重置</el-button> <el-button :icon="Refresh" @click="resetFilters">重置</el-button>
<el-button type="primary" :icon="Search" :loading="loading" @click="loadLedger" <el-button type="primary" :icon="Search" :loading="loading" @click="loadLedger">
>查询</el-button 查询
> </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> </div>
<div class="metric-grid"> <div class="metric-card metric-card--out">
<div class="metric-card"> <div class="metric-icon-wrap rose">
<span>总条数</span> <el-icon><TopRight /></el-icon>
<strong>{{ total }} </strong>
</div> </div>
<div class="metric-card"> <div class="metric-info">
<span>账合计</span> <span class="metric-label">本页出账合计</span>
<strong>{{ money(inAmount) }}</strong> <strong class="metric-val rose-val">{{ money(outAmount) }}</strong>
</div> <span class="metric-sub-pill danger">资金流出</span>
<div class="metric-card">
<span>出账合计</span>
<strong>{{ money(outAmount) }}</strong>
</div> </div>
</div> </div>
<el-form class="filter-panel" label-position="top"> <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-form-item label="用户 ID">
<el-input v-model="filters.user_id" clearable placeholder="按用户筛选" /> <el-input v-model="filters.user_id" clearable placeholder="按用户 ID 检索" />
</el-form-item> </el-form-item>
<el-form-item label="订单 ID"> <el-form-item label="订单 ID">
<el-input v-model="filters.order_id" clearable placeholder="按订单筛选" /> <el-input v-model="filters.order_id" clearable placeholder="按订单 ID 检索" />
</el-form-item> </el-form-item>
<el-form-item label="业务类型"> <el-form-item label="业务类型">
<el-select v-model="filters.biz_type" clearable placeholder="全部业务" class="full-control"> <el-select
v-model="filters.biz_type"
clearable
placeholder="全部业务类型"
class="full-control"
>
<el-option label="订单冻结" value="order_freeze" /> <el-option label="订单冻结" value="order_freeze" />
<el-option label="订单取消释放" value="order_cancel_release" /> <el-option label="订单取消释放" value="order_cancel_release" />
<el-option label="订单结算" value="order_settlement" /> <el-option label="订单结算" value="order_settlement" />
@@ -127,49 +172,461 @@ function directionLabel(direction: string) {
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-form> </el-form>
</section>
<el-table v-loading="loading" class="table-panel" :data="ledger"> <!-- 资金流水表格 -->
<el-table-column prop="ledger_no" label="流水号" min-width="230" /> <section class="section-card table-section-card">
<el-table-column label="用户" min-width="130"> <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 }"> <template #default="{ row }">
<strong>{{ row.user_phone || `用户 ${row.user_id}` }}</strong> <span class="mono-code">{{ row.ledger_no }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="订单" min-width="160">
<el-table-column label="关联用户" min-width="150">
<template #default="{ row }"> <template #default="{ row }">
<RouterLink v-if="row.order_id" :to="adminPath(`orders/${row.order_id}`)">{{ <div class="user-cell">
row.order_no || row.order_id <span class="user-title">{{ row.user_phone || `用户 ID: ${row.user_id}` }}</span>
}}</RouterLink> <small v-if="row.user_phone" class="user-sub">UID: {{ row.user_id }}</small>
<span v-else>-</span> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="biz_type" label="业务类型" width="150" />
<el-table-column label="方向" width="90"> <el-table-column label="关联订单" min-width="160">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="directionType(row.direction)">{{ directionLabel(row.direction) }}</el-tag> <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> </template>
</el-table-column> </el-table-column>
<el-table-column label="金额" width="110">
<template #default="{ row }">{{ money(Number(row.amount_cent)) }}</template> <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>
<el-table-column label="余额类型" width="110">
<template #default="{ row }">{{ balanceTypeLabel(row.balance_type) }}</template> <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>
<el-table-column label="变化后余额" width="130">
<template #default="{ row }">{{ money(Number(row.balance_after_cent)) }}</template> <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>
<el-table-column label="创建时间" min-width="180">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template> <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-column>
</el-table> </el-table>
</div>
<AdminTablePagination <AdminTablePagination
v-if="total > 0" v-if="total > 0"
v-model:current-page="currentPage" v-model:current-page="currentPage"
v-model:page-size="currentPageSize" v-model:page-size="currentPageSize"
:total="total" :total="total"
:loading="loading" @change="handlePageChange"
@page-change="handlePageChange"
/> />
</section> </section>
</div>
</template> </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>
@@ -1,4 +1,16 @@
<script setup lang="ts"> <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 { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
@@ -9,9 +21,10 @@ import {
confirmPayment, confirmPayment,
type WithdrawalDetail, type WithdrawalDetail,
} from '@/features/admin/api/adminWithdrawal' } from '@/features/admin/api/adminWithdrawal'
import { formatCent } from '@/shared/utils/money' import { formatCentWithSymbol } from '@/shared/utils/money'
import { formatDateTime } from '@/shared/utils/time'
import { adminPath } from '@/shared/utils/adminPath' import { adminPath } from '@/shared/utils/adminPath'
import AdminTablePagination from '../components/AdminTablePagination.vue'
import WithdrawalDetailDialog from '../components/WithdrawalDetailDialog.vue' import WithdrawalDetailDialog from '../components/WithdrawalDetailDialog.vue'
const route = useRoute() const route = useRoute()
@@ -32,9 +45,9 @@ const showDetailDialog = ref(false)
const selectedWithdrawal = ref<WithdrawalDetail | null>(null) const selectedWithdrawal = ref<WithdrawalDetail | null>(null)
const statusOptions = [ const statusOptions = [
{ label: '全部', value: '' }, { label: '全部状态', value: '' },
{ label: '待审核', value: 'pending' }, { label: '待审核', value: 'pending' },
{ label: '处理中', value: 'processing' }, { label: '处理中 (待打款)', value: 'processing' },
{ label: '已完成', value: 'completed' }, { label: '已完成', value: 'completed' },
{ label: '已拒绝', value: 'rejected' }, { label: '已拒绝', value: 'rejected' },
{ label: '已取消', value: 'cancelled' }, { label: '已取消', value: 'cancelled' },
@@ -51,6 +64,16 @@ const withdrawalReturnQuery = computed<Record<string, string>>(() => {
return query 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(() => { onMounted(() => {
restoreListState() restoreListState()
void loadWithdrawals() void loadWithdrawals()
@@ -94,7 +117,7 @@ async function loadWithdrawals() {
withdrawals.value = result.items withdrawals.value = result.items
total.value = result.total total.value = result.total
} catch (error: any) { } catch (error: any) {
ElMessage.error(error.response?.data?.message || '加载失败') ElMessage.error(error.response?.data?.message || '提现列表加载失败')
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -105,12 +128,18 @@ function handleFilter() {
loadWithdrawals() loadWithdrawals()
} }
function resetFilters() {
filters.value.status = ''
filters.value.user_id = undefined
currentPage.value = 1
void loadWithdrawals()
}
function openDetail(withdrawal: WithdrawalDetail) { function openDetail(withdrawal: WithdrawalDetail) {
selectedWithdrawal.value = withdrawal selectedWithdrawal.value = withdrawal
showDetailDialog.value = true showDetailDialog.value = true
} }
/** 跳转订单管理页并预填号主搜索,快速查看该号主相关订单 */
function goToOwnerOrders(withdrawal: WithdrawalDetail) { function goToOwnerOrders(withdrawal: WithdrawalDetail) {
const keyword = withdrawal.user_phone?.trim() || String(withdrawal.user_id || '') const keyword = withdrawal.user_phone?.trim() || String(withdrawal.user_id || '')
void router.push({ void router.push({
@@ -153,7 +182,7 @@ async function handleReview(withdrawal: WithdrawalDetail, approved: boolean) {
async function handleConfirmPayment(withdrawal: WithdrawalDetail) { async function handleConfirmPayment(withdrawal: WithdrawalDetail) {
try { try {
const { value: remark } = await ElMessageBox.prompt( const { value: remark } = await ElMessageBox.prompt(
'请输入打款备注(如:已通过支付宝转账)', '请输入打款备注(如:已通过支付宝/微信转账)',
'确认打款', '确认打款',
{ {
confirmButtonText: '确认完成', confirmButtonText: '确认完成',
@@ -161,7 +190,7 @@ async function handleConfirmPayment(withdrawal: WithdrawalDetail) {
inputType: 'textarea', inputType: 'textarea',
inputPlaceholder: '输入打款备注...', inputPlaceholder: '输入打款备注...',
inputValidator: value => { inputValidator: value => {
return value && value.trim().length > 0 return !!(value && value.trim().length > 0)
}, },
inputErrorMessage: '请输入打款备注', inputErrorMessage: '请输入打款备注',
} }
@@ -183,15 +212,15 @@ async function handleConfirmPayment(withdrawal: WithdrawalDetail) {
function statusLabel(status: string) { function statusLabel(status: string) {
const labels: Record<string, string> = { const labels: Record<string, string> = {
pending: '待审核', pending: '待审核',
processing: '处理中', processing: '待打款',
completed: '已完成', completed: '已打款',
rejected: '已拒绝', rejected: '已拒绝',
cancelled: '已取消', cancelled: '已取消',
} }
return labels[status] || status return labels[status] || status
} }
function statusType(status: string) { function statusType(status: string): 'success' | 'warning' | 'danger' | 'info' | 'primary' {
const types: Record<string, 'success' | 'warning' | 'danger' | 'info' | 'primary'> = { const types: Record<string, 'success' | 'warning' | 'danger' | 'info' | 'primary'> = {
pending: 'warning', pending: 'warning',
processing: 'primary', processing: 'primary',
@@ -215,29 +244,90 @@ function onDetailDialogSaved() {
showDetailDialog.value = false showDetailDialog.value = false
loadWithdrawals() loadWithdrawals()
} }
function money(cent: number | undefined | null) {
return formatCentWithSymbol(cent || 0)
}
</script> </script>
<template> <template>
<section class="page"> <div class="finance-page-container">
<div class="page-header-row"> <!-- 头部卡片 -->
<div class="page-header"> <header class="page-header-card">
<p class="eyebrow">Withdrawals</p> <div class="header-main">
<h1>提现管理</h1> <div class="title-group">
<p>审核用户提现申请并确认打款</p> <div class="eyebrow-tag">
<el-icon><Money /></el-icon>
<span>WITHDRAWALS</span>
</div> </div>
<h1 class="page-title">提现审核</h1>
<p class="page-subtitle">审核号主用户钱包提现申请核验转账账户并执行打款放款处理</p>
</div>
<div class="toolbar-actions"> <div class="toolbar-actions">
<el-button @click="loadWithdrawals">刷新</el-button> <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> </div>
<!-- 筛选器 --> <div class="metric-card metric-card--pay">
<el-card shadow="never" style="margin-bottom: 16px"> <div class="metric-icon-wrap indigo">
<el-form :model="filters" inline> <el-icon><Wallet /></el-icon>
<el-form-item label="状态"> </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 <el-select
v-model="filters.status" v-model="filters.status"
placeholder="全部" placeholder="全部状态"
style="width: 150px" class="full-control"
@change="handleFilter" @change="handleFilter"
> >
<el-option <el-option
@@ -248,70 +338,105 @@ function onDetailDialogSaved() {
/> />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="用户ID">
<el-form-item label="用户 ID">
<el-input <el-input
v-model.number="filters.user_id" v-model.number="filters.user_id"
placeholder="输入用户ID" placeholder="输入用户 ID 检索"
style="width: 150px"
clearable clearable
@clear="handleFilter" @clear="handleFilter"
/> />
</el-form-item> </el-form-item>
<el-form-item>
<el-button type="primary" @click="handleFilter"> 查询 </el-button>
</el-form-item>
</el-form> </el-form>
</el-card> </section>
<!-- 提现列表 --> <!-- 提现列表表格 -->
<el-table v-loading="loading" class="table-panel" :data="withdrawals"> <section class="section-card table-section-card">
<el-table-column prop="id" label="ID" width="70" /> <div class="table-card-header">
<el-table-column prop="withdraw_no" label="提现单号" min-width="180" /> <div class="table-header-title">
<el-table-column label="用户" min-width="140"> <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 }"> <template #default="{ row }">
<div>{{ row.user_nickname }}</div> <div class="withdraw-no-cell">
<div style="font-size: 12px; color: #999">{{ row.user_phone }}</div> <span class="mono-code">{{ row.withdraw_no }}</span>
<small class="id-tag">#ID: {{ row.id }}</small>
</div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="提现金额" width="120" align="right">
<el-table-column label="申请用户" min-width="160">
<template #default="{ row }"> <template #default="{ row }">
<span style="color: #f56c6c; font-weight: 600"> ¥{{ formatCent(row.amount_cent) }} </span> <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> </template>
</el-table-column> </el-table-column>
<el-table-column label="收款方式" min-width="180">
<el-table-column label="提现金额" min-width="140" align="right">
<template #default="{ row }"> <template #default="{ row }">
<div> <div class="amount-stack">
<el-tag size="small" style="margin-right: 4px"> <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) }} {{ accountTypeLabel(row.account_type) }}
</el-tag> </el-tag>
{{ row.account_name }} <span class="acc-name">{{ row.account_name || '-' }}</span>
</div> </div>
<div style="font-size: 12px; color: #666; margin-top: 4px"> <small class="acc-no">{{ row.account_no }}</small>
{{ row.account_no }} <small v-if="row.bank_name" class="bank-name">{{ row.bank_name }}</small>
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="状态" width="100">
<el-table-column label="状态" width="110" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="statusType(row.status)"> <el-tag :type="statusType(row.status)" class="status-pill">
{{ statusLabel(row.status) }} {{ statusLabel(row.status) }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="申请时间" width="160">
<el-table-column label="申请时间" min-width="165">
<template #default="{ row }"> <template #default="{ row }">
{{ new Date(row.created_at).toLocaleString('zh-CN') }} <span class="time-cell">{{ formatDateTime(row.created_at) }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="300" fixed="right">
<el-table-column label="操作" width="240" fixed="right" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-button size="small" @click="openDetail(row)"> 详情 </el-button> <div class="action-cell">
<el-button size="small" type="primary" plain @click="goToOwnerOrders(row)"> <el-button link type="primary" :icon="View" @click="openDetail(row)">
号主订单 详情
</el-button> </el-button>
<el-button link type="primary" @click="goToOwnerOrders(row)"> 号主订单 </el-button>
<el-button <el-button
v-if="row.status === 'pending'" v-if="row.status === 'pending'"
size="small" link
type="success" type="success"
@click="handleReview(row, true)" @click="handleReview(row, true)"
> >
@@ -319,7 +444,7 @@ function onDetailDialogSaved() {
</el-button> </el-button>
<el-button <el-button
v-if="row.status === 'pending'" v-if="row.status === 'pending'"
size="small" link
type="danger" type="danger"
@click="handleReview(row, false)" @click="handleReview(row, false)"
> >
@@ -327,27 +452,26 @@ function onDetailDialogSaved() {
</el-button> </el-button>
<el-button <el-button
v-if="row.status === 'processing'" v-if="row.status === 'processing'"
size="small" link
type="primary" type="primary"
@click="handleConfirmPayment(row)" @click="handleConfirmPayment(row)"
> >
确认打款 确认打款
</el-button> </el-button>
</div>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</div>
<el-pagination <AdminTablePagination
v-if="total > pageSize" v-if="total > 0"
v-model:current-page="currentPage" v-model:current-page="currentPage"
v-model:page-size="pageSize" v-model:page-size="pageSize"
:total="total" :total="total"
:page-sizes="[10, 20, 50, 100]" @change="loadWithdrawals"
layout="total, sizes, prev, pager, next, jumper"
style="margin-top: 16px; justify-content: center"
@size-change="loadWithdrawals"
@current-change="loadWithdrawals"
/> />
</section>
<!-- 详情对话框 --> <!-- 详情对话框 -->
<WithdrawalDetailDialog <WithdrawalDetailDialog
@@ -356,52 +480,421 @@ function onDetailDialogSaved() {
:return-query="withdrawalReturnQuery" :return-query="withdrawalReturnQuery"
@saved="onDetailDialogSaved" @saved="onDetailDialogSaved"
/> />
</section> </div>
</template> </template>
<style scoped> <style scoped>
.page { .finance-page-container {
padding: 24px; display: flex;
flex-direction: column;
gap: 20px;
padding: 0 4px 32px;
} }
.page-header-row { /* ================= 头部卡片 ================= */
.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; display: flex;
justify-content: space-between;
align-items: flex-start; align-items: flex-start;
margin-bottom: 24px; justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
} }
.page-header { .title-group {
flex: 1; display: flex;
flex-direction: column;
gap: 6px;
} }
.eyebrow { .eyebrow-tag {
margin: 0 0 8px; display: inline-flex;
color: #909399; align-items: center;
font-size: 12px; gap: 6px;
font-weight: 600; font-size: 11px;
text-transform: uppercase; font-weight: 700;
letter-spacing: 0.5px; letter-spacing: 0.8px;
color: #7c3aed;
background: #f5f3ff;
padding: 3px 10px;
border-radius: 20px;
width: fit-content;
} }
.page-header h1 { .page-title {
margin: 0 0 8px;
font-size: 28px;
font-weight: 600;
}
.page-header p {
margin: 0; margin: 0;
color: #666; 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 { .toolbar-actions {
display: flex; display: flex;
gap: 8px; align-items: center;
gap: 10px;
} }
.table-panel { /* ================= 核心指标卡片 ================= */
background: #fff; .summary-metric-grid {
border-radius: 8px; 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> </style>